Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag

This commit is contained in:
Brennan Benson
2026-08-27 15:35:17 -07:00
172 changed files with 6462 additions and 755 deletions
@@ -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<string> = { current: heldText }
const liveInputComposingRef: RefObject<boolean | undefined> = { current: composing }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { 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' })
})
})
@@ -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<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
@@ -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,
@@ -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()
@@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
@@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -34,6 +34,7 @@ type TerminalLivePendingInputFlush = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
@@ -50,6 +51,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const liveInputComposingRef = useRef<boolean | undefined>(undefined)
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
@@ -65,6 +67,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
@@ -106,6 +109,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
})
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<TTabType extends string>({
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
@@ -198,6 +203,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
@@ -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' },
+5 -5
View File
@@ -268,9 +268,9 @@ Common Commands:
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<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 <selector> Parent selector such as active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
return '--parent-worktree <selector> Parent selector such as identity:<identity>, active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
}
if (command === 'orchestration task-create' && flag === 'task-title') {
return '--task-title <text> 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 <n> Number of scroll pages',
'parent-worktree':
'--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
'--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
path: '--path <path> Path argument for the command',
prompt: '--prompt <text> Prompt text for agent-backed commands',
query: '--query <text> Search text for matching refs',
@@ -601,7 +601,7 @@ export function formatFlagHelp(flag: string): string {
'to-x': '--to-x <x> Destination window-local x coordinate',
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
'--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
'workspace-status':
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
+3
View File
@@ -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 <selector>] [--no-parent]')
expect(rootHelp).toContain('identity:<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:<id>')
expect(formatFlagHelp('parent-worktree')).toContain('identity:<identity>')
expect(setHelp).not.toContain('worktree:<id>')
expect(callMock).not.toHaveBeenCalled()
})
+4 -1
View File
@@ -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
},
@@ -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<string, unknown>).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')
})
})
+163 -3
View File
@@ -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<string, unknown> | 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.', '')
+31
View File
@@ -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
)
}
+130
View File
@@ -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<Server>()
afterEach(async () => {
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((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<void>((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<string, unknown>
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
}
})
}
})
})
+45 -19
View File
@@ -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<void> | 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<TResult>(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',
@@ -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()
})
})
@@ -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<string, unknown>
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
}
@@ -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' }
})
})
})
+2 -2
View File
@@ -19,7 +19,7 @@ export async function sendWebSocketRequest<TResult>(
return await sendRemoteRuntimeRequest<TResult>(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<TResult>(
)
} catch (error) {
if (error instanceof RemoteRuntimeClientError) {
throw new RuntimeClientError(error.code, error.message)
throw new RuntimeClientError(error.code, error.message, error.data)
}
throw error
}
+1 -1
View File
@@ -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:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-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:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
)
}
+1
View File
@@ -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.',
+21
View File
@@ -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❯')
})
})
+1
View File
@@ -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,
@@ -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
})
}
}
}
@@ -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<FileWithMtime | null> {
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,
@@ -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,
@@ -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.
@@ -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(
@@ -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',
+5 -1
View File
@@ -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<AiVaultDeletableAgent>(['rovo', 'grok'])
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>([
'rovo',
'grok',
'cline'
])
export type ValidateAiVaultSessionDeleteTargetArgs = {
agent: AiVaultAgent
@@ -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':
@@ -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, [
@@ -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)
})
})
@@ -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<AiVaultSession | null> {
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<string, unknown> | null {
try {
return asRecord(JSON.parse(content) as unknown)
} catch {
return null
}
}
+23 -3
View File
@@ -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<SessionFileDiscovery> {
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,
@@ -76,6 +76,7 @@ function resumableStateFactoryFor(
case 'devin':
case 'grok':
case 'hermes':
case 'cline':
case 'kimi':
case 'opencode':
case 'rovo':
@@ -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',
@@ -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',
@@ -55,6 +55,7 @@ function agentDiscoveries(
issues,
extensions: [...source.extensions],
filePredicate: source.filePredicate,
contentDependencyPath: source.contentDependencyPath,
directoryPredicate: source.directoryPredicate
})
return source.mergeRootDiscoveries
@@ -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')
}
}
@@ -33,6 +33,7 @@ export type AiVaultScanOptions = {
primeAgentSessionsDir?: string
droidSessionsDir?: string
droidProjectsDir?: string
clineSessionsDir?: string
kimiSessionsDir?: string
limit?: number
unlimited?: boolean
+21
View File
@@ -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: <sessions>/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(
+16
View File
@@ -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)))
@@ -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()
})
})
@@ -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
}
}
+30 -10
View File
@@ -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 {
+13 -4
View File
@@ -9,8 +9,17 @@ export const interactiveOutputCharsByPty = new Map<string, number>()
export const activeRendererPtys = new Set<string>()
export const visibleRendererPtys = new Set<string>()
export const rendererVisibilityKnownPtys = new Set<string>()
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<string>()
export const deliveredHiddenRendererResizeOutputPtys = new Set<string>()
export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000
@@ -21,11 +30,11 @@ export const providerSnapshotRequiredPtys = new Set<string>()
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
}
@@ -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 {
@@ -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'
@@ -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}`
@@ -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
+6 -1
View File
@@ -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
@@ -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()
})
})
+12 -1
View File
@@ -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 } : {}),
+1 -1
View File
@@ -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'
@@ -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)
@@ -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<typeof vi.fn>
}
).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)
})
})
+12
View File
@@ -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',
@@ -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(),
+11
View File
@@ -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',
@@ -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'
@@ -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()
@@ -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
@@ -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<ExecutionHostId, Record<string, WorktreeMeta>>()
const metadataForRepo = (repo: (typeof repos)[number]): Record<string, WorktreeMeta> => {
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<typeof createSshWorktreeMetaIndex>
>()
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,
@@ -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<string, WorktreeMeta>
): 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<string, WorktreeMeta>
) {
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id)
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id, allMetaOverride)
return mergeWorktree(repo.id, detected, meta, repo.displayName)
}
@@ -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<string, WorktreeMeta>,
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
})
@@ -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<WorktreeMeta> }) => {
(
_event,
args: {
worktreeId: string
executionHostId?: string
updates: Partial<WorktreeMeta>
}
) => {
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.
@@ -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 () => {
@@ -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<Store, ...>`), 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<string, WorktreeMeta>
getWorktreeMetaForHost?: (
worktreeId: string,
executionHostId: ExecutionHostId
) => WorktreeMeta | undefined
getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record<string, WorktreeMeta>
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => WorktreeMeta
setWorktreeMetaForHost?: (
worktreeId: string,
executionHostId: ExecutionHostId,
meta: Partial<WorktreeMeta>
) => WorktreeMeta
}
/** Canonical host snapshot, with a filtered legacy fallback for partial test/compatibility stores. */
export function readAllWorktreeMetaForHost(
store: Pick<HostQualifiedWorktreeMetaStore, 'getAllWorktreeMeta' | 'getAllWorktreeMetaForHost'>,
executionHostId: ExecutionHostId
): Record<string, WorktreeMeta> {
const qualified = store.getAllWorktreeMetaForHost?.(executionHostId)
if (qualified) {
return qualified
}
const projected: Record<string, WorktreeMeta> = {}
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<HostQualifiedWorktreeMetaStore, 'getWorktreeMetaForHost'>,
worktreeId: string,
executionHostId: ExecutionHostId
): WorktreeMeta | undefined {
return store.getWorktreeMetaForHost?.(worktreeId, executionHostId)
}
export function writeWorktreeMetaForHost(
store: Pick<HostQualifiedWorktreeMetaStore, 'setWorktreeMeta' | 'setWorktreeMetaForHost'>,
worktreeId: string,
executionHostId: ExecutionHostId,
meta: Partial<WorktreeMeta>
): WorktreeMeta {
return (
store.setWorktreeMetaForHost?.(worktreeId, executionHostId, meta) ??
store.setWorktreeMeta(worktreeId, { ...meta, hostId: executionHostId })
)
}
@@ -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<string> } {
const aliases = state.worktreeIdentityAliases ?? {}
const identityMeta = state.worktreeMetaByIdentity ?? {}
const repairedInstanceIds = new Map<string, string>()
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<string>()
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
if (getExecutionHostIdFromWorktreeHostIdentity(alias) === oldHostId) {
preservedWorktreeIds.add(getWorktreeIdFromHostIdentity(alias))
}
}
return { changed: plans.length > 0, preservedWorktreeIds }
}
@@ -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]
@@ -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<StoreRuntimeState, 'state'>
@@ -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>
): WorktreeMeta {
return setWorktreeMetaForHostOperation(
this[metadataLineageOperationsContext].runtime,
this[metadataLineageOperationsContext].scheduling,
worktreeId,
executionHostId,
meta
)
}
getAllWorktreeMeta(): Record<string, WorktreeMeta> {
return this[metadataLineageOperationsContext].runtime.state.worktreeMeta
}
getAllWorktreeMetaForHost(executionHostId: ExecutionHostId): Record<string, WorktreeMeta> {
return getAllWorktreeMetaForHostOperation(
this[metadataLineageOperationsContext].runtime,
executionHostId
)
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): 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<ExecutionHostId>(
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)
}
}
@@ -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<StoreRuntimeState, 'state'>
/** 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<string, WorktreeMeta> {
const state = runtime.state
const projected: Record<string, WorktreeMeta> = {}
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>
): 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
}
@@ -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<WorktreeMeta>,
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
}
@@ -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.
@@ -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]
})
})
})
@@ -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<string, WorktreeMeta> 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
}
+1 -1
View File
@@ -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 {
@@ -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<AgentPromptActivity> = {}): 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)
@@ -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<TuiAgent>(['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<void> {
+91
View File
@@ -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',
+152 -61
View File
@@ -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<unknown> {
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<string[]> {
): Promise<RuntimeTerminalProjection> {
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<string[]> {
}): 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<string[]> {
private async readRendererVisibleSnapshotLines(
ptyId: string
): Promise<RuntimeTerminalProjection> {
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<RuntimeTerminalRead> {
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 } : {})
}
}
@@ -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
@@ -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<string, WorktreeMeta>
}
).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`],
@@ -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<string, GitWorktreeInfo>()
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,
@@ -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' })
})
})
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
@@ -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
})
})
})
@@ -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`
]
}
}
@@ -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<unknown> {
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`
]
}
}
@@ -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) {
@@ -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<unknown>
}
return {
id: 'attach',
ok: true,
result: {
dispatchId: (params as { dispatchId: string }).dispatchId,
state: 'outcome_unknown',
runtimeEpoch: 'worker-runtime'
},
_meta: { runtimeId: runtime.getRuntimeId() }
} satisfies RuntimeRpcResponse<unknown>
}
} 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)
})
})
@@ -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
@@ -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') {
@@ -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,
@@ -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()
@@ -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
)
})
})
@@ -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
})
}
}
@@ -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 } : {})
@@ -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()
+16
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More