From 0a72e71dae582b348e9663a83363599469f20652 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:03:30 -0700 Subject: [PATCH 1/8] Fix STA-5661: prevent rolldown const-folding of bridged exports (#16869) Rolldown miscompiles `export let fn = noop` by const-folding initializers and dropping setters. Refactor to use null-initialized impl vars behind wrapper functions instead, and add test to prevent regression. --- src/main/ipc/pty/delivery/debug.ts | 40 +++- src/main/ipc/pty/delivery/visibility-state.ts | 17 +- .../ipc/pty/provider/listener-lifecycle.ts | 9 +- ...xport-let-function-initializer-ban.test.ts | 176 ++++++++++++++++++ src/shared/source-scan/source-tree-scan.ts | 6 +- 5 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 src/shared/export-let-function-initializer-ban.test.ts diff --git a/src/main/ipc/pty/delivery/debug.ts b/src/main/ipc/pty/delivery/debug.ts index 99dae69488d..f9c11c19d97 100644 --- a/src/main/ipc/pty/delivery/debug.ts +++ b/src/main/ipc/pty/delivery/debug.ts @@ -88,31 +88,51 @@ export const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebu rendererDispatcherReadyForcedCount: 0 } -export let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ - ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT -}) -export let resetPtyRendererDeliveryDebugSnapshot = (): void => {} +// Why null-init + wrapper fns (not `export let fn = noop`): rolldown const-folds the noop into +// call sites and drops the setter's reassignment, so bridged closures never run in the built app (STA-5661). +let readPtyRendererDeliveryDebugSnapshotImpl: (() => PtyRendererDeliveryDebugSnapshot) | null = null +let resetPtyRendererDeliveryDebugSnapshotImpl: (() => void) | null = null // Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. -export let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} +let resetRendererDeliveryAccountingForLifecycleResetImpl: (() => void) | null = null // Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. -export let clearRendererDispatcherReadyWatchdog = (): void => {} +let clearRendererDispatcherReadyWatchdogImpl: (() => void) | null = null + +export function readPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { + return ( + readPtyRendererDeliveryDebugSnapshotImpl?.() ?? { + ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT + } + ) +} + +export function resetPtyRendererDeliveryDebugSnapshot(): void { + resetPtyRendererDeliveryDebugSnapshotImpl?.() +} + +export function resetRendererDeliveryAccountingForLifecycleReset(): void { + resetRendererDeliveryAccountingForLifecycleResetImpl?.() +} + +export function clearRendererDispatcherReadyWatchdog(): void { + clearRendererDispatcherReadyWatchdogImpl?.() +} export function setReadPtyRendererDeliveryDebugSnapshot( fn: () => PtyRendererDeliveryDebugSnapshot ): void { - readPtyRendererDeliveryDebugSnapshot = fn + readPtyRendererDeliveryDebugSnapshotImpl = fn } export function setResetPtyRendererDeliveryDebugSnapshot(fn: () => void): void { - resetPtyRendererDeliveryDebugSnapshot = fn + resetPtyRendererDeliveryDebugSnapshotImpl = fn } export function setResetRendererDeliveryAccountingForLifecycleReset(fn: () => void): void { - resetRendererDeliveryAccountingForLifecycleReset = fn + resetRendererDeliveryAccountingForLifecycleResetImpl = fn } export function setClearRendererDispatcherReadyWatchdog(fn: () => void): void { - clearRendererDispatcherReadyWatchdog = fn + clearRendererDispatcherReadyWatchdogImpl = fn } export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts index dbe9526ae2e..21c18508055 100644 --- a/src/main/ipc/pty/delivery/visibility-state.ts +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -9,8 +9,17 @@ export const interactiveOutputCharsByPty = new Map() export const activeRendererPtys = new Set() export const visibleRendererPtys = new Set() export const rendererVisibilityKnownPtys = new Set() -export let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} -export let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} +// Why null-init + wrapper fns: see debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661). +let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null +let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null + +export function invalidatePendingPtyDrainPriority(id?: string, schedule?: boolean): void { + invalidatePendingPtyDrainPriorityImpl?.(id, schedule) +} + +export function invalidatePendingPtyDrainPolicy(id?: string, schedule?: boolean): void { + invalidatePendingPtyDrainPolicyImpl?.(id, schedule) +} export const pendingHiddenRendererResizeOutputPtys = new Set() export const deliveredHiddenRendererResizeOutputPtys = new Set() export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 @@ -21,11 +30,11 @@ export const providerSnapshotRequiredPtys = new Set() export function setInvalidatePendingPtyDrainPriority( fn: (id?: string, schedule?: boolean) => void ): void { - invalidatePendingPtyDrainPriority = fn + invalidatePendingPtyDrainPriorityImpl = fn } export function setInvalidatePendingPtyDrainPolicy( fn: (id?: string, schedule?: boolean) => void ): void { - invalidatePendingPtyDrainPolicy = fn + invalidatePendingPtyDrainPolicyImpl = fn } diff --git a/src/main/ipc/pty/provider/listener-lifecycle.ts b/src/main/ipc/pty/provider/listener-lifecycle.ts index d6cb383c9a3..2b31257234e 100644 --- a/src/main/ipc/pty/provider/listener-lifecycle.ts +++ b/src/main/ipc/pty/provider/listener-lifecycle.ts @@ -17,7 +17,12 @@ export let rendererGateResetLoadHandler: (() => void) | null = null export let rendererGateResetGoneHandler: (() => void) | null = null export let rendererGateResetWebContents: WebContents | null = null // Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. -export let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} +// Why null-init + wrapper fn: see delivery/debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661). +let clearBackgroundedDeliverySyncForPtyImpl: ((id: string) => void) | null = null + +export function clearBackgroundedDeliverySyncForPty(id: string): void { + clearBackgroundedDeliverySyncForPtyImpl?.(id) +} export type RendererNavigationDetails = { isMainFrame: boolean @@ -42,7 +47,7 @@ export function setRebindProviderListeners(fn: (() => void) | null): void { } export function setClearBackgroundedDeliverySyncForPty(fn: (id: string) => void): void { - clearBackgroundedDeliverySyncForPty = fn + clearBackgroundedDeliverySyncForPtyImpl = fn } export function setSshOutputIntakeCleanup(fn: (() => void) | null): void { diff --git a/src/shared/export-let-function-initializer-ban.test.ts b/src/shared/export-let-function-initializer-ban.test.ts new file mode 100644 index 00000000000..a152ed61cf1 --- /dev/null +++ b/src/shared/export-let-function-initializer-ban.test.ts @@ -0,0 +1,176 @@ +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import type { parseAst as ParseAstFn } from 'vite' with { 'resolution-mode': 'import' } +import { scanSourceTree } from './source-scan/source-tree-scan' + +// Dynamic import: vite is ESM-only and this file typechecks under tsconfig.tc.cli.json's node16/CJS. +let parseAst: typeof ParseAstFn + +beforeAll(async () => { + ;({ parseAst } = await import('vite')) +}) + +/** + * Ban `export let fn = ` tree-wide: rolldown const-folds the + * initializer into call sites and drops the setter's reassignment, so the + * bridged closure silently never runs in the built app (dev is fine). Use a + * module-local null-initialized impl var behind exported wrapper functions + * instead (see src/main/ipc/pty/delivery/debug.ts); null-initialized + * `export let` slots compile correctly and stay allowed. Detection uses + * vite/rolldown's own oxc parser so this test sees exactly what the bundler sees. + */ + +type AstNode = { + type: string + start?: number + expression?: AstNode + expressions?: AstNode[] + declaration?: AstNode + kind?: string + declarations?: { + id?: { name?: string } + init?: AstNode | null + start?: number + }[] + specifiers?: { local?: { name?: string } }[] +} + +/** Casts, parens, `!`, and comma sequences around the function don't change what rolldown folds. */ +function unwrapInitializer(node: AstNode): AstNode { + let current = node + for (;;) { + if ( + current.expression && + [ + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', + 'TSNonNullExpression' + ].includes(current.type) + ) { + current = current.expression + continue + } + // A sequence evaluates to its last expression — that's what gets folded. + const sequenceTail = + current.type === 'SequenceExpression' ? current.expressions?.at(-1) : undefined + if (sequenceTail) { + current = sequenceTail + continue + } + return current + } +} + +export type FunctionInitializedExportLet = { name: string; line: number } + +export function findFunctionInitializedExportLets( + fileName: string, + sourceText: string +): FunctionInitializedExportLet[] { + // Cheap prefilter: full parses only for files that could contain the pattern. + if (!/\bexport\b/.test(sourceText) || !/\blet\b/.test(sourceText)) { + return [] + } + const lineOf = (offset: number): number => sourceText.slice(0, offset).split('\n').length + const program = parseAst(sourceText, { + lang: fileName.endsWith('.tsx') ? 'tsx' : 'ts' + }) as unknown as { body: AstNode[] } + const functionLets: (FunctionInitializedExportLet & { exported: boolean })[] = [] + const exportedNames = new Set() + const collectLets = (declaration: AstNode | undefined, exported: boolean): void => { + if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'let') { + return + } + for (const declarator of declaration.declarations ?? []) { + const initializer = declarator.init && unwrapInitializer(declarator.init) + if ( + initializer && + ['ArrowFunctionExpression', 'FunctionExpression'].includes(initializer.type) + ) { + functionLets.push({ + name: declarator.id?.name ?? '', + line: lineOf(declarator.start ?? 0), + exported + }) + } + } + } + for (const statement of program.body) { + if (statement.type === 'ExportNamedDeclaration') { + collectLets(statement.declaration, true) + // `let fn = () => {}; export { fn }` is the same live binding — same hazard. + for (const specifier of statement.specifiers ?? []) { + if (specifier.local?.name) { + exportedNames.add(specifier.local.name) + } + } + continue + } + collectLets(statement, false) + } + return functionLets + .filter((candidate) => candidate.exported || exportedNames.has(candidate.name)) + .map(({ name, line }) => ({ name, line })) +} + +describe('function-initialized export let ban', () => { + it('flags the shapes rolldown miscompiles', () => { + const flagged = [ + 'export let f = () => {}', + 'export let f = function () {}', + 'export let f = async () => {}', + 'export let f = (() => {}) as () => void', + 'export let f = (() => {})!', + 'export let f = (0, () => {})', + 'export let f: (id: string) => void = (_id) => {}', + 'let f = () => {}\nexport { f }', + 'let f = () => {}\nexport { f as g }' + ] + for (const source of flagged) { + expect(findFunctionInitializedExportLets('a.ts', source), source).toHaveLength(1) + } + }) + + it('reports the declaration line', () => { + const source = 'const a = 1\n\nexport let f = () => {}' + expect(findFunctionInitializedExportLets('a.ts', source)).toEqual([{ name: 'f', line: 3 }]) + }) + + it('allows the safe shapes', () => { + const allowed = [ + 'export let f: (() => void) | null = null', + 'export const f = () => {}', + 'let f = () => {}', + 'let f: (() => void) | null = null\nexport { f }', + 'export let n = 0', + 'export let f' + ] + for (const source of allowed) { + expect(findFunctionInitializedExportLets('a.ts', source), source).toEqual([]) + } + }) + + const repoRoot = resolve(__dirname, '..', '..') + // Tests are not rolldown-bundled (scanSourceTree excludes them by default); + // several drive the banned shape on purpose. + const files = scanSourceTree(join(repoRoot, 'src')) + + it('scans a plausible number of files', () => { + // A broken root or extension list would make the guard silently vacuous. + expect(files.length).toBeGreaterThan(500) + }) + + it('has no function-initialized export let in shipped code', () => { + const offenders = files.flatMap(({ relativePath, source }) => + findFunctionInitializedExportLets(relativePath, source).map( + (offender) => `src/${relativePath}:${offender.line} (${offender.name})` + ) + ) + expect( + offenders, + 'Rolldown miscompiles `export let fn = ` bridges: the initializer is folded into call sites and the setter reassignment is dropped in the built app. Use a module-local null-initialized impl var behind an exported wrapper function instead — see src/main/ipc/pty/delivery/debug.ts.' + ).toEqual([]) + }) +}) diff --git a/src/shared/source-scan/source-tree-scan.ts b/src/shared/source-scan/source-tree-scan.ts index 780354434c0..900d969b2ea 100644 --- a/src/shared/source-scan/source-tree-scan.ts +++ b/src/shared/source-scan/source-tree-scan.ts @@ -18,7 +18,11 @@ const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '.g export function isTestFile(relativePath: string): boolean { return ( /\.(?:test|spec)\.tsx?$/.test(relativePath) || - /(?:test-harness|test-utils|test-setup|test-fixture|repro)/.test(relativePath) || + // `repro` must be a whole token: a bare substring exempted the shipped + // windows-terminal-capability-reprobe.ts from every guard using this walk. + /(?:test-harness|test-utils|test-setup|test-fixture|\brepro\b|reproduction)/.test( + relativePath + ) || relativePath.includes('/__tests__/') ) } From a724aa7b0888f5ce7b9915a9f416ac36a56bc30b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:08:14 -0700 Subject: [PATCH 2/8] fix(mobile): report composing state from accessory backspace (#16757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): report composing state from accessory backspace The accessory path edits the field itself and then mirrors it, but called applyLiveInputMirror with two arguments where the signature takes three. The local option type declared it 2-ary, so the type checker never saw the drop. An omitted composing flag is not "not composing": it selects the Android-only heuristic that holds the trailing non-ASCII run. A pinyin preedit is plain ASCII, so the heuristic reads it as committed text and sends it. Typing `ni hao`, tapping accessory Backspace, then picking a candidate put `ni ha` on the PTY before the commit, giving `ni ha你好`. Korean survived this by accident - the non-ASCII heuristic re-derives the correct hold for Hangul - which is why it went unnoticed. The held range is the fact the mirror needs, and it is already in scope. Refs #13345 * fix(mobile): preserve accessory IME report provenance --- ...rminal-live-accessory-input-commit.test.ts | 34 ++++++++++++++++--- ...se-terminal-live-accessory-input-commit.ts | 8 +++-- .../use-terminal-live-input-commit.test.ts | 29 ++++++++++++++++ .../use-terminal-live-input-commit.ts | 2 ++ .../use-terminal-live-pending-input-flush.ts | 6 ++++ 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index 2542b315a8f..4474f043ebe 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -26,6 +26,7 @@ function createDeferredBoolean(): DeferredBoolean { } type AccessoryInputCommitHarnessOptions = { + readonly composing?: boolean readonly heldText?: string readonly sentText?: string readonly pendingHandle?: string | null @@ -46,6 +47,7 @@ type AccessoryInputCommitHarness = { } function createAccessoryInputCommitHarness({ + composing, heldText = '', sentText = '', pendingHandle = null, @@ -55,6 +57,7 @@ function createAccessoryInputCommitHarness({ }: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness { const activeHandle = 'terminal-a' const heldLiveInputTextRef: RefObject = { current: heldText } + const liveInputComposingRef: RefObject = { current: composing } const sentLiveInputTextRef: RefObject = { current: sentText } const pendingLiveInputHandleRef: RefObject = { current: pendingHandle } const liveInputRef: RefObject = { current: null } @@ -66,7 +69,9 @@ function createAccessoryInputCommitHarness({ return sendResult } } - const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {}) + const applyLiveInputMirror = vi.fn( + (_handle: string, _fieldText: string, _composing?: boolean) => {} + ) const clearPendingLiveInputCommit = vi.fn(() => {}) const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult) const waitForPendingLiveInputFlush = vi.fn(async () => waitResult) @@ -82,6 +87,7 @@ function createAccessoryInputCommitHarness({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, @@ -178,10 +184,11 @@ describe('terminal live accessory input commit hook', () => { expect(harness.sent).toEqual([]) }) - it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => { + it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => { // Given const harness = createAccessoryInputCommitHarness({ - heldText: '한', + composing: true, + heldText: 'ni hao', sentText: '', pendingHandle: 'terminal-a' }) @@ -190,14 +197,31 @@ describe('terminal live accessory input commit hook', () => { const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) // Then - expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '') + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true) expect(result).toEqual({ kind: 'handled' }) expect(harness.sent).toEqual([]) }) + it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => { + // Given + const harness = createAccessoryInputCommitHarness({ + heldText: '한글', + sentText: '', + pendingHandle: 'terminal-a' + }) + + // When + const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) + + // Then + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '한', undefined) + expect(result).toEqual({ kind: 'handled' }) + }) + it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => { // Given const harness = createAccessoryInputCommitHarness({ + composing: false, heldText: '', sentText: 'ab', pendingHandle: 'terminal-a' @@ -207,7 +231,7 @@ describe('terminal live accessory input commit hook', () => { const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) // Then - expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a') + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false) expect(result).toEqual({ kind: 'handled' }) }) }) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index 0893ae16098..ef6a2339822 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -21,10 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult( type TerminalLiveAccessoryInputCommitOptions = { readonly activeHandle: string | null - readonly applyLiveInputMirror: (handle: string, fieldText: string) => void + readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject + readonly liveInputComposingRef: RefObject readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly pendingLiveInputHandleRef: RefObject @@ -40,6 +41,7 @@ export function useTerminalLiveAccessoryInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, @@ -81,7 +83,8 @@ export function useTerminalLiveAccessoryInputCommit({ // field is edited here and the mirror diff syncs the PTY echo. setLiveInputCapture(editedText) liveInputRef.current?.setNativeProps({ text: editedText }) - applyLiveInputMirror(activeHandle, editedText) + // Preserve undefined so Android's heuristic hold still settles on its timer. + applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current) return { kind: 'handled' } } case 'commit-held-then-send': @@ -101,6 +104,7 @@ export function useTerminalLiveAccessoryInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index d4a39d2d417..b24636114a9 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -161,6 +161,35 @@ describe('terminal live input commit hook', () => { expect(sent).toEqual([]) }) + it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent } = createTerminalLiveInputCommitHarness() + changeLiveInput(handlers, 'ni hao', true) + + // When + await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' }) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2) + changeLiveInput(handlers, '你好', false) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['你好'])) + }) + + it('Given an Android heuristic hold When accessory Backspace edits it Then the remaining text still settles', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent } = createTerminalLiveInputCommitHarness() + changeLiveInput(handlers, '한글') + + // When + await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' }) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한'])) + }) + it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => { // Given vi.useFakeTimers() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index b05241f6421..25bbf1ae002 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, pendingLiveInputHandleRef, sentLiveInputTextRef, waitForPendingLiveInputFlush @@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index 75dd3e49f61..c01aa5af213 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -34,6 +34,7 @@ type TerminalLivePendingInputFlush = { readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject + readonly liveInputComposingRef: RefObject readonly pendingLiveInputHandleRef: RefObject readonly sentLiveInputTextRef: RefObject readonly waitForPendingLiveInputFlush: () => Promise @@ -50,6 +51,7 @@ export function useTerminalLivePendingInputFlush({ const heldCommitTimerRef = useRef | null>(null) const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') + const liveInputComposingRef = useRef(undefined) const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) const runMirrorStepRef = useRef(async () => false) @@ -65,6 +67,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer() cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' + liveInputComposingRef.current = undefined sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null }, [clearHeldCommitTimer]) @@ -106,6 +109,7 @@ export function useTerminalLivePendingInputFlush({ }) sentLiveInputTextRef.current = step.nextSentText heldLiveInputTextRef.current = step.heldText + liveInputComposingRef.current = composing pendingLiveInputHandleRef.current = step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null @@ -187,6 +191,7 @@ export function useTerminalLivePendingInputFlush({ heldCommitTimerRef.current = null } heldLiveInputTextRef.current = '' + liveInputComposingRef.current = undefined sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) @@ -198,6 +203,7 @@ export function useTerminalLivePendingInputFlush({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, pendingLiveInputHandleRef, sentLiveInputTextRef, waitForPendingLiveInputFlush From b241a68ae4467253beaac3ed1bb6a663fd9c5a01 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:08:40 -0700 Subject: [PATCH 3/8] Fix worktree identity collisions across hosts (#16691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workspaces): add collision-safe worktree identity * fix(workspaces): read worktree metadata per host and repair ambiguous identities The canonical identity store landed write-only: getWorktreeMetaForHost had no production callers while setWorktreeMetaForHost kept the legacy projection only for the first known owner, so a second host's edits persisted and were never read back. Wire the listing paths through host-qualified reads. An ambiguous alias was also unrecoverable — reads returned undefined and writes threw forever, and the throw escaped the detected-worktree loop, emptying the whole repo's sidebar. Fail open onto the most recently active instance instead. - collapse ambiguous aliases deterministically and persist the repair - reclaim identity rows in the metadata GC so they cannot outlive their locator or resurrect onto a worktree recreated at the same path - drop every host's rows when a locator is removed outright, not just the owner's - honour an explicit instanceId so the stale-lineage rotation guard still works - scope a rename to the moving host; other hosts keep their own locator - prefer the project host setup matching the repo's own execution host, so a repoId registered on two hosts no longer stamps the wrong one durably - reject an unencoded `|` in a host id, the invariant the alias delimiter needs - drop the never-populated hostGeneration from the canonical key * fix(workspaces): close remaining identity review gaps * fix(workspaces): close remaining review gaps * fix(workspaces): address review and CI regressions * test(workspaces): update host-qualified metadata expectations * fix(workspaces): preserve ambiguous identity records * fix(workspaces): snapshot metadata during listing * test(workspaces): mirror listing metadata snapshot in windows fixture * fix(workspaces): preserve identity routing for metadata writes * fix(workspaces): scope stale metadata cleanup by host * fix(workspaces): rekey identities on SSH readoption * fix(workspaces): fail closed for ambiguous board ids * perf(workspaces): snapshot metadata across catalog listing * fix(workspaces): retain neighboring manual order updates * test(workspaces): cover ambiguous board id index * fix(persistence): harden host-qualified worktree metadata * refactor(shared): split project host setup lookup * refactor(workspaces): simplify host-qualified metadata --- src/cli/help.ts | 10 +- src/cli/index.test.ts | 3 + src/cli/selectors.ts | 2 +- .../ipc/repos/project-host-setup-handlers.ts | 6 +- .../ipc/workspace-cleanup-broad-scan.test.ts | 32 +- .../ipc/workspace-cleanup-disconnected-ssh.ts | 13 +- src/main/ipc/workspace-cleanup-scan.ts | 7 +- src/main/ipc/worktree-metadata-merge.test.ts | 47 +++ src/main/ipc/worktree-metadata-merge.ts | 13 +- src/main/ipc/worktree-remote.ts | 2 +- ...ktrees-create-metadata-persistence.test.ts | 30 ++ .../worktrees-listing-fallback-rows.test.ts | 64 ++++ src/main/ipc/worktrees-test-harness.ts | 12 + src/main/ipc/worktrees-test-ipc-surface.ts | 4 + src/main/ipc/worktrees-windows.test.ts | 11 + .../create/folder-workspace-creation.ts | 2 +- .../listing/detected-provider-listing.ts | 9 +- .../listing/register-host-catalog-handlers.ts | 8 +- .../register-worktree-catalog-handlers.ts | 55 ++- .../listing/ssh-worktree-fallback.ts | 31 +- .../listing/worktree-discovery-metadata.ts | 28 +- .../register-worktree-metadata-handlers.ts | 23 +- src/main/persistence-repo-lifecycle.test.ts | 6 + .../host-qualified-worktree-meta.ts | 65 ++++ .../canonical-worktree-host-reassignment.ts | 128 +++++++ .../ssh-target-reassignment.ts | 21 +- .../metadata-lineage-operations.ts | 142 ++++--- .../worktree-identity-metadata.ts | 236 ++++++++++++ .../worktree-meta-write-normalization.ts | 55 +++ .../tracking-repos/repo-worktree-pruning.ts | 10 + .../worktree-metadata-normalization.test.ts | 132 ++++++- .../worktree-metadata-normalization.ts | 105 +++++- src/main/provisioned-root-ssh-adoption.ts | 2 +- src/main/runtime/orca-runtime.ts | 30 +- .../repo-worktree-row-resolution.test.ts | 65 ++++ .../runtime/repo-worktree-row-resolution.ts | 18 +- ...rktree-scan-admin-fingerprint-gate.test.ts | 28 +- src/main/workspace-space-repo-scan.ts | 12 +- .../worktree-identity-persistence.test.ts | 351 ++++++++++++++++++ src/main/worktree-metadata-ownership.test.ts | 35 ++ src/preload/api/worktree-api.ts | 6 +- ...-checks-panel-check-and-review-actions.tsx | 16 +- .../WorkspaceKanbanDrawer.search.test.tsx | 11 +- ...treeList.status-lane-lineage-drop.test.tsx | 9 +- .../sidebar/WorktreeMetaDialog.test.tsx | 23 ++ .../components/sidebar/WorktreeMetaDialog.tsx | 10 +- .../use-workspace-kanban-board-projection.ts | 3 +- .../use-workspace-kanban-status-actions.ts | 6 +- .../use-workspace-kanban-worktree-actions.ts | 66 +++- .../use-worktree-card-activation-actions.ts | 18 +- .../sidebar/use-worktree-card-foundation.ts | 2 + .../use-worktree-card-secondary-details.ts | 13 +- .../use-worktree-context-menu-commands.ts | 18 +- .../workspace-kanban-sidebar-drop.test.ts | 22 +- .../sidebar/workspace-kanban-sidebar-drop.ts | 27 +- .../drag/use-status-mutations.ts | 54 ++- .../worktree-unambiguous-id-index.test.ts | 43 +++ .../sidebar/worktree-unambiguous-id-index.ts | 17 + ...r-workspace-owner-routed-mutations.test.ts | 11 +- .../src/store/slices/worktree-helpers.ts | 81 +--- .../worktree-meta-update-application.ts | 69 ++++ .../worktrees-activity-persistence.test.ts | 2 +- .../worktrees-fetch-listing-merge.test.ts | 10 +- ...orktrees-linked-review-push-target.test.ts | 64 +++- .../worktrees-metadata-persistence.test.ts | 54 ++- .../worktrees-terminal-pr-url-linking.test.ts | 3 + .../listing/detected-worktree-meta.ts | 7 +- .../listing/worktree-meta-host-match.ts | 19 + .../metadata/update-worktree-meta.ts | 40 +- .../metadata/update-worktrees-meta.ts | 51 ++- .../metadata/worktree-meta-persist.ts | 14 +- .../worktrees/session/worktree-pin-reveal.ts | 20 +- src/shared/execution-host.test.ts | 13 + src/shared/execution-host.ts | 8 + src/shared/persisted-state-types.ts | 4 + src/shared/project-host-setup-lookup.ts | 30 ++ .../project-host-setup-projection.test.ts | 22 +- src/shared/project-host-setup-projection.ts | 23 -- src/shared/worktree/identity.test.ts | 28 ++ src/shared/worktree/identity.ts | 29 ++ src/shared/worktree/types.ts | 3 + 81 files changed, 2457 insertions(+), 365 deletions(-) create mode 100644 src/main/ipc/worktree-metadata-merge.test.ts create mode 100644 src/main/persistence/host-qualified-worktree-meta.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/canonical-worktree-host-reassignment.ts create mode 100644 src/main/persistence/loading-store/worktree-identity-metadata.ts create mode 100644 src/main/persistence/loading-store/worktree-meta-write-normalization.ts create mode 100644 src/main/worktree-identity-persistence.test.ts create mode 100644 src/main/worktree-metadata-ownership.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-unambiguous-id-index.ts create mode 100644 src/renderer/src/store/slices/worktree-meta-update-application.ts create mode 100644 src/renderer/src/store/slices/worktrees/listing/worktree-meta-host-match.ts create mode 100644 src/shared/project-host-setup-lookup.ts create mode 100644 src/shared/worktree/identity.test.ts create mode 100644 src/shared/worktree/identity.ts diff --git a/src/cli/help.ts b/src/cli/help.ts index d43c8294963..22272a2c28d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -268,9 +268,9 @@ Common Commands: Selectors: --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as id:::, name:, branch:, issue:, path:, or active/current + --worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` - --parent-worktree Parent worktree selector such as id:::, branch:, issue:, path:, or active/current + --parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current --no-parent Force no parent lineage for unrelated worktree creation/update Terminal Send Options: @@ -509,7 +509,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { return '--parent-current Use the current linked issue as parent' } if (command === 'worktree create' && flag === 'parent-worktree') { - return '--parent-worktree Parent selector such as active/current, id:::, branch:, issue:, path:, folder:, or worktree:' + return '--parent-worktree Parent selector such as identity:, active/current, id:::, branch:, issue:, path:, folder:, or worktree:' } if (command === 'orchestration task-create' && flag === 'task-title') { return '--task-title Concise title for the orchestration task' @@ -577,7 +577,7 @@ export function formatFlagHelp(flag: string): string { 'no-screenshot': '--no-screenshot Skip screenshot capture after the operation', pages: '--pages Number of scroll pages', 'parent-worktree': - '--parent-worktree Parent worktree selector such as id:::, branch:, issue:, path:, or active/current', + '--parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current', path: '--path Path argument for the command', prompt: '--prompt Prompt text for agent-backed commands', query: '--query Search text for matching refs', @@ -601,7 +601,7 @@ export function formatFlagHelp(flag: string): string { 'to-x': '--to-x Destination window-local x coordinate', 'to-y': '--to-y Destination window-local y coordinate', worktree: - '--worktree Worktree selector such as id:::, name:, branch:, issue:, path:, or active/current', + '--worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current', workspace: '--workspace Existing worktree selector for automation runs', 'workspace-status': '--workspace-status Board status id (defaults: todo, in-progress, in-review, completed)', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 8daf4db772b..1b190d9dbde 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -41,6 +41,7 @@ vi.mock('child_process', async () => { }) import { COMMAND_SPECS, main } from './index' +import { formatFlagHelp } from './help' import { GLOBAL_FLAGS, specPaths } from './args' import { okFixture, queueFixtures } from './test-fixtures' @@ -480,6 +481,7 @@ describe('orca root help', () => { const rootHelp = String(logSpy.mock.calls[0][0]) expect(rootHelp).not.toContain('--parent-workspace') expect(rootHelp).toContain('[--parent-worktree ] [--no-parent]') + expect(rootHelp).toContain('identity:') logSpy.mockClear() await main(['worktree', 'create', '--help'], '/tmp/repo') @@ -504,6 +506,7 @@ describe('orca root help', () => { const setHelp = String(logSpy.mock.calls[0][0]) expect(setHelp).not.toContain('--parent-workspace') expect(setHelp).not.toContain('folder:') + expect(formatFlagHelp('parent-worktree')).toContain('identity:') expect(setHelp).not.toContain('worktree:') expect(callMock).not.toHaveBeenCalled() }) diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index aef583ad0ac..cfc2dd68749 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -39,7 +39,7 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient) // server, so cwd-derived worktree selectors are only valid locally. throw new RuntimeClientError( 'invalid_argument', - `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:::, name:, branch:, issue:, or path:.` + `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as identity:, id:::, name:, branch:, issue:, or path:.` ) } diff --git a/src/main/ipc/repos/project-host-setup-handlers.ts b/src/main/ipc/repos/project-host-setup-handlers.ts index 610968614a2..3feb101324c 100644 --- a/src/main/ipc/repos/project-host-setup-handlers.ts +++ b/src/main/ipc/repos/project-host-setup-handlers.ts @@ -12,10 +12,8 @@ import type { ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult } from '../../../shared/project-types' -import { - getProjectHostSetupForRepo, - getProjectIdForProviderIdentity -} from '../../../shared/project-host-setup-projection' +import { getProjectIdForProviderIdentity } from '../../../shared/project-host-setup-projection' +import { getProjectHostSetupForRepo } from '../../../shared/project-host-setup-lookup' import { parseExecutionHostId } from '../../../shared/execution-host' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index 070ea461906..0bfa3e3ccc9 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -179,7 +179,7 @@ describe('workspace cleanup broad scan opt-in', () => { ...makeStore(), getWorktreeMeta: (worktreeId: string) => worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId] - } as Store + } as unknown as Store const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true @@ -561,6 +561,36 @@ describe('workspace cleanup broad scan opt-in', () => { }) }) + it('keeps a canonical-only SSH cleanup row blocked while local metadata collides', async () => { + const worktreeId = 'repo-1::/shared/workspace' + const localRepo = { ...REPO, path: '/local/repo' } + const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' } + const localMeta = makeWorktreeMeta({ + displayName: 'Local workspace', + hostId: 'local' + }) + const remoteMeta = makeWorktreeMeta({ + displayName: 'Canonical remote workspace', + hostId: 'ssh:ssh-1' + }) + const store = { + ...makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }), + getAllWorktreeMetaForHost: (hostId: string) => + hostId === 'ssh:ssh-1' ? { [worktreeId]: remoteMeta } : { [worktreeId]: localMeta } + } as unknown as Store + + const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect(result.candidates).toContainEqual( + expect.objectContaining({ + worktreeId, + displayName: 'Canonical remote workspace', + executionHostId: 'ssh:ssh-1', + blockers: ['ssh-disconnected'] + }) + ) + }) + it('does not synthesize a disconnected SSH row from same-id local metadata', async () => { const sharedPath = '/shared/workspace' const worktreeId = `${REPO.id}::${sharedPath}` diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index c0e136b7b77..b616bec22bb 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -15,6 +15,10 @@ import { isWorkspaceInactiveForCleanup } from './workspace-cleanup-candidate' import { getRepoOwnedWorktreeMeta, isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership' +import { + readAllWorktreeMetaForHost, + readWorktreeMetaForHost +} from '../persistence/host-qualified-worktree-meta' export function synthesizeDisconnectedSshCleanupCandidates( store: Store, @@ -25,6 +29,7 @@ export function synthesizeDisconnectedSshCleanupCandidates( includeAllWorkspaces = false ): WorkspaceCleanupCandidate[] { const repoWorktreePrefix = `${repo.id}::` + const executionHostId = getRepoExecutionHostId(repo) if (targetWorktreeIds) { const candidates: WorkspaceCleanupCandidate[] = [] // Why: targeted refreshes name their workspaces already; walking all @@ -33,7 +38,11 @@ export function synthesizeDisconnectedSshCleanupCandidates( if (!worktreeId.startsWith(repoWorktreePrefix)) { continue } - const meta = store.getWorktreeMeta(worktreeId) + const meta = + readWorktreeMetaForHost(store, worktreeId, executionHostId) ?? + (typeof store.getWorktreeMetaForHost === 'function' + ? undefined + : store.getWorktreeMeta(worktreeId)) if (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) { candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) } @@ -42,7 +51,7 @@ export function synthesizeDisconnectedSshCleanupCandidates( } const candidates: WorkspaceCleanupCandidate[] = [] - const allMeta = store.getAllWorktreeMeta() + const allMeta = readAllWorktreeMetaForHost(store, executionHostId) for (const worktreeId in allMeta) { if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) { continue diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index b87667f0a63..58b093dbd72 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -1,6 +1,8 @@ import type { Store } from '../persistence' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { mergeWorktree } from './worktree-logic' @@ -187,8 +189,11 @@ async function scanRepoWorkspaces( ? listWorkspaceCleanupFolderWorkspaces(store, repo, repoOwnerCount) : gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` + // Host-qualified first: the same repoId::path is a different checkout on each host. + const hostMeta = readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) const meta = store.getWorktreeMeta(worktreeId) - const ownedMeta = isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined + const ownedMeta = + hostMeta ?? (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined) return mergeWorktree(repo.id, gitWorktree, ownedMeta, repo.displayName) }) // Why: with includeAllWorkspaces the browser shows every workspace and lets diff --git a/src/main/ipc/worktree-metadata-merge.test.ts b/src/main/ipc/worktree-metadata-merge.test.ts new file mode 100644 index 00000000000..561897beb5c --- /dev/null +++ b/src/main/ipc/worktree-metadata-merge.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { canonicalWorktreeIdentity } from '../../shared/worktree/identity' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { mergeWorktree } from './worktree-metadata-merge' + +const git: GitWorktreeInfo = { + path: '/workspace/feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false +} + +describe('mergeWorktree identity projection', () => { + it('publishes canonical identity when host and instance metadata are known', () => { + const worktree = mergeWorktree('repo-1', git, { + instanceId: '11111111-1111-4111-8111-111111111111', + hostId: 'ssh:build-box', + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + }) + + expect(worktree.identity).toEqual({ + key: canonicalWorktreeIdentity({ + worktreeId: worktree.id, + executionHostId: 'ssh:build-box', + instanceId: '11111111-1111-4111-8111-111111111111' + }), + executionHostId: 'ssh:build-box', + instanceId: '11111111-1111-4111-8111-111111111111' + }) + }) + + it('omits canonical identity for legacy metadata without a proven host', () => { + const worktree = mergeWorktree('repo-1', git, undefined) + + expect(worktree.identity).toBeUndefined() + }) +}) diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 4fdfe07c72a..742467c63a2 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -4,6 +4,7 @@ import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' +import { createWorktreeIdentity } from '../../shared/worktree/identity' /** * Merge raw git worktree info with persisted user metadata into a full Worktree. @@ -16,8 +17,18 @@ export function mergeWorktree( ): Worktree { const branchShort = git.branch.replace(/^refs\/heads\//, '') const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance) + const worktreeId = `${repoId}::${git.path}` return { - id: `${repoId}::${git.path}`, + id: worktreeId, + ...(meta?.instanceId && meta.hostId + ? { + identity: createWorktreeIdentity({ + worktreeId, + executionHostId: meta.hostId, + instanceId: meta.instanceId + }) + } + : {}), ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId, ...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}), diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e71c16c7ffe..c20899ea9cf 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -48,7 +48,7 @@ import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' -import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' +import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup' import { getEffectiveHooks, loadHooks, parseOrcaYaml } from '../hooks' import { buildPosixRunnerScript, buildWindowsRunnerScript } from '../setup-runner-script-text' import { createSetupRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 7e2ebac49f5..1aeff9627b4 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -205,6 +205,36 @@ describe('registerWorktreeHandlers', () => { }) expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) + it('routes metadata updates through the explicitly selected host', () => { + store.setWorktreeMetaForHost.mockImplementation((_worktreeId, _executionHostId, meta) => meta) + + const result = handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + executionHostId: 'ssh:build-box', + updates: { comment: 'remote note' } + }) + + expect(store.setWorktreeMetaForHost).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + 'ssh:build-box', + { comment: 'remote note' } + ) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(result).toMatchObject({ comment: 'remote note' }) + }) + + it('rejects malformed execution-host identities at the IPC boundary', () => { + expect(() => + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + executionHostId: 'container:untrusted', + updates: { comment: 'must not write' } + }) + ).toThrow('Invalid execution host identity.') + + expect(store.setWorktreeMetaForHost).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + }) it('pushes a remote-client invalidation for renames but not read-state updates', () => { store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index 45bea853e67..c33f9e14ef8 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' +import { buildDetectedGitWorktrees } from './worktrees/listing/ssh-worktree-fallback' +import type { Store } from '../persistence/loading-store/store' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -224,6 +226,41 @@ describe('registerWorktreeHandlers', () => { }) }) + it('lists a canonical-only SSH row without leaking the same-id local row', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const worktreeId = 'repo-ssh::/shared/feature-wt' + const canonicalRemote = makeWorktreeMeta({ + displayName: 'Canonical remote', + hostId: 'ssh:conn-1' + }) + store.getRepo.mockReturnValue(repo) + store.getAllWorktreeMeta.mockReturnValue({ + [worktreeId]: makeWorktreeMeta({ displayName: 'Legacy local', hostId: 'local' }) + }) + ;( + store as typeof store & { + getAllWorktreeMetaForHost: ReturnType + } + ).getAllWorktreeMetaForHost = vi.fn(() => ({ [worktreeId]: canonicalRemote })) + + const listed = await handlers['worktrees:list'](null, { repoId: repo.id }) + + expect(listed).toEqual([ + expect.objectContaining({ + id: worktreeId, + displayName: 'Canonical remote', + hostId: 'ssh:conn-1' + }) + ]) + }) + it('falls back to reconstructed SSH rows when provider listing throws', async () => { const repo = { id: 'repo-ssh', @@ -424,4 +461,31 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' }) ]) }) + + it('loads repo ownership once while building multiple detected rows', () => { + const owner = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + store.getRepos.mockReturnValue([owner]) + + const detected = buildDetectedGitWorktrees( + store as unknown as Store, + owner, + ['/workspace/one', '/workspace/two', '/workspace/three'].map((path) => ({ + path, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + })), + {} + ) + + expect(detected).toHaveLength(3) + expect(store.getRepos).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 4e1c524fa67..0eacf52e472 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -73,6 +73,8 @@ export { /** Registers worktree IPC handlers against freshly reset shared mocks and returns the runtime stub. */ export function setupWorktreeHandlers(): WorktreeRuntimeStub { + delete (store as typeof store & { getAllWorktreeMetaForHost?: (...args: unknown[]) => unknown }) + .getAllWorktreeMetaForHost setPlatform(ORIGINAL_PLATFORM) clearConfiguredWorktreeSharedDirectoriesCacheForTests() __resetSshWorktreeCreateFetchCacheForTests() @@ -125,8 +127,10 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { store.getSparsePresets, store.getSettings, store.getWorktreeMeta, + store.getWorktreeMetaForHost, store.getAllWorktreeMeta, store.setWorktreeMeta, + store.setWorktreeMetaForHost, store.getProjectHostSetups, store.removeWorktreeMeta, store.removeWorkspaceSessionStateForWorktree, @@ -190,10 +194,18 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { workspaceDir: '/workspace' }) store.getWorktreeMeta.mockReturnValue(undefined) + // Host-qualified accessors delegate by default so payload assertions stay on one spy; + // host routing itself is covered by worktree-identity-persistence.test.ts. + store.getWorktreeMetaForHost.mockImplementation((...args: unknown[]) => + store.getWorktreeMeta(args[0] as string) + ) store.getAllWorktreeMeta.mockReturnValue({}) store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] }) resetRetirementCollisionKeyCacheForTests() store.setWorktreeMeta.mockReturnValue({}) + store.setWorktreeMetaForHost.mockImplementation((...args: unknown[]) => + store.setWorktreeMeta(args[0] as string, args[2] as object) + ) store.getProjectHostSetups.mockReturnValue([ { id: 'repo-1', diff --git a/src/main/ipc/worktrees-test-ipc-surface.ts b/src/main/ipc/worktrees-test-ipc-surface.ts index f8d70bfbbbc..cc4b497b1bc 100644 --- a/src/main/ipc/worktrees-test-ipc-surface.ts +++ b/src/main/ipc/worktrees-test-ipc-surface.ts @@ -22,8 +22,10 @@ export type TestStore = { getSparsePresets: StoreMock getSettings: StoreMock getWorktreeMeta: KeyedStoreMock + getWorktreeMetaForHost: StoreMock getAllWorktreeMeta: StoreMock setWorktreeMeta: KeyedStoreWriteMock + setWorktreeMetaForHost: StoreMock getProjectHostSetups: StoreMock removeWorktreeMeta: KeyedStoreMock removeWorkspaceSessionStateForWorktree: KeyedStoreMock @@ -54,8 +56,10 @@ export const store: TestStore = { getSparsePresets: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), + getWorktreeMetaForHost: vi.fn(), getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), + setWorktreeMetaForHost: vi.fn(), getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), removeWorkspaceSessionStateForWorktree: vi.fn(), diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index d4b3dde71dc..299c3690a1a 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -165,6 +165,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { getProjectHostSetups: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), + getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn(), addRetiredWorktreeName: vi.fn(), @@ -209,6 +210,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { store.getProjectHostSetups.mockReset() store.getSettings.mockReset() store.getWorktreeMeta.mockReset() + store.getAllWorktreeMeta.mockReset() store.setWorktreeMeta.mockReset() store.removeWorktreeMeta.mockReset() store.addRetiredWorktreeName.mockReset() @@ -251,6 +253,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { }) resolveSetupRunnerShellMock.mockReturnValue(undefined) store.getWorktreeMeta.mockReturnValue(undefined) + store.getAllWorktreeMeta.mockReturnValue({}) store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] }) store.setWorktreeMeta.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') @@ -501,6 +504,14 @@ describe('registerWorktreeHandlers – Windows path handling', () => { } : undefined ) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::C:/workspaces/improve-dashboard': { + lastActivityAt: 123, + displayName: 'Improve Dashboard', + linkedIssue: 123, + linkedPR: 456 + } + }) await handlers['worktrees:create'](null, { repoId: 'repo-1', diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts index 9a1bc65fc02..0d50cb2e565 100644 --- a/src/main/ipc/worktrees/create/folder-workspace-creation.ts +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import type { Repo } from '../../../../shared/repo-types' -import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup' import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types' import type { Store } from '../../../persistence/loading-store/store' import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index c3692d5c926..d855e582161 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -24,6 +24,8 @@ import { rememberLocalWorktreeRoots } from './detected-worktree-scan-cache' import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics' +import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' export async function listDetectedWorktreesForCapturedRepo( store: Store, @@ -36,8 +38,11 @@ export async function listDetectedWorktreesForCapturedRepo( providerAbort?.signal.aborted ? ({ providerAbortStatus: providerAbort.status() } as const) : undefined + const allMeta = isFolderRepo(repo) + ? undefined + : readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + ? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {})) : new Map() try { @@ -118,7 +123,7 @@ export async function listDetectedWorktreesForCapturedRepo( repoId: repo.id, authoritative: true, source: 'git', - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) + worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta) } } catch (err) { const aborted = abortedResult() diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index 50ce6082a42..0d47f3638c2 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -23,6 +23,7 @@ import { listDisconnectedSshWorktrees } from './ssh-worktree-fallback' import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { const { store } = context @@ -76,7 +77,10 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { ) ) } - const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + const metaIndex = createSshWorktreeMetaIndexForRepo( + readAllWorktreeMetaForHost(store, requestedExecutionHostId), + repo.id + ) return complete( buildDisconnectedDetectedWorktrees( store, @@ -111,7 +115,7 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { if (isFolderRepo(repo)) { return nothingForgotten } - const allMeta = store.getAllWorktreeMeta() + const allMeta = readAllWorktreeMetaForHost(store, requestedExecutionHostId) const forgottenWorktreeIds: string[] = [] for (const worktreeId of worktreeIds) { const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 64c28c08495..f2388b3ce43 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import { isFolderRepo } from '../../../../shared/repo-kind' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' @@ -21,6 +22,8 @@ import { warnOnce } from './worktree-listing-diagnostics' import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' const WORKTREE_LIST_ALL_CONCURRENCY = 8 @@ -49,9 +52,36 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo ipcMain.handle('worktrees:listAll', async () => { const repos = store.getRepos() - const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() + const legacyMetadata = + typeof store.getAllWorktreeMetaForHost === 'function' ? undefined : store.getAllWorktreeMeta() + const metadataByHost = new Map>() + const metadataForRepo = (repo: (typeof repos)[number]): Record => { + const hostId = getRepoExecutionHostId(repo) + const cached = metadataByHost.get(hostId) + if (cached) { + return cached + } + const metadata = + typeof store.getAllWorktreeMetaForHost === 'function' + ? store.getAllWorktreeMetaForHost(hostId) + : readAllWorktreeMetaForHost({ getAllWorktreeMeta: () => legacyMetadata ?? {} }, hostId) + metadataByHost.set(hostId, metadata) + return metadata + } + const sshMetaIndexByHost = new Map< + ExecutionHostId, + ReturnType + >() + const sshMetaIndexForRepo = (repo: (typeof repos)[number]) => { + const hostId = getRepoExecutionHostId(repo) + const cached = sshMetaIndexByHost.get(hostId) + if (cached) { + return cached + } + const index = createSshWorktreeMetaIndex(Object.entries(metadataForRepo(repo))) + sshMetaIndexByHost.set(hostId, index) + return index + } // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { @@ -68,7 +98,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo `${repo.connectionId}:${repo.id}`, `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo)) } loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) try { @@ -80,7 +110,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, err ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo)) } } else { const scan = await listDetectedGitWorktrees(store, repo) @@ -92,9 +122,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) + const metadata = metadataForRepo(repo) + return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) } catch (err) { warnOnce( loggedWorktreeListFailures, @@ -123,8 +154,11 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo if (!repo) { return [] } + const allMeta = repo.connectionId + ? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) + : undefined const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + ? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {})) : new Map() try { @@ -164,9 +198,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) + const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) + return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) } catch (err) { warnOnce( loggedWorktreeListFailures, diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index 26fb14f2341..7ec2cac1fc9 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -8,6 +8,11 @@ import type { Repo } from '../../../../shared/repo-types' import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../shared/worktree/types' import type { Store } from '../../../persistence/loading-store/store' import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + readWorktreeMetaForHost, + writeWorktreeMetaForHost +} from '../../../persistence/host-qualified-worktree-meta' +import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership' import { buildKnownOrcaWorkspaceLayouts, isLegacyRepoForExternalWorktreeVisibility, @@ -110,7 +115,7 @@ export function listDisconnectedSshWorktrees( ? { ...candidate.meta, ...ownershipUpdates } : candidate.meta if (Object.keys(ownershipUpdates).length > 0) { - store.setWorktreeMeta(candidate.id, ownershipUpdates) + writeWorktreeMetaForHost(store, candidate.id, expectedHostId, ownershipUpdates) } // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must @@ -131,7 +136,8 @@ export function listDisconnectedSshWorktrees( export function buildDetectedGitWorktrees( store: Store, repo: Repo, - gitWorktrees: GitWorktreeInfo[] + gitWorktrees: GitWorktreeInfo[], + allMetaOverride?: Record ): DetectedWorktree[] { const settings = store.getSettings() const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) @@ -145,9 +151,15 @@ export function buildDetectedGitWorktrees( resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), resolveConfiguredWorktreeBasePaths(repo) ) + const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.() + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length const detected = liveWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - let meta = store.getWorktreeMeta(worktreeId) + const legacyMeta = store.getWorktreeMeta?.(worktreeId) + const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}) + let meta = + readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ?? + getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) const detected = toDetectedWorktree({ repo, @@ -162,7 +174,13 @@ export function buildDetectedGitWorktrees( return detected } - meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) + meta = resolveWorktreeMetaWithDiscoveryBackfill( + store, + repo, + worktreeId, + allMeta, + repoOwnerCount + ) return toDetectedWorktree({ repo, worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), @@ -179,8 +197,9 @@ export function buildDetectedGitWorktrees( export function stampAndMergeVisibleDetectedWorktree( store: Store, repo: Repo, - detected: DetectedWorktree + detected: DetectedWorktree, + allMetaOverride?: Record ) { - const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id, allMetaOverride) return mergeWorktree(repo.id, detected, meta, repo.displayName) } diff --git a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts index c96e8dc2978..6af4d45c3cd 100644 --- a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts +++ b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts @@ -1,7 +1,13 @@ import type { Store } from '../../../persistence/loading-store/store' import type { Repo } from '../../../../shared/repo-types' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + readWorktreeMetaForHost, + writeWorktreeMetaForHost +} from '../../../persistence/host-qualified-worktree-meta' +import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership' import { randomUUID } from 'node:crypto' export function getProjectHostSetupMetaUpdates( @@ -29,9 +35,21 @@ export function getProjectHostSetupMetaUpdates( export function resolveWorktreeMetaWithDiscoveryBackfill( store: Store, repo: Repo, - worktreeId: string + worktreeId: string, + allMetaOverride?: Record, + repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length ): WorktreeMeta { - const existing = store.getWorktreeMeta(worktreeId) + const executionHostId = getRepoExecutionHostId(repo) + const legacyMeta = store.getWorktreeMeta?.(worktreeId) + const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.() + const existing = + readWorktreeMetaForHost(store, worktreeId, executionHostId) ?? + getRepoOwnedWorktreeMeta( + repo, + worktreeId, + allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}), + repoOwnerCount + ) const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) if (existing) { const updates = { @@ -40,11 +58,11 @@ export function resolveWorktreeMetaWithDiscoveryBackfill( } if (Object.keys(updates).length > 0) { // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. - return store.setWorktreeMeta(worktreeId, updates) + return writeWorktreeMetaForHost(store, worktreeId, executionHostId, updates) } return existing } - return store.setWorktreeMeta(worktreeId, { + return writeWorktreeMetaForHost(store, worktreeId, executionHostId, { lastActivityAt: Date.now(), ...ownershipUpdates }) diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts index 97d175a70df..359bd87f67d 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { parseExecutionHostId } from '../../../../shared/execution-host' import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { @@ -16,10 +17,23 @@ import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void { const { mainWindow, store, runtime } = context - ipcMain.handle( 'worktrees:updateMeta', - (_event, args: { worktreeId: string; updates: Partial }) => { + ( + _event, + args: { + worktreeId: string + executionHostId?: string + updates: Partial + } + ) => { + const executionHostId = + args.executionHostId === undefined + ? undefined + : parseExecutionHostId(args.executionHostId)?.id + if (args.executionHostId !== undefined && !executionHostId) { + throw new Error('Invalid execution host identity.') + } const validatedUpdates = normalizeLinkedWorkItemFields(args.updates) const updates = validatedUpdates.displayName !== undefined @@ -29,7 +43,10 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v firstAgentMessageRenameError: null } : validatedUpdates - const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates)) + const sanitizedUpdates = stripOrcaProvenanceMetaUpdates(updates) + const meta = executionHostId + ? store.setWorktreeMetaForHost(args.worktreeId, executionHostId, sanitizedUpdates) + : store.setWorktreeMeta(args.worktreeId, sanitizedUpdates) // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). if (args.updates.displayName !== undefined) { // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index e27287c147e..53b759a7d01 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -395,6 +395,12 @@ describe('Store', () => { // Local worktree meta survives; the SSH host's meta is pruned. expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined() expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() + store.flush() + const persisted = readDataFile() as PersistedState + const identityMetadata = Object.values(persisted.worktreeMetaByIdentity ?? {}) + expect(identityMetadata).toEqual([ + expect.objectContaining({ hostId: 'local', displayName: 'local-wt' }) + ]) }) it('removeProjectForHost keeps the surviving host session for a shared repo id + path', async () => { diff --git a/src/main/persistence/host-qualified-worktree-meta.ts b/src/main/persistence/host-qualified-worktree-meta.ts new file mode 100644 index 00000000000..a9d1c0e8fb1 --- /dev/null +++ b/src/main/persistence/host-qualified-worktree-meta.ts @@ -0,0 +1,65 @@ +import type { ExecutionHostId } from '../../shared/execution-host' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +/** + * The listing paths take partial store shapes (`Pick`), so the + * host-qualified accessors are optional here and fall back to the locator-keyed + * ones. A real Store always has them. + */ +export type HostQualifiedWorktreeMetaStore = { + getAllWorktreeMeta: () => Record + getWorktreeMetaForHost?: ( + worktreeId: string, + executionHostId: ExecutionHostId + ) => WorktreeMeta | undefined + getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record + setWorktreeMeta: (worktreeId: string, meta: Partial) => WorktreeMeta + setWorktreeMetaForHost?: ( + worktreeId: string, + executionHostId: ExecutionHostId, + meta: Partial + ) => WorktreeMeta +} + +/** Canonical host snapshot, with a filtered legacy fallback for partial test/compatibility stores. */ +export function readAllWorktreeMetaForHost( + store: Pick, + executionHostId: ExecutionHostId +): Record { + const qualified = store.getAllWorktreeMetaForHost?.(executionHostId) + if (qualified) { + return qualified + } + const projected: Record = {} + for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + if (!meta.hostId || meta.hostId === executionHostId) { + projected[worktreeId] = meta + } + } + return projected +} + +/** + * The row this host owns at `worktreeId`, or undefined. Deliberately does NOT fall back to the + * locator-keyed read: that would hand one host's metadata to another's row, which is the collision + * every caller here is trying to avoid. Callers keep their own same-id ownership guard as fallback. + */ +export function readWorktreeMetaForHost( + store: Pick, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeMeta | undefined { + return store.getWorktreeMetaForHost?.(worktreeId, executionHostId) +} + +export function writeWorktreeMetaForHost( + store: Pick, + worktreeId: string, + executionHostId: ExecutionHostId, + meta: Partial +): WorktreeMeta { + return ( + store.setWorktreeMetaForHost?.(worktreeId, executionHostId, meta) ?? + store.setWorktreeMeta(worktreeId, { ...meta, hostId: executionHostId }) + ) +} diff --git a/src/main/persistence/leasing-ssh-ptys/canonical-worktree-host-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/canonical-worktree-host-reassignment.ts new file mode 100644 index 00000000000..f9d3228d924 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/canonical-worktree-host-reassignment.ts @@ -0,0 +1,128 @@ +import { randomUUID } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' +import { + composeWorktreeHostIdentity, + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeIdFromHostIdentity +} from '../../../shared/worktree/host-qualified-identity' + +type IdentityMove = { + sourceKey: string + nextKey: string + instanceId: string + sourceMeta: WorktreeMeta + nextMeta: WorktreeMeta +} + +type AliasMove = { + oldAlias: string + nextAlias: string + identities: IdentityMove[] +} + +/** Move canonical aliases only after every destination occupant is proven compatible. */ +export function reassignCanonicalWorktreeMetadataHost( + state: PersistedState, + oldHostId: ExecutionHostId, + newHostId: ExecutionHostId +): { changed: boolean; preservedWorktreeIds: ReadonlySet } { + const aliases = state.worktreeIdentityAliases ?? {} + const identityMeta = state.worktreeMetaByIdentity ?? {} + const repairedInstanceIds = new Map() + const plans: AliasMove[] = [] + + for (const [oldAlias, rawKeys] of Object.entries(aliases)) { + if (getExecutionHostIdFromWorktreeHostIdentity(oldAlias) !== oldHostId) { + continue + } + const worktreeId = getWorktreeIdFromHostIdentity(oldAlias) + if (!worktreeId || rawKeys.length === 0) { + continue + } + const identities: IdentityMove[] = [] + let safe = true + for (const sourceKey of new Set(rawKeys)) { + const sourceMeta = identityMeta[sourceKey] + if (!sourceMeta) { + safe = false + break + } + let instanceId = sourceMeta.instanceId ?? repairedInstanceIds.get(sourceKey) + if (instanceId === undefined) { + instanceId = randomUUID() + repairedInstanceIds.set(sourceKey, instanceId) + } + const nextKey = canonicalWorktreeIdentity({ + worktreeId, + executionHostId: newHostId, + instanceId + }) + const nextMeta = { ...sourceMeta, instanceId, hostId: newHostId } + const duplicate = identities.find((identity) => identity.nextKey === nextKey) + if (duplicate && !isDeepStrictEqual(duplicate.nextMeta, nextMeta)) { + safe = false + break + } + if (!duplicate) { + identities.push({ sourceKey, nextKey, instanceId, sourceMeta, nextMeta }) + } + } + if (!safe) { + continue + } + + const nextAlias = composeWorktreeHostIdentity(newHostId, worktreeId) + const candidateKeys = new Set(identities.map((identity) => identity.nextKey)) + if ((aliases[nextAlias] ?? []).some((key) => !candidateKeys.has(key))) { + continue + } + if ( + identities.some((identity) => { + const existing = identityMeta[identity.nextKey] + return existing !== undefined && !isDeepStrictEqual(existing, identity.nextMeta) + }) + ) { + continue + } + plans.push({ oldAlias, nextAlias, identities }) + } + + if (plans.length > 0) { + state.worktreeIdentityAliases ??= {} + state.worktreeMetaByIdentity ??= {} + for (const plan of plans) { + for (const identity of plan.identities) { + identity.sourceMeta.instanceId ??= identity.instanceId + state.worktreeMetaByIdentity[identity.nextKey] ??= identity.nextMeta + } + state.worktreeIdentityAliases[plan.nextAlias] = [ + ...new Set([ + ...(state.worktreeIdentityAliases[plan.nextAlias] ?? []), + ...plan.identities.map((identity) => identity.nextKey) + ]) + ] + delete state.worktreeIdentityAliases[plan.oldAlias] + } + + const referenced = new Set(Object.values(state.worktreeIdentityAliases).flat()) + for (const sourceKey of new Set( + plans.flatMap((plan) => plan.identities.map((identity) => identity.sourceKey)) + )) { + if (!referenced.has(sourceKey)) { + delete state.worktreeMetaByIdentity[sourceKey] + } + } + } + + const preservedWorktreeIds = new Set() + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + if (getExecutionHostIdFromWorktreeHostIdentity(alias) === oldHostId) { + preservedWorktreeIds.add(getWorktreeIdFromHostIdentity(alias)) + } + } + return { changed: plans.length > 0, preservedWorktreeIds } +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index 919e4996486..c996dc445f2 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -17,6 +17,7 @@ import { migrateAutomationsForSshReadoption } from '../../automations/automation-ssh-readoption-migration' import { automationIdsPinnedToSshTarget } from '../scheduling-automations/automation-owner-projection' +import { reassignCanonicalWorktreeMetadataHost } from './canonical-worktree-host-reassignment' export type SshTargetReassignmentOperations = { state: PersistedState @@ -80,10 +81,16 @@ export function reassignSshTargetId( } repoIds.add(repo.id) } - // Re-point worktree metas whose hostId pointed at the old SSH host. + // Legacy locator rows cannot recover canonical-only metadata after target readoption. + const identityResult = reassignCanonicalWorktreeMetadataHost( + operations.state, + oldHostId, + newHostId + ) + // Re-point legacy rows unless a conflicting destination kept their canonical source in place. let metaChanged = false - for (const meta of Object.values(operations.state.worktreeMeta)) { - if (meta.hostId === oldHostId) { + for (const [worktreeId, meta] of Object.entries(operations.state.worktreeMeta)) { + if (meta.hostId === oldHostId && !identityResult.preservedWorktreeIds.has(worktreeId)) { meta.hostId = newHostId metaChanged = true } @@ -175,7 +182,13 @@ export function reassignSshTargetId( if (repoIds.size > 0 || setupsChanged) { operations.syncProjectHostSetupCompatibilityState() } - if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) { + if ( + repoIds.size > 0 || + metaChanged || + carrierChanged || + setupsChanged || + identityResult.changed + ) { // The rewrites above patch rows in place; the list projection caches on array // identity, so a same-identity array would keep serving pre-readoption owners. operations.state.repos = [...operations.state.repos] diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 056991dd1f4..f12af5d1583 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -1,45 +1,14 @@ -import { randomUUID } from 'node:crypto' import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' -import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import type { ExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' -import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { workspaceSessionOwnerPartitionForHost, workspaceSessionPartitionIdsForHost } from '../restoring-sessions/session-owner-removal' -import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' - -function getDefaultWorktreeMeta(): WorktreeMeta { - return { - instanceId: randomUUID(), - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null, - linkedWorkItem: null, - linkedTaskSourceContext: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: Date.now(), - lastActivityAt: 0, - workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID - } -} - import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import type { SessionHostPartitionOperations } from './session-host-partitions' @@ -50,6 +19,15 @@ import { partitionHasOtherRepoWorktreeTabs, removeWorkspaceSessionOwnerInPartition } from './session-host-partitions' +import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' +import { + getAllWorktreeMetaForHost as getAllWorktreeMetaForHostOperation, + getWorktreeMetaForHost as getWorktreeMetaForHostOperation, + migrateWorktreeMetadataLocator, + removeWorktreeMetadataForHost, + setWorktreeMetaForHost as setWorktreeMetaForHostOperation +} from './worktree-identity-metadata' +import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' type MetadataLineageOperationsRuntime = Pick @@ -70,34 +48,62 @@ export class MetadataLineageOperations { ) { this[metadataLineageOperationsContext] = { runtime, scheduling, sessions } } - getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { return this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] } + getWorktreeMetaForHost( + worktreeId: string, + executionHostId: ExecutionHostId + ): WorktreeMeta | undefined { + return getWorktreeMetaForHostOperation( + this[metadataLineageOperationsContext].runtime, + this[metadataLineageOperationsContext].scheduling, + worktreeId, + executionHostId + ) + } + + setWorktreeMetaForHost( + worktreeId: string, + executionHostId: ExecutionHostId, + meta: Partial + ): WorktreeMeta { + return setWorktreeMetaForHostOperation( + this[metadataLineageOperationsContext].runtime, + this[metadataLineageOperationsContext].scheduling, + worktreeId, + executionHostId, + meta + ) + } + getAllWorktreeMeta(): Record { return this[metadataLineageOperationsContext].runtime.state.worktreeMeta } + getAllWorktreeMetaForHost(executionHostId: ExecutionHostId): Record { + return getAllWorktreeMetaForHostOperation( + this[metadataLineageOperationsContext].runtime, + executionHostId + ) + } + setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { - const existing = - this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] || - getDefaultWorktreeMeta() - const updated = { ...existing, ...meta } - updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updated.linkedTaskSourceContext - ) - updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - updated.linkedWorkItem, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null - if (!updated.instanceId) { - updated.instanceId = randomUUID() + const state = this[metadataLineageOperationsContext].runtime.state + const stored = state.worktreeMeta[worktreeId] + const executionHostId = meta.hostId ?? stored?.hostId + if (executionHostId) { + return setWorktreeMetaForHostOperation( + this[metadataLineageOperationsContext].runtime, + this[metadataLineageOperationsContext].scheduling, + worktreeId, + executionHostId, + meta + ) } - this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] = updated + const updated = mergeWorktreeMetaForWrite(stored, meta) + state.worktreeMeta[worktreeId] = updated scheduleSave(this[metadataLineageOperationsContext].scheduling) return updated } @@ -120,6 +126,13 @@ export class MetadataLineageOperations { ownerPartition )) ) + // Mirror the legacy delete's scope below: a full removal takes every host's identity rows, + // otherwise the surviving owner keeps its own. + removeWorktreeMetadataForHost( + this[metadataLineageOperationsContext].runtime.state, + worktreeId, + hostId === undefined ? undefined : (hostId ?? undefined) + ) // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. const partitions = new Set( workspaceSessionPartitionIdsForHost(owner).filter( @@ -190,14 +203,27 @@ export class MetadataLineageOperations { scheduleSave(this[metadataLineageOperationsContext].scheduling) } - migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { - if ( - migrateWorktreeIdentityOperation( - this[metadataLineageOperationsContext].runtime.state, - oldWorktreeId, - newWorktreeId - ) - ) { + migrateWorktreeIdentity( + oldWorktreeId: string, + newWorktreeId: string, + executionHostId?: ExecutionHostId + ): void { + const state = this[metadataLineageOperationsContext].runtime.state + const persistedOwner = state.worktreeMeta[oldWorktreeId]?.hostId + const mover = executionHostId ?? persistedOwner ?? LOCAL_EXECUTION_HOST_ID + const legacyChanged = + executionHostId !== undefined && + persistedOwner !== undefined && + persistedOwner !== executionHostId + ? false + : migrateWorktreeIdentityOperation(state, oldWorktreeId, newWorktreeId) + const canonicalChanged = migrateWorktreeMetadataLocator( + state, + oldWorktreeId, + newWorktreeId, + mover + ) + if (legacyChanged || canonicalChanged) { scheduleSave(this[metadataLineageOperationsContext].scheduling) } } diff --git a/src/main/persistence/loading-store/worktree-identity-metadata.ts b/src/main/persistence/loading-store/worktree-identity-metadata.ts new file mode 100644 index 00000000000..396c2ffae85 --- /dev/null +++ b/src/main/persistence/loading-store/worktree-identity-metadata.ts @@ -0,0 +1,236 @@ +import { randomUUID } from 'node:crypto' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' +import { + composeWorktreeHostIdentity, + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeIdFromHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' +import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' + +type MetadataRuntime = Pick + +/** Select one readable row without discarding competing alias candidates. */ +function resolveAliasIdentityKey(state: PersistedState, alias: string): string | undefined { + const identityKeys = state.worktreeIdentityAliases?.[alias] ?? [] + if (identityKeys.length === 0) { + return undefined + } + const resolvable = identityKeys.filter((key) => state.worktreeMetaByIdentity?.[key]) + const candidates = resolvable.length > 0 ? resolvable : identityKeys + // Newest activity wins, then the greater key, so every host agrees on the survivor. + const winner = candidates.reduce((best, key) => { + const bestTouch = state.worktreeMetaByIdentity?.[best]?.lastActivityAt ?? 0 + const keyTouch = state.worktreeMetaByIdentity?.[key]?.lastActivityAt ?? 0 + if (keyTouch !== bestTouch) { + return keyTouch > bestTouch ? key : best + } + return key > best ? key : best + }) + return winner +} + +/** Drop identity metadata no alias points at any more. */ +export function pruneUnreferencedWorktreeIdentityMeta(state: PersistedState): boolean { + const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat()) + let changed = false + for (const identityKey of Object.keys(state.worktreeMetaByIdentity ?? {})) { + if (!referenced.has(identityKey)) { + delete state.worktreeMetaByIdentity?.[identityKey] + changed = true + } + } + return changed +} + +function migrateLegacyWorktreeMetadata( + state: PersistedState, + worktreeId: string, + executionHostId: ExecutionHostId +): boolean { + const meta = state.worktreeMeta[worktreeId] + if (!meta || (meta.hostId !== undefined && meta.hostId !== executionHostId)) { + return false + } + state.worktreeMetaByIdentity ??= {} + state.worktreeIdentityAliases ??= {} + let changed = false + const instanceId = meta.instanceId ?? randomUUID() + if (!meta.instanceId) { + meta.instanceId = instanceId + changed = true + } + if (!meta.hostId) { + meta.hostId = executionHostId + changed = true + } + const identityKey = canonicalWorktreeIdentity({ + worktreeId, + executionHostId, + instanceId + }) + if (!state.worktreeMetaByIdentity[identityKey]) { + state.worktreeMetaByIdentity[identityKey] = { + ...meta, + instanceId, + hostId: executionHostId + } + changed = true + } + const alias = composeWorktreeHostIdentity(executionHostId, worktreeId) + const aliases = state.worktreeIdentityAliases[alias] ?? [] + if (aliases.length === 0) { + state.worktreeIdentityAliases[alias] = [identityKey] + changed = true + } + return changed +} + +/** Drop the identity rows a locator owns, for one host or for every host. */ +export function removeWorktreeMetadataForHost( + state: PersistedState, + worktreeId: string, + executionHostId: ExecutionHostId | undefined +): boolean { + let changed = false + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + if (getWorktreeIdFromHostIdentity(alias) !== worktreeId) { + continue + } + if ( + executionHostId !== undefined && + getExecutionHostIdFromWorktreeHostIdentity(alias) !== executionHostId + ) { + continue + } + delete state.worktreeIdentityAliases?.[alias] + changed = true + } + return pruneUnreferencedWorktreeIdentityMeta(state) || changed +} + +/** + * Re-point one host's alias at a renamed locator. Host-scoped on purpose: a local + * folder move must not drag a remote host's alias to a path that host does not have. + */ +export function migrateWorktreeMetadataLocator( + state: PersistedState, + oldWorktreeId: string, + newWorktreeId: string, + executionHostId: ExecutionHostId +): boolean { + if (oldWorktreeId === newWorktreeId) { + return false + } + const oldAlias = composeWorktreeHostIdentity(executionHostId, oldWorktreeId) + const identityKeys = state.worktreeIdentityAliases?.[oldAlias] + if (!identityKeys || identityKeys.length === 0) { + return false + } + const newAlias = composeWorktreeHostIdentity(executionHostId, newWorktreeId) + state.worktreeIdentityAliases ??= {} + // A taken destination keeps its own occupant; stranding the mover at the old locator loses less + // than merging (which makes both unreadable) or dropping its row outright. + if ((state.worktreeIdentityAliases[newAlias] ?? []).length > 0) { + return false + } + state.worktreeIdentityAliases[newAlias] = [...identityKeys] + delete state.worktreeIdentityAliases[oldAlias] + return true +} +export function getWorktreeMetaForHost( + runtime: MetadataRuntime, + scheduling: WriteSchedulingOperations, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeMeta | undefined { + const state = runtime.state + const changed = migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId) + const alias = composeWorktreeHostIdentity(executionHostId, worktreeId) + const identityKey = resolveAliasIdentityKey(state, alias) + if (changed) { + scheduleSave(scheduling) + } + if (identityKey) { + return state.worktreeMetaByIdentity?.[identityKey] + } + const legacy = state.worktreeMeta[worktreeId] + return !legacy?.hostId || legacy.hostId === executionHostId ? legacy : undefined +} + +/** Read-only host projection used by listings that must include canonical-only rows. */ +export function getAllWorktreeMetaForHost( + runtime: MetadataRuntime, + executionHostId: ExecutionHostId +): Record { + const state = runtime.state + const projected: Record = {} + for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) { + if (!meta.hostId || meta.hostId === executionHostId) { + projected[worktreeId] = meta + } + } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== executionHostId) { + continue + } + const worktreeId = getWorktreeIdFromHostIdentity(alias) + const identityKey = resolveAliasIdentityKey(state, alias) + const meta = identityKey ? state.worktreeMetaByIdentity?.[identityKey] : undefined + if (!worktreeId || !meta || (meta.hostId && meta.hostId !== executionHostId)) { + continue + } + projected[worktreeId] = + meta.hostId === executionHostId ? meta : { ...meta, hostId: executionHostId } + } + return projected +} + +export function setWorktreeMetaForHost( + runtime: MetadataRuntime, + scheduling: WriteSchedulingOperations, + worktreeId: string, + executionHostId: ExecutionHostId, + meta: Partial +): WorktreeMeta { + const state = runtime.state + migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId) + const alias = composeWorktreeHostIdentity(executionHostId, worktreeId) + const identityKeys = state.worktreeIdentityAliases?.[alias] ?? [] + if (identityKeys.length > 1 && meta.instanceId === undefined) { + throw new Error('Worktree identity is ambiguous for this host and locator.') + } + const existingIdentityKey = resolveAliasIdentityKey(state, alias) + const existingIdentityMeta = existingIdentityKey + ? state.worktreeMetaByIdentity?.[existingIdentityKey] + : undefined + const legacy = state.worktreeMeta[worktreeId] + const existing = + existingIdentityMeta ?? + (!legacy?.hostId || legacy.hostId === executionHostId ? legacy : undefined) + // An explicit instanceId is a deliberate rotation (see worktree-lineage-pruning), so it wins. + const instanceId = meta.instanceId ?? existing?.instanceId ?? randomUUID() + const identityKey = canonicalWorktreeIdentity({ worktreeId, executionHostId, instanceId }) + const updated = mergeWorktreeMetaForWrite(existing, meta, { + instanceId, + hostId: executionHostId + }) + state.worktreeMetaByIdentity ??= {} + state.worktreeIdentityAliases ??= {} + if (existingIdentityKey && existingIdentityKey !== identityKey) { + delete state.worktreeMetaByIdentity[existingIdentityKey] + } + state.worktreeMetaByIdentity[identityKey] = updated + state.worktreeIdentityAliases[alias] = [identityKey] + // Keep the legacy projection only for the first known owner. + if (!legacy || legacy.hostId === executionHostId) { + state.worktreeMeta[worktreeId] = updated + } + scheduleSave(scheduling) + return updated +} diff --git a/src/main/persistence/loading-store/worktree-meta-write-normalization.ts b/src/main/persistence/loading-store/worktree-meta-write-normalization.ts new file mode 100644 index 00000000000..e2b8f338bdd --- /dev/null +++ b/src/main/persistence/loading-store/worktree-meta-write-normalization.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' +import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' + +type WorktreeMetaIdentity = { + instanceId: string + hostId: ExecutionHostId +} + +function createDefaultWorktreeMeta(): WorktreeMeta { + return { + instanceId: randomUUID(), + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: Date.now(), + lastActivityAt: 0, + workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID + } +} + +/** Merge and normalize the metadata shape shared by legacy and identity-keyed writes. */ +export function mergeWorktreeMetaForWrite( + existing: WorktreeMeta | undefined, + updates: Partial, + identity?: WorktreeMetaIdentity +): WorktreeMeta { + const updated = { ...(existing ?? createDefaultWorktreeMeta()), ...updates, ...identity } + updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) + const sourceContext = normalizeStoredTaskSourceContext(updated.linkedTaskSourceContext) + updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + updated.linkedWorkItem, + sourceContext + ) + ? sourceContext + : null + updated.instanceId ||= randomUUID() + return updated +} diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index 7032ef2ed60..f6d17b24aa2 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -4,9 +4,11 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { PersistedState } from '../../../shared/persisted-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { + getExecutionHostIdFromWorktreeHostIdentity, getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata' export function pruneWorktreeStateForRepo( state: PersistedState, @@ -67,6 +69,14 @@ export function pruneWorktreeStateForRepo( delete state.worktreeMeta[key] } } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + const rawId = getWorktreeIdFromHostIdentity(alias) + const aliasHost = getExecutionHostIdFromWorktreeHostIdentity(alias) + if (rawId.startsWith(prefix) && (hostId === null || aliasHost === hostId)) { + delete state.worktreeIdentityAliases?.[alias] + } + } + pruneUnreferencedWorktreeIdentityMeta(state) // Why: a host-scoped prune must touch only that host's session (legacy blob = local, one partition // per remote host); pruning every partition would wipe a surviving host's tabs and sleeping agents // for a shared repo id/path. A full removal (hostId === null) still clears every host. diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts index 5886ebb7196..2bb4fe2eec9 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import type { PersistedState } from '../../../shared/persisted-state-types' -import { normalizeWorktreeLinkedItemMetadata } from './worktree-metadata-normalization' +import { + gcStaleWorktreeMeta, + normalizeWorktreeLinkedItemMetadata, + WORKTREE_META_GC_GRACE_MS +} from './worktree-metadata-normalization' // Only the presence of an entry matters here; the normalizer never reads its linked-item fields. function makeMeta(): WorktreeMeta { @@ -38,6 +42,99 @@ describe('normalizeWorktreeLinkedItemMetadata', () => { expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) expect(state.workspaceLineageByChildKey).toEqual({}) }) + it('repairs malformed canonical metadata and alias maps', () => { + const state = makeState({ + worktreeMetaByIdentity: null as unknown as PersistedState['worktreeMetaByIdentity'], + worktreeIdentityAliases: { + 'local|r1::/tmp/wt': ['wt2:local:one', 'wt2:local:one', '', 42] + } as unknown as PersistedState['worktreeIdentityAliases'] + }) + + expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) + expect(state.worktreeMetaByIdentity).toEqual({}) + // A key with no backing row is what turns the next write into an ambiguous alias, so it goes. + expect(state.worktreeIdentityAliases).toEqual({}) + }) + + it('keeps alias keys that still resolve to a metadata row', () => { + const state = makeState({ + worktreeMetaByIdentity: { 'wt2:local:one': makeMeta() }, + worktreeIdentityAliases: { 'local|r1::/tmp/wt': ['wt2:local:one'] } + }) + + expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(false) + expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/wt': ['wt2:local:one'] }) + }) + + it('drops malformed canonical metadata entries', () => { + const state = makeState({ + worktreeMetaByIdentity: { + valid: makeMeta(), + malformed: null + } as unknown as PersistedState['worktreeMetaByIdentity'], + worktreeIdentityAliases: { 'local|r1::/tmp/wt': ['valid', 'malformed'] } + }) + + expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) + expect(state.worktreeMetaByIdentity).toEqual({ valid: makeMeta() }) + expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/wt': ['valid'] }) + }) + + it('preserves every host canonical row when one legacy locator row is malformed', () => { + const localKey = 'wt2:local:local-instance' + const remoteKey = 'wt2:ssh%3Abuilder:remote-instance' + const worktreeId = 'r1::/tmp/wt' + const state = makeState({ + worktreeMeta: { [worktreeId]: null } as unknown as PersistedState['worktreeMeta'], + worktreeMetaByIdentity: { [localKey]: makeMeta(), [remoteKey]: makeMeta() }, + worktreeIdentityAliases: { + [`local|${worktreeId}`]: [localKey], + [`ssh:builder|${worktreeId}`]: [remoteKey] + } + }) + + expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) + expect(state.worktreeMeta).toEqual({}) + expect(state.worktreeMetaByIdentity).toEqual({ + [localKey]: makeMeta(), + [remoteKey]: makeMeta() + }) + expect(state.worktreeIdentityAliases).toEqual({ + [`local|${worktreeId}`]: [localKey], + [`ssh:builder|${worktreeId}`]: [remoteKey] + }) + }) + + it('preserves valid canonical state when the whole legacy projection is malformed', () => { + const identityKey = 'wt2:ssh%3Abuilder:remote-instance' + const alias = 'ssh:builder|r1::/tmp/wt' + const state = makeState({ + worktreeMeta: null as unknown as PersistedState['worktreeMeta'], + worktreeMetaByIdentity: { [identityKey]: makeMeta() }, + worktreeIdentityAliases: { [alias]: [identityKey] } + }) + + expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) + expect(state.worktreeMeta).toEqual({}) + expect(state.worktreeMetaByIdentity).toEqual({ [identityKey]: makeMeta() }) + expect(state.worktreeIdentityAliases).toEqual({ [alias]: [identityKey] }) + }) + + it('prunes canonical metadata left unreachable by dangling-alias cleanup', () => { + const liveKey = 'wt2:local:live' + const orphanKey = 'wt2:local:orphan' + const state = makeState({ + worktreeMetaByIdentity: { [liveKey]: makeMeta(), [orphanKey]: makeMeta() }, + worktreeIdentityAliases: { + 'local|r1::/tmp/live': [liveKey], + 'local|r1::/tmp/dangling': ['missing-key'] + } + }) + + expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) + expect(state.worktreeMetaByIdentity).toEqual({ [liveKey]: makeMeta() }) + expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/live': [liveKey] }) + }) it('leaves already-normalized state clean', () => { const state = makeState({ @@ -47,3 +144,36 @@ describe('normalizeWorktreeLinkedItemMetadata', () => { expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(false) }) }) + +describe('gcStaleWorktreeMeta', () => { + it('reclaims the identity rows of a collected worktree', () => { + const worktreeId = 'r1::/definitely/missing/orca/path' + const identityKey = 'wt2:local:dead' + const remoteIdentityKey = 'wt2:ssh:live' + const state = makeState({ + repos: [], + projects: [], + worktreeMeta: { + [worktreeId]: { + hostId: 'local', + instanceId: 'dead', + displayName: 'dead', + lastActivityAt: Date.now() - WORKTREE_META_GC_GRACE_MS - 1 + } as unknown as WorktreeMeta + }, + worktreeMetaByIdentity: { [identityKey]: makeMeta(), [remoteIdentityKey]: makeMeta() }, + worktreeIdentityAliases: { + [`local|${worktreeId}`]: [identityKey], + [`ssh:conn-1|${worktreeId}`]: [remoteIdentityKey] + } + }) + + expect(gcStaleWorktreeMeta(state)).toBe(1) + // A surviving alias would re-attach this dead metadata to a worktree later + // created at the same repoId::path, and nothing else ever reclaims it. + expect(state.worktreeMetaByIdentity).toEqual({ [remoteIdentityKey]: expect.anything() }) + expect(state.worktreeIdentityAliases).toEqual({ + [`ssh:conn-1|${worktreeId}`]: [remoteIdentityKey] + }) + }) +}) diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index 81c6e94843e..2d5d5a647ed 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -15,6 +15,11 @@ import { } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' import type { PersistedState } from '../../../shared/persisted-state-types' +import { + pruneUnreferencedWorktreeIdentityMeta, + removeWorktreeMetadataForHost +} from '../loading-store/worktree-identity-metadata' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. @@ -75,11 +80,35 @@ export function gcStaleWorktreeMeta(state: PersistedState): number { delete state.worktreeMeta[key] delete state.worktreeLineageById[key] delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + // Identity rows are companions too: a surviving alias would re-attach this dead metadata to a + // worktree later created at the same repoId::path, and nothing else ever reclaims them. + removeWorktreeMetadataForHost(state, key, LOCAL_EXECUTION_HOST_ID) removed++ } return removed } +function normalizeLinkedMetadata(meta: WorktreeMeta): boolean { + const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) + const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) + const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + linkedWorkItem, + sourceContext + ) + ? sourceContext + : null + let changed = false + if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { + meta.linkedWorkItem = linkedWorkItem + changed = true + } + if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) { + meta.linkedTaskSourceContext = linkedTaskSourceContext + changed = true + } + return changed +} + export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { // Why: a hand-corrupted null lineage map would throw on the companion deletes below. The repair // itself is dirty state — without it the map stays null on disk and is repaired again every load. @@ -98,8 +127,7 @@ export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): bool Object.keys(state.worktreeLineageById).length > 0 || Object.keys(state.workspaceLineageByChildKey).length > 0 state.worktreeMeta = {} - // Companions go with the discarded map; a stranded lineage row would otherwise re-attach to a - // worktree recreated at the same repoId::path. + // Legacy lineage belongs to the discarded locator projection; canonical host state does not. state.worktreeLineageById = {} state.workspaceLineageByChildKey = {} changed ||= rawWorktreeMeta !== undefined || hadLineage @@ -109,29 +137,74 @@ export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): bool // keeps timestamp-less keys forever and every downstream consumer trusts the Record type. if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { delete state.worktreeMeta[key] - // Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would - // otherwise re-attach to a worktree recreated at the same repoId::path. + // Legacy lineage belongs to this corrupt locator row; canonical host state is independent. delete state.worktreeLineageById[key] delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] changed = true continue } - const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) - const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) - const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - linkedWorkItem, - sourceContext - ) - ? sourceContext - : null - if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { - meta.linkedWorkItem = linkedWorkItem + changed = normalizeLinkedMetadata(meta) || changed + } + const rawIdentityMetadata = state.worktreeMetaByIdentity as unknown + if ( + rawIdentityMetadata !== undefined && + (typeof rawIdentityMetadata !== 'object' || + rawIdentityMetadata === null || + Array.isArray(rawIdentityMetadata)) + ) { + state.worktreeMetaByIdentity = {} + changed = true + } + for (const [identityKey, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + delete state.worktreeMetaByIdentity?.[identityKey] changed = true + continue } - if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) { - meta.linkedTaskSourceContext = linkedTaskSourceContext + changed = normalizeLinkedMetadata(meta) || changed + } + + const rawAliases = state.worktreeIdentityAliases as unknown + const aliasesAreObject = + typeof rawAliases === 'object' && rawAliases !== null && !Array.isArray(rawAliases) + const aliasesAreMalformed = rawAliases !== undefined && !aliasesAreObject + const aliasesCanProveReachability = aliasesAreObject && Object.keys(rawAliases).length > 0 + if (aliasesAreMalformed) { + state.worktreeIdentityAliases = {} + changed = true + } + for (const [alias, rawIdentityKeys] of Object.entries(state.worktreeIdentityAliases ?? {})) { + if (!Array.isArray(rawIdentityKeys)) { + delete state.worktreeIdentityAliases?.[alias] + changed = true + continue + } + // Drop keys with no backing row: a dangling key is what turns the next write into an + // ambiguous alias, and nothing downstream can resolve it anyway. + const identityKeys = [ + ...new Set( + rawIdentityKeys.filter( + (key): key is string => + typeof key === 'string' && + key.length > 0 && + Boolean(state.worktreeMetaByIdentity?.[key]) + ) + ) + ] + if (identityKeys.length === 0) { + delete state.worktreeIdentityAliases?.[alias] + changed = true + } else if ( + identityKeys.length !== rawIdentityKeys.length || + identityKeys.some((key, index) => key !== rawIdentityKeys[index]) + ) { + state.worktreeIdentityAliases ??= {} + state.worktreeIdentityAliases[alias] = identityKeys changed = true } } + if (aliasesCanProveReachability) { + changed = pruneUnreferencedWorktreeIdentityMeta(state) || changed + } return changed } diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index dd74f6acbed..6a12c63b4a2 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -15,7 +15,7 @@ import { } from '../shared/ephemeral-vm-recipes' import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store' import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' -import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-projection' +import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup' import { isTuiAgent } from '../shared/tui-agent-config' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f83e5e777e4..495045821da 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -538,11 +538,11 @@ import { splitWorktreeIdForFilesystem, worktreeIdComparisonKey } from '../../shared/worktree/id' +import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection' import { - getProjectIdForProviderIdentity, getProjectHostSetupForRepo, getProjectHostSetupWorktreeMeta -} from '../../shared/project-host-setup-projection' +} from '../../shared/project-host-setup-lookup' import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' @@ -918,6 +918,7 @@ import { type RepoWorktreeRowDeps } from './repo-worktree-row-resolution' import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' +import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' import { withTimeout } from '../../shared/promise-timeout-fallback' import { getLocalWorktreePathAccess, @@ -1354,6 +1355,7 @@ type RuntimeStore = { getAllWorktreeMeta: Store['getAllWorktreeMeta'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] + setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] removeWorktreeMeta: Store['removeWorktreeMeta'] getWorktreeLineage?: Store['getWorktreeLineage'] getAllWorktreeLineage?: Store['getAllWorktreeLineage'] @@ -20835,7 +20837,7 @@ export class OrcaRuntimeService { workspaceKind: 'git', worktreeId: worktree.id, repoId: worktree.repoId, - ...((worktree.hostId ?? meta?.hostId) ? { hostId: worktree.hostId ?? meta?.hostId } : {}), + ...((meta?.hostId ?? worktree.hostId) ? { hostId: meta?.hostId ?? worktree.hostId } : {}), terminalPlatform, repo: repo?.displayName ?? worktree.repoId, path: worktree.path, @@ -24261,7 +24263,10 @@ export class OrcaRuntimeService { const metaById = store.getAllWorktreeMeta() const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) + // A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it. + const meta = + readWorktreeMetaForHost(store, worktreeId, expectedHostId) ?? + getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) const worktree = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId @@ -27129,12 +27134,20 @@ export class OrcaRuntimeService { createdAt }) } - this.store.setWorktreeMeta(worktree.id, stripOrcaProvenanceMetaUpdates(persistedMetaUpdates)) + const metadataUpdates = stripOrcaProvenanceMetaUpdates(persistedMetaUpdates) + const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId + if (executionHostId && this.store.setWorktreeMetaForHost) { + this.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates) + } else { + this.store.setWorktreeMeta(worktree.id, metadataUpdates) + } // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(worktree.repoId) - return await this.showManagedWorktree(`id:${worktree.id}`) + return await this.showManagedWorktree( + worktree.identity?.key ? `identity:${worktree.identity.key}` : `id:${worktree.id}` + ) } persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { @@ -32322,7 +32335,10 @@ export class OrcaRuntimeService { throw new Error('selector_not_found') } - if (selector.startsWith('id:')) { + if (selector.startsWith('identity:')) { + const identityKey = selector.slice('identity:'.length) + candidates = worktrees.filter((worktree) => worktree.identity?.key === identityKey) + } else if (selector.startsWith('id:')) { const worktreeId = explicitWorktreeId ?? selector.slice(3) candidates = worktrees.filter((worktree) => worktree.id === worktreeId) if (candidates.length === 0) { diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index d4bff4a9442..32111ef2a53 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -5,6 +5,7 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { Store } from '../persistence' import { + listStoredWorktreeRowsForRepo, resolveRepoWorktreeRows, resolveScopedWorktreeIdRow, type RepoWorktreeRowDeps @@ -111,6 +112,70 @@ describe('host-qualified scoped worktree resolution', () => { displayName: 'feature' }) }) + it('uses the host-qualified metadata owner when a legacy row has another host', async () => { + const deps = createDeps([ + repo('shared', '/remote/repo', { + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + ]) + const worktreeId = 'shared::/same/worktree' + const remoteMeta = { + displayName: 'remote workspace', + hostId: 'ssh:builder', + instanceId: 'remote-instance' + } as unknown as WorktreeMeta + deps.metaById[worktreeId] = { + displayName: 'stale local workspace', + hostId: 'local', + instanceId: 'local-instance' + } as unknown as WorktreeMeta + ;( + deps.store as Store & { + getWorktreeMetaForHost: () => WorktreeMeta + } + ).getWorktreeMetaForHost = () => remoteMeta + + const rows = await resolveRepoWorktreeRows( + deps, + deps.store.getRepos()[0]!, + deps.metaById, + new Map() + ) + + expect(rows[0]).toMatchObject({ + displayName: 'remote workspace', + hostId: 'ssh:builder', + instanceId: 'remote-instance' + }) + }) + + it('restores canonical-only SSH rows without leaking colliding local metadata', () => { + const local = repo('shared', '/local/repo', { executionHostId: 'local' }) + const remote = repo('shared', '/remote/repo', { + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + const deps = createDeps([local, remote]) + deps.metaById['shared::/local/worktree'] = { + displayName: 'local workspace', + hostId: 'local' + } as WorktreeMeta + ;( + deps.store as Store & { + getAllWorktreeMetaForHost: () => Record + } + ).getAllWorktreeMetaForHost = () => ({ + 'shared::/remote/worktree': { + displayName: 'remote workspace', + hostId: 'ssh:builder' + } as unknown as WorktreeMeta + }) + + expect(listStoredWorktreeRowsForRepo(deps.store, remote, 2)).toEqual([ + expect.objectContaining({ path: '/remote/worktree' }) + ]) + }) it.each([ ['runtime:windows', String.raw`C:\Users\dev\orca worktree`], diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index a9ef618ced7..e5e88607629 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -4,6 +4,11 @@ import { worktreeIdComparisonKey } from '../../shared/worktree/id' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + readAllWorktreeMetaForHost, + readWorktreeMetaForHost, + writeWorktreeMetaForHost +} from '../persistence/host-qualified-worktree-meta' import { isFolderRepo } from '../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { withTimeout } from '../../shared/promise-timeout-fallback' @@ -62,7 +67,9 @@ export function listStoredWorktreeRowsForRepo( ): GitWorktreeInfo[] { const expectedHostId = getRepoExecutionHostId(repo) const byWorktreeId = new Map() - for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + for (const [worktreeId, meta] of Object.entries( + readAllWorktreeMetaForHost(store, expectedHostId) + )) { const parsed = splitWorktreeId(worktreeId) if (!parsed || parsed.repoId !== repo.id) { continue @@ -134,15 +141,18 @@ export async function resolveRepoWorktreeRows( const worktreeId = `${repo.id}::${gitWorktree.path}` // Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp. const existingMeta = metaById[worktreeId] - const ownedExistingMeta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) + // A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it. + const ownedExistingMeta = + readWorktreeMetaForHost(store, worktreeId, expectedHostId) ?? + getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) const meta = ownedExistingMeta?.instanceId ? ownedExistingMeta : ownedExistingMeta || (!existingMeta && repoOwnerCount === 1) - ? store.setWorktreeMeta(worktreeId, {}) + ? writeWorktreeMetaForHost(store, worktreeId, expectedHostId, {}) : undefined const merged = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - hostId: repoOwnerCount === 1 ? (existingMeta?.hostId ?? expectedHostId) : expectedHostId + hostId: meta?.hostId ?? expectedHostId } return { ...merged, diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index 1aa49b76d8e..3750884f84f 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -43,6 +43,7 @@ import { WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS } from './orca-runtime' import { RESOLVED_WORKTREE_REPO_TIMEOUT_MS } from './repo-worktree-row-resolution' +import { canonicalWorktreeIdentity } from '../../shared/worktree/identity' const REPO_ID = 'repo-local' const REPO_PATH = '/Users/me/dev/app' @@ -75,8 +76,15 @@ function makeMeta(overrides: Record = {}) { function makeStore(options: { connectionId?: string; repoCount?: number; repoPath?: string } = {}) { const metaById: Record> = { - [WORKTREE_ID]: makeMeta(), - [MAIN_WORKTREE_ID]: makeMeta({ displayName: 'main' }) + [WORKTREE_ID]: makeMeta({ + hostId: 'local', + instanceId: '11111111-1111-4111-8111-111111111111' + }), + [MAIN_WORKTREE_ID]: makeMeta({ + displayName: 'main', + hostId: 'local', + instanceId: '22222222-2222-4222-8222-222222222222' + }) } const basePath = options.repoPath ?? REPO_PATH const repos = Array.from({ length: options.repoCount ?? 1 }, (_unused, index) => ({ @@ -586,6 +594,22 @@ describe('scoped explicit worktree-id resolution', () => { expect(resolved.id).toBe(MAIN_WORKTREE_ID) expect(scannedRepoPaths()).toEqual([REPO_PATH]) }) + it('resolves an exact canonical identity without relying on the mutable locator', async () => { + const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) + const resolve = (selector: string): Promise<{ id: string }> => + ( + runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise<{ id: string }> } + ).resolveWorktreeSelector(selector) + const identityKey = canonicalWorktreeIdentity({ + worktreeId: MAIN_WORKTREE_ID, + executionHostId: 'local', + instanceId: '22222222-2222-4222-8222-222222222222' + }) + + const resolved = await resolve(`identity:${identityKey}`) + + expect(resolved.id).toBe(MAIN_WORKTREE_ID) + }) it('still finds worktrees in other repos through the fleet path', async () => { const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) diff --git a/src/main/workspace-space-repo-scan.ts b/src/main/workspace-space-repo-scan.ts index 8b97a89c041..ba1b2e4db4c 100644 --- a/src/main/workspace-space-repo-scan.ts +++ b/src/main/workspace-space-repo-scan.ts @@ -10,6 +10,8 @@ import type { } from '../shared/workspace-space-types' import { mapWithConcurrency } from '../shared/map-with-concurrency' import { getRepoExecutionHostId } from '../shared/execution-host' +import { readWorktreeMetaForHost } from './persistence/host-qualified-worktree-meta' +import { getRepoOwnedWorktreeMeta } from './worktree-metadata-ownership' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' @@ -104,7 +106,15 @@ function reportProgress( function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree { const worktreeId = `${repo.id}::${gitWorktree.path}` - return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName) + const executionHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + const allMeta = store.getAllWorktreeMeta?.() + const legacyMeta = store.getWorktreeMeta?.(worktreeId) + const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}) + const meta = + readWorktreeMetaForHost(store, worktreeId, executionHostId) ?? + getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) } export async function scanWorkspaceSpaceRepo(args: { diff --git a/src/main/worktree-identity-persistence.test.ts b/src/main/worktree-identity-persistence.test.ts new file mode 100644 index 00000000000..c66a2d72ce8 --- /dev/null +++ b/src/main/worktree-identity-persistence.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' +import { canonicalWorktreeIdentity } from '../shared/worktree/identity' +import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' +import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' + +describe('host-qualified worktree metadata', () => { + const worktreeId = 'repo-1::/workspace/feature' + const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444' + + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('stores independent metadata for colliding locators on two hosts', () => { + const store = createStore() + + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) + store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + + expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature') + expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe( + 'Remote feature' + ) + store.flush() + const persisted = readDataFile() as PersistedState + expect(Object.keys(persisted.worktreeMetaByIdentity ?? {})).toHaveLength(2) + expect(store.getWorktreeMeta(worktreeId)?.displayName).toBe('Local feature') + }) + it('projects canonical-only metadata for exactly one host', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) + store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { + displayName: 'Remote feature' + }) + + expect(store.getAllWorktreeMetaForHost('local')).toEqual({ + [worktreeId]: expect.objectContaining({ displayName: 'Local feature', hostId: 'local' }) + }) + expect(store.getAllWorktreeMetaForHost('ssh:build-box')).toEqual({ + [worktreeId]: expect.objectContaining({ + displayName: 'Remote feature', + hostId: 'ssh:build-box' + }) + }) + }) + it('reloads host-specific metadata without collapsing it to the legacy locator', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) + store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + store.flush() + + const reloaded = createStore() + expect(reloaded.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature') + expect(reloaded.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe( + 'Remote feature' + ) + }) + it('keeps canonical metadata current for legacy writers on a known owner', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { comment: 'before' }) + + store.setWorktreeMeta(worktreeId, { comment: 'after' }) + + expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after') + }) + it('backfills one stable instance for legacy metadata that omitted it', () => { + const seed = createStore() + seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' }) + seed.flush() + const legacy = readDataFile() as PersistedState + delete legacy.worktreeMeta[worktreeId]?.instanceId + delete legacy.worktreeMetaByIdentity + delete legacy.worktreeIdentityAliases + writeDataFile(legacy) + + const store = createStore() + const first = store.getWorktreeMetaForHost(worktreeId, 'local') + const second = store.getWorktreeMetaForHost(worktreeId, 'local') + store.setWorktreeMeta(worktreeId, { comment: 'after migration' }) + store.flush() + + expect(first?.instanceId).toBeTruthy() + expect(second?.instanceId).toBe(first?.instanceId) + expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after migration') + const migrated = readDataFile() as PersistedState + expect(migrated.worktreeMeta[worktreeId]?.hostId).toBe('local') + expect(Object.keys(migrated.worktreeMetaByIdentity ?? {})).toHaveLength(1) + }) + // Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree + // listing loop, taking every workspace in the repo down with it and never self-healing. + it('collapses an ambiguous locator onto its most recently active instance', () => { + const seed = createStore() + const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' }) + seed.flush() + const persisted = readDataFile() as PersistedState + const alias = composeWorktreeHostIdentity('local', worktreeId) + const secondKey = canonicalWorktreeIdentity({ + worktreeId, + executionHostId: 'local', + instanceId: '33333333-3333-4333-8333-333333333333' + }) + persisted.worktreeMetaByIdentity ??= {} + persisted.worktreeIdentityAliases ??= {} + persisted.worktreeMetaByIdentity[secondKey] = { + ...first, + instanceId: '33333333-3333-4333-8333-333333333333', + displayName: 'Second', + lastActivityAt: 1 + } + persisted.worktreeIdentityAliases[alias] = [ + ...(persisted.worktreeIdentityAliases[alias] ?? []), + secondKey + ] + writeDataFile(persisted) + + const store = createStore() + expect(() => + store.setWorktreeMetaForHost(worktreeId, 'local', { comment: 'ambiguous write' }) + ).toThrow('Worktree identity is ambiguous for this host and locator.') + + const repaired = createStore() + expect(repaired.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Second') + repaired.flush() + const afterRead = readDataFile() as PersistedState + expect(afterRead.worktreeIdentityAliases?.[alias]).toEqual([ + persisted.worktreeIdentityAliases?.[alias]?.[0], + secondKey + ]) + expect(Object.keys(afterRead.worktreeMetaByIdentity ?? {})).toHaveLength(2) + }) + + it('removes only the selected legacy-owner metadata when locators collide', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) + store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + + store.removeWorktreeMeta(worktreeId, 'local') + + expect(store.getWorktreeMetaForHost(worktreeId, 'local')).toBeUndefined() + expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe( + 'Remote feature' + ) + }) + + it('removes only the selected host metadata when locators collide', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) + store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + + store.removeWorktreeMeta(worktreeId, 'ssh:build-box') + + expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature') + expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')).toBeUndefined() + store.flush() + expect( + Object.keys((readDataFile() as PersistedState).worktreeMetaByIdentity ?? {}) + ).toHaveLength(1) + }) + + it('moves the locator alias without changing canonical identity', () => { + const store = createStore() + const meta = store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Feature' }) + const identityKey = canonicalWorktreeIdentity({ + worktreeId, + executionHostId: 'local', + instanceId: meta.instanceId! + }) + const renamedId = 'repo-1::/workspace/renamed-feature' + + store.migrateWorktreeIdentity(worktreeId, renamedId) + + expect(store.getWorktreeMetaForHost(worktreeId, 'local')).toBeUndefined() + expect(store.getWorktreeMetaForHost(renamedId, 'local')?.instanceId).toBe(meta.instanceId) + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.worktreeMetaByIdentity?.[identityKey]?.displayName).toBe('Feature') + expect( + persisted.worktreeIdentityAliases?.[composeWorktreeHostIdentity('local', renamedId)] + ).toEqual([identityKey]) + }) + + it('keeps the canonical identity stable when the locator changes', () => { + const store = createStore() + const meta = store.setWorktreeMetaForHost(worktreeId, 'local', { + displayName: 'Feature' + }) + + const before = canonicalWorktreeIdentity({ + worktreeId, + executionHostId: 'local', + instanceId: meta.instanceId! + }) + const after = canonicalWorktreeIdentity({ + worktreeId: 'repo-1::/workspace/renamed-feature', + executionHostId: 'local', + instanceId: meta.instanceId! + }) + + expect(after).toBe(before) + expect(composeWorktreeHostIdentity('local', worktreeId)).toBe( + 'local|repo-1::/workspace/feature' + ) + }) + it('honours a deliberate instanceId rotation instead of pinning the stored one', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Feature' }) + + // worktree-lineage-pruning rotates a proven-missing parent so path reuse cannot + // validate old lineage; pinning the existing id silently disarmed that guard. + const rotated = store.setWorktreeMeta(worktreeId, { instanceId: ROTATED_INSTANCE_ID }) + + expect(rotated.instanceId).toBe(ROTATED_INSTANCE_ID) + expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.instanceId).toBe(ROTATED_INSTANCE_ID) + store.flush() + const persisted = readDataFile() as PersistedState + // The old identity row goes with the rotation rather than lingering unreachable. + expect(Object.keys(persisted.worktreeMetaByIdentity ?? {})).toEqual([ + canonicalWorktreeIdentity({ + worktreeId, + executionHostId: 'local', + instanceId: ROTATED_INSTANCE_ID + }) + ]) + }) + + it('leaves other hosts at the old locator when one host renames its folder', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) + store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + const renamedId = 'repo-1::/workspace/renamed-feature' + + store.migrateWorktreeIdentity(worktreeId, renamedId, 'local') + + expect(store.getWorktreeMetaForHost(renamedId, 'local')?.displayName).toBe('Local feature') + // The SSH host never moved, so its row must stay reachable at the path it still has. + expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe( + 'Remote feature' + ) + }) + + it('drops every host identity row when a locator is removed outright', () => { + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) + store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + + store.removeWorktreeMeta(worktreeId) + + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.worktreeMetaByIdentity ?? {}).toEqual({}) + expect(persisted.worktreeIdentityAliases ?? {}).toEqual({}) + }) + + it('repairs a missing canonical instance id while re-adopting an SSH target', () => { + const oldHostId = 'ssh:old-target' as const + const newHostId = 'ssh:new-target' as const + const seed = createStore() + seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) + seed.flush() + const persisted = readDataFile() as PersistedState + const oldAlias = composeWorktreeHostIdentity(oldHostId, worktreeId) + const oldKey = persisted.worktreeIdentityAliases?.[oldAlias]?.[0] + expect(oldKey).toBeTruthy() + delete persisted.worktreeMetaByIdentity?.[oldKey!]?.instanceId + writeDataFile(persisted) + + const store = createStore() + store.reassignSshTargetId('old-target', 'new-target') + store.flush() + + const repaired = readDataFile() as PersistedState + const newAlias = composeWorktreeHostIdentity(newHostId, worktreeId) + const newKey = repaired.worktreeIdentityAliases?.[newAlias]?.[0] + expect(repaired.worktreeIdentityAliases?.[oldAlias]).toBeUndefined() + expect(newKey).toBeTruthy() + expect(repaired.worktreeMetaByIdentity?.[newKey!]).toMatchObject({ + displayName: 'Remote feature', + hostId: newHostId, + instanceId: expect.any(String) + }) + expect(repaired.worktreeMetaByIdentity?.[oldKey!]).toBeUndefined() + }) + + it('preserves source metadata when the re-adoption destination is divergent', () => { + const oldHostId = 'ssh:old-target' as const + const newHostId = 'ssh:new-target' as const + const store = createStore() + store.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Source feature' }) + store.setWorktreeMetaForHost(worktreeId, newHostId, { + displayName: 'Destination feature' + }) + + store.reassignSshTargetId('old-target', 'new-target') + + expect(store.getWorktreeMetaForHost(worktreeId, oldHostId)?.displayName).toBe('Source feature') + expect(store.getWorktreeMetaForHost(worktreeId, newHostId)?.displayName).toBe( + 'Destination feature' + ) + expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe(oldHostId) + store.flush() + const persisted = readDataFile() as PersistedState + expect( + persisted.worktreeIdentityAliases?.[composeWorktreeHostIdentity(oldHostId, worktreeId)] + ).toHaveLength(1) + expect( + persisted.worktreeIdentityAliases?.[composeWorktreeHostIdentity(newHostId, worktreeId)] + ).toHaveLength(1) + }) + + it('deduplicates an equivalent destination during SSH target re-adoption', () => { + const oldHostId = 'ssh:old-target' as const + const newHostId = 'ssh:new-target' as const + const seed = createStore() + seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) + seed.flush() + const persisted = readDataFile() as PersistedState + const oldAlias = composeWorktreeHostIdentity(oldHostId, worktreeId) + const oldKey = persisted.worktreeIdentityAliases?.[oldAlias]?.[0] + const source = oldKey ? persisted.worktreeMetaByIdentity?.[oldKey] : undefined + expect(source?.instanceId).toBeTruthy() + const newAlias = composeWorktreeHostIdentity(newHostId, worktreeId) + const newKey = canonicalWorktreeIdentity({ + worktreeId, + executionHostId: newHostId, + instanceId: source!.instanceId! + }) + persisted.worktreeMetaByIdentity ??= {} + persisted.worktreeIdentityAliases ??= {} + persisted.worktreeMetaByIdentity[newKey] = { ...source!, hostId: newHostId } + persisted.worktreeIdentityAliases[newAlias] = [newKey] + writeDataFile(persisted) + + const store = createStore() + store.reassignSshTargetId('old-target', 'new-target') + store.flush() + + const deduped = readDataFile() as PersistedState + expect(deduped.worktreeIdentityAliases?.[oldAlias]).toBeUndefined() + expect(deduped.worktreeIdentityAliases?.[newAlias]).toEqual([newKey]) + expect(deduped.worktreeMetaByIdentity?.[oldKey!]).toBeUndefined() + expect(deduped.worktreeMetaByIdentity?.[newKey]).toEqual({ ...source!, hostId: newHostId }) + }) +}) diff --git a/src/main/worktree-metadata-ownership.test.ts b/src/main/worktree-metadata-ownership.test.ts new file mode 100644 index 00000000000..5582decec01 --- /dev/null +++ b/src/main/worktree-metadata-ownership.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { getRepoOwnedWorktreeMeta } from './worktree-metadata-ownership' +import type { Repo } from '../shared/repo-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' + +const worktreeId = 'repo-1::/workspace/feature' +const localRepo: Repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'Local repo', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' +} +const sshRepo: Repo = { + ...localRepo, + path: '/workspace/repo', + connectionId: 'build-box', + executionHostId: 'ssh:build-box' +} +const localMeta = { hostId: 'local', displayName: 'local metadata' } as WorktreeMeta + +function resolve(repo: Repo, ownerCount: number): WorktreeMeta | undefined { + return getRepoOwnedWorktreeMeta(repo, worktreeId, { [worktreeId]: localMeta }, ownerCount) +} + +describe('getRepoOwnedWorktreeMeta', () => { + it('rejects another host metadata when repo IDs and paths collide', () => { + expect(resolve(sshRepo, 2)).toBeUndefined() + }) + + it('keeps legacy metadata for a single-owner repo', () => { + expect(resolve(localRepo, 1)).toBe(localMeta) + }) +}) diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 7a85bd51a32..d665d278580 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -112,7 +112,11 @@ export type WorktreeApi = { expectedHead: string hostId?: ExecutionHostId }) => Promise - updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise + updateMeta: (args: { + worktreeId: string + executionHostId?: ExecutionHostId + updates: Partial + }) => Promise listLineage: () => Promise<{ lineage: Record workspaceLineage?: Record diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index d4e2b96c1d8..e423c3e8797 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -349,11 +349,20 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe ) const handleUnlinkPullRequest = useCallback(() => { - if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) { + if ( + !activeWorktreeId || + !activeWorktree || + activeReview?.provider !== 'github' || + linkedPR === null + ) { return } - void updateWorktreeMeta(activeWorktreeId, { linkedPR: null }) - }, [activeReview?.provider, activeWorktreeId, linkedPR, updateWorktreeMeta]) + void updateWorktreeMeta( + activeWorktreeId, + { linkedPR: null }, + { executionHostId: activeWorktree.hostId } + ) + }, [activeReview?.provider, activeWorktree, activeWorktreeId, linkedPR, updateWorktreeMeta]) const handleLinkAnotherPullRequest = useCallback(() => { if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') { @@ -364,6 +373,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe // Why: the same workspace ID can exist under two hosts. Naming the owner // keeps the dialog on this workspace instead of the ambiguous lookup. repoId: activeWorktree.repoId, + executionHostId: activeWorktree.hostId, currentDisplayName: activeWorktree.displayName, currentIssue: activeWorktree.linkedIssue, currentPR: activeWorktree.linkedPR ?? activeReview.number, diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx index 7cb3081474e..5cb49c71cce 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx @@ -8,6 +8,7 @@ import { useAppStore } from '@/store' import type { Repo } from '../../../../shared/repo-types' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { Worktree } from '../../../../shared/worktree/types' +import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' @@ -164,7 +165,7 @@ vi.mock('./workspace-board-task-status-sync', async (importOriginal) => ({ })) type UpdateWorktreesMeta = ( - updatesByWorktreeId: ReadonlyMap> + updates: readonly WorktreeMetaBatchUpdate[] | ReadonlyMap> ) => Promise const statuses = [ @@ -410,7 +411,13 @@ describe('WorkspaceKanbanDrawer search', () => { }) }) - const dropped = updateWorktreesMeta.mock.calls.at(-1)?.[0].get(omega.id) + const payload = updateWorktreesMeta.mock.calls.at(-1)?.[0] + let dropped: Partial | undefined + if (Array.isArray(payload)) { + dropped = payload.find((entry) => entry.worktreeId === omega.id)?.updates + } else if (payload && 'get' in payload) { + dropped = payload.get(omega.id) + } expect(dropped?.workspaceStatus).toBe('todo') expect(dropped?.manualOrder).toBeGreaterThan(gamma.manualOrder ?? 0) expect(dropped?.manualOrder).toBeLessThan(delta.manualOrder ?? 0) diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 47cc038799d..4e726dca60c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -8,6 +8,7 @@ import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' +import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers' import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-status-defaults' import { WORKSPACE_STATUS_DRAG_IDS_TYPE, @@ -369,7 +370,13 @@ async function dropWorktreesOnStatusLane( function committedStatusUpdates(): Map> { expect(mockStore.updateWorktreesMeta).toHaveBeenCalledTimes(1) - return mockStore.updateWorktreesMeta.mock.calls[0]![0] as Map> + const input = mockStore.updateWorktreesMeta.mock.calls[0]![0] as + | readonly WorktreeMetaBatchUpdate[] + | ReadonlyMap> + if ('get' in input) { + return new Map(input) + } + return new Map(input.map((entry) => [entry.worktreeId, entry.updates])) } describe('WorktreeList status-lane drop carries visible lineage children (#9083)', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx index 13b4bbac1b8..338c61190c5 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -133,6 +133,7 @@ function openDialog( /** Extra owners of the same workspace ID, which the index reads as ambiguous. */ otherRepos?: { repoId: string; worktree?: Partial }[] modalRepoId?: string + modalExecutionHostId?: string linearViewerOrganizationUrlKey?: string } = {} ): void { @@ -169,6 +170,7 @@ function openDialog( modalData: { worktreeId: options.worktreeId ?? worktree.id, ...(options.modalRepoId ? { repoId: options.modalRepoId } : {}), + ...(options.modalExecutionHostId ? { executionHostId: options.modalExecutionHostId } : {}), currentDisplayName: worktree.displayName, currentComment: worktree.comment, focus: 'comment' @@ -328,6 +330,27 @@ describe('WorktreeMetaDialog issue link row', () => { expect(updates.linkedIssue).toBe(99) expect(updates).not.toHaveProperty('linkedLinearIssue') }) + it('qualifies a save with the host selected by the opening row', async () => { + openDialog({ + worktree: { hostId: 'ssh:build-box' }, + modalExecutionHostId: 'ssh:build-box' + }) + + fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), { + target: { value: 'remote note' } + }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => + expect(updateWorktreeMeta).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ comment: 'remote note' }), + { executionHostId: 'ssh:build-box' } + ) + ) + }) // updateWorktreeMeta stamps lastActivityAt on any comment write, which would // reorder the workspace under the time-decay sidebar sort. diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 0482a644221..e714deb0453 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -30,6 +30,7 @@ import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input' +import { parseExecutionHostId } from '../../../../shared/execution-host' import { WorktreeDisplayNameField } from './WorktreeDisplayNameField' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { @@ -56,6 +57,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const isOpen = isEditMeta const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : '' + const executionHostId = + typeof modalData.executionHostId === 'string' + ? (parseExecutionHostId(modalData.executionHostId)?.id ?? undefined) + : undefined const currentDisplayName = typeof modalData.currentDisplayName === 'string' ? modalData.currentDisplayName : '' const currentComment = @@ -224,7 +229,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { try { const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks) - const result = await updateWorktreeMeta(worktreeId, updates) + const result = executionHostId + ? await updateWorktreeMeta(worktreeId, updates, { executionHostId }) + : await updateWorktreeMeta(worktreeId, updates) // Why: a failed save refetches and reverts the optimistic write. Closing // here would report success for an edit that silently undid itself, and // would discard the name, comment and PR changes in the same payload. @@ -249,6 +256,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { } }, [ worktreeId, + executionHostId, canSave, draft, snapshot, diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts index 455fd81849c..74d3a9ec47a 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts @@ -5,6 +5,7 @@ import { groupWorkspaceKanbanWorktrees } from './workspace-kanban-worktree-group import { buildWorkspaceKanbanLaneViews } from './workspace-kanban-search' import { useWorkspaceKanbanSearch } from './use-workspace-kanban-search' import { registerWorkspaceKanbanSidebarDropGroups } from './workspace-kanban-sidebar-drop' +import { buildUnambiguousWorktreeIdIndex } from './worktree-unambiguous-id-index' import { composeWorktreeHostIdentity, getWorktreeHostIdentity @@ -38,7 +39,7 @@ export function useWorkspaceKanbanBoardProjection(args: { [args.allWorktrees, args.sortBy, args.workspaceStatuses, visibleWorktreeIds] ) const worktreeById = useMemo( - () => new Map(args.allWorktrees.map((worktree) => [worktree.id, worktree])), + () => buildUnambiguousWorktreeIdIndex(args.allWorktrees), [args.allWorktrees] ) const boardWorktrees = useMemo( diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts index 3945c648a6d..5e5f9729ddc 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts @@ -90,7 +90,11 @@ export function useWorkspaceKanbanStatusActions(args: { recordInteraction() for (const worktree of args.allWorktrees) { if (getWorkspaceStatus(worktree, args.workspaceStatuses) === statusId) { - void args.updateWorktreeMeta(worktree.id, { workspaceStatus: fallbackStatus }) + void args.updateWorktreeMeta( + worktree.id, + { workspaceStatus: fallbackStatus }, + { executionHostId: worktree.hostId ?? 'local' } + ) } } }, diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts index 687328e30c6..96647d65763 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts @@ -9,6 +9,7 @@ import { } from './worktree-manual-order' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' +import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers' import type { WorktreeManualOrderCatalog } from './worktree-manual-order-catalog' type LaneView = { items: readonly Worktree[] } @@ -54,14 +55,18 @@ export function useWorkspaceKanbanWorktreeActions(args: { return } recordInteraction() - void args.updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + void args.updateWorktreeMeta( + worktreeId, + { workspaceStatus: status }, + { executionHostId: current.hostId ?? 'local' } + ) args.maybeSyncTaskStatuses([worktreeId], status) }, [args] ) const moveWorktreesToStatus = useCallback( (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const updates = new Map>() + const updates: WorktreeMetaBatchUpdate[] = [] const changedIds: string[] = [] for (const worktreeId of worktreeIds) { const current = args.worktreeById.get(worktreeId) @@ -69,7 +74,11 @@ export function useWorkspaceKanbanWorktreeActions(args: { continue } changedIds.push(worktreeId) - updates.set(worktreeId, { workspaceStatus: status }) + updates.push({ + worktreeId, + updates: { workspaceStatus: status }, + executionHostId: current.hostId ?? 'local' + }) } if (changedIds.length === 0) { return @@ -87,7 +96,7 @@ export function useWorkspaceKanbanWorktreeActions(args: { dropIndex: number writeManualOrder?: boolean }) => { - const updates = new Map>() + const updates: WorktreeMetaBatchUpdate[] = [] const writeManualOrder = drop.writeManualOrder ?? shouldWriteDropManualOrder(drop.worktreeIds, drop.status) const order = writeManualOrder @@ -106,23 +115,40 @@ export function useWorkspaceKanbanWorktreeActions(args: { if (!current) { continue } + const next: Partial = {} if (getWorkspaceStatus(current, args.workspaceStatuses) !== drop.status) { - updates.set(worktreeId, { workspaceStatus: drop.status }) + next.workspaceStatus = drop.status + } + updates.push({ + worktreeId, + updates: next, + executionHostId: current.hostId ?? 'local' + }) + } + for (const [worktreeId, manualOrder] of order.updates) { + const entry = updates.find((candidate) => candidate.worktreeId === worktreeId) + if (entry) { + entry.updates = { ...entry.updates, ...manualOrder } + continue + } + const current = args.worktreeById.get(worktreeId) + if (current) { + updates.push({ + worktreeId, + updates: manualOrder, + executionHostId: current.hostId ?? 'local' + }) } } - if (writeManualOrder) { - for (const [worktreeId, manualOrder] of order.updates) { - updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) - } - } - if (updates.size === 0) { + const changed = updates.filter((entry) => Object.keys(entry.updates).length > 0) + if (changed.length === 0) { return } if (writeManualOrder && order.changed) { args.setSortBy('manual') } recordInteraction() - void args.updateWorktreesMeta(updates) + void args.updateWorktreesMeta(changed) args.maybeSyncTaskStatuses(drop.worktreeIds, drop.status) }, [args, shouldWriteDropManualOrder] @@ -157,20 +183,28 @@ export function useWorkspaceKanbanWorktreeActions(args: { if (!current || current.isPinned) { return } - void args.updateWorktreeMeta(worktreeId, { isPinned: true }) + void args.updateWorktreeMeta( + worktreeId, + { isPinned: true }, + { executionHostId: current.hostId ?? 'local' } + ) }, [args] ) const pinWorktrees = useCallback( (worktreeIds: readonly string[]) => { - const updates = new Map() + const updates: WorktreeMetaBatchUpdate[] = [] for (const worktreeId of worktreeIds) { const current = args.worktreeById.get(worktreeId) if (current && !current.isPinned) { - updates.set(worktreeId, { isPinned: true }) + updates.push({ + worktreeId, + updates: { isPinned: true }, + executionHostId: current.hostId ?? 'local' + }) } } - if (updates.size > 0) { + if (updates.length > 0) { recordInteraction() void args.updateWorktreesMeta(updates) } diff --git a/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts index 84874e4fa54..cc8f6002e59 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts @@ -107,9 +107,13 @@ export function useWorktreeCardActivationActions({ // Inline rename has no surface for the failure; the store already logs and // refetches, which reverts the optimistic title in place. async (displayName: string): Promise => { - await updateWorktreeMeta(worktree.id, { displayName }) + await updateWorktreeMeta( + worktree.id, + { displayName }, + { executionHostId: worktree.hostId ?? 'local' } + ) }, - [updateWorktreeMeta, worktree.id] + [updateWorktreeMeta, worktree.hostId, worktree.id] ) const handleDoubleClick = useCallback( @@ -123,6 +127,7 @@ export function useWorktreeCardActivationActions({ openModal('edit-meta', { worktreeId: worktree.id, repoId: worktree.repoId, + executionHostId: worktree.hostId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -134,6 +139,7 @@ export function useWorktreeCardActivationActions({ affiliateListMode, worktree.comment, worktree.displayName, + worktree.hostId, worktree.id, worktree.linkedIssue, worktree.linkedPR, @@ -145,9 +151,13 @@ export function useWorktreeCardActivationActions({ (event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() - updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread }) + updateWorktreeMeta( + worktree.id, + { isUnread: !worktree.isUnread }, + { executionHostId: worktree.hostId ?? 'local' } + ) }, - [worktree.id, worktree.isUnread, updateWorktreeMeta] + [worktree.hostId, worktree.id, worktree.isUnread, updateWorktreeMeta] ) return { handleClick, handleRenameTitle, handleDoubleClick, handleToggleUnreadQuick } diff --git a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts index ec59f8172e9..fc339bbe33f 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts @@ -50,6 +50,7 @@ export function useWorktreeCardFoundation({ // Why: the same workspace ID can exist under two hosts. Naming the owner // keeps the dialog on the clicked row instead of the ambiguous lookup. repoId: worktree.repoId, + executionHostId: worktree.hostId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -66,6 +67,7 @@ export function useWorktreeCardFoundation({ openModal('edit-meta', { worktreeId: worktree.id, repoId: worktree.repoId, + executionHostId: worktree.hostId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts index 64e9da831a6..e5ffe899c8d 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts @@ -149,27 +149,28 @@ export function useWorktreeCardSecondaryDetails({ (hoverReviewProvider === 'azure-devops' && linkedAzureDevOpsPR !== null) || (hoverReviewProvider === 'gitea' && linkedGiteaPR !== null) const handleUnlinkReview = useCallback(() => { + const options = { executionHostId: worktree.hostId ?? 'local' } switch (hoverReviewProvider) { case 'github': - void updateWorktreeMeta(worktree.id, { linkedPR: null }) + void updateWorktreeMeta(worktree.id, { linkedPR: null }, options) return case 'gitlab': - void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }) + void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }, options) return case 'bitbucket': - void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }) + void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }, options) return case 'azure-devops': - void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }) + void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }, options) return case 'gitea': - void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }) + void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }, options) break case 'unsupported': case undefined: break } - }, [hoverReviewProvider, updateWorktreeMeta, worktree.id]) + }, [hoverReviewProvider, updateWorktreeMeta, worktree.hostId, worktree.id]) const handleOpenLinearIssueInOrca = useCallback( (e: React.MouseEvent) => { e.stopPropagation() diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index 41320c81c02..7fb0790267b 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -42,7 +42,11 @@ export function useWorktreeContextMenuCommands(args: { window.api.ui.writeClipboardText(args.worktree.path) }, [args]) const handleToggleRead = useCallback(() => { - args.updateWorktreeMeta(args.worktree.id, { isUnread: !args.worktree.isUnread }) + args.updateWorktreeMeta( + args.worktree.id, + { isUnread: !args.worktree.isUnread }, + { executionHostId: args.worktree.hostId ?? 'local' } + ) }, [args]) const handleTogglePin = useCallback(() => { args.setWorktreesPinnedAndReveal([args.worktree.id], !args.worktree.isPinned) @@ -100,8 +104,17 @@ export function useWorktreeContextMenuCommands(args: { args.onAssignWorkspaceStatus?.(plan.worktreeIds, status) return } + const localWriteIds = new Set(plan.localWriteIds) void Promise.all( - plan.localWriteIds.map((id) => args.updateWorktreeMeta(id, { workspaceStatus: status })) + args.activeContextWorktrees + .filter((worktree) => localWriteIds.has(worktree.id)) + .map((worktree) => + args.updateWorktreeMeta( + worktree.id, + { workspaceStatus: status }, + { executionHostId: worktree.hostId ?? 'local' } + ) + ) ) }, [args] @@ -110,6 +123,7 @@ export function useWorktreeContextMenuCommands(args: { args.openModal('edit-meta', { worktreeId: args.worktree.id, repoId: args.worktree.repoId, + executionHostId: args.worktree.hostId, currentDisplayName: args.worktree.displayName, currentIssue: args.worktree.linkedIssue, currentPR: args.worktree.linkedPR, diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 0a333ca9180..4edd77c6357 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -574,7 +574,9 @@ describe('workspace kanban sidebar drop updates', () => { }) expect(result.shouldSwitchToManual).toBe(false) - expect(Array.from(result.updates)).toEqual([['todo-a', { workspaceStatus: 'doing' }]]) + expect(result.updates).toEqual([ + { worktreeId: 'todo-a', updates: { workspaceStatus: 'doing' }, executionHostId: 'local' } + ]) }) it('keeps the dropped board position when Manual sort is active', () => { @@ -601,12 +603,16 @@ describe('workspace kanban sidebar drop updates', () => { }) expect(result.shouldSwitchToManual).toBe(true) - expect(result.updates.get('todo-a')).toEqual({ + expect(result.updates.find((entry) => entry.worktreeId === 'todo-a')?.updates).toEqual({ workspaceStatus: 'doing', manualOrder: 9000 }) - expect(result.updates.get('doing-a')).toEqual({ manualOrder: 10_000 }) - expect(result.updates.get('doing-b')).toEqual({ manualOrder: 8000 }) + expect(result.updates.find((entry) => entry.worktreeId === 'doing-a')?.updates).toEqual({ + manualOrder: 10_000 + }) + expect(result.updates.find((entry) => entry.worktreeId === 'doing-b')?.updates).toEqual({ + manualOrder: 8000 + }) }) it('keeps board drops sparse when filtered rows already have durable ranks', () => { @@ -662,8 +668,12 @@ describe('workspace kanban sidebar drop updates', () => { now: 10_000 }) - expect([...result.updates]).toEqual([ - ['todo-a', { workspaceStatus: 'doing', manualOrder: 1500 }] + expect(result.updates).toEqual([ + { + worktreeId: 'todo-a', + updates: { workspaceStatus: 'doing', manualOrder: 1500 }, + executionHostId: 'local' + } ]) }) }) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index 8672bbc5582..d0db89c9faa 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -14,6 +14,7 @@ import { shouldWriteManualOrderForGroupDrop, type WorktreeDragGroup } from './worktree-manual-order' +import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers' import { CARD_SELECTOR, getCardDropTarget, @@ -221,7 +222,7 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: { sortBy: string now: number }): { - updates: Map> + updates: WorktreeMetaBatchUpdate[] shouldSwitchToManual: boolean } { const sourceGroupKeys = args.worktreeIds.flatMap((worktreeId) => { @@ -245,25 +246,41 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: { }) : { changed: false, updates: new Map() } - const updates = new Map>() + const updates: WorktreeMetaBatchUpdate[] = [] for (const worktreeId of args.worktreeIds) { const current = args.worktreeById.get(worktreeId) if (!current) { continue } + const next: Partial = {} if (getWorkspaceStatus(current, args.workspaceStatuses) !== args.status) { - updates.set(worktreeId, { workspaceStatus: args.status }) + next.workspaceStatus = args.status } + updates.push({ + worktreeId, + updates: next, + executionHostId: current.hostId ?? 'local' + }) } if (writeManualOrder) { for (const [worktreeId, manualOrder] of order.updates) { - updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) + const current = args.worktreeById.get(worktreeId) + const entry = updates.find((candidate) => candidate.worktreeId === worktreeId) + if (entry) { + entry.updates = { ...entry.updates, ...manualOrder } + } else if (current) { + updates.push({ + worktreeId, + updates: manualOrder, + executionHostId: current.hostId ?? 'local' + }) + } } } return { - updates, + updates: updates.filter((entry) => Object.keys(entry.updates).length > 0), shouldSwitchToManual: writeManualOrder && order.changed } } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts index 435d8e34cf2..6243f34cf02 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts @@ -6,6 +6,7 @@ import type { Worktree } from '../../../../../../shared/worktree/types' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' +import type { WorktreeMetaBatchUpdate } from '../../../../store/slices/worktree-helpers' import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status' import { buildManualOrderUpdatesForGroupDrop, @@ -37,22 +38,30 @@ export function useWorktreeStatusMutations(args: { if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { return } - void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + void updateWorktreeMeta( + worktreeId, + { workspaceStatus: status }, + { executionHostId: current.hostId ?? 'local' } + ) }, [updateWorktreeMeta, worktreeMap, workspaceStatuses] ) const moveWorktreesToStatus = useCallback( (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const updates = new Map() + const updates: WorktreeMetaBatchUpdate[] = [] for (const worktreeId of worktreeIds) { const current = worktreeMap.get(worktreeId) if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { continue } - updates.set(worktreeId, { workspaceStatus: status }) + updates.push({ + worktreeId, + updates: { workspaceStatus: status }, + executionHostId: current.hostId ?? 'local' + }) } - if (updates.size > 0) { + if (updates.length > 0) { void updateWorktreesMeta(updates) } }, @@ -70,7 +79,7 @@ export function useWorktreeStatusMutations(args: { rankByWorktreeId: manualOrderCatalog.rankByWorktreeId, allWorktreeIds: manualOrderCatalog.orderedIds }) - const updates = new Map>() + const updates = new Map() for (const worktreeId of dropArgs.worktreeIds) { const current = worktreeMap.get(worktreeId) if (!current) { @@ -80,13 +89,20 @@ export function useWorktreeStatusMutations(args: { if (getWorkspaceStatus(current, workspaceStatuses) !== dropArgs.status) { next.workspaceStatus = dropArgs.status } - updates.set(worktreeId, next) + updates.set(worktreeId, { + worktreeId, + updates: next, + executionHostId: current.hostId ?? 'local' + }) } for (const [worktreeId, manualOrder] of order.updates) { - updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) + const entry = updates.get(worktreeId) + if (entry) { + entry.updates = { ...entry.updates, ...manualOrder } + } } - for (const [worktreeId, update] of Array.from(updates)) { - if (Object.keys(update).length === 0) { + for (const [worktreeId, entry] of updates) { + if (Object.keys(entry.updates).length === 0) { updates.delete(worktreeId) } } @@ -97,7 +113,7 @@ export function useWorktreeStatusMutations(args: { if (order.changed) { setSortBy('manual') } - void updateWorktreesMeta(updates) + void updateWorktreesMeta([...updates.values()]) }, [manualOrderCatalog, setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] ) @@ -129,14 +145,18 @@ export function useWorktreeStatusMutations(args: { rankByWorktreeId: manualOrderCatalog.rankByWorktreeId, allWorktreeIds: manualOrderCatalog.orderedIds }) - if (!result.changed) { - return + if (result.changed) { + setSortBy('manual') } - // Why: only switch to Manual after a real move so accidental click-drags don't change the sort. - setSortBy('manual') - void updateWorktreesMeta(result.updates) + void updateWorktreesMeta( + [...result.updates].map(([worktreeId, updates]) => ({ + worktreeId, + updates, + executionHostId: worktreeMap.get(worktreeId)?.hostId ?? 'local' + })) + ) }, - [manualOrderCatalog, setSortBy, updateWorktreesMeta] + [manualOrderCatalog, setSortBy, updateWorktreesMeta, worktreeMap] ) const shouldShowWorkspaceBoardDropIndicator = useCallback( @@ -165,7 +185,7 @@ export function useWorktreeStatusMutations(args: { allWorktreeIds: manualOrderCatalog.orderedIds, rankByWorktreeId: manualOrderCatalog.rankByWorktreeId }) - if (result.updates.size === 0) { + if (result.updates.length === 0) { return } // Why: switch to Manual when the drop changes order so the placement stays visible. diff --git a/src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts b/src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts new file mode 100644 index 00000000000..5c6afdd3199 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildUnambiguousWorktreeIdIndex } from './worktree-unambiguous-id-index' +import type { Worktree } from '../../../../shared/worktree/types' + +function worktree(id: string, hostId: Worktree['hostId']): Worktree { + return { + id, + repoId: 'repo', + path: `/repo/${id}`, + branch: id, + isMainWorktree: false, + hostId + } as Worktree +} + +describe('buildUnambiguousWorktreeIdIndex', () => { + it('keeps unique bare ids', () => { + const local = worktree('repo::local', 'local') + const remote = worktree('repo::remote', 'ssh:host') + + expect(buildUnambiguousWorktreeIdIndex([local, remote])).toEqual( + new Map([ + ['repo::local', local], + ['repo::remote', remote] + ]) + ) + }) + + it('drops bare ids claimed by multiple hosts', () => { + const local = worktree('repo::feature', 'local') + const remote = worktree('repo::feature', 'ssh:host') + + expect(buildUnambiguousWorktreeIdIndex([local, remote]).has('repo::feature')).toBe(false) + }) + + it('keeps later unique ids after an ambiguity is found', () => { + const local = worktree('repo::feature', 'local') + const remote = worktree('repo::feature', 'ssh:host') + const other = worktree('repo::other', 'local') + + expect(buildUnambiguousWorktreeIdIndex([local, remote, other]).get('repo::other')).toBe(other) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-unambiguous-id-index.ts b/src/renderer/src/components/sidebar/worktree-unambiguous-id-index.ts new file mode 100644 index 00000000000..ce3f2fcf91b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-unambiguous-id-index.ts @@ -0,0 +1,17 @@ +import type { Worktree } from '../../../../shared/worktree/types' + +export function buildUnambiguousWorktreeIdIndex( + worktrees: readonly Worktree[] +): Map { + const index = new Map() + const ambiguous = new Set() + for (const worktree of worktrees) { + if (index.has(worktree.id)) { + index.delete(worktree.id) + ambiguous.add(worktree.id) + } else if (!ambiguous.has(worktree.id)) { + index.set(worktree.id, worktree) + } + } + return index +} diff --git a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts index cf15e35e885..91c78d42a82 100644 --- a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts @@ -79,11 +79,12 @@ describe('folder workspace owner-routed mutations', () => { folderWorkspaces: [folderWorkspace] }) - await store - .getState() - .updateWorktreesMeta( - new Map([[folderWorkspaceKey(folderWorkspace.id), { manualOrder: 9000 }]]) - ) + await store.getState().updateWorktreesMeta([ + { + worktreeId: folderWorkspaceKey(folderWorkspace.id), + updates: { manualOrder: 9000 } + } + ]) expect(folderWorkspacesUpdate).toHaveBeenCalledWith({ folderWorkspaceId: folderWorkspace.id, diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index fb368bec070..5d4fafd1e22 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -37,7 +37,6 @@ import type { PendingWorktreeCreation, WorktreeCreationPhase } from '@/lib/pending-worktree-creation' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { AppState } from '../types' import type { WorktreeRefreshAllOptions } from './worktree-refresh-options' export type { WorktreePurgeTarget, WorktreePurgeTargets } from './worktree-purge-target' @@ -46,6 +45,10 @@ export type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree- import type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types' export { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +export { + applyWorktreeUpdates, + withoutErasedRequiredWorktreeFields +} from './worktree-meta-update-application' import type { RendererRemoveWorktreeResult } from './renderer-remove-worktree-result' export type WorktreeFetchOptions = { @@ -64,10 +67,17 @@ export type DirectSshWorktreeFetchOptions = WorktreeFetchOptions & { export type WorktreeMetaUpdateGuard = (worktree: Worktree | DetectedWorktree | undefined) => boolean export type WorktreeMetaUpdateOptions = { + /** Required to mutate one row when the legacy locator exists on multiple hosts. */ + executionHostId?: ExecutionHostId shouldApply?: WorktreeMetaUpdateGuard /** Skip the automatic review refetch when the caller owns an equivalent refresh. */ suppressHostedReviewRefresh?: boolean } +export type WorktreeMetaBatchUpdate = { + worktreeId: string + updates: Partial + executionHostId?: ExecutionHostId +} export type WorktreeRenameRequest = { worktreeId: string @@ -264,9 +274,7 @@ export type WorktreeSlice = { options?: WorktreeMetaUpdateOptions ) => Promise<{ ok: true } | { ok: false; error: string }> ensureHostedReviewPushTarget: (worktreeId: string) => Promise - updateWorktreesMeta: ( - updatesByWorktreeId: ReadonlyMap> - ) => Promise + updateWorktreesMeta: (updatesByWorktreeId: readonly WorktreeMetaBatchUpdate[]) => Promise /** * Pin/unpin worktrees, then reveal the first changed one. The reveal keeps * the shortcut action visible even though pinned worktrees also remain in @@ -369,68 +377,3 @@ export function findWorktreeById( return undefined } - -type RequiredKey = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T] - -// Why: a present-but-undefined key in a spread ERASES the field. That is the -// intended wire signal for clearing optional metadata (pushTarget), but on a -// field Worktree declares required it produced a live `displayName: undefined` -// that crashed the worktree palette (crash a1f81ea1). Typed off Worktree so a -// newly-required field is protected automatically. -const ERASURE_PROTECTED_KEYS: Record, keyof WorktreeMeta>, true> = { - displayName: true, - comment: true, - linkedIssue: true, - linkedPR: true, - linkedLinearIssue: true, - isArchived: true, - isUnread: true, - isPinned: true, - sortOrder: true, - lastActivityAt: true -} - -export function withoutErasedRequiredWorktreeFields( - updates: Partial -): Partial { - const erased = Object.keys(ERASURE_PROTECTED_KEYS).filter( - (key) => updates[key as keyof WorktreeMeta] === undefined && Object.hasOwn(updates, key) - ) - if (erased.length === 0) { - return updates - } - - const next = { ...updates } - for (const key of erased) { - delete next[key as keyof WorktreeMeta] - } - return next -} - -export function applyWorktreeUpdates( - worktreesByRepo: Record, - worktreeId: string, - rawUpdates: Partial -): Record { - const updates = withoutErasedRequiredWorktreeFields(rawUpdates) - const repoId = getRepoIdFromWorktreeId(worktreeId) - const worktrees = worktreesByRepo[repoId] - if (!worktrees) { - return worktreesByRepo - } - - let changed = false - const nextWorktrees = worktrees.map((worktree) => { - if (worktree.id !== worktreeId) { - return worktree - } - - changed = true - return { ...worktree, ...updates } - }) - if (!changed) { - return worktreesByRepo - } - - return { ...worktreesByRepo, [repoId]: nextWorktrees } -} diff --git a/src/renderer/src/store/slices/worktree-meta-update-application.ts b/src/renderer/src/store/slices/worktree-meta-update-application.ts new file mode 100644 index 00000000000..a65309f9adf --- /dev/null +++ b/src/renderer/src/store/slices/worktree-meta-update-application.ts @@ -0,0 +1,69 @@ +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { Worktree } from '../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { worktreeRowMatchesMetaHost } from './worktrees/listing/worktree-meta-host-match' + +type RequiredKey = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T] + +// Why: a present-but-undefined key in a spread ERASES the field. That is the +// intended wire signal for clearing optional metadata, but this protects required +// Worktree fields from disappearing in optimistic projections. +const ERASURE_PROTECTED_KEYS: Record, keyof WorktreeMeta>, true> = { + displayName: true, + comment: true, + linkedIssue: true, + linkedPR: true, + linkedLinearIssue: true, + isArchived: true, + isUnread: true, + isPinned: true, + sortOrder: true, + lastActivityAt: true +} + +export function withoutErasedRequiredWorktreeFields( + updates: Partial +): Partial { + const erased = Object.keys(ERASURE_PROTECTED_KEYS).filter( + (key) => updates[key as keyof WorktreeMeta] === undefined && Object.hasOwn(updates, key) + ) + if (erased.length === 0) { + return updates + } + + const next = { ...updates } + for (const key of erased) { + delete next[key as keyof WorktreeMeta] + } + return next +} + +export function applyWorktreeUpdates( + worktreesByRepo: Record, + worktreeId: string, + rawUpdates: Partial, + executionHostId?: ExecutionHostId +): Record { + const updates = withoutErasedRequiredWorktreeFields(rawUpdates) + const repoId = getRepoIdFromWorktreeId(worktreeId) + const worktrees = worktreesByRepo[repoId] + if (!worktrees) { + return worktreesByRepo + } + + let changed = false + const nextWorktrees = worktrees.map((worktree) => { + if (worktree.id !== worktreeId || !worktreeRowMatchesMetaHost(worktree, executionHostId)) { + return worktree + } + + changed = true + return { ...worktree, ...updates } + }) + if (!changed) { + return worktreesByRepo + } + + return { ...worktreesByRepo, [repoId]: nextWorktrees } +} diff --git a/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts b/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts index c2f9e18b4fd..f1fdbfc2a5e 100644 --- a/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts @@ -102,7 +102,7 @@ describe('worktree remote runtime mutations', () => { [ 'updateWorktreesMeta', (store: ReturnType, worktreeId: string) => - store.getState().updateWorktreesMeta(new Map([[worktreeId, { isUnread: true }]])) + store.getState().updateWorktreesMeta([{ worktreeId, updates: { isUnread: true } }]) ], [ 'markWorktreeUnread', diff --git a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts index 3e295c7c73b..0e3ead18d88 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts @@ -282,12 +282,10 @@ describe('fetchWorktrees', () => { const refresh = store.getState().fetchWorktrees('repo1') await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1)) - await store.getState().updateWorktreesMeta( - new Map([ - [daily.id, { manualOrder: 100 }], - [relay.id, { manualOrder: 200 }] - ]) - ) + await store.getState().updateWorktreesMeta([ + { worktreeId: daily.id, updates: { manualOrder: 100 } }, + { worktreeId: relay.id, updates: { manualOrder: 200 } } + ]) resolveListing([refreshedDaily, relay]) await refresh diff --git a/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts b/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts index df136f1ee3a..09f191ac732 100644 --- a/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts +++ b/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts @@ -65,6 +65,7 @@ describe('worktree remote runtime mutations', () => { }) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { linkedPR: 2548, pushTarget } }) expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget) @@ -88,9 +89,9 @@ describe('worktree remote runtime mutations', () => { } as Partial) await store.getState().updateWorktreeMeta(wt.id, { linkedPR: null }) - expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { linkedPR: null, pushTarget: undefined } }) expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toBeUndefined() @@ -119,9 +120,9 @@ describe('worktree remote runtime mutations', () => { .getState() .updateWorktreeMeta(wt.id, { linkedPR: null }, { suppressHostedReviewRefresh: true }) - expect(fetchHostedReviewForBranch).not.toHaveBeenCalled() expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { linkedPR: null, pushTarget: undefined } }) expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ @@ -152,9 +153,9 @@ describe('worktree remote runtime mutations', () => { } as Partial) await store.getState().updateWorktreeMeta(wt.id, { linkedGitLabMR: 42 }) - expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { linkedGitLabMR: 42, linkedPR: null, pushTarget: undefined } }) expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ @@ -186,6 +187,7 @@ describe('worktree remote runtime mutations', () => { }) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { pushTarget: newPushTarget } }) expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(newPushTarget) @@ -300,9 +302,9 @@ describe('worktree remote runtime mutations', () => { await store.getState().updateWorktreeMeta(wt.id, { linkedPR: 0 }) - expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled() expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { linkedPR: 0 } }) }) @@ -326,9 +328,9 @@ describe('worktree remote runtime mutations', () => { await store.getState().updateWorktreeMeta(wt.id, { linkedPR: 2548 }) - expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled() expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { linkedPR: 2548 } }) }) @@ -361,6 +363,7 @@ describe('worktree remote runtime mutations', () => { }) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { linkedPR: 2548, pushTarget } }) expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget) @@ -397,6 +400,7 @@ describe('worktree remote runtime mutations', () => { }) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { pushTarget } }) expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget) @@ -520,7 +524,8 @@ describe('worktree remote runtime mutations', () => { id: 'repo-ssh::/home/orca/runtime-wt', repoId: 'repo-ssh', path: '/home/orca/runtime-wt', - linkedPR: 5571 + linkedPR: 5571, + hostId: 'ssh:ssh-1' }) mockApi.worktrees.resolvePrBase.mockResolvedValueOnce({ baseBranch: 'fork-head', @@ -549,6 +554,7 @@ describe('worktree remote runtime mutations', () => { }) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { pushTarget } }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() @@ -607,6 +613,51 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled() expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() }) + it('updates only the explicitly selected owner when locators collide', async () => { + const store = createTestStore() + const worktreeId = 'repo-shared::/same/path' + const sshA = makeWorktree({ + id: worktreeId, + repoId: 'repo-shared', + hostId: 'ssh:ssh-a', + runtimeOwnerEnvironmentId: 'hub-a', + comment: 'A' + }) + const sshB = makeWorktree({ + id: worktreeId, + repoId: 'repo-shared', + hostId: 'ssh:ssh-b', + runtimeOwnerEnvironmentId: 'hub-b', + comment: 'B' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-set-selected-owner', + ok: true, + result: { worktree: sshB }, + _meta: { runtimeId: 'hub-b' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'hub-a' } as never, + worktreesByRepo: { 'repo-shared': [sshA, sshB] } + } as Partial) + + const result = await store + .getState() + .updateWorktreeMeta(worktreeId, { comment: 'selected B' }, { executionHostId: 'ssh:ssh-b' }) + + expect(result).toEqual({ ok: true }) + expect(store.getState().worktreesByRepo['repo-shared']).toEqual([ + expect.objectContaining({ hostId: 'ssh:ssh-a', comment: 'A' }), + expect.objectContaining({ hostId: 'ssh:ssh-b', comment: 'selected B' }) + ]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'hub-b', + method: 'worktree.set', + params: expect.objectContaining({ comment: 'selected B' }) + }) + ) + }) it('cleans up the in-flight lookup when restore is skipped for a genuinely ambiguous owner', async () => { const store = createTestStore() @@ -703,6 +754,7 @@ describe('worktree remote runtime mutations', () => { }) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: wt.hostId ?? 'local', updates: { pushTarget } }) expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget) diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index 52cbef1b691..fb7b28c01e8 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -155,7 +155,8 @@ describe('worktree remote runtime mutations', () => { const wt = makeWorktree({ id: 'repo-ssh::/home/orca/wt1', repoId: 'repo-ssh', - path: '/home/orca/wt1' + path: '/home/orca/wt1', + hostId: 'ssh:ssh-1' }) store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, @@ -176,6 +177,7 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: 'ssh:ssh-1', updates: expect.objectContaining({ comment: 'ssh note' }) }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() @@ -199,6 +201,7 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: 'local', updates: { displayName: 'Fix auth', pendingFirstAgentMessageRename: false, @@ -378,12 +381,10 @@ describe('worktree remote runtime mutations', () => { } as Partial) const unsubscribe = store.subscribe(subscriber) - await store.getState().updateWorktreesMeta( - new Map([ - [first.id, { workspaceStatus: 'in-review' }], - [second.id, { workspaceStatus: 'completed' }] - ]) - ) + await store.getState().updateWorktreesMeta([ + { worktreeId: first.id, updates: { workspaceStatus: 'in-review' } }, + { worktreeId: second.id, updates: { workspaceStatus: 'completed' } } + ]) unsubscribe() expect(store.getState().worktreesByRepo.repo1.map((w) => w.workspaceStatus)).toEqual([ @@ -412,13 +413,14 @@ describe('worktree remote runtime mutations', () => { }) store.setState({ worktreesByRepo: { repo1: [local, remote] } } as Partial) - await store.getState().updateWorktreesMeta(new Map([[worktreeId, { manualOrder: 9000 }]])) + await store.getState().updateWorktreesMeta([{ worktreeId, updates: { manualOrder: 9000 } }]) expect(store.getState().worktreesByRepo.repo1.map((row) => row.manualOrder)).toEqual([ 9000, 9000 ]) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId, + executionHostId: 'local', updates: { manualOrder: 9000 } }) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -428,4 +430,40 @@ describe('worktree remote runtime mutations', () => { timeoutMs: 15_000 }) }) + it('persists an explicit bulk host update to only the selected owner', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/same/path' + const local = makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'local' }) + const remote = makeWorktree({ + id: worktreeId, + repoId: 'repo1', + hostId: 'runtime:env-1' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-set-selected-owner', + ok: true, + result: { worktree: { ...remote, comment: 'selected' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ worktreesByRepo: { repo1: [local, remote] } } as Partial) + + await store + .getState() + .updateWorktreesMeta([ + { worktreeId, updates: { comment: 'selected' }, executionHostId: 'runtime:env-1' } + ]) + + expect(store.getState().worktreesByRepo.repo1).toEqual([ + expect.objectContaining({ hostId: 'local', comment: '' }), + expect.objectContaining({ hostId: 'runtime:env-1', comment: 'selected' }) + ]) + expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'worktree.set', + params: expect.objectContaining({ comment: 'selected' }) + }) + ) + }) }) diff --git a/src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts b/src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts index 2dfb3acad50..b0456581292 100644 --- a/src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts +++ b/src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts @@ -77,6 +77,7 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: 'local', updates: { linkedPR: 42 } }) }) @@ -123,6 +124,7 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: 'local', updates: { linkedPR: 42 } }) }) @@ -314,6 +316,7 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ worktreeId: wt.id, + executionHostId: 'local', updates: { linkedPR: 42 } }) }) diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts index d8d32bb78e2..762ad7e882a 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts @@ -16,10 +16,13 @@ import { worktreeMatchesHost } from './worktree-host-ownership' const folderWorkspaceWorktreeCache = new WeakMap() +import { worktreeRowMatchesMetaHost } from './worktree-meta-host-match' + export function applyDetectedWorktreeUpdates( detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'], worktreeId: string, - rawUpdates: Partial + rawUpdates: Partial, + executionHostId?: ExecutionHostId ): AppState['detectedWorktreesByRepo'] { // Why: mirrors applyWorktreeUpdates — detected rows feed the same palette. const updates = withoutErasedRequiredWorktreeFields(rawUpdates) @@ -29,7 +32,7 @@ export function applyDetectedWorktreeUpdates( for (const [repoId, result] of Object.entries(detectedWorktreesByRepo)) { let repoChanged = false const nextWorktrees = result.worktrees.map((worktree) => { - if (worktree.id !== worktreeId) { + if (worktree.id !== worktreeId || !worktreeRowMatchesMetaHost(worktree, executionHostId)) { return worktree } repoChanged = true diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-meta-host-match.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-meta-host-match.ts new file mode 100644 index 00000000000..dfb05ab4415 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-meta-host-match.ts @@ -0,0 +1,19 @@ +import { + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../../../shared/execution-host' + +/** + * Mirror findKnownWorktreeById: an unstamped row belongs to the local host. A stricter test + * silently no-ops the optimistic write, so a rename appears to do nothing until the next refetch. + */ +export function worktreeRowMatchesMetaHost( + worktree: { hostId?: ExecutionHostId }, + executionHostId: ExecutionHostId | undefined +): boolean { + return ( + executionHostId === undefined || + worktree.hostId === executionHostId || + (worktree.hostId === undefined && executionHostId === LOCAL_EXECUTION_HOST_ID) + ) +} 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 c8f0bae7971..3107d89ab15 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 @@ -28,13 +28,19 @@ import { trySettingsForWorktreeOwner } from '../listing/worktree-owner-settings' +import { findRepoForHost } from '../../repo-host-identity' export function createUpdateWorktreeMeta( set: WorktreeSliceSet, get: WorktreeSliceGet ): WorktreeSlice['updateWorktreeMeta'] { return async (worktreeId, updates, options) => { const shouldApplyUpdate = options?.shouldApply - const existingWorktree = get().getKnownWorktreeById(worktreeId) + const requestedHostId = options?.executionHostId + const existingWorktree = findKnownWorktreeById(get(), worktreeId, requestedHostId) + const executionHostId = + requestedHostId ?? + existingWorktree?.hostId ?? + (get().settings?.activeRuntimeEnvironmentId ? undefined : 'local') if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) { return { ok: true } } @@ -78,7 +84,7 @@ export function createUpdateWorktreeMeta( normalizedUpdates.pushTarget === undefined && existingWorktree && !existingWorktree.pushTarget - ? trySettingsForWorktreeOwner(get(), worktreeId) + ? trySettingsForWorktreeOwner(get(), worktreeId, executionHostId) : null const resolvedPushTarget = pushTargetOwnerSettings && existingWorktree && linkedPrForPushTarget !== null @@ -101,7 +107,7 @@ export function createUpdateWorktreeMeta( resolvedPushTarget === undefined && existingHostedReviewPushTargetLookup !== null && existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key - const worktreeForUpdate = get().getKnownWorktreeById(worktreeId) + const worktreeForUpdate = get().getKnownWorktreeById(worktreeId, executionHostId) if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) { return { ok: true } } @@ -116,7 +122,10 @@ export function createUpdateWorktreeMeta( (normalizedUpdates.linkedGiteaPR === null && (worktreeForUpdate?.linkedGiteaPR ?? null) !== null) const reviewRepo = shouldRefreshHostedReview - ? get().repos.find((repo) => repo.id === worktreeForUpdate?.repoId) + ? (findRepoForHost(get().repos, worktreeForUpdate?.repoId ?? '', { + hostId: executionHostId, + settings: get().settings + }) ?? undefined) : undefined const reviewBranch = worktreeForUpdate?.branch.replace(/^refs\/heads\//, '') @@ -139,15 +148,24 @@ export function createUpdateWorktreeMeta( let didApply = false set((s) => { - if (shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId))) { + if ( + shouldApplyUpdate && + !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId, executionHostId)) + ) { return {} } didApply = true - const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, enriched) + const nextWorktrees = applyWorktreeUpdates( + s.worktreesByRepo, + worktreeId, + enriched, + executionHostId + ) const nextDetectedWorktrees = applyDetectedWorktreeUpdates( s.detectedWorktreesByRepo, worktreeId, - enriched + enriched, + executionHostId ) const cacheKey = reviewRepo && reviewBranch @@ -231,7 +249,13 @@ export function createUpdateWorktreeMeta( } try { - await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched) + await persistWorktreeMeta( + settingsForWorktreeOwner(get(), worktreeId, executionHostId), + worktreeId, + enriched, + executionHostId ?? existingWorktree?.hostId, + worktreeForUpdate?.identity?.key + ) if ( !options?.suppressHostedReviewRefresh && reviewRepo && 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 8ec3d887ae2..78254535106 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 @@ -1,4 +1,4 @@ -import type { WorktreeSlice } from '../../worktree-helpers' +import type { WorktreeMetaBatchUpdate, WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' import { @@ -11,7 +11,6 @@ import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' import { getIndexedWorktreesById } from '../../../worktree-repo-index' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' function getKnownOwnerHostIds( state: ReturnType, @@ -26,25 +25,24 @@ function getKnownOwnerHostIds( } return [...hostIds] } - export function createUpdateWorktreesMeta( set: WorktreeSliceSet, get: WorktreeSliceGet ): WorktreeSlice['updateWorktreesMeta'] { - return async (updatesByWorktreeId) => { - if (updatesByWorktreeId.size === 0) { + return async (updates) => { + if (updates.length === 0) { return } - const gitWorktreeUpdates = new Map>() + const gitWorktreeUpdates: WorktreeMetaBatchUpdate[] = [] const folderWorkspaceUpdates: { folderWorkspaceId: string updates: ReturnType }[] = [] - for (const [worktreeId, updates] of updatesByWorktreeId) { - const scope = parseWorkspaceKey(worktreeId) + for (const entry of updates) { + const scope = parseWorkspaceKey(entry.worktreeId) if (scope?.type === 'folder') { - const folderUpdates = getFolderWorkspaceMetaUpdates(updates) + const folderUpdates = getFolderWorkspaceMetaUpdates(entry.updates) if (Object.keys(folderUpdates).length > 0) { folderWorkspaceUpdates.push({ folderWorkspaceId: scope.folderWorkspaceId, @@ -52,19 +50,25 @@ export function createUpdateWorktreesMeta( }) } } else { - gitWorktreeUpdates.set(worktreeId, updates) + gitWorktreeUpdates.push(entry) } } set((s) => { let nextWorktrees = s.worktreesByRepo let nextDetectedWorktrees = s.detectedWorktreesByRepo - for (const [worktreeId, updates] of gitWorktreeUpdates) { - nextWorktrees = applyWorktreeUpdates(nextWorktrees, worktreeId, updates) + for (const entry of gitWorktreeUpdates) { + nextWorktrees = applyWorktreeUpdates( + nextWorktrees, + entry.worktreeId, + entry.updates, + entry.executionHostId + ) nextDetectedWorktrees = applyDetectedWorktreeUpdates( nextDetectedWorktrees, - worktreeId, - updates + entry.worktreeId, + entry.updates, + entry.executionHostId ) } return nextWorktrees === s.worktreesByRepo && @@ -84,20 +88,27 @@ export function createUpdateWorktreesMeta( ...folderWorkspaceUpdates.map(({ folderWorkspaceId, updates }) => get().updateFolderWorkspace(folderWorkspaceId, updates) ), - ...Array.from(gitWorktreeUpdates, async ([worktreeId, updates]) => { + ...gitWorktreeUpdates.map(async ({ worktreeId, updates, executionHostId }) => { try { const state = get() - const ownerHostIds = getKnownOwnerHostIds(state, worktreeId) + const ownerHostIds = executionHostId + ? [executionHostId] + : getKnownOwnerHostIds(state, worktreeId) await (ownerHostIds.length === 0 ? persistWorktreeMeta(settingsForWorktreeOwner(state, worktreeId), worktreeId, updates) : Promise.all( - ownerHostIds.map((hostId) => - persistWorktreeMeta( + ownerHostIds.map((hostId) => { + const worktree = getIndexedWorktreesById(state.worktreesByRepo, worktreeId).find( + (candidate) => candidate.hostId === hostId + ) + return persistWorktreeMeta( settingsForWorktreeOwner(state, worktreeId, hostId), worktreeId, - updates + updates, + hostId, + worktree?.identity?.key ) - ) + }) )) } catch (err) { if (isRuntimeSelectorNotFoundError(err)) { diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts index 7ebdbcc2ccd..99e4ac18ce1 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts @@ -11,16 +11,22 @@ import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree- import { translate } from '@/i18n/i18n' import type { AppState } from '../../../types' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { encodePushTargetClearForRuntimeRpc } from './hosted-review-link-mutation' - export async function persistWorktreeMeta( settings: AppState['settings'], worktreeId: string, - updates: Partial + updates: Partial, + executionHostId?: ExecutionHostId, + identityKey?: string ): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { - await window.api.worktrees.updateMeta({ worktreeId, updates }) + await window.api.worktrees.updateMeta({ + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + updates + }) return } // Why: `worktree.set` parses in strip mode, so an older runtime drops the key @@ -55,7 +61,7 @@ export async function persistWorktreeMeta( target, 'worktree.set', { - worktree: toRuntimeWorktreeSelector(worktreeId), + worktree: identityKey ? `identity:${identityKey}` : toRuntimeWorktreeSelector(worktreeId), ...encodePushTargetClearForRuntimeRpc(updates) }, { timeoutMs: 15_000 } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts b/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts index e8975c76587..7c104f06ca2 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts @@ -1,4 +1,4 @@ -import type { WorktreeSlice } from '../../worktree-helpers' +import type { WorktreeMetaBatchUpdate, WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { getActiveSidebarWorkspaceId, @@ -9,11 +9,9 @@ import { isValidResolvedWorktreeLineageEdge } from '../../../../../../shared/resolved-worktree-lineage' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' import type { Worktree } from '../../../../../../shared/worktree/types' type WorktreeWithEmbeddedLineage = Worktree & { lineage?: WorktreeLineage | null } - function getProjectedLineage(get: WorktreeSliceGet, worktree: Worktree): WorktreeLineage | null { if (Object.hasOwn(get().worktreeLineageById, worktree.id)) { return get().worktreeLineageById[worktree.id] ?? null @@ -66,7 +64,7 @@ export function createSetWorktreesPinnedAndReveal( get().activeWorktreeId ) // Skip worktrees already in the target state so a no-op toggle doesn't scroll the viewport away. - const updates = new Map>() + const updates: WorktreeMetaBatchUpdate[] = [] const changedWorktreeIds = new Set() let didChange = false let revealWorktreeId: string | null = null @@ -79,9 +77,17 @@ export function createSetWorktreesPinnedAndReveal( changedWorktreeIds.add(worktreeId) const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { - void get().updateWorktreeMeta(worktreeId, { isPinned }) + void get().updateWorktreeMeta( + worktreeId, + { isPinned }, + { executionHostId: current.hostId ?? 'local' } + ) } else { - updates.set(worktreeId, { isPinned }) + updates.push({ + worktreeId, + updates: { isPinned }, + executionHostId: current.hostId ?? 'local' + }) } if (revealWorktreeId === null && worktreeId === activeSidebarWorktreeId) { revealWorktreeId = worktreeId @@ -99,7 +105,7 @@ export function createSetWorktreesPinnedAndReveal( revealWorktreeId = activeSidebarWorktreeId } // updateWorktreesMeta applies the store update synchronously, so the reveal below sees the row already rendered. - if (updates.size > 0) { + if (updates.length > 0) { void get().updateWorktreesMeta(updates) } if (revealWorktreeId !== null) { diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index 75ed6210ff8..de895978f3b 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -120,3 +120,16 @@ describe('execution host identity', () => { ) }) }) + +describe('execution host id delimiter invariant', () => { + it('rejects an unencoded pipe so a crafted id cannot rebind a worktree identity alias', () => { + // composeWorktreeHostIdentity splits at the first `|`, so `ssh:a|b` would resolve as `ssh:a`. + expect(parseExecutionHostId('ssh:a|b')).toBeNull() + expect(parseExecutionHostId('runtime:a|b')).toBeNull() + expect(parseExecutionHostId(toSshExecutionHostId('a|b'))).toEqual({ + kind: 'ssh', + id: 'ssh:a%7Cb', + targetId: 'a|b' + }) + }) +}) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index 18d584674bf..aacbc695cdc 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -81,6 +81,11 @@ export function parseExecutionHostId(value: string | null | undefined): ParsedEx if (!encoded) { return null } + // `|` must stay out of a host id: composeWorktreeHostIdentity uses it as its delimiter and + // splits at the first one, so an unencoded pipe would rebind an alias to a different host. + if (encoded.includes('|')) { + return null + } try { const targetId = decodeURIComponent(encoded) return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null @@ -93,6 +98,9 @@ export function parseExecutionHostId(value: string | null | undefined): ParsedEx if (!encoded) { return null } + if (encoded.includes('|')) { + return null + } try { const environmentId = decodeURIComponent(encoded) return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index 6266f8bc08f..a081eee76ab 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -77,6 +77,10 @@ export type PersistedState = { /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */ mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections worktreeMeta: Record + /** Canonical host/instance metadata; optional for legacy profiles. */ + worktreeMetaByIdentity?: Record + /** Host-qualified locator aliases to canonical identity keys. */ + worktreeIdentityAliases?: Record worktreeLineageById: Record workspaceLineageByChildKey: Record settings: GlobalSettings diff --git a/src/shared/project-host-setup-lookup.ts b/src/shared/project-host-setup-lookup.ts new file mode 100644 index 00000000000..d7d59f4cef9 --- /dev/null +++ b/src/shared/project-host-setup-lookup.ts @@ -0,0 +1,30 @@ +import { getRepoExecutionHostId } from './execution-host' +import type { ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import type { WorktreeMeta } from './worktree/meta-types' +import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' + +export function getProjectHostSetupForRepo( + setups: readonly ProjectHostSetup[], + repo: Repo +): ProjectHostSetup { + // A repo id can exist on multiple hosts; the host-qualified setup is authoritative. + const executionHostId = getRepoExecutionHostId(repo) + return ( + setups.find((setup) => setup.repoId === repo.id && setup.hostId === executionHostId) ?? + setups.find((setup) => setup.repoId === repo.id) ?? + projectHostSetupProjectionFromRepos([repo]).setups[0] + ) +} + +export function getProjectHostSetupWorktreeMeta( + setups: readonly ProjectHostSetup[], + repo: Repo +): Pick { + const setup = getProjectHostSetupForRepo(setups, repo) + return { + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id + } +} diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index 97ea8cc66b0..67fca1ad6b5 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest' import { projectHostSetupProjectionFromRepos, getProjectHostSetupsForProject, - getProjectHostSetupWorktreeMeta, isGitHubBackedRepo, getProjectIdForProviderIdentity, isProjectRemoteIdentityPending } from './project-host-setup-projection' +import { getProjectHostSetupWorktreeMeta } from './project-host-setup-lookup' import type { Repo } from './repo-types' function repo(overrides: Partial & Pick): Repo { @@ -664,3 +664,23 @@ describe('derived project identity stability', () => { expect(projection.projects.map((project) => project.id)).toEqual(['github:acme/app']) }) }) + +describe('getProjectHostSetupWorktreeMeta host selection', () => { + it("picks the setup for the repo's own execution host when a repoId spans two hosts", () => { + const sshRepo = repo({ + id: 'repo-shared', + path: '/remote/orca', + displayName: 'orca', + connectionId: 'build-box' + }) + // Local first in the array: a repoId-only match would stamp the wrong host durably. + const setups = [ + ...projectHostSetupProjectionFromRepos([ + repo({ id: 'repo-shared', path: '/local/orca', displayName: 'orca' }) + ]).setups, + ...projectHostSetupProjectionFromRepos([sshRepo]).setups + ] + + expect(getProjectHostSetupWorktreeMeta(setups, sshRepo).hostId).toBe('ssh:build-box') + }) +}) diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 090f421ccdd..954208ee27d 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -7,7 +7,6 @@ import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' import type { Repo } from './repo-types' -import type { WorktreeMeta } from './worktree/meta-types' type ProjectAccumulator = { project: Project @@ -337,25 +336,3 @@ export function getProjectHostSetupsForProject( ): readonly ProjectHostSetup[] { return setups.filter((setup) => setup.projectId === projectId) } - -export function getProjectHostSetupForRepo( - setups: readonly ProjectHostSetup[], - repo: Repo -): ProjectHostSetup { - return ( - setups.find((setup) => setup.repoId === repo.id) ?? - projectHostSetupProjectionFromRepos([repo]).setups[0] - ) -} - -export function getProjectHostSetupWorktreeMeta( - setups: readonly ProjectHostSetup[], - repo: Repo -): Pick { - const setup = getProjectHostSetupForRepo(setups, repo) - return { - projectId: setup.projectId, - hostId: setup.hostId, - projectHostSetupId: setup.id - } -} diff --git a/src/shared/worktree/identity.test.ts b/src/shared/worktree/identity.test.ts new file mode 100644 index 00000000000..c1e109cb7ad --- /dev/null +++ b/src/shared/worktree/identity.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { canonicalWorktreeIdentity, type WorktreeIdentityRef } from './identity' + +describe('canonical worktree identity', () => { + const local: WorktreeIdentityRef = { + worktreeId: 'repo-1::/workspace/feature', + executionHostId: 'local', + instanceId: '11111111-1111-4111-8111-111111111111' + } + const remote: WorktreeIdentityRef = { + ...local, + executionHostId: 'ssh:build-box' + } + + it('separates same locator and instance across execution hosts', () => { + expect(canonicalWorktreeIdentity(local)).not.toBe(canonicalWorktreeIdentity(remote)) + }) + + it('is stable when only the path locator changes', () => { + expect( + canonicalWorktreeIdentity({ ...local, worktreeId: 'repo-1::/workspace/renamed-feature' }) + ).toBe(canonicalWorktreeIdentity(local)) + }) + + it('does not use the display name as identity input', () => { + expect(canonicalWorktreeIdentity(local)).toBe(canonicalWorktreeIdentity({ ...local })) + }) +}) diff --git a/src/shared/worktree/identity.ts b/src/shared/worktree/identity.ts new file mode 100644 index 00000000000..382d4329792 --- /dev/null +++ b/src/shared/worktree/identity.ts @@ -0,0 +1,29 @@ +import type { ExecutionHostId } from '../execution-host' + +/** Stable fields needed to identify one checkout occupant. */ +export type WorktreeIdentityRef = { + /** Existing repo/path locator; mutable when the folder moves. */ + worktreeId: string + /** Host that owns execution for this checkout. */ + executionHostId: ExecutionHostId + /** Durable identity for this checkout occupant. */ + instanceId: string +} +export type WorktreeIdentity = Omit & { + key: string +} + +/** + * Canonical exact identity for one worktree occupant. + * The locator is deliberately excluded so a folder rename preserves identity. + */ +export function canonicalWorktreeIdentity(ref: WorktreeIdentityRef): string { + return `wt2:${encodeURIComponent(ref.executionHostId)}:${encodeURIComponent(ref.instanceId)}` +} +export function createWorktreeIdentity(ref: WorktreeIdentityRef): WorktreeIdentity { + return { + key: canonicalWorktreeIdentity(ref), + executionHostId: ref.executionHostId, + instanceId: ref.instanceId + } +} diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index af6252f5c3a..97c11abf3eb 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -5,6 +5,7 @@ import type { TuiAgent } from '../tui-agent' import type { DiffComment, MobileDiffReviewState } from '../diff-comment-types' import type { EphemeralVmCheckoutMode } from '../orca-yaml-hook-types' import type { BuiltInWorktreeVisibilitySourceId } from '../repo-types' +import type { WorktreeIdentity } from './identity' export type WorkspaceLinkedItem = { provider: 'github' | 'gitlab' | 'linear' | 'jira' @@ -60,6 +61,8 @@ export type WorkspaceStatusDefinition = { export type Worktree = { id: string // `${repoId}::${path}` instanceId?: string + /** Immutable host/instance identity. Optional while legacy rows migrate. */ + identity?: WorktreeIdentity repoId: string /** Durable project identity. Optional while legacy repo-only workspaces migrate. */ projectId?: string From 913509edeb0ada8e6a8c57f3b2360dd60d424de1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:25:30 -0700 Subject: [PATCH 4/8] fix(orchestration): prevent slow worker-start stalls (#16300) * Extend orchestration agent submission timing budgets * fix(orchestration): preserve mutation recovery identity * fix(orchestration): preserve recovery executable identity * fix(orchestration): keep worker starts and recovery commands safe * test(orchestration): cover federated worker preflight * fix(orchestration): harden mutation recovery * fix(orchestration): redact dispatch recovery credentials * chore: preserve upstream skill dialog formatting * test(orchestration): stabilize agent prompt submit e2e * fix(orchestration): validate federated start receipts * perf(runtime): cache unchanged prompt verification tail * fix(orchestration): reject worker-start timer overflow * fix(orchestration): normalize worker-start timeout defaults * fix(orchestration): normalize worker-start readiness budgets * fix(orchestration): normalize federated readiness timeout * test(runtime): tolerate current-main degradation exports * chore: preserve current-main orcad formatting * chore: drop unrelated formatting carryover --- .../handlers/orchestration-gate-cli.test.ts | 10 + src/cli/index.ts | 5 +- .../orchestration-mutation-recovery.test.ts | 269 ++++++++++++++++++ src/cli/orchestration-mutation-recovery.ts | 166 ++++++++++- src/cli/runtime/client-error-recovery.ts | 31 ++ src/cli/runtime/client-recovery.test.ts | 130 +++++++++ src/cli/runtime/client.ts | 64 +++-- .../orchestration-recovery-command.test.ts | 133 +++++++++ .../runtime/orchestration-recovery-command.ts | 102 +++++++ .../runtime/websocket-transport-error.test.ts | 101 +++++++ src/cli/runtime/websocket-transport.ts | 4 +- ...ent-prompt-submission-verification.test.ts | 31 +- .../agent-prompt-submission-verification.ts | 29 +- src/main/runtime/orca-runtime.ts | 45 ++- ...estration-federated-attach-receipt.test.ts | 36 +++ .../orchestration-federated-attach-receipt.ts | 53 ++++ ...ion-federated-worker-start-receipt.test.ts | 84 ++++++ ...-federated-worker-start-unknown-receipt.ts | 25 ++ .../orchestration-federated-worker-start.ts | 133 ++++----- .../rpc/methods/orchestration-federation.ts | 13 +- .../orchestration-migration-behavior.test.ts | 81 ++++++ ...orchestration-worker-start-budgets.test.ts | 34 +++ .../orchestration-worker-start-budgets.ts | 32 +++ .../rpc/methods/orchestration-workers.ts | 17 +- .../runtime-rpc-long-poll-transport.test.ts | 110 ++++++- src/main/runtime/runtime-rpc.ts | 16 ++ .../ssh-remote-cli-host-passthrough.test.ts | 18 ++ .../ssh/ssh-remote-cli-host-passthrough.ts | 2 + .../orchestration-timing-budgets.test.ts | 51 ++++ src/shared/orchestration-timing-budgets.ts | 50 ++++ src/shared/remote-runtime-client-error.ts | 4 + .../remote-runtime-shared-control-protocol.ts | 6 +- .../terminal-send-agent-prompt-submit.spec.ts | 102 +++++-- tests/tools/repro-terminal-send-submit.mjs | 124 +++++--- 34 files changed, 1912 insertions(+), 199 deletions(-) create mode 100644 src/cli/orchestration-mutation-recovery.test.ts create mode 100644 src/cli/runtime/client-error-recovery.ts create mode 100644 src/cli/runtime/client-recovery.test.ts create mode 100644 src/cli/runtime/orchestration-recovery-command.test.ts create mode 100644 src/cli/runtime/orchestration-recovery-command.ts create mode 100644 src/cli/runtime/websocket-transport-error.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts create mode 100644 src/shared/orchestration-timing-budgets.test.ts create mode 100644 src/shared/orchestration-timing-budgets.ts diff --git a/src/cli/handlers/orchestration-gate-cli.test.ts b/src/cli/handlers/orchestration-gate-cli.test.ts index 0bcdddbc98e..b4be315c155 100644 --- a/src/cli/handlers/orchestration-gate-cli.test.ts +++ b/src/cli/handlers/orchestration-gate-cli.test.ts @@ -206,6 +206,16 @@ describe('orchestration gate commands carry caller identity', () => { 'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.', { orchestrationRequestId: 'mutation_1', + originalCommand: [ + 'orca', + 'orchestration', + 'gate-create', + '--task', + 'task_1', + '--question', + 'ship?', + '--json' + ], failedStage: 'dispatch_input', residualResources: [ { kind: 'worktree', id: 'repo::child' }, diff --git a/src/cli/index.ts b/src/cli/index.ts index 26cfb8eea49..2a8921a2cdb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import { reportCliError } from './format' import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' +import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -149,7 +150,9 @@ export async function main( undefined, undefined, remotePairingCode, - remoteEnvironment + remoteEnvironment, + resolveOrchestrationCliExecutable(), + argv ) return client }, diff --git a/src/cli/orchestration-mutation-recovery.test.ts b/src/cli/orchestration-mutation-recovery.test.ts new file mode 100644 index 00000000000..7664cc7ec55 --- /dev/null +++ b/src/cli/orchestration-mutation-recovery.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from 'vitest' +import { runProcess } from '../shared/child-process/run-process' +import { + orchestrationMutationRecoveryError, + renderCommand +} from './orchestration-mutation-recovery' +import { RuntimeClientError } from './runtime-client' + +describe('orchestration mutation recovery', () => { + it('queries a known dispatch before issuing the keyed retry', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + }) + ) as RuntimeClientError + + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + 'request_1' + ], + workerDeathInferred: false + } + }) + expect(result.message.indexOf('orca orchestration worker-show')).toBeLessThan( + result.message.indexOf('orca orchestration worker-start') + ) + expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ + 'Run orca orchestration worker-show --dispatch dispatch_1 --json before retrying.', + 'Run orca orchestration worker-start --task task_1 --retry-request request_1.' + ]) + }) + + it('does not invent a dispatch for an old-client-shaped error', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_2', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_2'] + }) + ) as RuntimeClientError + + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_2', + retryCommand: expect.arrayContaining(['--retry-request', 'request_2']), + workerDeathInferred: false + } + }) + expect((result.data as Record).recovery).not.toHaveProperty('dispatchId') + expect(result.message).not.toContain('worker death') + }) + + it('renders the exact executable and safely quotes original arguments', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_3', + dispatchId: 'dispatch_3', + originalCommand: [ + 'orca-dev', + 'orchestration', + 'worker-start', + '--task', + 'task 3', + '--comment', + 'literal $(do-not-run)' + ] + }) + ) as RuntimeClientError + + expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ + 'Run orca-dev orchestration worker-show --dispatch dispatch_3 --json before retrying.', + "Run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3." + ]) + expect(result.message).toContain("'literal $(do-not-run)'") + }) + + it('parses legacy command text without losing quoted arguments', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_4', + originalCommand: + 'orca-ide orchestration worker-stop --dispatch dispatch_4 --comment "quoted value"' + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([ + 'orca-ide', + 'orchestration', + 'worker-stop', + '--dispatch', + 'dispatch_4', + '--comment', + 'quoted value', + '--retry-request', + 'request_4' + ]) + }) + + it.each([ + [ + 'gate-create', + ['orca', 'orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?'] + ], + [ + 'worker-retain', + ['orca', 'orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json'] + ] + ])('replays exact %s argv with the keyed retry', (_name, originalCommand) => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_exact', + originalCommand + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([...originalCommand, '--retry-request', 'request_exact']) + }) + + it('reuses the request identity without duplicating an existing retry flag', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_reused', + originalCommand: [ + 'orca', + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1', + '--retry-request=request_reused' + ] + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([ + 'orca', + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1', + '--retry-request', + 'request_reused' + ]) + }) + + it('renders Windows cmd recovery guidance without quote drift or percent expansion', () => { + expect( + renderCommand( + ['orca', 'orchestration', 'worker-start', '--comment', 'literal "quoted" %PATH% & safe'], + 'win32', + { ComSpec: 'C:\\Windows\\System32\\cmd.exe' } + ) + ).toBe( + '"orca" "orchestration" "worker-start" "--comment" "literal ""quoted"" "^%"PATH"^%" & safe"' + ) + }) + + it('keeps PowerShell and POSIX recovery guidance literal', () => { + expect( + renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', { + ComSpec: 'powershell.exe' + }) + ).toBe("& 'orca' 'literal \\\"quoted\\\" $HOME'") + expect(renderCommand(['orca', 'literal $(do-not-run)'], 'darwin')).toBe( + "orca 'literal $(do-not-run)'" + ) + }) + + it.runIf(process.platform !== 'win32')( + 'round trips POSIX recovery argv through /bin/sh', + async () => { + const values = ['with spaces', "apostrophe's", 'literal $(do-not-run)', 'line one\nline two'] + const command = renderCommand( + [ + process.execPath, + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1)))', + ...values + ], + 'darwin' + ) + + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + + expect(result).toMatchObject({ code: 0, stderr: '', timedOut: false }) + expect(JSON.parse(result.stdout)).toEqual(values) + } + ) + + it.each([ + [ + 'split', + ['orca', 'orchestration', 'send', '--pairing-code', 'split-secret', '--subject', 'status'], + 'split-secret' + ], + [ + 'equals', + ['orca', 'orchestration', 'send', '--pairing-code=equals-secret', '--subject', 'status'], + 'equals-secret' + ], + [ + 'dispatch split', + [ + 'orca', + 'orchestration', + 'send', + '--dispatch-capability', + 'split-dispatch-secret', + '--subject', + 'status' + ], + 'split-dispatch-secret' + ], + [ + 'dispatch equals', + [ + 'orca', + 'orchestration', + 'send', + '--dispatch-capability=equals-dispatch-secret', + '--subject', + 'status' + ], + 'equals-dispatch-secret' + ] + ])('blocks recovery and removes %s credentials', (_name, originalCommand, secret) => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_secret', + originalCommand + }) + ) as RuntimeClientError + const output = JSON.stringify({ message: result.message, data: result.data }) + + expect(output).not.toContain(secret) + expect(result.message).toContain('Recovery is blocked') + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_secret', + recoveryBlocked: true + } + }) + expect(result.data).not.toHaveProperty('originalCommand') + expect((result.data as { recovery: object }).recovery).not.toHaveProperty('retryCommand') + }) +}) diff --git a/src/cli/orchestration-mutation-recovery.ts b/src/cli/orchestration-mutation-recovery.ts index 8e4a03f911d..63261561caf 100644 --- a/src/cli/orchestration-mutation-recovery.ts +++ b/src/cli/orchestration-mutation-recovery.ts @@ -1,4 +1,12 @@ -import { RuntimeClientError } from './runtime-client' +import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' +import { + recoverableOrchestrationArgs, + resolveOrchestrationCliExecutable +} from './runtime/orchestration-recovery-command' +import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line' +import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument' +import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' +import type { AgentStartupShell } from '../shared/tui-agent-startup-shell' export function orchestrationMutationRecoveryError(error: unknown): unknown { if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) { @@ -9,16 +17,64 @@ export function orchestrationMutationRecoveryError(error: unknown): unknown { if (typeof requestId !== 'string' || requestId.length === 0) { return error } + const dispatchId = typeof data?.dispatchId === 'string' ? data.dispatchId : undefined + const parsedOriginalCommand = commandParts(data?.originalCommand) + const originalCommand = parsedOriginalCommand + ? recoverableOrchestrationArgs(parsedOriginalCommand) + : undefined + const safeData = { ...data } + delete safeData.originalCommand + const retryCommand = originalCommand + ? [...originalCommand, '--retry-request', requestId] + : undefined + const executable = originalCommand?.[0] ?? resolveOrchestrationCliExecutable() + const queryCommand = dispatchId + ? [executable, 'orchestration', 'worker-show', '--dispatch', dispatchId, '--json'] + : undefined + const recovery = { + orchestrationRequestId: requestId, + ...(dispatchId ? { dispatchId } : {}), + ...(queryCommand ? { queryCommand } : {}), + ...(retryCommand ? { retryCommand } : {}), + recoveryBlocked: !retryCommand, + disposition: 'outcome_unknown', + workerDeathInferred: false + } + const retryStep = retryCommand + ? `Run ${renderCommand(retryCommand)}.` + : 'Recovery is blocked until the exact original command is available; no retry command was emitted.' + const nextSteps = queryCommand + ? [`Run ${renderCommand(queryCommand)} before retrying.`, retryStep] + : [retryStep] const message = [ stripUnsafeRetryAdvice(error.message, requestId), 'The orchestration mutation may already have taken effect; do not assume it failed.', - `Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`, + ...nextSteps, typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined, Array.isArray(data?.residualResources) ? `Residual resources: ${JSON.stringify(data.residualResources)}.` : undefined ].filter((line): line is string => line !== undefined) - return new RuntimeClientError(error.code, message.join('\n'), error.data) + const recoveredData = { + ...safeData, + orchestrationRequestId: requestId, + ...(originalCommand ? { originalCommand } : {}), + recovery, + nextSteps + } + // Preserve the RPC failure envelope so --json callers retain the request id and + // runtime metadata while receiving the structured recovery guidance. + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { + ...error.response.error, + message: message.join('\n'), + data: recoveredData + } + }) + } + return new RuntimeClientError(error.code, message.join('\n'), recoveredData) } function isUnknownMutationOutcomeCode(code: string): boolean { @@ -36,6 +92,110 @@ function objectRecord(value: unknown): Record | undefined { : undefined } +function commandParts(value: unknown): string[] | undefined { + if (Array.isArray(value) && value.every((part) => typeof part === 'string')) { + return [...value] + } + if (typeof value === 'string' && value.length > 0) { + return parseCommandLine(value) + } + return undefined +} + +function parseCommandLine(value: string): string[] | undefined { + const parts: string[] = [] + let part = '' + let quote: "'" | '"' | undefined + let tokenStarted = false + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (quote === "'") { + if (character === "'") { + quote = undefined + } else { + part += character + } + tokenStarted = true + continue + } + if (quote === '"') { + if (character === '"') { + quote = undefined + } else if (character === '\\' && ['"', '\\'].includes(value[index + 1] ?? '')) { + part += value[++index] + } else { + part += character + } + tokenStarted = true + continue + } + if (character === "'" || character === '"') { + quote = character + tokenStarted = true + } else if (/\s/.test(character)) { + if (tokenStarted) { + parts.push(part) + part = '' + tokenStarted = false + } + } else if (character === '\\' && [' ', '\\', "'", '"'].includes(value[index + 1] ?? '')) { + part += value[++index] + tokenStarted = true + } else { + part += character + tokenStarted = true + } + } + if (quote !== undefined) { + return undefined + } + if (tokenStarted) { + parts.push(part) + } + return parts.length > 0 ? parts : undefined +} + +export function renderCommand( + command: readonly string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env +): string { + const shell = resolveRecoveryShell(platform, env) + const rendered = command.map((value) => quoteRecoveryArgument(value, shell)).join(' ') + return shell === 'powershell' && rendered ? `& ${rendered}` : rendered +} + +function resolveRecoveryShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): AgentStartupShell { + if (platform !== 'win32') { + return 'posix' + } + return resolveWindowsShellStartupFamily( + env.ORCA_TERMINAL_WINDOWS_SHELL ?? env.ORCA_WINDOWS_SHELL ?? env.ComSpec ?? env.COMSPEC + ) +} + +function quoteRecoveryArgument(value: string, shell: AgentStartupShell): string { + if (shell === 'cmd') { + // cmd has no single-quote syntax and expands %VAR% inside double quotes; + // use the argv encoder that keeps quote parity and escapes percent pairs. + return quoteWindowsCmdArgument(value) + } + if (shell === 'powershell') { + return quotePowerShellNativeArgument(value) + } + return shellQuote(value) +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value + } + return `'${value.replaceAll("'", `'"'"'`)}'` +} + function stripUnsafeRetryAdvice(message: string, requestId: string): string { return message .replace(' Restart Orca and try again.', '') diff --git a/src/cli/runtime/client-error-recovery.ts b/src/cli/runtime/client-error-recovery.ts new file mode 100644 index 00000000000..89ff4fa884f --- /dev/null +++ b/src/cli/runtime/client-error-recovery.ts @@ -0,0 +1,31 @@ +import { RuntimeClientError, RuntimeRpcFailureError } from './types' + +export function attachMutationRecovery( + error: unknown, + requestId: string | undefined, + originalCommand?: string[] +): unknown { + if (!requestId || !(error instanceof RuntimeClientError)) { + return error + } + const data = { + ...(error.data && typeof error.data === 'object' ? error.data : {}), + orchestrationRequestId: requestId, + ...(originalCommand ? { originalCommand } : {}) + } + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { + ...error.response.error, + message: `${error.message} Orchestration mutation request ID: ${requestId}.`, + data + } + }) + } + return new RuntimeClientError( + error.code, + `${error.message} Orchestration mutation request ID: ${requestId}.`, + data + ) +} diff --git a/src/cli/runtime/client-recovery.test.ts b/src/cli/runtime/client-recovery.test.ts new file mode 100644 index 00000000000..3da40edd5f0 --- /dev/null +++ b/src/cli/runtime/client-recovery.test.ts @@ -0,0 +1,130 @@ +import { createServer, type Server } from 'node:net' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' +import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client' + +const servers = new Set() + +afterEach(async () => { + await Promise.all( + [...servers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()) + }) + ) + ) + servers.clear() +}) + +describe('RuntimeClient orchestration recovery identity', () => { + it('rejects a worker-start timeout whose client grace would overflow timers', () => { + const client = new RuntimeClient(undefined, 60_000, null, null, 'orca') + const resolve = ( + client as unknown as { + resolveMethodTimeoutMs: (method: string, params?: unknown) => number + } + ).resolveMethodTimeoutMs.bind(client) + const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + expect(resolve('orchestration.workerStart', { timeoutMs: maxValid })).toBe(MAX_TIMER_DELAY_MS) + expect(() => resolve('orchestration.workerStart', { timeoutMs: maxValid + 1 })).toThrow( + 'derived timeout must be' + ) + }) + + it('attaches the request and exact retry identity to a real RPC failure response', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-recovery-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const request = JSON.parse(buffer.slice(0, newline)) as { id: string; method: string } + const response = + request.method === 'status.get' + ? { + id: request.id, + ok: true, + result: { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'runtime-1' } + } + : { + id: request.id, + ok: false, + error: { + code: 'runtime_timeout', + message: 'request timed out', + data: { requestId: 'request_1', dispatchId: 'dispatch_1' } + }, + _meta: { runtimeId: 'runtime-1' } + } + socket.end(`${JSON.stringify(response)}\n`) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeFileSync( + join(userDataPath, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: 'runtime-1', + pid: 1, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }) + ) + + const client = new RuntimeClient(userDataPath, 500, null, null, 'orca') + try { + await client.call('orchestration.workerStart', { task: 'task_1' }) + throw new Error('expected worker-start failure') + } catch (error) { + expect(error).toBeInstanceOf(RuntimeRpcFailureError) + const recovered = orchestrationMutationRecoveryError(error) as { + data?: Record + response?: { id?: string; _meta?: { runtimeId?: string } } + } + expect(recovered).toBeInstanceOf(RuntimeRpcFailureError) + expect(recovered.response).toMatchObject({ + id: expect.any(String), + _meta: { runtimeId: 'runtime-1' } + }) + expect(recovered.data).toMatchObject({ + orchestrationRequestId: expect.any(String), + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'], + recovery: { + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + expect.any(String) + ], + recoveryBlocked: false + } + }) + } + }) +}) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 31c5b94e5c9..f9a855ae929 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -11,6 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus, projectRemoteAppStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { attachMutationRecovery } from './client-error-recovery' import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, @@ -19,6 +20,16 @@ import { import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate' import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope' import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout' +import { + isWorkerStartTimeoutWithinTimerLimit, + resolveWorkerStartClientTimeoutMs, + resolveWorkerStartReadinessTimeoutMs +} from '../../shared/orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { + buildOrchestrationRecoveryCommand, + resolveOrchestrationCliExecutable +} from './orchestration-recovery-command' // Why: for long-poll methods the caller's method-level // `params.timeoutMs` is the inner waiter budget; we extend the client-side @@ -40,6 +51,8 @@ export class RuntimeClient { private readonly requestTimeoutMs: number private readonly remotePairing: PairingOffer | null private readonly environmentSelector: string | null + private readonly cliExecutable: string + private readonly originalArgs: readonly string[] | undefined private readonly remoteCompat: RemoteRuntimeCompatGate private orchestrationContractCheck: Promise | null = null private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope( @@ -53,11 +66,15 @@ export class RuntimeClient { userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000, remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null, - environmentSelector = process.env.ORCA_ENVIRONMENT ?? null + environmentSelector = process.env.ORCA_ENVIRONMENT ?? null, + cliExecutable = resolveOrchestrationCliExecutable(), + originalArgs?: readonly string[] ) { this.userDataPath = userDataPath this.requestTimeoutMs = requestTimeoutMs this.environmentSelector = environmentSelector + this.cliExecutable = cliExecutable + this.originalArgs = originalArgs ? [...originalArgs] : undefined this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector) } @@ -79,6 +96,9 @@ export class RuntimeClient { const orchestrationRequestId = orchestrationMutation ? (options?.orchestrationRequestId ?? randomUUID()) : undefined + const originalCommand = orchestrationMutation + ? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs) + : undefined const compatibilityEnvelope = method.startsWith('orchestration.') ? { ...this.orchestrationCompatibility, @@ -107,10 +127,14 @@ export class RuntimeClient { envelope }) } catch (error) { - throw attachMutationRecovery(error, orchestrationRequestId) + throw attachMutationRecovery(error, orchestrationRequestId, originalCommand) } if (response.ok === false) { - throw new RuntimeRpcFailureError(response) + throw attachMutationRecovery( + new RuntimeRpcFailureError(response), + orchestrationRequestId, + originalCommand + ) } if (this.environmentSelector) { markEnvironmentUsed(this.userDataPath, this.environmentSelector, { @@ -124,10 +148,14 @@ export class RuntimeClient { try { response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) } catch (error) { - throw attachMutationRecovery(error, orchestrationRequestId) + throw attachMutationRecovery(error, orchestrationRequestId, originalCommand) } if (response.ok === false) { - throw new RuntimeRpcFailureError(response) + throw attachMutationRecovery( + new RuntimeRpcFailureError(response), + orchestrationRequestId, + originalCommand + ) } return response } @@ -138,6 +166,18 @@ export class RuntimeClient { // to resolve. Without this, a 5 min wait would still die at the 60 s default. // See design doc §3.1. private resolveMethodTimeoutMs(method: string, params?: unknown): number { + if (method === 'orchestration.workerStart') { + const requestedValue = getTimeoutMsParam(params) + const requested = typeof requestedValue === 'number' ? requestedValue : Number(requestedValue) + if (!isWorkerStartTimeoutWithinTimerLimit(requested)) { + throw new RuntimeClientError( + 'invalid_argument', + `--timeout-ms is too large for worker-start transport grace; the derived timeout must be <= ${MAX_TIMER_DELAY_MS}ms.` + ) + } + const readiness = resolveWorkerStartReadinessTimeoutMs(requested) + return Math.max(resolveWorkerStartClientTimeoutMs(readiness), this.requestTimeoutMs) + } if ( (method === 'orchestration.check' && isWaitingCheck(params)) || method === 'terminal.wait' @@ -241,20 +281,6 @@ export class RuntimeClient { } } -function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown { - if (!requestId || !(error instanceof RuntimeClientError)) { - return error - } - return new RuntimeClientError( - error.code, - `${error.message} Orchestration mutation request ID: ${requestId}.`, - { - ...(error.data && typeof error.data === 'object' ? error.data : {}), - orchestrationRequestId: requestId - } - ) -} - function throwDesktopActivationBlocked(): never { throw new RuntimeClientError( 'desktop_activation_blocked', diff --git a/src/cli/runtime/orchestration-recovery-command.test.ts b/src/cli/runtime/orchestration-recovery-command.test.ts new file mode 100644 index 00000000000..68c0a72c507 --- /dev/null +++ b/src/cli/runtime/orchestration-recovery-command.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { + buildOrchestrationRecoveryCommand, + resolveOrchestrationCliExecutable +} from './orchestration-recovery-command' + +describe('orchestration recovery command identity', () => { + it.each([ + ['configured dev', { ORCA_CLI_COMMAND: 'orca-dev' }, 'darwin', 'orca-dev'], + ['configured WSL', { ORCA_CLI_COMMAND: 'orca-ide' }, 'linux', 'orca-ide'], + ['dev checkout', { ORCA_DEV_REPO_ROOT: '/repo' }, 'darwin', 'orca-dev'], + ['packaged Linux', {}, 'linux', 'orca-ide'], + ['local macOS', {}, 'darwin', 'orca'], + ['local Windows', {}, 'win32', 'orca'] + ] as const)('resolves the %s CLI identity', (_name, env, platform, expected) => { + expect(resolveOrchestrationCliExecutable(env, platform)).toBe(expected) + }) + + it('reconstructs the keyed worker-start command from its RPC params', () => { + expect( + buildOrchestrationRecoveryCommand( + 'orchestration.workerStart', + { + task: 'task_1', + on: 'windows', + worktree: 'new-top-level', + timeoutMs: 90_000, + devMode: false + }, + 'orca' + ) + ).toEqual([ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--on', + 'windows', + '--worktree', + 'new-top-level', + '--timeout-ms', + '90000' + ]) + }) + + it('preserves the selected executable and raw command arguments', () => { + expect( + buildOrchestrationRecoveryCommand( + 'orchestration.workerStart', + { task: 'task_1' }, + 'orca-dev', + [ + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--comment', + 'literal $(do-not-run) "quoted"', + '--json' + ] + ) + ).toEqual([ + 'orca-dev', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--comment', + 'literal $(do-not-run) "quoted"', + '--json' + ]) + }) + + it.each([ + [ + 'gate-create', + 'orchestration.gateCreate', + ['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?'] + ], + [ + 'worker-retain', + 'orchestration.workerRetain', + ['orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json'] + ] + ] as const)('preserves exact raw argv for %s recovery', (_name, method, args) => { + expect(buildOrchestrationRecoveryCommand(method, {}, 'orca', args)).toEqual(['orca', ...args]) + }) + + it.each([ + ['split', ['orchestration', 'send', '--pairing-code', 'secret', '--subject', 'status']], + ['equals', ['orchestration', 'send', '--pairing-code=secret', '--subject', 'status']], + [ + 'dispatch split', + ['orchestration', 'send', '--dispatch-capability', 'secret', '--subject', 'status'] + ], + [ + 'dispatch equals', + ['orchestration', 'send', '--dispatch-capability=secret', '--subject', 'status'] + ] + ])('blocks %s credential argv instead of exposing it', (_name, args) => { + expect( + buildOrchestrationRecoveryCommand('orchestration.send', {}, 'orca', args) + ).toBeUndefined() + }) + + it('supports the explicit executable-first form', () => { + expect( + buildOrchestrationRecoveryCommand('orca-ide', 'orchestration.workerStop', { + dispatch: 'dispatch_1' + }) + ).toEqual(['orca-ide', 'orchestration', 'worker-stop', '--dispatch', 'dispatch_1']) + }) + + it('reconstructs worker-retain when raw argv is unavailable', () => { + expect( + buildOrchestrationRecoveryCommand('orchestration.workerRetain', { + dispatch: 'dispatch_1' + }) + ).toEqual([ + resolveOrchestrationCliExecutable(), + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1' + ]) + }) + + it('does not fabricate identity for unsupported mutations or malformed params', () => { + expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined() + expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined() + }) +}) diff --git a/src/cli/runtime/orchestration-recovery-command.ts b/src/cli/runtime/orchestration-recovery-command.ts new file mode 100644 index 00000000000..706b1656ac6 --- /dev/null +++ b/src/cli/runtime/orchestration-recovery-command.ts @@ -0,0 +1,102 @@ +export function resolveOrchestrationCliExecutable( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string { + const configured = env.ORCA_CLI_COMMAND?.trim() + if (configured) { + return configured + } + if (env.ORCA_DEV_REPO_ROOT) { + return 'orca-dev' + } + return platform === 'linux' ? 'orca-ide' : 'orca' +} + +export function buildOrchestrationRecoveryCommand( + method: string, + params: unknown, + executable?: string, + originalArgs?: readonly string[] +): string[] | undefined +export function buildOrchestrationRecoveryCommand( + executable: string, + method: string, + params: unknown, + originalArgs?: readonly string[] +): string[] | undefined +export function buildOrchestrationRecoveryCommand( + methodOrExecutable: string, + paramsOrMethod: unknown, + executableOrParams?: unknown, + originalArgs?: readonly string[] +): string[] | undefined { + const legacyOrder = typeof paramsOrMethod === 'string' && executableOrParams !== undefined + const method = (legacyOrder ? paramsOrMethod : methodOrExecutable) as string + const params = legacyOrder ? executableOrParams : paramsOrMethod + const executable = legacyOrder + ? methodOrExecutable + : typeof executableOrParams === 'string' + ? executableOrParams + : resolveOrchestrationCliExecutable() + if (originalArgs && originalArgs.length > 0) { + const recoverableArgs = recoverableOrchestrationArgs(originalArgs) + return recoverableArgs ? [executable, ...recoverableArgs] : undefined + } + const command = + method === 'orchestration.workerStart' + ? 'worker-start' + : method === 'orchestration.workerStop' + ? 'worker-stop' + : method === 'orchestration.workerAbandon' + ? 'worker-abandon' + : method === 'orchestration.workerRelease' + ? 'worker-release' + : method === 'orchestration.workerRetain' + ? 'worker-retain' + : undefined + if (!command || params === null || typeof params !== 'object') { + return undefined + } + const record = params as Record + const requiredKey = command === 'worker-start' ? 'task' : 'dispatch' + if (typeof record[requiredKey] !== 'string' || record[requiredKey].length === 0) { + return undefined + } + const args = [executable, 'orchestration', command] + for (const [key, value] of Object.entries(record)) { + if (value === undefined || value === null || value === false) { + continue + } + const flag = `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}` + if (value === true) { + args.push(flag) + } else if (typeof value === 'string' || typeof value === 'number') { + args.push(flag, String(value)) + } + } + return args +} + +export function recoverableOrchestrationArgs(args: readonly string[]): string[] | undefined { + const containsCredential = args.some((arg) => + ['--pairing-code', '--dispatch-capability'].some( + (flag) => arg === flag || arg.startsWith(`${flag}=`) + ) + ) + if (containsCredential) { + return undefined + } + const result: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--retry-request') { + index += 1 + continue + } + if (arg.startsWith('--retry-request=')) { + continue + } + result.push(arg) + } + return result +} diff --git a/src/cli/runtime/websocket-transport-error.test.ts b/src/cli/runtime/websocket-transport-error.test.ts new file mode 100644 index 00000000000..ab3df967f03 --- /dev/null +++ b/src/cli/runtime/websocket-transport-error.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' + +const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } = vi.hoisted( + () => ({ + sendRemoteRuntimeRequest: vi.fn(), + sendRemoteRuntimeRequestWithStatusPreflight: vi.fn() + }) +) + +vi.mock('../../shared/remote-runtime-client', () => ({ + RemoteRuntimeClientError, + sendRemoteRuntimeRequest, + sendRemoteRuntimeRequestWithStatusPreflight +})) + +import { + sendWebSocketRequest, + sendWebSocketRequestWithStatusPreflight +} from './websocket-transport' + +const pairing = { + v: 2, + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key' +} as never + +describe('CLI WebSocket transport error identity', () => { + it('preserves recovery data from one-shot transport errors', async () => { + sendRemoteRuntimeRequest.mockRejectedValueOnce( + new RemoteRuntimeClientError('runtime_timeout', 'timed out', { + data: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + } + }) + ) + + try { + await sendWebSocketRequest(pairing, 'orchestration.workerStart', {}, 100) + throw new Error('expected transport failure') + } catch (error) { + expect(error).toMatchObject({ + code: 'runtime_timeout', + data: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + } + }) + expect(orchestrationMutationRecoveryError(error)).toMatchObject({ + data: { + recovery: { + dispatchId: 'dispatch_1', + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + 'request_1' + ] + } + } + }) + } + }) + + it('preserves recovery data through status-preflight transport errors', async () => { + sendRemoteRuntimeRequestWithStatusPreflight.mockRejectedValueOnce( + new RemoteRuntimeClientError('runtime_timeout', 'timed out', { + data: { orchestrationRequestId: 'request_2' } + }) + ) + + await expect( + sendWebSocketRequestWithStatusPreflight( + pairing, + 'orchestration.workerStart', + {}, + 100, + () => {} + ) + ).rejects.toMatchObject({ + code: 'runtime_timeout', + data: { orchestrationRequestId: 'request_2' } + }) + }) +}) diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts index d71c1cbd671..8c7696d9f7d 100644 --- a/src/cli/runtime/websocket-transport.ts +++ b/src/cli/runtime/websocket-transport.ts @@ -19,7 +19,7 @@ export async function sendWebSocketRequest( return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope) } catch (error) { if (error instanceof RemoteRuntimeClientError) { - throw new RuntimeClientError(error.code, error.message) + throw new RuntimeClientError(error.code, error.message, error.data) } throw error } @@ -44,7 +44,7 @@ export async function sendWebSocketRequestWithStatusPreflight( ) } catch (error) { if (error instanceof RemoteRuntimeClientError) { - throw new RuntimeClientError(error.code, error.message) + throw new RuntimeClientError(error.code, error.message, error.data) } throw error } diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index 4a802df8d33..ec7c2b85378 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -4,6 +4,7 @@ import { AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS, type AgentPromptActivity, isAgentPromptStalledError, + readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -23,6 +24,17 @@ function activity(overrides: Partial = {}): AgentPromptActi describe('agent prompt submission verification', () => { afterEach(() => vi.useRealTimers()) + it('reuses wait text while the PTY output sequence is unchanged', () => { + const cache: { outputSequence?: number; waitText?: string } = {} + const readWaitText = vi.fn(() => 'retained terminal tail') + + expect(readAgentPromptWaitText(cache, 7, readWaitText)).toBe('retained terminal tail') + expect(readAgentPromptWaitText(cache, 7, readWaitText)).toBe('retained terminal tail') + expect(readAgentPromptWaitText(cache, 8, readWaitText)).toBe('retained terminal tail') + + expect(readWaitText).toHaveBeenCalledTimes(2) + }) + it('accepts an observed working transition', async () => { vi.useFakeTimers() let current = activity() @@ -80,6 +92,21 @@ describe('agent prompt submission verification', () => { await rejected }) + it('accepts a working transition after the former five-second deadline', async () => { + vi.useFakeTimers() + let current = activity() + const verification = verifyAgentPromptSubmission({ + baseline: current, + readActivity: () => current + }) + + await vi.advanceTimersByTimeAsync(5_000) + current = activity({ workingSequence: 5, status: 'working' }) + await vi.advanceTimersByTimeAsync(50) + + await expect(verification).resolves.toBeUndefined() + }) + it('blocks when permission appears after submit', async () => { vi.useFakeTimers() let current = activity() @@ -207,7 +234,7 @@ describe('agent prompt submission verification', () => { await rejected }) - it('holds the longer hook window open past the default timeout', async () => { + it('holds the extended hook window open past the former hook timeout', async () => { vi.useFakeTimers() let current = activity() const verification = verifyAgentPromptSubmission({ @@ -216,7 +243,7 @@ describe('agent prompt submission verification', () => { timeoutMs: AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS }) - await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 1_000) + await vi.advanceTimersByTimeAsync(15_000 + 1_000) current = activity({ explicitWorkingStartedAt: 9_000, status: 'working' }) await vi.advanceTimersByTimeAsync(50) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index e8373bbaf1f..5bd1a622321 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,12 +1,8 @@ +export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' +import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import type { TuiAgent } from '../../shared/tui-agent' -export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000 -// Why: these panes prove a turn start only through the out-of-process hook — kimi has no synthetic -// title profile and codex suppresses the hook-driven working frame (synthesizeWorkingTitle: false), -// so the first proof lags Enter by agent startup, not by one TUI repaint. Capped so the worst case -// (8s render gate + this wait + chunked paste) still fits RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS -// (30s, src/relay/dispatcher.ts), the budget a paired client's submission runs under. -export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = 15_000 +export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['codex', 'kimi']) @@ -26,6 +22,11 @@ export type AgentPromptActivity = Readonly<{ status: 'working' | 'permission' | 'idle' | null }> +export type AgentPromptWaitTextCache = { + outputSequence?: number + waitText?: string +} + type AgentPromptVerificationOptions = { baseline: AgentPromptActivity readActivity: () => AgentPromptActivity @@ -51,6 +52,20 @@ export function isAgentPromptStalledError(error: unknown): boolean { ) } +export function readAgentPromptWaitText( + cache: AgentPromptWaitTextCache, + outputSequence: number, + readWaitText: () => string +): string { + if (cache.outputSequence === outputSequence && cache.waitText !== undefined) { + return cache.waitText + } + const waitText = readWaitText() + cache.outputSequence = outputSequence + cache.waitText = waitText + return waitText +} + export async function verifyAgentPromptSubmission( options: AgentPromptVerificationOptions ): Promise { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 495045821da..6afbdfda612 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -106,6 +106,8 @@ import { } from '../../shared/agent-prompt-injection' import { type AgentPromptActivity, + type AgentPromptWaitTextCache, + readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -6097,7 +6099,8 @@ export class OrcaRuntimeService { method: string, params: unknown, timeoutMs?: number, - envelope?: RuntimeOrchestrationEnvelope + envelope?: RuntimeOrchestrationEnvelope, + internal?: { contractVerified?: boolean } ): Promise { if (!this.orchestrationEnvironmentTransport) { throw new OrchestrationError( @@ -6105,7 +6108,7 @@ export class OrcaRuntimeService { 'Connected-server orchestration is unavailable in this runtime.' ) } - if (isOrchestrationMutation(method, params)) { + if (isOrchestrationMutation(method, params) && !internal?.contractVerified) { const statusResponse = await this.orchestrationEnvironmentTransport.call( selector, 'status.get', @@ -19530,7 +19533,8 @@ export class OrcaRuntimeService { private getTerminalAgentStatusSnapshot( handle: string, - expectedPtyId: string + expectedPtyId: string, + waitTextOverride?: string ): TerminalAgentStatusSnapshot { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -19550,11 +19554,9 @@ export class OrcaRuntimeService { { title: pty.pty.title, updatedAt: pty.pty.titleUpdatedAt }, { title: pty.pty.lastOscTitle, updatedAt: pty.pty.lastOscTitleAt } ) - const waitText = buildTerminalWaitText( - pty.pty.tailBuffer, - pty.pty.tailPartialLine, - pty.pty.preview - ) + const waitText = + waitTextOverride ?? + buildTerminalWaitText(pty.pty.tailBuffer, pty.pty.tailPartialLine, pty.pty.preview) return { waitText, waitBlockedAt: pty.pty.waitBlockedAt, @@ -19582,7 +19584,9 @@ export class OrcaRuntimeService { { title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 } ) return { - waitText: buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview), + waitText: + waitTextOverride ?? + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview), waitBlockedAt: leaf.waitBlockedAt, title: title?.title ?? null, titleStatus: title ? detectAgentStatusFromTitle(title.title) : leaf.lastAgentStatus, @@ -20241,7 +20245,8 @@ export class OrcaRuntimeService { } assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) - const baseline = this.getAgentPromptActivity(handle, ptyId) + const waitTextCache: AgentPromptWaitTextCache = {} + const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false if (!suffixWrote) { @@ -20249,7 +20254,7 @@ export class OrcaRuntimeService { } await verifyAgentPromptSubmission({ baseline, - readActivity: () => this.getAgentPromptActivity(handle, ptyId), + readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), timeoutMs: resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)), signal: options.signal }) @@ -20278,8 +20283,13 @@ export class OrcaRuntimeService { } } - private getAgentPromptActivity(handle: string, ptyId: string): AgentPromptActivity { + private getAgentPromptActivity( + handle: string, + ptyId: string, + waitTextCache?: AgentPromptWaitTextCache + ): AgentPromptActivity { this.assertLiveTerminalHandleTargetsPty(handle, ptyId) + const outputSequence = this.getPtyOutputSequence(ptyId) const explicitCandidate = this.getFreshExplicitAgentStatusForHandle(handle) const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId) const explicit = @@ -20297,7 +20307,14 @@ export class OrcaRuntimeService { (!explicit || lifecycle.updatedAt > explicit.updatedAt || (lifecycle.updatedAt === explicit.updatedAt && lifecycle.status === 'permission')) - const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + const waitText = waitTextCache + ? readAgentPromptWaitText( + waitTextCache, + outputSequence, + () => this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText + ) + : undefined + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId, waitText) const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle) ? 'permission' : lifecycleIsNewer @@ -20312,7 +20329,7 @@ export class OrcaRuntimeService { // stateStartedAt, not updatedAt — same-state tool/prompt pings refresh updatedAt and would // otherwise pass off an in-progress turn as a new one. explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null, - outputSequence: this.getPtyOutputSequence(ptyId), + outputSequence, status } } diff --git a/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts new file mode 100644 index 00000000000..400232fdffb --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { parseRemoteFederatedWorkerStartReceipt } from './orchestration-federated-attach-receipt' + +describe('remote federated worker start receipt', () => { + it.each([ + { name: 'missing runtime epoch', receipt: {} }, + { name: 'non-string runtime epoch', receipt: { runtimeEpoch: 42 } }, + { name: 'missing worktree id', receipt: { runtimeEpoch: 'epoch_remote' } }, + { + name: 'missing terminal handle', + receipt: { + runtimeEpoch: 'epoch_remote', + worktreeId: 'worktree_remote', + terminalHandle: undefined + } + } + ])('rejects a ready receipt with $name', ({ receipt }) => { + expect(() => + parseRemoteFederatedWorkerStartReceipt({ + dispatchId: 'ctx_remote', + state: 'ready', + terminalHandle: 'term_remote', + ...receipt + }) + ).toThrow('invalid ready receipt') + }) + + it('accepts a non-ready receipt without ready-only resources', () => { + expect( + parseRemoteFederatedWorkerStartReceipt({ + dispatchId: 'ctx_remote', + state: 'outcome_unknown' + }) + ).toMatchObject({ dispatchId: 'ctx_remote', state: 'outcome_unknown' }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts new file mode 100644 index 00000000000..fc62a31788b --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-attach-receipt.ts @@ -0,0 +1,53 @@ +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' + +export type RemoteFederatedWorkerStartReceipt = { + dispatchId: string + state: string + runtimeEpoch?: string + worktreeId?: string + terminalHandle?: string + setup?: { state: string } + launch?: OrchestrationWorkerLaunchReceipt + effects?: unknown[] + residualResources?: unknown[] + failedStage?: string + lastError?: string +} + +export type ReadyRemoteFederatedWorkerStartReceipt = RemoteFederatedWorkerStartReceipt & { + state: 'ready' + runtimeEpoch: string + worktreeId: string + terminalHandle: string +} + +export function parseRemoteFederatedWorkerStartReceipt( + value: unknown +): RemoteFederatedWorkerStartReceipt { + if (!isRecord(value) || !isNonEmptyString(value.dispatchId) || !isNonEmptyString(value.state)) { + throw new Error('The worker server returned an invalid attachment receipt.') + } + if ( + value.state === 'ready' && + (!isNonEmptyString(value.runtimeEpoch) || + !isNonEmptyString(value.worktreeId) || + !isNonEmptyString(value.terminalHandle)) + ) { + throw new Error('The worker server returned an invalid ready receipt.') + } + return value as RemoteFederatedWorkerStartReceipt +} + +export function isReadyRemoteFederatedWorkerStartReceipt( + receipt: RemoteFederatedWorkerStartReceipt +): receipt is ReadyRemoteFederatedWorkerStartReceipt { + return receipt.state === 'ready' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts new file mode 100644 index 00000000000..270c5f6a322 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-receipt.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { startFederatedWorker } from './orchestration-federated-worker-start' + +describe('federated worker start receipt validation', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const database of databases.splice(0)) { + database.close() + } + }) + + it('marks a malformed ready receipt outcome unknown without persisting resources', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + databases.push(db) + const run = db.createRun({ + objective: 'federated worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_remote', + name: 'remote', + peerFingerprint: 'remote_peer' + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockImplementation( + async (_environmentId, method, params) => { + if (method === 'status.get') { + return { + capabilities: [ + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY + ] + } + } + return { + dispatchId: (params as { dispatchId: string }).dispatchId, + state: 'ready', + worktreeId: 'worktree_remote', + terminalHandle: 'term_remote' + } + } + ) + + const result = (await startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'remote', + worktree: 'id:worktree_remote', + terminal: 'term_remote' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_start', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + })) as { dispatchId: string; state: string; lastError?: string } + + expect(result).toMatchObject({ + state: 'outcome_unknown', + lastError: 'The worker server returned an invalid ready receipt.' + }) + expect(db.getFederatedDispatch(result.dispatchId)).toMatchObject({ + remote_runtime_epoch: null, + remote_worktree_id: null, + remote_terminal_handle: null + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts new file mode 100644 index 00000000000..f0c7354c3e5 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start-unknown-receipt.ts @@ -0,0 +1,25 @@ +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' + +export function federatedUnknownReceipt( + worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, + taskId: string, + serverName: string, + launch: OrchestrationWorkerLaunchReceipt +): unknown { + return { + taskId, + dispatchId: worker.dispatch_id, + state: 'outcome_unknown', + stage: worker.stage, + server: { name: serverName }, + launch, + failedStage: worker.stage, + lastError: worker.last_error, + effects: [], + residualResources: [], + nextCommands: [ + `orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`, + `orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json` + ] + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 28c6530d2a0..0f6bd44f67f 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -17,11 +17,17 @@ import { assertWorkerLaunchPreferencesRuntimeSupported, assertWorkerLaunchPreferencesCreateTerminal, createPendingWorkerLaunchReceipt, - resolveFederatedWorkerLaunchReceipt, - type OrchestrationWorkerLaunchReceipt + resolveFederatedWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation' +import { resolveFederatedWorkerStartBudgets } from './orchestration-worker-start-budgets' import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { + isReadyRemoteFederatedWorkerStartReceipt, + parseRemoteFederatedWorkerStartReceipt +} from './orchestration-federated-attach-receipt' +import { isWorkerStartTimeoutWithinTimerLimit } from '../../../../shared/orchestration-timing-budgets' +import { federatedUnknownReceipt } from './orchestration-federated-worker-start-unknown-receipt' export async function startFederatedWorker(args: { params: WorkerStartInput @@ -37,6 +43,12 @@ export async function startFederatedWorker(args: { } }): Promise { const { params, runtime, db, task, runId, orchestrationMutation } = args + if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { + throw new OrchestrationError( + 'invalid_argument', + '--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.' + ) + } if (!orchestrationMutation) { throw new OrchestrationError( 'invalid_argument', @@ -58,13 +70,13 @@ export async function startFederatedWorker(args: { model: params.model, effort: params.effort }) - const server = runtime.resolveOrchestrationWorkerServer(params.on as string) + const budgets = resolveFederatedWorkerStartBudgets(params.timeoutMs) const status = (await runtime.callOrchestrationWorkerServer( server.environmentId, 'status.get', undefined, - params.timeoutMs + budgets.preflightTimeoutMs )) as RuntimeStatus if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { throw new OrchestrationError( @@ -112,7 +124,7 @@ export async function startFederatedWorker(args: { terminal: params.terminal ?? null, agent: params.agent ?? null, launch: requestedLaunch, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: budgets.readinessTimeoutMs, setup: setupDecision, setupSource: createsWorktree ? params.setup @@ -131,39 +143,42 @@ export async function startFederatedWorker(args: { }) db.recordWorkerStage({ dispatchId: started.dispatch.id, stage: 'remote_attach_requested' }) try { - const remote = (await runtime.callOrchestrationWorkerServer( - server.environmentId, - 'orchestration.federationAttachStart', - { - dispatchId: started.dispatch.id, - taskId: task.id, - taskSpec: task.spec, - // Carry the home dispatch depth across the federation boundary so a - // remote worker cannot be mistaken for a root when it dispatches again. - depth: started.dispatch.depth, - protocolVersion: federationProtocolVersion, - worktree, - name: params.name, - repo: params.repo, - baseBranch: params.baseBranch, - displayName: params.displayName, - comment: params.comment, - setup: createsWorktree ? (params.setup ?? 'run') : undefined, - setupSource: createsWorktree - ? params.setup - ? 'explicit_request' - : 'orchestration_default' - : undefined, - terminal: params.terminal, - agent: params.agent, - model: params.model, - effort: params.effort, - timeoutMs: params.timeoutMs, - devMode: params.devMode - }, - (params.timeoutMs ?? 60_000) + 15_000, - { orchestrationRequestId: orchestrationMutation.requestId } - )) as RemoteStartReceipt + const remote = parseRemoteFederatedWorkerStartReceipt( + await runtime.callOrchestrationWorkerServer( + server.environmentId, + 'orchestration.federationAttachStart', + { + dispatchId: started.dispatch.id, + taskId: task.id, + taskSpec: task.spec, + // Carry the home dispatch depth across the federation boundary so a + // remote worker cannot be mistaken for a root when it dispatches again. + depth: started.dispatch.depth, + protocolVersion: federationProtocolVersion, + worktree, + name: params.name, + repo: params.repo, + baseBranch: params.baseBranch, + displayName: params.displayName, + comment: params.comment, + setup: createsWorktree ? (params.setup ?? 'run') : undefined, + setupSource: createsWorktree + ? params.setup + ? 'explicit_request' + : 'orchestration_default' + : undefined, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + timeoutMs: budgets.readinessTimeoutMs, + devMode: params.devMode + }, + budgets.attachDeadlineMs, + { orchestrationRequestId: orchestrationMutation.requestId }, + { contractVerified: true } + ) + ) if (remote.dispatchId !== started.dispatch.id) { throw new OrchestrationError( 'resource_server_mismatch', @@ -175,7 +190,7 @@ export async function startFederatedWorker(args: { requestedLaunch, remote.state === 'ready' ) - if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) { + if (isReadyRemoteFederatedWorkerStartReceipt(remote)) { db.updateFederatedDispatchResources({ dispatchId: started.dispatch.id, remoteRuntimeEpoch: remote.runtimeEpoch, @@ -202,7 +217,7 @@ export async function startFederatedWorker(args: { server: { environmentId: server.environmentId, name: server.name }, setup: remote.setup, launch, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: budgets.readinessTimeoutMs, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] } @@ -257,20 +272,6 @@ export async function startFederatedWorker(args: { } } -type RemoteStartReceipt = { - dispatchId: string - state: string - runtimeEpoch: string - worktreeId?: string - terminalHandle?: string - setup?: { state: string } - launch?: OrchestrationWorkerLaunchReceipt - effects?: unknown[] - residualResources?: unknown[] - failedStage?: string - lastError?: string -} - function isKnownRemoteStartFailure(code: string): boolean { return [ 'invalid_argument', @@ -280,27 +281,3 @@ function isKnownRemoteStartFailure(code: string): boolean { 'capability_unsupported' ].includes(code) } - -function federatedUnknownReceipt( - worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, - taskId: string, - serverName: string, - launch: OrchestrationWorkerLaunchReceipt -): unknown { - return { - taskId, - dispatchId: worker.dispatch_id, - state: 'outcome_unknown', - stage: worker.stage, - server: { name: serverName }, - launch, - failedStage: worker.stage, - lastError: worker.last_error, - effects: [], - residualResources: [], - nextCommands: [ - `orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`, - `orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json` - ] - } -} diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index 4b48632c39a..421bf51586e 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -18,6 +18,10 @@ import { import { FederationAttachStartParams } from './orchestration-federation-start-schema' import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt' import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation' +import { + isWorkerStartTimeoutWithinTimerLimit, + resolveWorkerStartReadinessTimeoutMs +} from '../../../../shared/orchestration-timing-budgets' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ defineMethod({ @@ -30,6 +34,13 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ 'Federated worker attachment requires a durable retry request.' ) } + if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { + throw new OrchestrationError( + 'invalid_argument', + '--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.' + ) + } + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) if (params.worktree === 'current' || params.worktree === 'new-child') { throw new OrchestrationError( 'invalid_argument', @@ -198,7 +209,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ failedStage = 'agent_readiness' const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 + timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts index 1d562e1ee5a..4e516e32fd3 100644 --- a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' +import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' import { RpcDispatcher } from '../dispatcher' import { ORCHESTRATION_METHODS } from './orchestration' import { startFederatedWorker } from './orchestration-federated-worker-start' @@ -246,4 +248,83 @@ describe('orchestration migration behavior', () => { expect(db.getTask(task.id)?.status).toBe('ready') expect(db.getDispatchContext(task.id)).toBeUndefined() }) + + it('runs one real contract preflight before federated attach', async () => { + const db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'federated worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:tab' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + databases.push(db) + + const calls: { method: string; params: unknown }[] = [] + const runtime = new OrcaRuntimeService(null, undefined, { + orchestrationEnvironmentTransport: { + resolve: () => ({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer' + }), + call: async (_selector, method, params) => { + calls.push({ method, params }) + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + ...runtime.getStatus(), + capabilities: [ + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY + ] + }, + _meta: { runtimeId: runtime.getRuntimeId() } + } satisfies RuntimeRpcResponse + } + return { + id: 'attach', + ok: true, + result: { + dispatchId: (params as { dispatchId: string }).dispatchId, + state: 'outcome_unknown', + runtimeEpoch: 'worker-runtime' + }, + _meta: { runtimeId: runtime.getRuntimeId() } + } satisfies RuntimeRpcResponse + } + } satisfies OrchestrationEnvironmentTransport + }) + runtime.setOrchestrationDb(db) + + const result = await startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'remote-work', + agent: 'codex' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_start', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + }) + + expect(result).toMatchObject({ state: 'outcome_unknown' }) + expect(calls.map(({ method }) => method)).toEqual([ + 'status.get', + 'orchestration.federationAttachStart' + ]) + expect(calls.filter(({ method }) => method === 'status.get')).toHaveLength(1) + }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts new file mode 100644 index 00000000000..6e600dd5177 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { MAX_TIMER_DELAY_MS } from '../../../../shared/timer-delay' +import { + ORCHESTRATION_READINESS_TIMEOUT_MS, + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS +} from '../../../../shared/orchestration-timing-budgets' +import { resolveFederatedWorkerStartBudgets } from './orchestration-worker-start-budgets' + +describe('worker-start transport budgets', () => { + it('keeps the exact maximum derived timeout representable', () => { + const timeoutMs = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + const budgets = resolveFederatedWorkerStartBudgets(timeoutMs, 1_000) + expect(budgets.outerDeadlineMs).toBe(1_000 + MAX_TIMER_DELAY_MS) + }) + + it('does not silently shorten an overflowing request', () => { + const timeoutMs = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + 1 + expect(() => resolveFederatedWorkerStartBudgets(timeoutMs, 1_000)).toThrow( + 'derived timeout must fit' + ) + }) + + it('normalizes non-positive requests to the ordinary readiness default', () => { + const expectedOuterDeadline = + 1_000 + ORCHESTRATION_READINESS_TIMEOUT_MS + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + expect(resolveFederatedWorkerStartBudgets(0, 1_000).readinessTimeoutMs).toBe( + ORCHESTRATION_READINESS_TIMEOUT_MS + ) + expect(resolveFederatedWorkerStartBudgets(0, 1_000).outerDeadlineMs).toBe(expectedOuterDeadline) + expect(resolveFederatedWorkerStartBudgets(-1, 1_000).outerDeadlineMs).toBe( + expectedOuterDeadline + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts new file mode 100644 index 00000000000..de5f3db40d9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-budgets.ts @@ -0,0 +1,32 @@ +import { + ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS, + isWorkerStartTimeoutWithinTimerLimit, + resolveFederationAttachDeadlineMs, + resolveWorkerStartReadinessTimeoutMs, + resolveWorkerStartClientTimeoutMs +} from '../../../../shared/orchestration-timing-budgets' + +export function resolveFederatedWorkerStartBudgets( + timeoutMs: number | undefined, + nowMs = Date.now() +) { + if (!isWorkerStartTimeoutWithinTimerLimit(timeoutMs)) { + throw new Error( + '--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.' + ) + } + // Normalize non-positive/invalid values exactly as the client does so local + // and federated transports share the ordinary default contract. + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(timeoutMs) + const outerDeadlineMs = nowMs + resolveWorkerStartClientTimeoutMs(readinessTimeoutMs) + return { + readinessTimeoutMs, + outerDeadlineMs, + preflightTimeoutMs: ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS, + attachDeadlineMs: resolveFederationAttachDeadlineMs({ + readinessTimeoutMs, + outerDeadlineMs, + nowMs + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 0b8891481cd..61271525939 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -22,6 +22,10 @@ import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' import { resolveDispatchCreator } from './orchestration-dispatch-creator' import { resolveOrchestrationCaller } from './orchestration-run-scope' +import { + isWorkerStartTimeoutWithinTimerLimit, + resolveWorkerStartReadinessTimeoutMs +} from '../../../../shared/orchestration-timing-budgets' export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ defineMethod({ @@ -31,6 +35,13 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ params, { runtime, orchestrationMutation, orchestrationCompatibilityEvidence } ) => { + if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { + throw new OrchestrationError( + 'invalid_argument', + `--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.` + ) + } + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) const db = runtime.getOrchestrationDb() // Why: worker-start was the only Run-scoped verb that skipped this, so a // declared --from could name someone else's pane and inherit their depth. @@ -111,7 +122,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ terminal: params.terminal ?? null, agent: agent ?? null, launch: launch.receipt, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: readinessTimeoutMs, setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', setupSource: createsWorktree ? params.setup @@ -208,7 +219,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ failedStage = 'agent_readiness' const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 + timeoutMs: readinessTimeoutMs }) persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { @@ -268,7 +279,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ stage: worker.stage, setup: setupReceipt, launch: launch.receipt, - timeoutMs: params.timeoutMs ?? 60_000, + timeoutMs: readinessTimeoutMs, effects, residualResources: [], ...(terminalRevealWarning ? { warning: terminalRevealWarning } : {}) diff --git a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts index 43279ea7931..74dbe9e0cde 100644 --- a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts +++ b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import { readRuntimeMetadata } from './runtime-metadata' -import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { classifyRuntimeLongPoll, OrcaRuntimeRpcServer } from './runtime-rpc' import { sendRequest, openFramedSession, @@ -33,6 +33,36 @@ vi.mock('../git/worktree', () => { }) describe('OrcaRuntimeRpcServer', () => { + it('classifies worker-start as a keepalive-backed long poll', () => { + expect( + classifyRuntimeLongPoll({ + id: 'req_worker_start', + authToken: 'token', + method: 'orchestration.workerStart', + params: { task: 'task_1', timeoutMs: 60_000 } + }) + ).toBe('wait') + }) + + it('keeps agent-prompt submission sockets alive during verification', () => { + expect( + classifyRuntimeLongPoll({ + id: 'req_prompt', + authToken: 'token', + method: 'terminal.send', + params: { agentPrompt: true } + }) + ).toBe('wait') + expect( + classifyRuntimeLongPoll({ + id: 'req_direct', + authToken: 'token', + method: 'terminal.send', + params: { agentPrompt: false } + }) + ).toBeNull() + }) + it('rejects oversized RPC frames instead of buffering them indefinitely', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -74,6 +104,45 @@ describe('OrcaRuntimeRpcServer', () => { // Exercise the real socket (not a mock) so we catch buffer/flush regressions // that a unit-level test would miss. describe('long-poll transport (§3.1)', () => { + it('emits keepalives while orchestration.workerStart blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 30 + }) + const dispatch = server['dispatcher'] + vi.spyOn(dispatch, 'dispatch').mockImplementation(async (request) => { + await sleep(120) + return { + id: request.id, + ok: true, + result: { dispatch: { id: 'dispatch_1' } }, + _meta: { runtimeId: runtime.getRuntimeId() } + } + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_worker_start', + authToken: metadata!.authToken, + method: 'orchestration.workerStart', + params: { task: 'task_1', timeoutMs: 60_000 } + }) + await session.done + + expect( + session.frames.filter((frame) => frame._keepalive === true).length + ).toBeGreaterThanOrEqual(2) + expect(session.frames.filter((frame) => frame.ok !== undefined)).toHaveLength(1) + } finally { + await server.stop() + } + }) + it('emits keepalive frames while a check --wait handler blocks', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -242,6 +311,45 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('emits keepalive frames while agent-prompt verification blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 30 + }) + const dispatch = server['dispatcher'] + vi.spyOn(dispatch, 'dispatch').mockImplementation(async (request) => { + await sleep(120) + return { + id: request.id, + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: runtime.getRuntimeId() } + } + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_prompt', + authToken: metadata!.authToken, + method: 'terminal.send', + params: { agentPrompt: true } + }) + await session.done + + expect( + session.frames.filter((frame) => frame._keepalive === true).length + ).toBeGreaterThanOrEqual(2) + expect(session.frames.filter((frame) => frame.ok !== undefined)).toHaveLength(1) + } finally { + await server.stop() + } + }) + it('releases terminal.wait long-poll slot when the client closes mid-wait', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b28619b2eed..a6a2d91763c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -466,12 +466,28 @@ export type RuntimeLongPollClass = 'ask' | 'browser-host' | 'wait' // Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1. export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollClass | null { + // Worker start waits for readiness and then verifies the submitted prompt; + // the complete operation can run for 90–110s. Keep every local transport + // (Unix sockets and Windows named pipes) alive for that long poll. + if (request.method === 'orchestration.workerStart') { + return 'wait' + } if (request.method === 'browser.clientHost.attach') { return 'browser-host' } if (request.method === 'terminal.wait') { return 'wait' } + // Agent-prompt submission waits for the PTY's lifecycle transition (up to + // the verification budget); keep the local socket alive for that wait. + if ( + request.method === 'terminal.send' && + typeof request.params === 'object' && + request.params !== null && + (request.params as { agentPrompt?: unknown }).agentPrompt === true + ) { + return 'wait' + } // Why: orchestration.ask blocks unconditionally (default 600 s) holding the // RPC open until a reply lands or the deadline passes, so it needs the same // keepalive as check --wait or the 30 s socket idle timer tears it down. It diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index b4d7ca07fef..e66cbd77507 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -106,11 +106,29 @@ describe('buildHostCliEnv', () => { expect(env.PATH).toBe('/host/bin') expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data') expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub') + expect(env.ORCA_CLI_COMMAND).toBe('orca') expect(env.ELECTRON_RUN_AS_NODE).toBe('1') expect(env.NODE_OPTIONS).toBeUndefined() expect(env.ORCA_NODE_OPTIONS).toBe('--inspect') }) + it.each([ + ['dev host', { ORCA_DEV_REPO_ROOT: '/repo', ORCA_CLI_COMMAND: 'orca-dev' }], + ['packaged Linux host', { ORCA_CLI_COMMAND: 'orca-ide' }], + ['local host', {}], + ['WSL host', { WSL_DISTRO_NAME: 'Ubuntu', ORCA_CLI_COMMAND: 'orca-ide' }], + ['Windows host', { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }] + ])('pins %s recovery to the remote shim', (_name, hostEnv) => { + const env = buildHostCliEnv({ + hostEnv, + remoteEnv: { ORCA_CLI_COMMAND: 'untrusted-remote-command' }, + userDataPath: '/host/user-data', + remoteCwd: '/srv/repo' + }) + + expect(env.ORCA_CLI_COMMAND).toBe('orca') + }) + it('namespaces identical remote artifact paths by stable SSH target', () => { const artifactInput = { sourceKey: '/srv/repo/report.html', diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 7749584a61a..05a785c8482 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -159,6 +159,8 @@ export function buildHostCliEnv(args: { // subprocess cwd cannot be chdir'd there; ORCA_CLI_CWD carries it for // cwd-based selectors like `--worktree active`. env.ORCA_CLI_CWD = args.remoteCwd + // Why: recovery commands run on the SSH execution host through its relay shim. + env.ORCA_CLI_COMMAND = 'orca' // Why: same node-mode hygiene as the shipped CLI launchers — stash and clear // NODE_OPTIONS so Electron's node bootstrap does not inherit them. env.ORCA_NODE_OPTIONS = args.hostEnv.NODE_OPTIONS ?? '' diff --git a/src/shared/orchestration-timing-budgets.test.ts b/src/shared/orchestration-timing-budgets.test.ts new file mode 100644 index 00000000000..c7cdea5221c --- /dev/null +++ b/src/shared/orchestration-timing-budgets.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_PROMPT_EFFECT_TIMEOUT_MS, + ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS, + ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS, + ORCHESTRATION_READINESS_TIMEOUT_MS, + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS, + isWorkerStartTimeoutWithinTimerLimit, + resolveFederationAttachDeadlineMs, + resolveFederationAttachTimeoutMs, + resolveWorkerStartClientTimeoutMs +} from './orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from './timer-delay' + +describe('orchestration timing budgets', () => { + it('keeps the worker-start budget strictly nested', () => { + const readiness = ORCHESTRATION_READINESS_TIMEOUT_MS + const verify = readiness + AGENT_PROMPT_EFFECT_TIMEOUT_MS + const federated = resolveFederationAttachTimeoutMs(readiness) + const outer = resolveWorkerStartClientTimeoutMs(readiness) + + expect(ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS + federated).toBeLessThan(outer) + expect(verify).toBeLessThan(federated) + expect(federated).toBeLessThan(outer) + expect(ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS).toBeGreaterThan(AGENT_PROMPT_EFFECT_TIMEOUT_MS) + expect(ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS).toBeGreaterThan( + ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS + ) + }) + + it('caps attach at the outer deadline after preflight', () => { + expect( + resolveFederationAttachDeadlineMs({ + readinessTimeoutMs: 60_000, + outerDeadlineMs: 105_000, + nowMs: 10_000 + }) + ).toBe(95_000) + }) + + it('accepts the exact maximum readiness timeout and rejects the first overflow', () => { + const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + expect(isWorkerStartTimeoutWithinTimerLimit(maxValid)).toBe(true) + expect(isWorkerStartTimeoutWithinTimerLimit(maxValid + 1)).toBe(false) + }) + + it('keeps ordinary defaults within the timer limit', () => { + expect(isWorkerStartTimeoutWithinTimerLimit(undefined)).toBe(true) + expect(isWorkerStartTimeoutWithinTimerLimit(0)).toBe(true) + }) +}) diff --git a/src/shared/orchestration-timing-budgets.ts b/src/shared/orchestration-timing-budgets.ts new file mode 100644 index 00000000000..cc6890bf0f0 --- /dev/null +++ b/src/shared/orchestration-timing-budgets.ts @@ -0,0 +1,50 @@ +/** Shared timing contracts for agent submission and worker-start transports. */ +import { MAX_TIMER_DELAY_MS } from './timer-delay' + +export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 30_000 +export const ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS = 5_000 +export const ORCHESTRATION_READINESS_TIMEOUT_MS = 60_000 +export const ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 10_000 +export const ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 20_000 +export const SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS + 30_000 + +export function resolveWorkerStartReadinessTimeoutMs(timeoutMs: number | undefined): number { + return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : ORCHESTRATION_READINESS_TIMEOUT_MS +} + +export function resolveFederationAttachTimeoutMs( + readinessTimeoutMs = ORCHESTRATION_READINESS_TIMEOUT_MS +): number { + return readinessTimeoutMs + ORCHESTRATION_FEDERATION_ATTACH_GRACE_MS +} + +export function resolveWorkerStartClientTimeoutMs( + readinessTimeoutMs = ORCHESTRATION_READINESS_TIMEOUT_MS +): number { + return readinessTimeoutMs + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS +} + +export function isWorkerStartTimeoutWithinTimerLimit(timeoutMs: number | undefined): boolean { + const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(timeoutMs) + return ( + Number.isSafeInteger(readinessTimeoutMs) && + resolveWorkerStartClientTimeoutMs(readinessTimeoutMs) <= MAX_TIMER_DELAY_MS + ) +} + +export function resolveFederationAttachDeadlineMs(args: { + readinessTimeoutMs?: number + outerDeadlineMs: number + nowMs?: number +}): number { + const nowMs = args.nowMs ?? Date.now() + return Math.max( + 1, + Math.min( + resolveFederationAttachTimeoutMs(args.readinessTimeoutMs), + args.outerDeadlineMs - nowMs + ) + ) +} diff --git a/src/shared/remote-runtime-client-error.ts b/src/shared/remote-runtime-client-error.ts index 487a1108b87..ec76b55af64 100644 --- a/src/shared/remote-runtime-client-error.ts +++ b/src/shared/remote-runtime-client-error.ts @@ -9,6 +9,8 @@ export type RemoteRuntimePairingStage = 'connect' | 'host-identity' | 'access-gr */ export class RemoteRuntimeClientError extends Error { readonly code: string + /** Structured recovery data must survive the WebSocket adapter unchanged. */ + readonly data?: unknown readonly pairingStage?: RemoteRuntimePairingStage readonly closeCode?: number @@ -16,6 +18,7 @@ export class RemoteRuntimeClientError extends Error { code: string, message: string, details?: { + data?: unknown pairingStage?: RemoteRuntimePairingStage closeCode?: number } @@ -23,6 +26,7 @@ export class RemoteRuntimeClientError extends Error { super(message) this.name = 'RemoteRuntimeClientError' this.code = code + this.data = details?.data this.pairingStage = details?.pairingStage this.closeCode = details?.closeCode } diff --git a/src/shared/remote-runtime-shared-control-protocol.ts b/src/shared/remote-runtime-shared-control-protocol.ts index 4daae215989..e7857a08d69 100644 --- a/src/shared/remote-runtime-shared-control-protocol.ts +++ b/src/shared/remote-runtime-shared-control-protocol.ts @@ -157,7 +157,11 @@ export function toRemoteRuntimeClientError(error: unknown): RemoteRuntimeClientE return error } if (error instanceof Error) { - return new RemoteRuntimeClientError('runtime_error', error.message) + const data = + typeof error === 'object' && error !== null && 'data' in error + ? (error as { data?: unknown }).data + : undefined + return new RemoteRuntimeClientError('runtime_error', error.message, { data }) } return new RemoteRuntimeClientError('runtime_error', String(error)) } diff --git a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts index 1ae525d29da..3567cb1d8a2 100644 --- a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts +++ b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts @@ -1,52 +1,97 @@ import { execFile } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' import { expect, test } from './helpers/orca-app' +import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override' import { waitForSessionReady } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime-client' +import { recognizeAgentProcess } from '../../src/shared/agent-process-recognition' +import { SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS } from '../../src/shared/orchestration-timing-budgets' const execFileAsync = promisify(execFile) const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-send-agent-prompt-')) -const fixtureBin = path.join(fixtureRoot, 'bin') const fixtureReport = path.join(fixtureRoot, 'report.json') const fixtureMarker = `ORCA_TERMINAL_SEND_E2E_${process.pid}` const fixtureScript = path.join(process.cwd(), 'tests', 'tools', 'repro-terminal-send-submit.mjs') -const fakeCodex = path.join(fixtureBin, process.platform === 'win32' ? 'codex.cmd' : 'codex') +const fakeCodex = path.join(fixtureRoot, process.platform === 'win32' ? 'codex.cmd' : 'codex') +const fakeCodexCommand = buildFakeAgentCommandOverride(fakeCodex) +const swallowedEnterFixtureTimeoutMs = SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS -mkdirSync(fixtureBin) writeFileSync( fakeCodex, process.platform === 'win32' ? `@echo off\r\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "%ORCA_FAKE_AGENT_REPORT%" --marker "%ORCA_FAKE_AGENT_MARKER%" --allow-unframed-paste %*\r\n` - : `#!/usr/bin/env sh\nexec "${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER" "$@"\n`, + : `#!/usr/bin/env sh\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER" "$@"\n`, 'utf8' ) if (process.platform !== 'win32') { chmodSync(fakeCodex, 0o755) } -test.use({ - seedTestRepo: false, - orcaAppExtraEnv: { - PATH: `${fixtureBin}${path.delimiter}${process.env.PATH ?? ''}`, - ORCA_FAKE_AGENT_REPORT: fixtureReport, - ORCA_FAKE_AGENT_MARKER: fixtureMarker - } -}) - test.afterAll(() => { rmSync(fixtureRoot, { recursive: true, force: true }) }) +async function createFakeCodexTerminal( + userDataDir: string, + testRepoPath: string, + args: string[] = [] +): Promise { + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const expectedPath = path.resolve(testRepoPath) + const findWorktree = async (): Promise<{ id: string } | undefined> => { + const listed = await client.call<{ worktrees: { id: string; path: string }[] }>( + 'worktree.list', + {} + ) + return listed.result.worktrees.find((worktree) => { + const candidatePath = path.resolve(worktree.path) + return process.platform === 'win32' + ? candidatePath.toLowerCase() === expectedPath.toLowerCase() + : candidatePath === expectedPath + }) + } + await expect.poll(async () => Boolean(await findWorktree()), { timeout: 60_000 }).toBe(true) + const worktree = await findWorktree() + if (!worktree) { + throw new Error(`runtime did not register ${testRepoPath}`) + } + const created = await client.call<{ terminal: { handle: string } }>('terminal.create', { + worktree: `id:${worktree.id}`, + command: [fakeCodexCommand, ...args].join(' '), + launchAgent: 'codex', + env: { + ORCA_FAKE_AGENT_REPORT: fixtureReport, + ORCA_FAKE_AGENT_MARKER: fixtureMarker + }, + title: 'terminal send submit repro' + }) + const handle = created.result.terminal.handle + await expect + .poll( + async () => { + const inspected = await client.call<{ + process: { foregroundProcess: string | null } + }>('terminal.inspectProcess', { terminal: handle }) + return recognizeAgentProcess(inspected.result.process.foregroundProcess)?.agent ?? null + }, + { timeout: 30_000 } + ) + .toBe('codex') + return handle +} + test('CLI text plus Enter waits for a slow agent composer before submitting', async ({ electronApp, orcaPage, testRepoPath }) => { - test.setTimeout(90_000) + test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath) const repoRoot = process.cwd() let stdout = '' try { @@ -58,8 +103,8 @@ test('CLI text plus Enter waits for a slow agent composer before submitting', as path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, - '--agent-command', - 'codex', + '--terminal', + terminal, '--report', fixtureReport, '--marker', @@ -94,9 +139,14 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ orcaPage, testRepoPath }) => { - test.setTimeout(90_000) + test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath, [ + '--swallow-first-enter', + '--timeout-ms', + String(swallowedEnterFixtureTimeoutMs) + ]) const repoRoot = process.cwd() let stdout = '' try { @@ -108,8 +158,10 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, - '--agent-command', - 'codex --swallow-first-enter', + '--terminal', + terminal, + '--timeout-ms', + String(swallowedEnterFixtureTimeoutMs), '--expect-stalled', '--report', fixtureReport, @@ -120,7 +172,7 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ { cwd: repoRoot, env: { ...process.env, ORCA_DEV_USER_DATA_PATH: userDataDir }, - timeout: 60_000 + timeout: 90_000 } ) stdout = result.stdout @@ -137,6 +189,7 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ prematureEnters: 0, receivedEnters: 1, swallowedEnters: 1, + configuredTimeoutMs: swallowedEnterFixtureTimeoutMs, markerReceived: true }) }) @@ -146,9 +199,12 @@ test('CLI does not write prompt bytes into an active permission dialog', async ( orcaPage, testRepoPath }) => { - test.setTimeout(90_000) + test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath, [ + '--permission-before-send' + ]) const repoRoot = process.cwd() let stdout = '' try { @@ -160,6 +216,8 @@ test('CLI does not write prompt bytes into an active permission dialog', async ( path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, + '--terminal', + terminal, '--expect-blocked', '--report', fixtureReport, diff --git a/tests/tools/repro-terminal-send-submit.mjs b/tests/tools/repro-terminal-send-submit.mjs index 201e51c9364..7e1c0152012 100644 --- a/tests/tools/repro-terminal-send-submit.mjs +++ b/tests/tools/repro-terminal-send-submit.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' -import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' +import { pathToFileURL } from 'node:url' const SCRIPT_PATH = import.meta.filename const BEGIN = '\x1b[200~' @@ -118,6 +119,20 @@ async function closeTerminal(cli, handle, cwd) { } } +async function waitForPermissionPrompt(cli, handle, cwd) { + const deadline = Date.now() + 10_000 + let lastTerminal = null + while (Date.now() < deadline) { + const shown = await callOrca(cli, ['terminal', 'show', '--terminal', handle], cwd) + lastTerminal = shown.terminal ?? null + if (lastTerminal?.agentWait) { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`terminal permission prompt was not observed: ${JSON.stringify(lastTerminal)}`) +} + async function waitForWorktreeSelector(cli, repoId, cwd) { const deadline = Date.now() + 10_000 const expectedPath = path.resolve(cwd) @@ -138,18 +153,25 @@ async function waitForWorktreeSelector(cli, repoId, cwd) { } async function createFakeCodexCommand(tempDir, args) { + const launcherPath = path.join(tempDir, 'codex') + const runnerPath = path.join(tempDir, 'fake-agent.mjs') + await writeFile(runnerPath, `import ${JSON.stringify(pathToFileURL(SCRIPT_PATH).href)}\n`, 'utf8') if (process.platform === 'win32') { - const launcherPath = path.join(tempDir, 'codex.cmd') + const commandPath = path.join(tempDir, 'codex.cmd') await writeFile( - launcherPath, - `@echo off\r\n"${process.execPath}" "${SCRIPT_PATH}" %*\r\n`, + commandPath, + `@echo off\r\n"${process.execPath}" "%~dp0fake-agent.mjs" %*\r\n`, 'utf8' ) - return [shellQuote(launcherPath), ...args].join(' ') + return [shellQuote(commandPath), ...args].join(' ') } - const launcherPath = path.join(tempDir, 'codex') - await symlink(process.execPath, launcherPath) - return [shellQuote(launcherPath), shellQuote(SCRIPT_PATH), ...args].join(' ') + await writeFile( + launcherPath, + `#!/usr/bin/env sh\n"${process.execPath}" "${runnerPath}" "$@"\n`, + 'utf8' + ) + await chmod(launcherPath, 0o755) + return [shellQuote(launcherPath), ...args].join(' ') } async function parentMain() { @@ -162,43 +184,48 @@ async function parentMain() { const prompt = `${marker} ${'slow composer payload '.repeat(24)}` const expectStalled = hasFlag('expect-stalled') const expectBlocked = hasFlag('expect-blocked') + const providedHandle = argValue('terminal') await mkdir(tempDir, { recursive: true }) await rm(reportPath, { force: true }) - const command = - argValue('agent-command') ?? - (await createFakeCodexCommand(tempDir, [ - '--fake-agent', - '--report', - shellQuote(reportPath), - '--marker', - shellQuote(marker), - '--timeout-ms', - String(timeoutMs), - ...(expectBlocked ? ['--permission-before-send'] : []), - ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) - ])) - const added = await callOrca(cli, ['repo', 'add', '--path', cwd], cwd) - const repoId = added.repo?.id - if (!repoId) { - throw new Error('repo add returned no id') + let handle = providedHandle + if (!handle) { + const command = + argValue('agent-command') ?? + (await createFakeCodexCommand(tempDir, [ + '--fake-agent', + '--report', + shellQuote(reportPath), + '--marker', + shellQuote(marker), + '--timeout-ms', + String(timeoutMs), + ...(expectStalled ? ['--swallow-first-enter'] : []), + ...(expectBlocked ? ['--permission-before-send'] : []), + ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) + ])) + const added = await callOrca(cli, ['repo', 'add', '--path', cwd], cwd) + const repoId = added.repo?.id + if (!repoId) { + throw new Error('repo add returned no id') + } + const worktreeSelector = await waitForWorktreeSelector(cli, repoId, cwd) + const created = await callOrca( + cli, + [ + 'terminal', + 'create', + '--worktree', + worktreeSelector, + '--title', + 'terminal send submit repro', + '--command', + command + ], + cwd + ) + handle = created.terminal?.handle } - const worktreeSelector = await waitForWorktreeSelector(cli, repoId, cwd) - const created = await callOrca( - cli, - [ - 'terminal', - 'create', - '--worktree', - worktreeSelector, - '--title', - 'terminal send submit repro', - '--command', - command - ], - cwd - ) - const handle = created.terminal?.handle if (!handle) { throw new Error('terminal create returned no handle') } @@ -209,6 +236,7 @@ async function parentMain() { if (!setupReport) { throw new Error('terminal permission prompt did not materialize') } + await waitForPermissionPrompt(cli, handle, cwd) } else { await callOrca( cli, @@ -278,6 +306,7 @@ async function parentMain() { } async function fakeAgentMain() { + process.title = 'codex' const reportPath = argValue('report') const marker = argValue('marker') const timeoutMs = parsePositiveInteger('timeout-ms', DEFAULT_TIMEOUT_MS) @@ -291,10 +320,14 @@ async function fakeAgentMain() { process.stdin.setRawMode(true) } process.stdin.resume() - process.stdout.write('OpenAI Codex\nmodel: fake\ndirectory: fixture\n> ') - if (permissionBeforeSend) { - process.stdout.write('\nPermission required\nAllow once\nAllow always\nReject\n') - } + process.stdout.write('\x1b]0;Codex working\x07') + setTimeout(() => { + const title = permissionBeforeSend ? 'Codex permission' : 'Codex Ready' + process.stdout.write(`\x1b]0;${title}\x07OpenAI Codex\nmodel: fake\ndirectory: fixture\n> `) + if (permissionBeforeSend) { + process.stdout.write('\nPermission required\nAllow once\nAllow always\nReject\n') + } + }, 100) let input = '' let countedCarriages = 0 @@ -308,6 +341,7 @@ async function fakeAgentMain() { const writeReport = async (submitted) => { const hasBracketedPasteFrame = input.includes(BEGIN) && input.includes(END) const report = { + configuredTimeoutMs: timeoutMs, contractOk: prematureEnters === 0 && (permissionBeforeSend || !pasteFramingRequired || hasBracketedPasteFrame), From bc3911a3a64e38d1d7a5af68a02a4e3182ee95c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:26:39 -0700 Subject: [PATCH 5/8] fix(native-chat): separate image attachment paths from following prompt text (STA-4993) (#15820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): separate image paths from following prompt text (STA-4993) Native Chat image send wrote a framed path and then the prompt with no separator, so after the TUI unwrapped the paste the two glued together (`…pngdescribe`). Put a trailing space after the frame when text follows, share that rule with clipboard image paste and terminal drops, and split the image send path out so the runtime send file stays under the line cap. * refactor(native-chat): keep image separator fix focused * fix(native-chat): keep consecutive image frames bare * test(runtime): update export parity for terminal degradation * test(native-chat): pin image frame separator contract --- .../native-chat/NativeChatComposer.test.tsx | 4 +- .../native-chat/NativeChatComposer.tsx | 2 +- .../native-chat-runtime-image-send.ts | 76 +++++++++++++++++++ ...ive-chat-runtime-send-launch-draft.test.ts | 8 +- .../native-chat-runtime-send.test.ts | 44 +++++++---- .../native-chat/native-chat-runtime-send.ts | 65 +--------------- .../terminal-drop-path-writer.ts | 6 +- src/shared/image-paste-following-text.test.ts | 31 ++++++++ src/shared/image-paste-following-text.ts | 25 ++++++ 9 files changed, 179 insertions(+), 82 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts create mode 100644 src/shared/image-paste-following-text.test.ts create mode 100644 src/shared/image-paste-following-text.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index e7a6269f533..2d90ac30126 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -70,9 +70,11 @@ vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessageVerified: (...args: unknown[]) => mocks.sendNativeChatMessageVerified(...args), typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args), - sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: vi.fn() +})) vi.mock('./claude-model-switch-confirmation', () => ({ createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => mocks.createClaudeModelSwitchConfirmationObserver(...args) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 7ec609f8aa1..a0fec999d8a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -5,10 +5,10 @@ import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { sendNativeChatMessage, sendNativeChatTypedCommand, - sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' +import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-commands' diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts new file mode 100644 index 00000000000..5dd62b5ef98 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -0,0 +1,76 @@ +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { imagePasteWritesFollowedByText } from '../../../../shared/image-paste-following-text' +import { NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat-answer-stepping' +import { + buildNativeChatImagePasteBytes, + buildNativeChatPasteBytes, + NATIVE_CHAT_SUBMIT +} from './native-chat-send' +import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' +import { + clearConfirmDurationMs, + clearThenWrite, + clearUnsubmittedAgentInput, + sendNativeChatMessage, + type NativeChatSendHandle, + type NativeChatSendOptions +} from './native-chat-runtime-send' + +export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 + +type RuntimeSettings = ReturnType + +export function sendNativeChatMessageWithImageAttachments( + settings: RuntimeSettings, + ptyId: string, + text: string, + imagePaths: readonly string[], + options?: NativeChatSendOptions +): NativeChatSendHandle { + if (imagePaths.length === 0) { + return sendNativeChatMessage(settings, ptyId, text, options) + } + const trimmedText = text.trim() + const durationMs = + (trimmedText.length > 0 + ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options) + return enqueueNativeChatPtySend( + ptyId, + durationMs, + ({ isCancelled, delay, markSubmitted }) => { + if (isCancelled()) { + return + } + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + for (const payload of imagePasteWritesFollowedByText( + imagePaths.map(buildNativeChatImagePasteBytes), + trimmedText.length > 0 + )) { + sendRuntimePtyInput(settings, ptyId, payload) + } + if (trimmedText.length > 0) { + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }) + return + } + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }) + }, + { + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) + } + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index 32f4947c3d6..35d8fc9444d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -13,12 +13,14 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { NATIVE_CHAT_CLEAR_CONFIRM_MS, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, NATIVE_CHAT_SUBMIT_DELAY_MS, resetNativeChatPtySendQueuesForTests, - sendNativeChatMessage, - sendNativeChatMessageWithImageAttachments + sendNativeChatMessage } from './native-chat-runtime-send' +import { + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, + sendNativeChatMessageWithImageAttachments +} from './native-chat-runtime-image-send' import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' import { AGENT_TUI_CLEAR_INPUT_MAX, diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 9a16199d52a..dc7fa05e830 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -14,21 +14,19 @@ import { sendNativeChatTypedCommand, sendNativeChatMessageVerified, typeNativeChatCommand, - sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, resetNativeChatPtySendQueuesForTests, - NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, NATIVE_CHAT_SUBMIT_DELAY_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT } from './native-chat-runtime-send' import { - buildNativeChatImagePasteBytes, - buildNativeChatPasteBytes, - NATIVE_CHAT_SUBMIT -} from './native-chat-send' + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, + sendNativeChatMessageWithImageAttachments +} from './native-chat-runtime-image-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' const SETTINGS = {} as Parameters[0] const PTY = 'pty-1' @@ -337,24 +335,21 @@ describe('sendNativeChatMessageWithImageAttachments', () => { NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS ) + const framedImageWithSeparator = '\x1b[200~/tmp/orca-paste-image.png\x1b[201~ ' expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png') + framedImageWithSeparator ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - buildNativeChatPasteBytes('what do you see?') - ) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'what do you see?') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) - it('waits the normal submit gap for an attachment-only send', () => { + it('does not append a trailing separator on an attachment-only send', () => { const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', [ '/tmp/orca-paste-image.png' ]) @@ -363,7 +358,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png') + '\x1b[200~/tmp/orca-paste-image.png\x1b[201~' ]) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1) @@ -374,6 +369,25 @@ describe('sendNativeChatMessageWithImageAttachments', () => { expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) + it('treats whitespace-only prompt input as attachment-only', () => { + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, ' ', ['/tmp/orca-paste-image.png']) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '\x1b[200~/tmp/orca-paste-image.png\x1b[201~' + ]) + }) + + it('keeps multiple image frames bare and separates only the final frame from prompt text', () => { + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'hello', ['/tmp/a.png', '/tmp/b.png']) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '\x1b[200~/tmp/a.png\x1b[201~', + '\x1b[200~/tmp/b.png\x1b[201~ ' + ]) + }) + it('cancels deferred prompt and Enter writes after the attachment path', () => { const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'describe', [ '/tmp/orca-paste-image.png' @@ -384,7 +398,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { // Pre-clear + image body + cancel clear; no Enter. expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png'), + '\x1b[200~/tmp/orca-paste-image.png\x1b[201~ ', NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT ]) expect(sendRuntimePtyInput.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)).toBe( diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a3b34b4f716..a845fe10d16 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -14,11 +14,7 @@ import { NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat-answer-stepping' -import { - buildNativeChatImagePasteBytes, - buildNativeChatPasteBytes, - NATIVE_CHAT_SUBMIT -} from './native-chat-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' import { AGENT_TUI_COMMAND_KEY_INTERVAL_MS, typeAgentTuiCommand @@ -33,8 +29,6 @@ import { export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } export { resetNativeChatPtySendQueuesForTests } -export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 - // Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends // start from an empty line so a prior cancelled paste cannot glue onto the next // prompt. Not used on verified option commands — model-switch confirmation @@ -73,7 +67,7 @@ export type NativeChatSendHandle = { type RuntimeSettings = ReturnType -function clearUnsubmittedAgentInput( +export function clearUnsubmittedAgentInput( settings: RuntimeSettings, ptyId: string, options?: NativeChatSendOptions @@ -89,7 +83,7 @@ function clearUnsubmittedAgentInput( * draft is still visible — the injected line count is only a lower bound on what * the buffer holds, since the user can type into the TUI directly. */ -function clearThenWrite( +export function clearThenWrite( settings: RuntimeSettings, ptyId: string, options: NativeChatSendOptions | undefined, @@ -117,7 +111,7 @@ function clearThenWrite( } /** Extra time a send needs when it stops to confirm the clear before the body. */ -function clearConfirmDurationMs(options?: NativeChatSendOptions): number { +export function clearConfirmDurationMs(options?: NativeChatSendOptions): number { return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 } @@ -272,57 +266,6 @@ export function sendNativeChatTypedCommand( ) } -export function sendNativeChatMessageWithImageAttachments( - settings: RuntimeSettings, - ptyId: string, - text: string, - imagePaths: readonly string[], - options?: NativeChatSendOptions -): NativeChatSendHandle { - if (imagePaths.length === 0) { - return sendNativeChatMessage(settings, ptyId, text, options) - } - const trimmedText = text.trim() - const durationMs = - (trimmedText.length > 0 - ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS - : NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options) - return enqueueNativeChatPtySend( - ptyId, - durationMs, - ({ isCancelled, delay, markSubmitted }) => { - if (isCancelled()) { - return - } - clearThenWrite(settings, ptyId, options, delay, () => { - if (isCancelled()) { - return - } - for (const imagePath of imagePaths) { - sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) - } - if (trimmedText.length > 0) { - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() - }) - }) - return - } - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() - }) - }) - }, - { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) - } - ) -} - /** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the * composer is empty. */ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts index d97bca17a63..f8ed3766251 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts @@ -1,4 +1,5 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { separateImagePasteFromFollowingText } from '../../../../shared/image-paste-following-text' import { shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' @@ -65,7 +66,10 @@ export async function writeTerminalDropPathsToCapturedTarget({ canPasteImageDropPathRaw(nextPath, targetShell) const needsSeparatorAfterImage = nextPath !== undefined && !nextPathIsRawPasteImage const payload = pathIsRawPasteImage - ? `${wrapTerminalBracketedPasteText(path)}${needsSeparatorAfterImage ? ' ' : ''}` + ? separateImagePasteFromFollowingText( + wrapTerminalBracketedPasteText(path), + needsSeparatorAfterImage + ) : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( () => writeTerminalPastePtyInput(liveTransport, payload), diff --git a/src/shared/image-paste-following-text.test.ts b/src/shared/image-paste-following-text.test.ts new file mode 100644 index 00000000000..ef46c4c7a9e --- /dev/null +++ b/src/shared/image-paste-following-text.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR, + imagePasteWritesFollowedByText +} from './image-paste-following-text' + +const IMAGE_A = '\x1b[200~/tmp/orca-paste-a.png\x1b[201~' +const IMAGE_B = '\x1b[200~/tmp/orca-paste-b.png\x1b[201~' + +describe('imagePasteWritesFollowedByText', () => { + it('separates an attachment path from following prompt text by a single space', () => { + expect(imagePasteWritesFollowedByText([IMAGE_A], true)).toEqual([`${IMAGE_A} `]) + expect(IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR).toBe(' ') + }) + + it('keeps an attachment-only send as the framed path with no trailing separator', () => { + expect(imagePasteWritesFollowedByText([IMAGE_A], false)).toEqual([IMAGE_A]) + }) + + it('returns no writes when there are no image pastes', () => { + expect(imagePasteWritesFollowedByText([], true)).toEqual([]) + }) + + it('keeps back-to-back image frames bare and separates only the final frame from prompt text', () => { + expect(imagePasteWritesFollowedByText([IMAGE_A, IMAGE_B], true)).toEqual([ + IMAGE_A, + `${IMAGE_B} ` + ]) + expect(imagePasteWritesFollowedByText([IMAGE_A, IMAGE_B], false)).toEqual([IMAGE_A, IMAGE_B]) + }) +}) diff --git a/src/shared/image-paste-following-text.ts b/src/shared/image-paste-following-text.ts new file mode 100644 index 00000000000..4efd7363427 --- /dev/null +++ b/src/shared/image-paste-following-text.ts @@ -0,0 +1,25 @@ +// One space after a bracketed image paste so later typed/sent text cannot glue +// onto the path (`…pngadd` → `…png add`). Desktop terminal drops already do this +// when an image is followed by a non-image path. +export const IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR = ' ' + +export function separateImagePasteFromFollowingText( + framedImagePaste: string, + followedByNonImageInput: boolean +): string { + return followedByNonImageInput + ? `${framedImagePaste}${IMAGE_PASTE_FOLLOWING_TEXT_SEPARATOR}` + : framedImagePaste +} + +export function imagePasteWritesFollowedByText( + framedImagePastes: readonly string[], + followedByText: boolean +): string[] { + return framedImagePastes.map((paste, index) => + separateImagePasteFromFollowingText( + paste, + index === framedImagePastes.length - 1 && followedByText + ) + ) +} From 419e3b449685fa10490d07e64e3e3daf01574360 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:28:09 -0700 Subject: [PATCH 6/8] Fix terminal reads that flatten composer drafts into output (#16711) * fix(terminal): separate composer drafts from read output Rendered screen reads treated cursor-line suggestion overlays as PTY output. Detect composer-owned text from cell attributes and cursor context, remove it from tail, and expose it as structured draft metadata. * fix(terminal): handle wrapped composer overlays * fix(terminal): preserve draft wrapping and tail alignment * fix(terminal): preserve composer wrap boundaries * fix(terminal): preserve draft continuations with middle dots * fix(terminal): recognize configurable Codex status lines --- src/cli/specs/core.ts | 1 + src/cli/terminal-format-draft.test.ts | 21 + src/cli/terminal-format.ts | 1 + src/main/daemon/headless-emulator.ts | 16 + .../terminal-cursor-line-context.test.ts | 71 ++++ .../daemon/terminal-cursor-line-context.ts | 99 +++++ src/main/runtime/orca-runtime.test.ts | 91 +++++ src/main/runtime/orca-runtime.ts | 138 +++++-- .../orchestration/worker-output-archive.ts | 11 +- .../orchestration-worker-archive-read.ts | 1 + .../orchestration-worker-output.test.ts | 25 ++ .../methods/orchestration-worker-output.ts | 13 +- .../orchestration-worker-release.test.ts | 12 +- src/main/runtime/terminal-projection.test.ts | 59 +++ src/shared/runtime-terminal-contracts.ts | 2 + src/shared/terminal-composer-draft.test.ts | 369 ++++++++++++++++++ src/shared/terminal-composer-draft.ts | 173 ++++++++ 17 files changed, 1056 insertions(+), 47 deletions(-) create mode 100644 src/cli/terminal-format-draft.test.ts create mode 100644 src/main/daemon/terminal-cursor-line-context.test.ts create mode 100644 src/main/daemon/terminal-cursor-line-context.ts create mode 100644 src/main/runtime/terminal-projection.test.ts create mode 100644 src/shared/terminal-composer-draft.test.ts create mode 100644 src/shared/terminal-composer-draft.ts diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index d4422943f67..aaf94bb0d64 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -208,6 +208,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.', 'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.', 'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.', + 'When present, draft is UI-only composer text excluded from tail; never treat it as terminal output or a submitted instruction.', '--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.', 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', 'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.', diff --git a/src/cli/terminal-format-draft.test.ts b/src/cli/terminal-format-draft.test.ts new file mode 100644 index 00000000000..3f38e48dd21 --- /dev/null +++ b/src/cli/terminal-format-draft.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { formatTerminalRead } from './terminal-format' + +describe('formatTerminalRead draft separation', () => { + it('labels composer drafts outside terminal output', () => { + const output = formatTerminalRead({ + terminal: { + handle: 'term_1', + status: 'running', + tail: ['Build passed', '❯'], + truncated: false, + nextCursor: null, + source: 'screen', + draft: 'proceed with the release' + } + }) + + expect(output).toContain('draft: "proceed with the release"') + expect(output).toContain('\n\nBuild passed\n❯') + }) +}) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 806a39cb588..4d57e7693a1 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -141,6 +141,7 @@ export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): s `handle: ${terminal.handle}`, `status: ${terminal.status}`, ...(terminal.source ? [`source: ${terminal.source}`] : []), + ...(terminal.draft ? [`draft: ${JSON.stringify(terminal.draft)}`] : []), ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []), ...oldestCursor, ...latestCursor, diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e26ce0d3094..18a9b25e6d3 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -23,6 +23,8 @@ import { import { installDeviceAttributesResponder } from './startup-device-attributes-responder' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' +import { readTerminalCursorLineContext } from './terminal-cursor-line-context' export type HeadlessEmulatorOptions = { cols: number @@ -302,6 +304,20 @@ export class HeadlessEmulator { return lines } + getVisibleBufferRange(): { start: number; endExclusive: number; totalLength: number } { + const buffer = this.terminal.buffer.active + const start = buffer.viewportY + return { + start, + endExclusive: Math.min(buffer.length, start + this.terminal.rows), + totalLength: buffer.length + } + } + + getCursorLineContext(rowsAbove = this.terminal.rows): TerminalCursorContext | null { + return readTerminalCursorLineContext(this.terminal, rowsAbove) + } + getBufferTailLines(limit: number): string[] { const buffer = this.terminal.buffer.active const start = Math.max(0, buffer.length - Math.max(0, Math.floor(limit))) diff --git a/src/main/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts new file mode 100644 index 00000000000..fcf5fa2b492 --- /dev/null +++ b/src/main/daemon/terminal-cursor-line-context.test.ts @@ -0,0 +1,71 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' +import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft' +import { HeadlessEmulator } from './headless-emulator' +import { readTerminalCursorLineContext } from './terminal-cursor-line-context' + +function writeSync(terminal: Terminal, data: string): void { + const core = (terminal as unknown as { _core: { writeSync(data: string): void } })._core + core.writeSync(data) +} + +describe('readTerminalCursorLineContext', () => { + it.each([ + { cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' }, + { cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' } + ])( + 'preserves a space at a dimmed soft-wrap boundary with $cols columns', + ({ cols, cursorRowTail, continuation }) => { + const terminal = new Terminal({ cols, rows: 6, allowProposedApi: true }) + writeSync( + terminal, + `${'─'.repeat(cols)}\r\n❯ \x1b7\x1b[2mproceed with the release\x1b[22m\x1b8` + ) + + const context = readTerminalCursorLineContext(terminal, 16) + + expect(context?.rawAfterCursor).toBe(cursorRowTail) + expect(context?.rowsBelow).toEqual([continuation, '', '', '']) + expect(context?.rowsBelowWrapped).toEqual([true, false, false, false]) + expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release') + terminal.dispose() + } + ) + + it('preserves a typed space before the cursor moves onto a wrapped row', () => { + const terminal = new Terminal({ cols: 19, rows: 6, allowProposedApi: true }) + writeSync(terminal, '───────────────────\r\n❯ proceed with the release') + + const context = readTerminalCursorLineContext(terminal, 16) + + expect(context?.typedRows).toEqual(['───────────────────', '❯ proceed with the ', 'release']) + expect(context?.rowsWrapped).toEqual([false, false, true]) + expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release') + terminal.dispose() + }) + + it('recognizes a colored context-only Codex status footer', () => { + const terminal = new Terminal({ cols: 80, rows: 8, allowProposedApi: true }) + writeSync( + terminal, + '\x1b[1m›\x1b[22m \x1b7review the change\r\n \r\n\x1b[38;2;242;181;144mContext 0% used\x1b[0m\x1b8' + ) + + const context = readTerminalCursorLineContext(terminal, 16) + + expect(detectTerminalComposerDraft(context)?.text).toBe('review the change') + terminal.dispose() + }) + + it('finds a composer prompt more than 16 wrapped rows above the cursor', async () => { + const draft = `proceed ${'with '.repeat(65)}release` + const emulator = new HeadlessEmulator({ cols: 19, rows: 30 }) + await emulator.write(`${'─'.repeat(19)}\r\n❯ ${draft}`) + + const context = emulator.getCursorLineContext() + + expect(context?.rows.length).toBeGreaterThan(17) + expect(detectTerminalComposerDraft(context)?.text).toBe(draft) + emulator.dispose() + }) +}) diff --git a/src/main/daemon/terminal-cursor-line-context.ts b/src/main/daemon/terminal-cursor-line-context.ts new file mode 100644 index 00000000000..cf9593e13e8 --- /dev/null +++ b/src/main/daemon/terminal-cursor-line-context.ts @@ -0,0 +1,99 @@ +import type { IBufferLine, Terminal } from '@xterm/headless' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' + +function undimmedText(line: IBufferLine, fromX = 0, trimRight = true): string { + let text = '' + for (let x = fromX; x < line.length; x += 1) { + const cell = line.getCell(x) + if (!cell || cell.isDim() || cell.getWidth() === 0) { + continue + } + text += cell.getChars() || ' ' + } + return trimRight ? text.trimEnd() : text +} + +function firstVisibleCellIsBold(line: IBufferLine): boolean { + for (let x = 0; x < line.length; x += 1) { + const cell = line.getCell(x) + if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { + continue + } + return Boolean(cell.isBold()) + } + return false +} + +function firstVisibleCellHasCustomForeground(line: IBufferLine): boolean { + for (let x = 0; x < line.length; x += 1) { + const cell = line.getCell(x) + if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { + continue + } + return !cell.isFgDefault() + } + return false +} + +export function readTerminalCursorLineContext( + terminal: Terminal, + rowsAbove: number +): TerminalCursorContext | null { + const buffer = terminal.buffer.active + const cursorRow = buffer.baseY + buffer.cursorY + const cursorLine = buffer.getLine(cursorRow) + if (!cursorLine) { + return null + } + const rows: string[] = [] + const typedRows: string[] = [] + const promptGlyphBoldRows: boolean[] = [] + const rowsWrapped: boolean[] = [] + const start = Math.max(buffer.viewportY, cursorRow - Math.max(0, Math.floor(rowsAbove))) + for (let row = start; row <= cursorRow; row += 1) { + const line = buffer.getLine(row) + const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false + rows.push(line?.translateToString(!nextLineIsWrapped) ?? '') + typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') + promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line) : false) + rowsWrapped.push(line?.isWrapped ?? false) + } + const rowsBelow: string[] = [] + const typedRowsBelow: string[] = [] + const rowsBelowWrapped: boolean[] = [] + const rowsBelowCustomForeground: boolean[] = [] + const end = Math.min( + buffer.viewportY + terminal.rows - 1, + cursorRow + Math.max(0, Math.floor(rowsAbove)) + ) + for (let row = cursorRow + 1; row <= end; row += 1) { + const line = buffer.getLine(row) + const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false + rowsBelow.push(line?.translateToString(!nextLineIsWrapped) ?? '') + typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') + rowsBelowWrapped.push(line?.isWrapped ?? false) + rowsBelowCustomForeground.push(line ? firstVisibleCellHasCustomForeground(line) : false) + } + return { + rows, + typedRows, + promptGlyphBoldRows, + rowsWrapped, + rowsBelow, + typedRowsBelow, + rowsBelowWrapped, + rowsBelowCustomForeground, + beforeCursor: cursorLine.translateToString(true, 0, buffer.cursorX), + afterCursor: undimmedText( + cursorLine, + buffer.cursorX, + !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false) + ), + rawAfterCursor: cursorLine.translateToString( + !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), + buffer.cursorX + ), + cursorHidden: !terminal.modes.showCursor, + cursorViewportRow: cursorRow - buffer.viewportY + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 40c9eafd621..3d507c5da88 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -19354,6 +19354,97 @@ describe('OrcaRuntimeService', () => { }) }) + it('separates composer draft text from rendered terminal output', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b[?1049hBuild passed\r\n────────\r\n❯ \x1b[2mproceed with the release\r\n and close the pull request\x1b[22m\x1b[1A\x1b[3G', + 100 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle) + + expect(read).toMatchObject({ + source: 'screen', + tail: ['Build passed', '────────', '❯'], + draft: 'proceed with the release\nand close the pull request' + }) + }) + + it('keeps renderer-fallback composer drafts separate from terminal output', async () => { + const serializeBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049hBuild passed\r\n────────\r\n❯ \x1b[2mproceed with the release\x1b[22m\x1b[3G', + cols: 80, + rows: 24 + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasRendererSerializer: () => true, + serializeBuffer + }) + syncSinglePty(runtime) + runtime.onPtyData('pty-1', `${Array.from({ length: 3000 }, () => '').join('\n')}\n`, 100) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle) + + expect(read).toMatchObject({ + source: 'screen', + tail: ['Build passed', '────────', '❯'], + draft: 'proceed with the release' + }) + expect(serializeBuffer).toHaveBeenCalledWith('pty-1', { + scrollbackRows: 0, + altScreenForcesZeroRows: false + }) + }) + + it('separates composer drafts from provider-owned terminal screens', async () => { + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: '\x1b[?1049hBuild passed\r\n────────\r\n❯ \x1b[2mproceed with the release\x1b[22m\x1b[3G', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + syncSinglePty(runtime) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-1', + { value: 900, generation: 'continued' }, + 0 + ) + const [terminal] = (await runtime.listTerminals()).terminals + + const read = await runtime.readTerminal(terminal.handle) + + expect(read).toMatchObject({ + source: 'screen', + tail: ['Build passed', '────────', '❯'], + draft: 'proceed with the release' + }) + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + it('does not use renderer visible-screen fallback for cursor transcript reads', async () => { const serializeBuffer = vi.fn().mockResolvedValue({ data: 'Visible TUI\n', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6afbdfda612..b57e44aacf3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -27,6 +27,7 @@ import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range' +import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft' import type { RemoteTerminalSourceRangeConsumerHooks, RemoteTerminalSourceRangeReplacementPublication, @@ -1896,11 +1897,17 @@ export type RuntimeTerminalDataMeta = Readonly<{ type RuntimeVisibleTerminalState = { lines: string[] + draft?: string isAlternateScreen: boolean sequence: number generation: number } +type RuntimeTerminalProjection = { + lines: string[] + draft?: string +} + type ProviderBufferAcquisition = { generation: number scrollbackRows: number @@ -14053,13 +14060,18 @@ export class OrcaRuntimeService { !recoveredWorkerFallback && this.isKnownUnattachedLocalDaemonPty(ptyId) if (recoveredWorkerFallback || neverAttachedProviderFallback) { - const providerLines = await this.readProviderTerminalTailLines( + const providerProjection = await this.readProviderTerminalTailLines( ptyId, opts.limit, providerSnapshot ) - if (providerLines.length > 0) { - return buildVisibleSnapshotReadFallback(read, providerLines, opts.limit) + if (providerProjection.lines.length > 0) { + return buildVisibleSnapshotReadFallback( + read, + providerProjection.lines, + opts.limit, + providerProjection.draft + ) } } const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) @@ -14083,21 +14095,21 @@ export class OrcaRuntimeService { ) { return read } - let lines = visibleState?.lines ?? [] - if (lines.length === 0) { - lines = await this.readRendererVisibleSnapshotLines(ptyId) + let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } + if (projection.lines.length === 0) { + projection = await this.readRendererVisibleSnapshotLines(ptyId) } - if (lines.length === 0) { + if (projection.lines.length === 0) { return read } - return buildVisibleSnapshotReadFallback(read, lines, opts.limit) + return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) } private async readProviderTerminalTailLines( ptyId: string, limit: number | undefined, snapshotOptions: ProviderSnapshotReadOptions = {} - ): Promise { + ): Promise { const generation = this.getPtyLifecycleGeneration(ptyId) const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) const snapshot = await this.serializeProviderTerminalBuffer( @@ -14106,21 +14118,21 @@ export class OrcaRuntimeService { snapshotOptions ) if (!snapshot) { - return [] + return { lines: [] } } // Why: a cached acquisition can carry scrollback this caller did not ask for, // so visible-only reads parse the grid itself rather than trusting the request. if (snapshotOptions.visibleScreenOnly) { - const lines = await this.parseVisibleSnapshotLines(snapshot) + const projection = await this.parseVisibleSnapshot(snapshot) // Live bytes ordered after the provider frame make that frame stale. return this.getPtyLifecycleGeneration(ptyId) === generation && this.getPtyOutputSequence(ptyId) <= snapshot.seq - ? lines - : [] + ? projection + : { lines: [] } } const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` if (data.length === 0) { - return [] + return { lines: [] } } const emulator = new HeadlessEmulator({ cols: snapshot.cols, @@ -14129,11 +14141,11 @@ export class OrcaRuntimeService { }) try { await emulator.write(data) - const lines = visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit)) + const projection = projectTerminalTailLines(emulator, lineLimit) return this.getPtyLifecycleGeneration(ptyId) === generation && this.getPtyOutputSequence(ptyId) <= snapshot.seq - ? lines - : [] + ? projection + : { lines: [] } } finally { emulator.dispose() } @@ -14150,11 +14162,11 @@ export class OrcaRuntimeService { if (!knownAlternateScreen && !visibleState?.isAlternateScreen) { return preview } - let lines = visibleState?.lines ?? [] - if (lines.length === 0) { - lines = await this.readRendererVisibleSnapshotLines(ptyId) + let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } + if (projection.lines.length === 0) { + projection = await this.readRendererVisibleSnapshotLines(ptyId) } - return lines.length > 0 ? buildPreview(lines, '') : preview + return projection.lines.length > 0 ? buildPreview(projection.lines, '') : preview } private async readVisibleTerminalState( @@ -14208,7 +14220,7 @@ export class OrcaRuntimeService { return liveState } } - const lines = await this.parseVisibleSnapshotLines(snapshot) + const projection = await this.parseVisibleSnapshot(snapshot) if ( this.getPtyLifecycleGeneration(ptyId) !== generation || this.getPtyOutputSequence(ptyId) > snapshot.seq @@ -14216,7 +14228,8 @@ export class OrcaRuntimeService { return null } const visibleState: RuntimeVisibleTerminalState = { - lines, + lines: projection.lines, + ...(projection.draft ? { draft: projection.draft } : {}), isAlternateScreen: snapshot.alternateScreen ?? false, sequence: snapshot.seq, generation @@ -14240,21 +14253,23 @@ export class OrcaRuntimeService { ) { return null } + const projection = projectVisibleTerminalLines(state.emulator) return { - lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()), + lines: projection.lines, + ...(projection.draft ? { draft: projection.draft } : {}), isAlternateScreen: state.emulator.isAlternateScreen, sequence: state.outputSequence, generation } } - private async parseVisibleSnapshotLines(snapshot: { + private async parseVisibleSnapshot(snapshot: { data: string cols: number rows: number - }): Promise { + }): Promise<{ lines: string[]; draft?: string }> { if (snapshot.data.length === 0) { - return [] + return { lines: [] } } const emulator = new HeadlessEmulator({ cols: snapshot.cols, @@ -14263,19 +14278,21 @@ export class OrcaRuntimeService { }) try { await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) - return visibleNonBlankTerminalLines(emulator.getVisibleLines()) + return projectVisibleTerminalLines(emulator) } finally { emulator.dispose() } } - private async readRendererVisibleSnapshotLines(ptyId: string): Promise { + private async readRendererVisibleSnapshotLines( + ptyId: string + ): Promise { const controller = this.ptyController if (!controller?.serializeBuffer) { - return [] + return { lines: [] } } if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) { - return [] + return { lines: [] } } try { // Why: raw PTY tails can be whitespace-only while a full-screen TUI is @@ -14290,11 +14307,11 @@ export class OrcaRuntimeService { null ) if (!snapshot || snapshot.data.length === 0) { - return [] + return { lines: [] } } - return this.parseVisibleSnapshotLines(snapshot) + return this.parseVisibleSnapshot(snapshot) } catch { - return [] + return { lines: [] } } } @@ -19275,12 +19292,11 @@ export class OrcaRuntimeService { opts: { limit?: number } = {} ): Promise { const visibleState = await this.readVisibleTerminalState(ptyId) - const lines = - visibleState?.lines ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) - if (lines.length === 0) { + const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) + if (projection.lines.length === 0) { return { ...read, source: 'screen-unavailable' } } - return buildVisibleSnapshotReadFallback(read, lines, opts.limit) + return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) } // Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent @@ -40973,6 +40989,46 @@ function visibleNonBlankTerminalLines(lines: string[]): string[] { return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0) } +function projectVisibleTerminalLines(emulator: HeadlessEmulator): { + lines: string[] + draft?: string +} { + const lines = emulator.getVisibleLines() + const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) + if (draft) { + lines[draft.promptRow] = draft.promptGlyph + for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { + lines[row] = '' + } + } + return { + lines: visibleNonBlankTerminalLines(lines), + ...(draft ? { draft: draft.text } : {}) + } +} + +export function projectTerminalTailLines( + emulator: HeadlessEmulator, + limit: number +): RuntimeTerminalProjection { + const tail = emulator.getBufferTailLines(limit) + const visible = emulator.getVisibleLines() + const visibleRange = emulator.getVisibleBufferRange() + const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) + if (draft && visibleRange.endExclusive === visibleRange.totalLength) { + visible[draft.promptRow] = draft.promptGlyph + for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { + visible[row] = '' + } + const scrollbackTail = tail.slice(0, Math.max(0, tail.length - visible.length)) + tail.splice(0, tail.length, ...scrollbackTail, ...visibleNonBlankTerminalLines(visible)) + } + return { + lines: visibleNonBlankTerminalLines(tail).slice(-limit), + ...(draft ? { draft: draft.text } : {}) + } +} + // Why: every read carries its source, so a caller that asked for a screen and got a response // with no source at all knows it reached a host that predates screen reads — rather than // mistaking the stream for the screen. Rendered lines only ever enter a read through @@ -40985,7 +41041,8 @@ function labelTerminalReadSource(resolved: RuntimeTerminalRead): RuntimeTerminal function buildVisibleSnapshotReadFallback( read: RuntimeTerminalRead, visibleLines: string[], - limit: number | undefined + limit: number | undefined, + draft?: string ): RuntimeTerminalRead { const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) const lineBoundedTail = visibleLines.slice(-lineLimit) @@ -40999,7 +41056,8 @@ function buildVisibleSnapshotReadFallback( limited: read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited, returnedLineCount: charBoundedTail.tail.length, - source: 'screen' + source: 'screen', + ...(draft ? { draft } : {}) } } diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index c6fe9970feb..f6f2b52ecb1 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -31,6 +31,7 @@ export type WorkerTranscriptSnapshotArchive = { export type WorkerTerminalTailArchive = { lines: string[] + draft?: string truncated: boolean terminalStatus: string warnings: string[] @@ -85,8 +86,13 @@ export async function captureWorkerOutputArchive(args: { `Output could not be preserved for Dispatch ${args.dispatchId}; the terminal was retained. ${error instanceof Error ? error.message : String(error)}` ) } - const redacted = redactWorkerTerminalLines(terminal.tail) + const redacted = redactWorkerTerminalLines([ + ...terminal.tail, + ...(terminal.draft ? [terminal.draft] : []) + ]) const bounded = boundArchiveLines(redacted.lines) + const draft = terminal.draft ? bounded.lines.at(-1) : undefined + const lines = terminal.draft ? bounded.lines.slice(0, -1) : bounded.lines // Why: an exited PTY zeroes its tail immediately, so an empty capture is a distinct receipt, // not silent success — worker-read must be able to say why nothing is there. const empty = bounded.lines.every((line) => line.trim() === '') @@ -94,7 +100,8 @@ export async function captureWorkerOutputArchive(args: { kind: 'terminal_tail', status: empty ? 'empty' : 'captured', content: { - lines: bounded.lines, + lines, + ...(draft ? { draft } : {}), truncated: terminal.truncated || bounded.truncated, terminalStatus: terminal.status, warnings: empty diff --git a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts index 29e01e45558..1830944c98c 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts @@ -206,6 +206,7 @@ function readArchivedTerminalTail( handle: args.resource.terminal_handle, status: 'exited', tail, + ...(!cursor && content.draft ? { draft: content.draft } : {}), truncated: content.truncated, nextCursor, returnedLineCount: tail.length diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts index 0e24a6bced8..d1349c92454 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts @@ -157,6 +157,31 @@ describe('exact orchestration worker output', () => { expect(JSON.stringify(result)).not.toContain(capability) }) + it('redacts dispatch capabilities from terminal composer drafts', async () => { + const capability = `dcap_${'A'.repeat(43)}` + readTerminal.mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['safe output'], + draft: `send --dispatch-capability ${capability}`, + truncated: false, + nextCursor: '9' + }) + providerSession = null + + const result = await read() + + expect(result).toMatchObject({ + source: 'terminal', + terminal: { + tail: ['safe output'], + draft: 'send --dispatch-capability [dispatch capability redacted]' + }, + warnings: ['Dispatch capability tokens were redacted from terminal output.'] + }) + expect(JSON.stringify(result)).not.toContain(capability) + }) + it('rejects an old cursor after the exact provider session changes', async () => { const initial = await read() if (initial.source !== 'transcript') { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.ts index bd63da46546..b559b4cae6a 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-output.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.ts @@ -135,7 +135,12 @@ async function readTerminalOutput( cursor: cursor?.source === 'terminal' ? cursor.position : undefined, limit: args.limit }) - const redactedTerminal = redactWorkerTerminalLines(terminal.tail) + const redactedTerminal = redactWorkerTerminalLines([ + ...terminal.tail, + ...(terminal.draft ? [terminal.draft] : []) + ]) + const redactedTail = redactedTerminal.lines.slice(0, terminal.tail.length) + const redactedDraft = terminal.draft ? redactedTerminal.lines.at(-1) : undefined const position = terminal.nextCursor !== null && /^\d+$/.test(terminal.nextCursor) ? Number.parseInt(terminal.nextCursor, 10) @@ -148,7 +153,11 @@ async function readTerminalOutput( dispatchId: args.dispatchId, source: 'terminal', sourceIdentity, - terminal: { ...terminal, tail: redactedTerminal.lines }, + terminal: { + ...terminal, + tail: redactedTail, + ...(redactedDraft ? { draft: redactedDraft } : {}) + }, cursor: nextCursor, status: { worker: args.workerState, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts index 9563a848880..b32fb7756bd 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts @@ -587,6 +587,7 @@ describe('orchestration worker release', () => { handle: 'term_worker', status: 'running', tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'], + draft: `send --dispatch-capability dcap_${'b'.repeat(24)}`, truncated: false, nextCursor: '3' }) @@ -596,19 +597,24 @@ describe('orchestration worker release', () => { const page1 = (await call('orchestration.workerRead', { dispatch: dispatchId, limit: 2 - })) as { archived?: boolean; terminal: { tail: string[] }; cursor: string | null } - expect(page1.archived).toBe(true) + })) as { + archived?: boolean + terminal: { tail: string[]; draft?: string } + cursor: string | null + } expect(page1.terminal.tail).toEqual([ 'first line', 'capability [dispatch capability redacted] leaked' ]) + expect(page1.terminal.draft).toBe('send --dispatch-capability [dispatch capability redacted]') expect(page1.cursor).not.toBeNull() const page2 = (await call('orchestration.workerRead', { dispatch: dispatchId, cursor: page1.cursor as string - })) as { terminal: { tail: string[] }; cursor: string | null } + })) as { terminal: { tail: string[]; draft?: string }; cursor: string | null } expect(page2.terminal.tail).toEqual(['last line']) + expect(page2.terminal.draft).toBeUndefined() expect(page2.cursor).toBeNull() // The live terminal is never consulted after release. expect(runtime.readTerminal).not.toHaveBeenCalled() diff --git a/src/main/runtime/terminal-projection.test.ts b/src/main/runtime/terminal-projection.test.ts new file mode 100644 index 00000000000..7dff087e74c --- /dev/null +++ b/src/main/runtime/terminal-projection.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' +import type { HeadlessEmulator } from '../daemon/headless-emulator' +import { projectTerminalTailLines } from './orca-runtime' + +describe('projectTerminalTailLines', () => { + it('does not splice a scrolled viewport into unrelated tail rows', () => { + const context: TerminalCursorContext = { + rows: ['────────', '❯ proceed'], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: 'proceed', + cursorHidden: false, + cursorViewportRow: 1 + } + const emulator = { + getBufferTailLines: () => ['old output', 'tail output'], + getVisibleLines: () => ['────────', '❯ proceed'], + getVisibleBufferRange: () => ({ start: 0, endExclusive: 2, totalLength: 10 }), + getCursorLineContext: () => context + } as unknown as HeadlessEmulator + + expect(projectTerminalTailLines(emulator, 2)).toEqual({ + lines: ['old output', 'tail output'], + draft: 'proceed' + }) + }) + + it('backfills the prompt row when a small limit contains only draft continuations', () => { + const context: TerminalCursorContext = { + rows: ['────────', '❯ proceed'], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsBelow: [' with', ' release'], + typedRowsBelow: ['', ''], + rowsBelowWrapped: [true, true], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: 'proceed', + cursorHidden: false, + cursorViewportRow: 2 + } + const emulator = { + getBufferTailLines: () => [' with', ' release'], + getVisibleLines: () => ['Build passed', '────────', '❯ proceed', ' with', ' release'], + getVisibleBufferRange: () => ({ start: 0, endExclusive: 5, totalLength: 5 }), + getCursorLineContext: () => context + } as unknown as HeadlessEmulator + + expect(projectTerminalTailLines(emulator, 2)).toEqual({ + lines: ['────────', '❯'], + draft: 'proceed with release' + }) + }) +}) diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index a1692e5f754..02eb5df9ebd 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -185,6 +185,8 @@ export type RuntimeTerminalRead = { latestCursor?: string returnedLineCount?: number source?: 'stream' | 'screen' | 'screen-unavailable' + /** UI-only composer text, excluded from `tail`. */ + draft?: string } export type RuntimeTerminalRename = { diff --git a/src/shared/terminal-composer-draft.test.ts b/src/shared/terminal-composer-draft.test.ts new file mode 100644 index 00000000000..9e61796c4f9 --- /dev/null +++ b/src/shared/terminal-composer-draft.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from 'vitest' +import { detectTerminalComposerDraft } from './terminal-composer-draft' + +describe('detectTerminalComposerDraft', () => { + it('separates a cursor-right suggestion from the composer line', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ proceed with the release'], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: 'proceed with the release', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toEqual({ + text: 'proceed with the release', + promptRow: 8, + cursorRow: 8, + endRow: 8, + promptGlyph: '❯' + }) + }) + + it('keeps stock dim placeholders out of draft metadata', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› Ask Codex to do anything'], + typedRows: ['›'], + promptGlyphBoldRows: [true], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: '› ', + afterCursor: '', + rawAfterCursor: 'Ask Codex to do anything', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toBeNull() + }) + + it('does not duplicate real typed text left of the cursor', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ review the change'], + typedRows: ['────────', '❯ review the change'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: '❯ review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 2 + })?.text + ).toBe('review the change') + }) + + it('preserves a shell prompt that happens to use the Claude glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['last command output', '❯ git status'], + typedRows: ['last command output', '❯ git status'], + promptGlyphBoldRows: [false, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: '❯ git status', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 7 + }) + ).toBeNull() + }) + + it('rejects a hidden-cursor dialog even when its selected row uses a composer glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› 1. Yes, continue', ' Press enter to continue'], + typedRows: ['› 1. Yes, continue', ' Press enter to continue'], + promptGlyphBoldRows: [true, false], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: ' Press enter to continue', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: true, + cursorViewportRow: 5 + }) + ).toBeNull() + }) + + it('separates dimmed suggestion rows below the restored cursor', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ proceed with the release'], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsBelow: [' and close the pull request', '────────'], + typedRowsBelow: ['', '────────'], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: 'proceed with the release', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toEqual({ + text: 'proceed with the release\nand close the pull request', + promptRow: 8, + cursorRow: 8, + endRow: 9, + promptGlyph: '❯' + }) + }) + + it('recognizes the bold Codex ultra composer glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['» review the change'], + typedRows: ['» review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['gpt-5.6 · ~/repo'], + typedRowsBelow: ['gpt-5.6 · ~/repo'], + beforeCursor: '» review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: '»' }) + }) + + it('recognizes the bold Codex composer when its footer is visible', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› review the change'], + typedRows: ['› review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['gpt-5.6 · ~/repo'], + typedRowsBelow: ['gpt-5.6 · ~/repo'], + beforeCursor: '› review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: '›' }) + }) + + it('recognizes a Codex composer with a context-only status footer', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› review the change'], + typedRows: ['› review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['', ' Context 12% used'], + typedRowsBelow: ['', ''], + rowsBelowWrapped: [false, false], + beforeCursor: '› review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: '›' }) + }) + + it('recognizes a Codex composer with an arbitrary dimmed thread-title footer', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› review the change'], + typedRows: ['› review the change'], + promptGlyphBoldRows: [true], + rowsBelow: ['', ' Release train'], + typedRowsBelow: ['', ''], + rowsBelowWrapped: [false, false], + beforeCursor: '› review the change', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toMatchObject({ text: 'review the change', promptGlyph: '›' }) + }) + + it('preserves an ordinary shell prompt that uses the Codex glyph', () => { + expect( + detectTerminalComposerDraft({ + rows: ['last command output', '› git status'], + typedRows: ['last command output', '› git status'], + promptGlyphBoldRows: [false, true], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: '› git status', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 7 + }) + ).toBeNull() + }) + + it('keeps the side-thread placeholder out of draft metadata', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› Ask a follow-up question'], + typedRows: ['›'], + promptGlyphBoldRows: [true], + rowsBelow: [], + typedRowsBelow: [], + beforeCursor: '› ', + afterCursor: '', + rawAfterCursor: 'Ask a follow-up question', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toBeNull() + }) + + it('keeps a wrapped stock placeholder out of draft metadata', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› Ask Codex to do'], + typedRows: ['›'], + promptGlyphBoldRows: [true], + rowsBelow: [' anything'], + typedRowsBelow: [''], + beforeCursor: '› ', + afterCursor: '', + rawAfterCursor: 'Ask Codex to do', + cursorHidden: false, + cursorViewportRow: 4 + }) + ).toBeNull() + }) + + it('joins soft-wrapped continuation rows without inserting a newline', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ proceed with the'], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['release'], + typedRowsBelow: [''], + rowsBelowWrapped: [true], + beforeCursor: '❯ ', + afterCursor: '', + rawAfterCursor: 'proceed with the ', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('proceed with the release') + }) + + it('keeps a draft continuation containing a middle dot', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ proceed '], + typedRows: ['────────', '❯'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['deploy · verify', '────────'], + typedRowsBelow: ['', '────────'], + rowsBelowWrapped: [true, false], + beforeCursor: '❯ ', + afterCursor: 'proceed ', + rawAfterCursor: 'proceed ', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('proceed deploy · verify') + }) + + it('keeps a model-like continuation before the Codex status footer', () => { + expect( + detectTerminalComposerDraft({ + rows: ['› compare '], + typedRows: ['› compare '], + promptGlyphBoldRows: [true], + rowsBelow: ['gpt-5 · verify', 'gpt-5.6 · Context 12% used'], + typedRowsBelow: ['gpt-5 · verify', 'gpt-5.6 · Context 12% used'], + rowsBelowWrapped: [false, false], + beforeCursor: '› compare ', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 4 + })?.text + ).toBe('compare\ngpt-5 · verify') + }) + + it('keeps typed soft-wrapped rows below a restored cursor in the draft', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ proceed with the '], + typedRows: ['────────', '❯ proceed with the '], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['release'], + typedRowsBelow: ['release'], + rowsBelowWrapped: [true], + beforeCursor: '❯ proceed', + afterCursor: ' with the ', + rawAfterCursor: ' with the ', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('proceed with the release') + }) + + it('keeps typed hard-newline rows below a restored cursor in the draft', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ first line'], + typedRows: ['────────', '❯ first line'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: [' second line', '────────'], + typedRowsBelow: [' second line', '────────'], + rowsBelowWrapped: [false, false], + beforeCursor: '❯ first', + afterCursor: ' line', + rawAfterCursor: ' line', + cursorHidden: false, + cursorViewportRow: 8 + })?.text + ).toBe('first line\nsecond line') + }) + + it('keeps typed continuation rows after an intentional blank draft line', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ first line'], + typedRows: ['────────', '❯ first line'], + promptGlyphBoldRows: [false, false], + rowsWrapped: [false, false], + rowsBelow: ['', ' second line', '────────'], + typedRowsBelow: ['', ' second line', '────────'], + rowsBelowWrapped: [false, false, false], + beforeCursor: '❯ first', + afterCursor: ' line', + rawAfterCursor: ' line', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toMatchObject({ text: 'first line\n\nsecond line', endRow: 10 }) + }) + + it('stops before a blank row that only precedes the composer frame', () => { + expect( + detectTerminalComposerDraft({ + rows: ['────────', '❯ first line'], + typedRows: ['────────', '❯ first line'], + promptGlyphBoldRows: [false, false], + rowsBelow: ['', '────────'], + typedRowsBelow: ['', '────────'], + beforeCursor: '❯ first line', + afterCursor: '', + rawAfterCursor: '', + cursorHidden: false, + cursorViewportRow: 8 + }) + ).toMatchObject({ text: 'first line', endRow: 8 }) + }) +}) diff --git a/src/shared/terminal-composer-draft.ts b/src/shared/terminal-composer-draft.ts new file mode 100644 index 00000000000..8acb0fc3b72 --- /dev/null +++ b/src/shared/terminal-composer-draft.ts @@ -0,0 +1,173 @@ +export type TerminalCursorContext = { + rows: string[] + typedRows: string[] + promptGlyphBoldRows: boolean[] + rowsWrapped?: boolean[] + rowsBelow: string[] + typedRowsBelow: string[] + rowsBelowWrapped?: boolean[] + rowsBelowCustomForeground?: boolean[] + beforeCursor: string + afterCursor: string + rawAfterCursor: string + cursorHidden: boolean + cursorViewportRow: number +} + +export type TerminalComposerDraft = { + text: string + promptRow: number + cursorRow: number + endRow: number + promptGlyph: '❯' | '›' | '»' +} + +const COMPOSER_FRAME_LINE = /^[─━-]{8,}\s*$/ +const CODEX_FOOTER_LINE = /^\s*(?:gpt-\S+|o\d\S*)\s+[·•]\s+\S.*$/i + +function composerContinuationRows( + context: TerminalCursorContext, + afterCursor: string, + codexFooterIndex: number +): { text: string; wrapped: boolean }[] { + if (!afterCursor.trim() && !context.typedRowsBelow.some((row) => row.trim())) { + return [] + } + const continuation: { text: string; wrapped: boolean }[] = [] + const hasTypedContinuationAfter = context.rowsBelow.map(() => false) + let hasFollowingTyped = false + for (let index = context.rowsBelow.length - 1; index >= 0; index -= 1) { + const raw = context.rowsBelow[index] ?? '' + if (COMPOSER_FRAME_LINE.test(raw) || index === codexFooterIndex) { + hasFollowingTyped = false + continue + } + hasTypedContinuationAfter[index] = hasFollowingTyped + if ((context.typedRowsBelow[index] ?? '').trim()) { + hasFollowingTyped = true + } + } + for (let index = 0; index < context.rowsBelow.length; index += 1) { + const raw = context.rowsBelow[index] ?? '' + if (COMPOSER_FRAME_LINE.test(raw) || index === codexFooterIndex) { + break + } + if (!raw.trim() && !hasTypedContinuationAfter[index]) { + break + } + const typed = context.typedRowsBelow[index] ?? '' + continuation.push({ + text: typed.trim() ? typed : raw, + wrapped: context.rowsBelowWrapped?.[index] ?? false + }) + } + return continuation +} + +function findCodexFooterIndex(context: TerminalCursorContext): number { + for (let index = context.rowsBelow.length - 1; index >= 0; index -= 1) { + const row = context.rowsBelow[index] ?? '' + if (!row.trim()) { + continue + } + const undimmed = context.typedRowsBelow[index] ?? '' + const hasFooterGap = index > 0 && !(context.rowsBelow[index - 1] ?? '').trim() + const isDimmedFooter = + hasFooterGap && !undimmed.trim() && context.rowsBelowWrapped?.[index] === false + const isColoredFooter = + hasFooterGap && + context.rowsBelowCustomForeground?.[index] === true && + context.rowsBelowWrapped?.[index] === false + return isDimmedFooter || isColoredFooter || CODEX_FOOTER_LINE.test(row) ? index : -1 + } + return -1 +} + +function isStockPlaceholder( + afterCursor: string, + continuationRows: { text: string; wrapped: boolean }[] +): boolean { + const text = [afterCursor, ...continuationRows.map((row) => row.text)] + .join(' ') + .replace(/\s+/g, ' ') + .trim() + return ( + /^Try\s+["“]/.test(text) || + text === 'Ask Codex to do anything' || + text === 'Ask a follow-up question' + ) +} + +export function detectTerminalComposerDraft( + context: TerminalCursorContext | null | undefined +): TerminalComposerDraft | null { + if (!context || context.cursorHidden || context.rows.length === 0) { + return null + } + const cursorIndex = context.rows.length - 1 + const codexFooterIndex = findCodexFooterIndex(context) + let afterCursor = context.afterCursor || context.rawAfterCursor + let continuationRows = composerContinuationRows(context, afterCursor, codexFooterIndex) + if (isStockPlaceholder(afterCursor, continuationRows)) { + afterCursor = '' + continuationRows = [] + } + const cursorText = `${context.beforeCursor}${afterCursor}` + for (let index = cursorIndex; index >= 0; index -= 1) { + const row = context.rows[index] ?? '' + const glyph = row.match(/^\s*([❯›»])/)?.[1] as '❯' | '›' | '»' | undefined + if (glyph) { + if (glyph === '❯' && !COMPOSER_FRAME_LINE.test(context.rows[index - 1] ?? '')) { + return null + } + if ( + (glyph === '›' || glyph === '»') && + (context.promptGlyphBoldRows[index] !== true || codexFooterIndex === -1) + ) { + return null + } + const lines: { text: string; wrapped: boolean }[] = + index === cursorIndex + ? [{ text: cursorText.replace(/^\s*[❯›»]\s?/, ''), wrapped: false }, ...continuationRows] + : [ + { + text: (context.typedRows[index] ?? row).replace(/^\s*[❯›»]\s?/, ''), + wrapped: false + }, + ...context.typedRows.slice(index + 1, cursorIndex).map((text, offset) => ({ + text, + wrapped: context.rowsWrapped?.[index + 1 + offset] ?? false + })), + { + text: cursorText, + wrapped: context.rowsWrapped?.[cursorIndex] ?? false + }, + ...continuationRows + ] + const text = lines + .map((line, lineIndex) => { + const continuesPrevious = lineIndex > 0 && line.wrapped + const continuesNext = lines[lineIndex + 1]?.wrapped ?? false + const start = continuesPrevious ? '' : lineIndex > 0 ? '\n' : '' + const content = continuesPrevious ? line.text : line.text.trimStart() + return `${start}${continuesNext ? content : content.trimEnd()}` + }) + .join('') + .trim() + if (!text) { + return null + } + return { + text, + promptRow: context.cursorViewportRow - (cursorIndex - index), + cursorRow: context.cursorViewportRow, + endRow: context.cursorViewportRow + continuationRows.length, + promptGlyph: glyph + } + } + if (row.length > 0 && context.rowsWrapped?.[index] !== true && !/^\s/.test(row)) { + return null + } + } + return null +} From efb4050f4da3a3ffb30c6bfa02a9b9be6f9d5592 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:33:26 -0700 Subject: [PATCH 7/8] fix(ai-vault): index Cline sessions (#16814) * fix(ai-vault): index Cline sessions * fix(ai-vault): constrain Cline session discovery --- .../remote-session-scanner-cline-source.ts | 42 +++++++ .../remote-session-scanner-discovery.ts | 53 ++++++-- .../remote-session-scanner-sources.ts | 2 + .../ai-vault/remote-session-scanner-types.ts | 1 + .../ai-vault/remote-session-scanner.test.ts | 92 ++++++++++++++ .../ai-vault/session-delete-target.test.ts | 31 +++++ src/main/ai-vault/session-delete-target.ts | 6 +- .../ai-vault/session-scanner-agent-parser.ts | 3 + .../ai-vault/session-scanner-agent-sources.ts | 21 ++++ .../session-scanner-cline-parser.test.ts | 117 ++++++++++++++++++ .../ai-vault/session-scanner-cline-parser.ts | 116 +++++++++++++++++ .../ai-vault/session-scanner-discovery.ts | 26 +++- .../ai-vault/session-scanner-parse-cache.ts | 1 + .../session-scanner-service-env.test.ts | 2 + .../ai-vault/session-scanner-service-env.ts | 1 + .../session-scanner-source-discovery.ts | 1 + .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ai-vault/session-scanner.test.ts | 21 ++++ src/shared/ai-vault-resume-command.ts | 2 + src/shared/ai-vault-session-deletion.ts | 3 +- src/shared/ai-vault-types.ts | 2 + 22 files changed, 528 insertions(+), 17 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-cline-source.ts create mode 100644 src/main/ai-vault/session-scanner-cline-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-cline-parser.ts diff --git a/src/main/ai-vault/remote-session-scanner-cline-source.ts b/src/main/ai-vault/remote-session-scanner-cline-source.ts new file mode 100644 index 00000000000..f2f79089f9a --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-cline-source.ts @@ -0,0 +1,42 @@ +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { isMissingRemoteSessionPathError } from './remote-session-file-stat' +import type { RemoteSessionSource } from './remote-session-scanner-types' +import { + clineMessagesPathForMetadata, + isClineSessionMetadataPath, + parseClineSessionContent +} from './session-scanner-cline-parser' + +export function remoteClineSource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + return { + agent: 'cline', + rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'), + extensions: ['.json'], + filePredicate: isClineSessionMetadataPath, + contentDependencyPath: clineMessagesPathForMetadata, + directoryPredicate: (_name, depth) => depth === 0, + parse: async (file, content, context) => { + let messagesContent: string | null = null + try { + throwIfAiVaultScanCancelled(context.signal) + const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path)) + throwIfAiVaultScanCancelled(context.signal) + messagesContent = read.isBinary ? null : read.content + } catch (error) { + throwIfAiVaultScanCancelled(context.signal) + if (!isMissingRemoteSessionPathError(error)) { + throw error + } + } + return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }) + } + } +} diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 329bdb2e584..159bf049da6 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -29,18 +29,7 @@ export async function discoverRemoteSourceCandidates(args: { const files = await mapRemoteScanBatches( paths, REMOTE_DISCOVERY_CONCURRENCY, - (path) => - statRemoteSessionFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues, - { - missingIsExpected: Boolean(args.source.fixedChildFileSegments), - signal: args.context.signal - } - ), + (path) => statRemoteCandidateFile(path, args.source, args.context, args.issues), args.context.signal ) return files @@ -52,6 +41,46 @@ export async function discoverRemoteSourceCandidates(args: { })) } +async function statRemoteCandidateFile( + path: string, + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + const file = await statRemoteSessionFile( + context.provider, + path, + source.agent, + context.executionHostId, + issues, + { + missingIsExpected: Boolean(source.fixedChildFileSegments), + signal: context.signal + } + ) + if (!file || !source.contentDependencyPath) { + return file + } + const dependency = await statRemoteSessionFile( + context.provider, + source.contentDependencyPath(path), + source.agent, + context.executionHostId, + issues, + { missingIsExpected: true, signal: context.signal } + ) + if (!dependency) { + return file + } + const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs) + return { + ...file, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0) + } +} + async function listRemoteFixedChildFiles( source: RemoteSessionSource, context: RemoteScannerContext, diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 8bd9fdaf61d..1fd0ca30a61 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -17,6 +17,7 @@ import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subag import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' +import { remoteClineSource } from './remote-session-scanner-cline-source' import type { RemoteParserOptions, RemoteScannerContext, @@ -77,6 +78,7 @@ export function remoteSessionSources( parseCursorSessionContent, (path) => remotePathSegments(path).includes('agent-transcripts') ), + remoteClineSource(remoteHome, hostPlatform), source( 'hermes', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 8c9774abe14..405ea7f4626 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -33,6 +33,7 @@ export type RemoteSessionSource = { codexHome?: string extensions: readonly string[] filePredicate?: (path: string) => boolean + contentDependencyPath?: (path: string) => string // Depth 0 denotes a direct child of rootDir. directoryPredicate?: (name: string, depth: number) => boolean // A canonical file directly beneath every top-level session directory. diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 7884fa9461d..c1c99bcbf12 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -5,6 +5,98 @@ import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-f import { primeAgentFixture } from './session-scanner-prime-agent-fixtures' describe('scanRemoteAiVaultSessions', () => { + it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = '1786466194549_xrzrl' + const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + version: 1, + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + provider: 'deepseek', + model: 'deepseek-v4-flash', + cwd: '/home/ada/repo' + }), + 10 + ) + provider.addFile( + `${sessionDir}/${sessionId}.messages.json`, + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:38:00.000Z', + sessionId, + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'Fix remote Cline history' }] + } + ] + }), + 11 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'cline', + sessionId, + title: 'Fix remote Cline history', + cwd: '/home/ada/repo', + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + filePath: `${sessionDir}/${sessionId}.json` + }) + }) + + it('indexes and resumes Cline sessions from a Windows SSH host', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = '1786466194549_xrzrl' + const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}` + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + cwd: 'C:/repo/app' + }), + 10 + ) + provider.addFile( + `${sessionDir}/${sessionId}.messages.json`, + JSON.stringify({ + updated_at: '2026-08-11T16:38:00.000Z', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }] + }), + 11 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'cline', + sessionId, + executionHostPlatform: 'win32', + filePath: `${sessionDir}/${sessionId}.json`, + resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""` + }) + }) + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/session-delete-target.test.ts b/src/main/ai-vault/session-delete-target.test.ts index 39c95f4f5f6..cf51fecdad9 100644 --- a/src/main/ai-vault/session-delete-target.test.ts +++ b/src/main/ai-vault/session-delete-target.test.ts @@ -19,6 +19,37 @@ const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') const GROK_ROOT = join(HOME, '.grok', 'sessions') describe('validateAiVaultSessionDeleteTarget', () => { + it('allows a canonical Cline manifest and removes its whole session directory', () => { + const root = join('/tmp', 'cline-sessions') + const sessionId = '1786466194549_xrzrl' + const sessionDir = join(root, sessionId) + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cline', + filePath: join(sessionDir, `${sessionId}.json`), + executionHostId: 'local', + rootOptions: { clineSessionsDir: root } + }) + + expect(result).toMatchObject({ + allowed: true, + agent: 'cline', + removals: [{ path: sessionDir, kind: 'directory' }] + }) + }) + + it('rejects a Cline messages companion as an undiscoverable delete target', () => { + const root = join('/tmp', 'cline-sessions') + const sessionId = '1786466194549_xrzrl' + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cline', + filePath: join(root, sessionId, `${sessionId}.messages.json`), + executionHostId: 'local', + rootOptions: { clineSessionsDir: root } + }) + + expect(result).toEqual({ allowed: false, agent: 'cline', reason: 'undiscoverable-path' }) + }) + it('allows a supported agent whose file resolves inside its known root', () => { const result = validateAiVaultSessionDeleteTarget({ agent: 'gemini', diff --git a/src/main/ai-vault/session-delete-target.ts b/src/main/ai-vault/session-delete-target.ts index 11ef38039dc..13320ce39e9 100644 --- a/src/main/ai-vault/session-delete-target.ts +++ b/src/main/ai-vault/session-delete-target.ts @@ -21,7 +21,11 @@ import type { AiVaultScanOptions } from './session-scanner-types' // Agents whose session IS the directory holding the scanned file: everything // beside it belongs to the same session (rovo's session_context.json, grok's // chat_history.jsonl), so the directory is the only complete delete unit. -const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set(['rovo', 'grok']) +const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set([ + 'rovo', + 'grok', + 'cline' +]) export type ValidateAiVaultSessionDeleteTargetArgs = { agent: AiVaultAgent diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 61554f663fe..83f23943241 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -2,6 +2,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' +import { parseClineSessionFile } from './session-scanner-cline-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' @@ -72,6 +73,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('prime-agent', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) + case 'cline': + return parseClineSessionFile(candidate.file, platform) case 'devin': return parseDevinSessionFile(candidate.file, platform) case 'kimi': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 803bf6a3f07..8b2e66ea5b0 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -4,6 +4,10 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion' import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { + clineMessagesPathForMetadata, + isClineSessionMetadataPath +} from './session-scanner-cline-parser' import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' @@ -40,6 +44,8 @@ const DEVIN_TRANSCRIPTS_DIR = join( ) const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') +const CLINE_SESSIONS_DIR = + process.env.CLINE_SESSION_DATA_DIR?.trim() || join(homedir(), '.cline', 'data', 'sessions') /** * Where one agent's session files live and which of them count as sessions. @@ -55,6 +61,8 @@ export type AiVaultAgentSource = { rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[] extensions: readonly string[] filePredicate?: (filePath: string) => boolean + // A sibling whose stat participates in candidate freshness and recency. + contentDependencyPath?: (filePath: string) => string // Return false to skip a directory; depth 0 is a child of the root. directoryPredicate?: (name: string, depth: number) => boolean // Roots that are alternates for one install rather than distinct locations, @@ -221,6 +229,19 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { ], extensions: ['.jsonl'] }, + cline: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.clineSessionsDir ?? CLINE_SESSIONS_DIR, wslHomeDirs, [ + '.cline', + 'data', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: isClineSessionMetadataPath, + contentDependencyPath: clineMessagesPathForMetadata, + // Cline stores one manifest directly beneath each session directory. + directoryPredicate: (_name, depth) => depth === 0 + }, kimi: { rootDirs: (options, wslHomeDirs) => sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ diff --git a/src/main/ai-vault/session-scanner-cline-parser.test.ts b/src/main/ai-vault/session-scanner-cline-parser.test.ts new file mode 100644 index 00000000000..6d96bcb114f --- /dev/null +++ b/src/main/ai-vault/session-scanner-cline-parser.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('Cline AI Vault sessions', () => { + it('indexes one manifest-backed session without surfacing its messages file as a phantom', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = '1786466194549_xrzrl' + const sessionDir = join(roots.clineSessionsDir, sessionId) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, `${sessionId}.json`), + JSON.stringify({ + version: 1, + session_id: sessionId, + source: 'cli', + started_at: '2026-08-11T16:36:34.551Z', + status: 'idle', + interactive: true, + provider: 'deepseek', + model: 'deepseek-v4-flash', + cwd: '/repo/cline', + workspace_root: '/repo/cline' + }) + ) + await writeFile( + join(sessionDir, `${sessionId}.messages.json`), + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:38:00.000Z', + agent: 'lead', + sessionId, + messages: [ + { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] + }, + { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: 'I will inspect the scanner.' }], + ts: 1_786_466_280_000, + modelInfo: { id: 'deepseek-v4-flash', provider: 'deepseek' } + } + ] + }) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1) + expect(result.sessions.find((session) => session.agent === 'cline')).toMatchObject({ + sessionId, + title: 'Fix the Cline vault scanner', + cwd: '/repo/cline', + model: 'deepseek-v4-flash', + messageCount: 2, + resumeCommand: `cd '/repo/cline' && cline --id '${sessionId}'`, + filePath: join(sessionDir, `${sessionId}.json`) + }) + + await writeFile( + join(sessionDir, `${sessionId}.messages.json`), + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:39:00.000Z', + sessionId, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'I inspected the scanner.' }] }, + { role: 'user', content: [{ type: 'text', text: 'Add the regression test too' }] } + ] + }) + ) + + const rescanned = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(rescanned.sessions.find((session) => session.agent === 'cline')).toMatchObject({ + messageCount: 3, + updatedAt: '2026-08-11T16:39:00.000Z' + }) + }) + + it('only indexes manifests directly beneath a session directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-nested-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'direct-session' + const nestedId = 'nested-session' + await mkdir(join(roots.clineSessionsDir, sessionId), { recursive: true }) + await mkdir(join(roots.clineSessionsDir, 'workspace', nestedId), { recursive: true }) + const metadata = JSON.stringify({ session_id: sessionId, cwd: '/repo' }) + await writeFile(join(roots.clineSessionsDir, sessionId, `${sessionId}.json`), metadata) + await writeFile( + join(roots.clineSessionsDir, 'workspace', nestedId, `${nestedId}.json`), + JSON.stringify({ session_id: nestedId, cwd: '/unexpected' }) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1) + expect(result.sessions.find((session) => session.agent === 'cline')?.sessionId).toBe(sessionId) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts new file mode 100644 index 00000000000..21a21664205 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -0,0 +1,116 @@ +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import type { FileWithMtime } from './session-scanner-types' +import { + arrayValue, + asRecord, + extractContentText, + extractString, + normalizeTitleText +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export function isClineSessionMetadataPath(filePath: string): boolean { + const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean) + const fileName = segments.pop() + const sessionId = segments.pop() + return Boolean(fileName && sessionId && fileName === `${sessionId}.json`) +} + +export function clineMessagesPathForMetadata(filePath: string): string { + return filePath.endsWith('.json') ? `${filePath.slice(0, -'.json'.length)}.messages.json` : '' +} + +export async function parseClineSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan') + let messagesContent: string | null = null + try { + messagesContent = await wslGatedReadFile( + clineMessagesPathForMetadata(file.path), + 'utf-8', + 'scan' + ) + } catch (error) { + // The manifest is written before the first turn; a missing messages file is + // a valid metadata-only session, while a WSL gate refusal must stay visible. + if (error instanceof WslTranscriptFsError || !isMissingSessionPathError(error)) { + throw error + } + } + return parseClineSessionContent(file, metadataContent, messagesContent, platform) +} + +function isMissingSessionPathError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return code === 'ENOENT' || code === 'ENOTDIR' +} + +export function parseClineSessionContent( + file: FileWithMtime, + metadataContent: string, + messagesContent: string | null, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const metadata = parseJsonRecord(metadataContent) + if (!metadata) { + return null + } + const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean) + const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? '' + const accumulator = createAccumulator({ agent: 'cline', file, sessionId }) + accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) + accumulator.model = extractString(metadata.model) + updateTimeline(accumulator, metadata.started_at) + + const messages = messagesContent ? parseJsonRecord(messagesContent) : null + if (messages) { + updateTimeline(accumulator, messages.updated_at) + for (const value of arrayValue(messages.messages)) { + const message = asRecord(value) + const role = message?.role + if (!message || (role !== 'user' && role !== 'assistant')) { + continue + } + accumulator.messageCount++ + updateTimeline(accumulator, message.ts) + const content = message.content + if (role === 'user' && !accumulator.fallbackTitle) { + accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '') + } + if (role === 'assistant' && !accumulator.model) { + accumulator.model = extractString(asRecord(message.modelInfo)?.id) + } + addPreviewContent(accumulator, role, content, message.ts) + } + } + accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '') + + return finalizeSession(accumulator, platform, options) +} + +function parseJsonRecord(content: string): Record | null { + try { + return asRecord(JSON.parse(content) as unknown) + } catch { + return null + } +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 9ec569bc547..a5cf386043e 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -14,6 +14,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean + contentDependencyPath?: (path: string) => string directoryPredicate?: (name: string, depth: number) => boolean }): Promise { let paths: string[] @@ -41,11 +42,13 @@ export async function discoverFiles(args: { for (const path of paths) { try { const fileStat = await wslGatedStat(path, 'scan') + const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path)) + const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0) files.push({ path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: fileStat.size + (dependencyStat?.size ?? 0), dev: fileStat.dev, ino: fileStat.ino, nlink: fileStat.nlink @@ -65,6 +68,23 @@ export async function discoverFiles(args: { } } +async function optionalContentDependencyStat( + filePath: string | undefined +): Promise<{ mtimeMs: number; size: number } | null> { + if (!filePath) { + return null + } + try { + const fileStat = await wslGatedStat(filePath, 'scan') + return { mtimeMs: fileStat.mtimeMs, size: fileStat.size } + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + return null + } +} + export async function walkSessionFiles( dirPath: string, agent: AiVaultAgent, diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 563055ffa39..aaabaf76d6e 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -76,6 +76,7 @@ function resumableStateFactoryFor( case 'devin': case 'grok': case 'hermes': + case 'cline': case 'kimi': case 'opencode': case 'rovo': diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts index 5d06c0508e8..b56b849270b 100644 --- a/src/main/ai-vault/session-scanner-service-env.test.ts +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -33,6 +33,7 @@ describe('buildAiVaultServiceEnv', () => { const env = buildAiVaultServiceEnv( { CODEX_HOME: '/home/dev/.codex', + CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions', COPILOT_HOME: '/home/dev/.copilot', DEVIN_HOME: '/home/dev/.devin', GROK_HOME: '/home/dev/.grok', @@ -49,6 +50,7 @@ describe('buildAiVaultServiceEnv', () => { expect(env).toEqual({ CODEX_HOME: '/home/dev/.codex', + CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions', COPILOT_HOME: '/home/dev/.copilot', DEVIN_HOME: '/home/dev/.devin', GROK_HOME: '/home/dev/.grok', diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index e2f5309e70e..a29dd9d2108 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -34,6 +34,7 @@ export const RUNTIME_ENV_ALLOWLIST = [ // dropping one hides every session of a user who relocated that agent's home. const AGENT_ROOT_ENV_ALLOWLIST = [ 'CODEX_HOME', + 'CLINE_SESSION_DATA_DIR', 'COPILOT_HOME', 'DEVIN_HOME', 'GROK_HOME', diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index fd368f6c172..fef37ebcda0 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -55,6 +55,7 @@ function agentDiscoveries( issues, extensions: [...source.extensions], filePredicate: source.filePredicate, + contentDependencyPath: source.contentDependencyPath, directoryPredicate: source.directoryPredicate }) return source.mergeRootDiscoveries diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 6da538c6a5f..e7dd54196f5 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -24,6 +24,7 @@ export function isolatedScanRoots(root: string) { primeAgentSessionsDir: join(root, 'prime-agent-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), + clineSessionsDir: join(root, 'cline-sessions'), kimiSessionsDir: join(root, 'kimi-sessions') } } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 9021b1f7c02..ab553b63568 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -33,6 +33,7 @@ export type AiVaultScanOptions = { primeAgentSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string + clineSessionsDir?: string kimiSessionsDir?: string limit?: number unlimited?: boolean diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index feff1b19f89..14f37d566b8 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -647,6 +647,26 @@ describe('scanAiVaultSessions', () => { ]) ) + const clineSessionId = 'cline-session' + const clineSessionDir = join(roots.clineSessionsDir, clineSessionId) + await mkdir(clineSessionDir, { recursive: true }) + await writeFile( + join(clineSessionDir, `${clineSessionId}.json`), + JSON.stringify({ + session_id: clineSessionId, + started_at: '2026-05-01T10:10:30.000Z', + model: 'cline-model', + cwd: '/tmp/cline' + }) + ) + await writeFile( + join(clineSessionDir, `${clineSessionId}.messages.json`), + JSON.stringify({ + updated_at: '2026-05-01T10:10:31.000Z', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }] + }) + ) + // Kimi: /wd_*/session_*/state.json + sibling agents/main/wire.jsonl, // with the work dir resolved from the top-level session_index.jsonl. const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session') @@ -738,6 +758,7 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('prime-agent')).toBe( `cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'` ) + expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'") expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index 26c5b637e82..7ec417c7ef3 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -210,6 +210,8 @@ function buildAgentResumeInvocation( return `${baseCommand} --session ${sessionArg}` case 'copilot': return `${baseCommand} --resume=${sessionArg}` + case 'cline': + return `${baseCommand} --id ${sessionArg}` case 'claude': case 'cursor': case 'gemini': diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts index 9e25f632abe..d3c2211ab84 100644 --- a/src/shared/ai-vault-session-deletion.ts +++ b/src/shared/ai-vault-session-deletion.ts @@ -41,7 +41,8 @@ export const AI_VAULT_DELETABLE_AGENTS = [ 'omp', 'claude', 'rovo', - 'grok' + 'grok', + 'cline' ] as const satisfies readonly AiVaultAgent[] export type AiVaultDeletableAgent = (typeof AI_VAULT_DELETABLE_AGENTS)[number] diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index a6dff1b59a9..00f25510922 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -18,6 +18,7 @@ export const AI_VAULT_AGENTS = [ 'openclaw', 'devin', 'droid', + 'cline', 'kimi' ] as const satisfies readonly TuiAgent[] @@ -59,6 +60,7 @@ export const AI_VAULT_AGENT_LABELS = { openclaw: 'OpenClaw', devin: 'Devin', droid: 'Droid', + cline: 'Cline', kimi: 'Kimi' } as const satisfies Record From fe714878951d3e301e01d862f1ec5ce5b9fb3717 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:33:53 -0700 Subject: [PATCH 8/8] test(main): isolate deferred worktree watcher setup (#16836) --- .../attach-main-window-services.test.ts | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 51777682474..8a340d840ec 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,6 +17,8 @@ const { registerWorktreeHandlersMock, registerPtyHandlersMock, hydrateLocalPtyRegistryAtBootMock, + setWorktreeBaseDirectoryWatcherSyncContextMock, + scheduleWorktreeBaseDirectoryWatcherSyncMock, setupAutoUpdaterMock, browserManagerUnregisterAllMock, runWorktreeChangeInvalidatorsMock, @@ -40,6 +42,8 @@ const { registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), + setWorktreeBaseDirectoryWatcherSyncContextMock: vi.fn(), + scheduleWorktreeBaseDirectoryWatcherSyncMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), browserManagerUnregisterAllMock: vi.fn(), runWorktreeChangeInvalidatorsMock: vi.fn(), @@ -98,6 +102,11 @@ vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) +vi.mock('../ipc/worktree-base-directory-watcher', () => ({ + setWorktreeBaseDirectoryWatcherSyncContext: setWorktreeBaseDirectoryWatcherSyncContextMock, + scheduleWorktreeBaseDirectoryWatcherSync: scheduleWorktreeBaseDirectoryWatcherSyncMock +})) + vi.mock('../browser/browser-manager', () => ({ browserManager: { unregisterAll: browserManagerUnregisterAllMock @@ -222,27 +231,7 @@ async function fireReadyToShow(mainWindow: MainWindowStub): Promise { describe('attachMainWindowServices', () => { beforeEach(() => { - onMock.mockReset() - removeAllListenersMock.mockReset() - removeListenerMock.mockReset() - handleMock.mockReset() - removeHandlerMock.mockReset() - setPermissionRequestHandlerMock.mockReset() - setPermissionCheckHandlerMock.mockReset() - systemPreferencesAskForMediaAccessMock.mockReset() - systemPreferencesGetMediaAccessStatusMock.mockReset() - registerRepoHandlersMock.mockReset() - setRepoRemoteClientNotifierMock.mockReset() - setWorktreeCatalogRemoteClientNotifierMock.mockReset() - registerWorktreeHandlersMock.mockReset() - registerPtyHandlersMock.mockReset() - hydrateLocalPtyRegistryAtBootMock.mockReset() - setupAutoUpdaterMock.mockReset() - browserManagerUnregisterAllMock.mockReset() - acknowledgePendingTccPromptNoticeMock.mockReset() - consumePendingTccPromptNoticeMock.mockReset() - dismissTccPromptNoticeMock.mockReset() - releasePendingTccPromptNoticeMock.mockReset() + vi.resetAllMocks() systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted') }) @@ -945,4 +934,21 @@ describe('attachMainWindowServices', () => { identity }) }) + + it('keeps deferred worktree watcher setup inside the service boundary', async () => { + const mainWindow = createMainWindow() + const store = createStore() + + vi.useFakeTimers() + try { + attachMainWindowServices(mainWindow as never, store, createRuntime() as never) + + expect(setWorktreeBaseDirectoryWatcherSyncContextMock).toHaveBeenCalledWith(store, mainWindow) + expect(scheduleWorktreeBaseDirectoryWatcherSyncMock).toHaveBeenCalledWith(store, mainWindow) + + await vi.advanceTimersByTimeAsync(100) + } finally { + vi.useRealTimers() + } + }) })