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 84be68ec5ae..1dfab4b0ffe 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, @@ -192,10 +198,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' }) @@ -204,14 +211,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' @@ -221,7 +245,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 729dab19a01..ec86e995de7 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': { @@ -102,6 +105,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 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/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/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/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/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/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/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/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/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/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.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 f83e5e777e4..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, @@ -106,6 +107,8 @@ import { } from '../../shared/agent-prompt-injection' import { type AgentPromptActivity, + type AgentPromptWaitTextCache, + readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -538,11 +541,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 +921,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 +1358,7 @@ type RuntimeStore = { getAllWorktreeMeta: Store['getAllWorktreeMeta'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] + setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] removeWorktreeMeta: Store['removeWorktreeMeta'] getWorktreeLineage?: Store['getWorktreeLineage'] getAllWorktreeLineage?: Store['getAllWorktreeLineage'] @@ -1892,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 @@ -6095,7 +6106,8 @@ export class OrcaRuntimeService { method: string, params: unknown, timeoutMs?: number, - envelope?: RuntimeOrchestrationEnvelope + envelope?: RuntimeOrchestrationEnvelope, + internal?: { contractVerified?: boolean } ): Promise { if (!this.orchestrationEnvironmentTransport) { throw new OrchestrationError( @@ -6103,7 +6115,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', @@ -14048,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) @@ -14078,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( @@ -14101,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, @@ -14124,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() } @@ -14145,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( @@ -14203,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 @@ -14211,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 @@ -14235,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, @@ -14258,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 @@ -14285,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: [] } } } @@ -19270,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 @@ -19528,7 +19549,8 @@ export class OrcaRuntimeService { private getTerminalAgentStatusSnapshot( handle: string, - expectedPtyId: string + expectedPtyId: string, + waitTextOverride?: string ): TerminalAgentStatusSnapshot { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -19548,11 +19570,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, @@ -19580,7 +19600,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, @@ -20239,7 +20261,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) { @@ -20247,7 +20270,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 }) @@ -20276,8 +20299,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 = @@ -20295,7 +20323,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 @@ -20310,7 +20345,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 } } @@ -20835,7 +20870,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 +24296,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 +27167,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 +32368,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) { @@ -40940,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 @@ -40952,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) @@ -40966,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/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/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-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/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/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/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/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/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() + } + }) }) 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/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/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/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/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/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 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/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/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 + ) + ) +} 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/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/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/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/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__/') ) } 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 +} 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 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),