Merge remote-tracking branch 'origin/main' into brennanb2025/agent-map-done-signal

# Conflicts:
#	src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts
This commit is contained in:
Brennan Benson
2026-08-13 01:17:15 -07:00
341 changed files with 20891 additions and 1950 deletions
+64
View File
@@ -61,3 +61,67 @@ describe('terminal close CLI', () => {
expect(help).toContain('durable persistence')
})
})
describe('terminal split CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends the old-host attribution bypass through the legacy-compatible env field', async () => {
const call = vi.fn(async (method: string) =>
method === 'status.get'
? {
result: {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}
}
: { result: { split: { handle: 'term-2', parentHandle: 'term-1' } } }
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal split']({
flags: new Map([
['terminal', 'term-1'],
['direction', 'horizontal']
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenNthCalledWith(1, 'status.get')
expect(call).toHaveBeenNthCalledWith(
2,
'terminal.split',
{
terminal: 'term-1',
direction: 'horizontal',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => {
const call = vi.fn().mockResolvedValue({
result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] }
})
await expect(
TERMINAL_HANDLERS['terminal split']({
flags: new Map([['terminal', 'term-1']]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
).rejects.toMatchObject({
code: 'runtime_update_required',
message: expect.stringContaining('Update the host and try again')
})
expect(call).toHaveBeenCalledTimes(1)
expect(call).not.toHaveBeenCalledWith('terminal.split', expect.anything())
})
})
+52 -17
View File
@@ -8,7 +8,8 @@ import type {
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait
RuntimeTerminalWait,
RuntimeStatus
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
@@ -37,6 +38,14 @@ import {
getRequiredWorktreeSelector,
getTerminalHandle
} from '../selectors'
import {
addLegacyTerminalAttributionDisableRequest,
hostSupportsTerminalCreateAttributionDisable,
hostSupportsTerminalSplitAttributionDisable,
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
withLegacyTerminalAttributionDisabledEnv
} from '../../shared/legacy-terminal-attribution-env'
// Why: terminal wait legitimately needs to outlive the CLI's default RPC
// timeout. Even without an explicit server timeout, the client must allow
@@ -137,17 +146,30 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
const useRendererBackedInteractiveTerminal =
!client.isRemote && shouldUseRendererBackedInteractiveTerminal(command)
const focus = flags.get('focus') === true
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command,
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
// unless the caller explicitly asks for focus.
focus,
...(focus ? { presentation: 'focused' } : {}),
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
})
const status = await client.call<RuntimeStatus>('status.get')
if (!hostSupportsTerminalCreateAttributionDisable(status.result)) {
throw new RuntimeClientError(
'runtime_update_required',
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
}
const result = await client.call<{ terminal: RuntimeTerminalCreate }>(
'terminal.create',
{
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command,
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
// unless the caller explicitly asks for focus.
focus,
...(focus ? { presentation: 'focused' } : {}),
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
},
{ expectedRuntimeId: status.result.runtimeId }
)
printResult(result, json, formatTerminalCreate)
},
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
@@ -168,11 +190,24 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
) {
throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical')
}
const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', {
terminal: await getTerminalHandle(flags, cwd, client),
direction: directionFlag,
command: getOptionalStringFlag(flags, 'command')
})
const status = await client.call<RuntimeStatus>('status.get')
if (!hostSupportsTerminalSplitAttributionDisable(status.result)) {
throw new RuntimeClientError(
'runtime_update_required',
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
}
const result = await client.call<{ split: RuntimeTerminalSplit }>(
'terminal.split',
{
terminal: await getTerminalHandle(flags, cwd, client),
direction: directionFlag,
command: getOptionalStringFlag(flags, 'command'),
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined)
},
{ expectedRuntimeId: status.result.runtimeId }
)
printResult(result, json, formatTerminalSplit)
}
}
+202 -82
View File
@@ -3112,6 +3112,10 @@ describe('orca cli worktree awareness', () => {
it('passes explicit focus through terminal.create', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3136,13 +3140,19 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: undefined,
title: 'RUNNER',
focus: true,
presentation: 'focused'
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'RUNNER',
focus: true,
presentation: 'focused'
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('prints terminal.read fallback screen lines in json mode', async () => {
@@ -3184,6 +3194,10 @@ describe('orca cli worktree awareness', () => {
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3209,19 +3223,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3248,20 +3272,30 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3287,17 +3321,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
title: 'Codex exec',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex exec',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3323,17 +3367,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
title: 'Codex exec',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex exec',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for Codex review commands after long options', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3359,17 +3413,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
title: 'Codex review',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex review',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for Codex help commands', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3395,17 +3459,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
title: 'Codex help',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex help',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3431,19 +3505,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3469,19 +3553,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude',
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'claude',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps Claude print commands on the background terminal path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3507,12 +3601,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
title: 'Claude print',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Claude print',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
@@ -3819,6 +3919,10 @@ describe('orca cli worktree awareness', () => {
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3842,12 +3946,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: undefined,
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('collects and formats memory diagnostics', async () => {
@@ -3955,6 +4065,10 @@ describe('orca cli worktree awareness', () => {
it('does not force remote Codex terminal creates through a local renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3982,12 +4096,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
title: 'Codex',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
+1
View File
@@ -87,6 +87,7 @@ export class RuntimeClient {
}
: {}
const envelope = {
expectedRuntimeId: options?.expectedRuntimeId,
orchestrationCapability: options?.orchestrationCapability,
orchestrationContractVersion: method.startsWith('orchestration.')
? ORCHESTRATION_CONTRACT_VERSION
+42
View File
@@ -50,6 +50,48 @@ describe('runtime transport timeout validation', () => {
// Why: these tests create Unix domain socket servers in temp directories.
// Windows does not support Unix domain sockets in the same way.
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
it('forwards the expected runtime fence in local requests', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
let receivedRequest: Record<string, unknown> | null = null
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
socket.write(
`${JSON.stringify({
id: receivedRequest.id,
ok: true,
result: { terminal: { handle: 'terminal-1' } },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
await sendRequest(
{
runtimeId: 'runtime-1',
pid: 123,
transports: [{ kind: 'unix', endpoint }],
authToken: 'token',
startedAt: 1
},
'terminal.create',
{ worktree: 'id:worktree-1' },
1_000,
{ expectedRuntimeId: 'runtime-1' }
)
expect(receivedRequest).toMatchObject({
method: 'terminal.create',
expectedRuntimeId: 'runtime-1'
})
})
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
+1
View File
@@ -186,6 +186,7 @@ export async function sendRequest<TResult>(
authToken: metadata.authToken,
method,
params,
expectedRuntimeId: envelope?.expectedRuntimeId,
orchestrationCapability: envelope?.orchestrationCapability,
orchestrationContractVersion: envelope?.orchestrationContractVersion,
orchestrationRequestId: envelope?.orchestrationRequestId,
@@ -2,7 +2,7 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileStat } from '../providers/types'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import { recordSessionScanIssue } from './session-scan-issues'
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
@@ -32,7 +32,7 @@ export async function statRemoteSessionFile(
} catch (error) {
throwIfAiVaultScanCancelled(options?.signal)
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
recordRemoteSessionScanIssue(issues, {
recordSessionScanIssue(issues, {
executionHostId,
agent,
path,
@@ -1,24 +0,0 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const REMOTE_SCAN_ISSUE_LIMIT = 500
export function recordRemoteSessionScanIssue(
issues: AiVaultScanIssue[],
issue: AiVaultScanIssue
): void {
if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) {
issues.push(issue)
return
}
if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) {
// Kinded: this row is a scan notice, not a skipped transcript — the panel
// counts unkinded issues as skipped transcript files.
issues.push({
executionHostId: issue.executionHostId,
agent: issue.agent,
kind: 'notice',
path: 'Agent Session History scan',
message: 'Additional scan issues were omitted.'
})
}
}
@@ -7,7 +7,7 @@ import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import { recordSessionScanIssue } from './session-scan-issues'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
@@ -124,7 +124,7 @@ function recordRemoteDirectoryIssue(
err: unknown
): void {
if (!isMissingRemoteSessionPathError(err)) {
recordRemoteSessionScanIssue(issues, {
recordSessionScanIssue(issues, {
executionHostId,
agent: source.agent,
kind: 'host',
+3 -3
View File
@@ -24,7 +24,7 @@ import { createAntigravityWorkspaceResolver } from './session-scanner-antigravit
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import { recordSessionScanIssue } from './session-scan-issues'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
@@ -205,7 +205,7 @@ async function scanRemoteInScopeSessions(args: {
}
if (index < candidates.length && sessions.length < args.limit) {
recordRemoteSessionScanIssue(args.issues, {
recordSessionScanIssue(args.issues, {
executionHostId: args.context.executionHostId,
agent: 'codex',
kind: 'scope',
@@ -241,7 +241,7 @@ async function parseRemoteSessionCandidate(
return session
} catch (err) {
throwIfAiVaultScanCancelled(context.signal)
recordRemoteSessionScanIssue(issues, {
recordSessionScanIssue(issues, {
executionHostId: context.executionHostId,
agent: candidate.source.agent,
path: candidate.file.path,
@@ -1,10 +1,10 @@
import { stat } from 'node:fs/promises'
import type {
AiVaultAgent,
AiVaultFirstUserPromptResult,
AiVaultSession
} from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
@@ -118,7 +118,9 @@ async function fileWithMtimeForPath(
}
try {
const info = await stat(filePath)
// 'scan' matches the parser this feeds, so the two halves of one re-parse
// share a lane instead of the stat jumping the live-transcript queue.
const info = await wslGatedStat(filePath, 'scan')
return {
path: filePath,
mtimeMs: info.mtimeMs,
+27
View File
@@ -0,0 +1,27 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
// Why: a stalled WSL distro or an unreachable remote host fails one probe per
// discovered path, so an uncapped list ships one row per transcript file in the
// tree over IPC. Local and remote scans share the bound — the failure shape is
// the same and both feed the same panel.
const SCAN_ISSUE_LIMIT = 500
/** Append a scan issue, collapsing everything past the cap into one notice. */
export function recordSessionScanIssue(issues: AiVaultScanIssue[], issue: AiVaultScanIssue): void {
if (issues.length < SCAN_ISSUE_LIMIT - 1) {
issues.push(issue)
return
}
if (issues.length === SCAN_ISSUE_LIMIT - 1) {
// Kinded: this row is a scan notice, not a skipped transcript — the panel
// counts unkinded issues as skipped transcript files. Spread first so a
// remote issue keeps its executionHostId and a local one never gains an
// explicit `undefined` (which would outrank the scanner's own stamping).
issues.push({
...issue,
kind: 'notice',
path: 'Agent Session History scan',
message: 'Additional scan issues were omitted.'
})
}
}
@@ -1,8 +1,28 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values'
const HISTORY_MATCH_WINDOW_MS = 2_000
/**
* The local-scan `readHistory`; remote scans inject their own transport. A
* missing or unreadable history file is genuinely "no enrichment", but a gate
* refusal must propagate so the caller records a scan issue — degrading it to
* null lists the session with a missing cwd and no retry signal, and the
* resolver's memo relies on the rejection to evict rather than pin a stall.
*/
export async function readLocalAntigravityHistory(path: string): Promise<string | null> {
try {
return await wslGatedReadFile(path, 'utf-8', 'scan')
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
}
type AntigravityHistoryEntry = {
timestampMs: number
workspace: string
@@ -26,8 +46,19 @@ export function createAntigravityWorkspaceResolver(
}
let index = indexes.get(historyPath)
if (!index) {
index = readHistory(historyPath).then(indexAntigravityHistory)
indexes.set(historyPath, index)
// Why: a read failure is transient (a stalled WSL distro refuses here),
// so it must not be memoized — every later session under this history
// file would inherit the rejection for the process lifetime.
const pending: Promise<AntigravityHistoryIndex> = readHistory(historyPath)
.then(indexAntigravityHistory)
.catch((error: unknown) => {
if (indexes.get(historyPath) === pending) {
indexes.delete(historyPath)
}
throw error
})
index = pending
indexes.set(historyPath, pending)
}
const workspace = findAntigravityWorkspace(session, await index)
return workspace ? { ...session, cwd: workspace } : session
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -26,11 +26,16 @@ export async function parseAntigravitySessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseAntigravitySessionLines({ file, lines, platform })
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseAntigravitySessionLines({ file, lines, platform })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
export async function parseAntigravitySessionContent(
@@ -1,5 +1,3 @@
import { createReadStream } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type {
@@ -8,6 +6,14 @@ import type {
AiVaultSubagentListResult,
AiVaultSubagentRunStatus
} from '../../shared/ai-vault-types'
import {
openTranscriptReadStream,
wslGatedReaddir,
wslGatedReadFile,
wslGatedStat
} from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import {
@@ -29,6 +35,10 @@ const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000
// Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY):
// opening every subagent read stream at once stalls over SSH/WSL UNC paths.
const SUBAGENT_PARSE_CONCURRENCY = 8
// 'scan', not 'exact', even though a user expands this on demand: it is bulk
// directory work feeding the same parsers as the main scan, and the exact lane
// stays reserved for live transcript probes.
const SUBAGENT_FS_PRIORITY = 'scan'
const TASK_NOTIFICATION_MARKER = '<task-notification>'
const TOOL_USE_RESULT_MARKER = '"toolUseResult"'
@@ -71,8 +81,13 @@ export async function listClaudeSubagentSessions(args: {
let entries
try {
entries = await readdir(subagentsDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(subagentsDir, SUBAGENT_FS_PRIORITY)
} catch (err) {
// A gate refusal is a stalled distro, not a session without subagents —
// report it so the panel offers a retry instead of showing an empty list.
if (err instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, { agent: 'claude', path: subagentsDir, message: err.message })
}
return { sessions: [], issues }
}
@@ -130,7 +145,7 @@ async function parseSubagentTranscript(args: {
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession | null> {
try {
const fileStat = await stat(args.filePath)
const fileStat = await wslGatedStat(args.filePath, SUBAGENT_FS_PRIORITY)
const session = await parseClaudeSessionFile(
{
path: args.filePath,
@@ -161,7 +176,11 @@ async function parseSubagentTranscript(args: {
}
}
} catch (err) {
args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) })
recordSessionScanIssue(args.issues, {
agent: 'claude',
path: args.filePath,
message: errorMessage(err)
})
return null
}
}
@@ -187,11 +206,13 @@ function resolveSubagentStatus(args: {
// 'async_launched', notification 'running') are superseded by terminal ones.
async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<string, string>> {
const statuses = new Map<string, string>()
const input = openTranscriptReadStream(
parentFilePath,
{ encoding: 'utf-8' },
SUBAGENT_FS_PRIORITY
)
const lines = createInterface({ input, crlfDelay: Infinity })
try {
const lines = createInterface({
input: createReadStream(parentFilePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const hasNotification = line.includes(TASK_NOTIFICATION_MARKER)
const hasTaskResult =
@@ -232,6 +253,11 @@ async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<
}
} catch {
// A missing/unreadable parent transcript degrades to recency-only status.
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-read failure cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
return statuses
}
@@ -269,7 +295,8 @@ function taskNotificationBlockText(block: unknown): string {
async function readSubagentMeta(transcriptPath: string): Promise<ClaudeSubagentMeta> {
const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json`
try {
const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown)
const raw = await wslGatedReadFile(metaPath, 'utf-8', SUBAGENT_FS_PRIORITY)
const record = asRecord(JSON.parse(raw) as unknown)
return {
description: normalizeTitleText(extractString(record?.description) ?? ''),
agentType: extractString(record?.agentType)
@@ -1,4 +1,4 @@
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
@@ -42,7 +42,7 @@ export async function parseCodexSessionFile(
executionHostId?: ExecutionHostId
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
@@ -1,7 +1,7 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
// Codex names threads lazily in <CODEX_HOME>/session_index.jsonl; transcripts
@@ -77,7 +77,7 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<strin
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
let signature: string
try {
const indexStat = await stat(indexPath)
const indexStat = await wslGatedStat(indexPath, 'scan')
signature = `${indexStat.size}:${indexStat.mtimeMs}`
} catch {
return new Map()
@@ -88,10 +88,14 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<strin
return cachedTitles
}
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
signature,
titles
}))
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then(({ titles, refused }) => {
// Why: a gate refusal is a stalled distro, not a title-less index — caching
// it would pin "no titles" until the index's size or mtime changes.
if (refused && codexSessionIndexTitleCache.get(codexHome) === pending) {
codexSessionIndexTitleCache.delete(codexHome)
}
return { signature, titles }
})
storeCodexSessionIndexTitleCacheEntry(codexHome, pending)
return (await pending).titles
}
@@ -133,11 +137,11 @@ function storeCodexSessionIndexTitleCacheEntry(
async function readCodexSessionIndexTitlesFromDisk(
indexPath: string
): Promise<Map<string, string>> {
): Promise<{ titles: Map<string, string>; refused: boolean }> {
const titleBySessionId = new Map<string, string>()
try {
const lines = createInterface({
input: createReadStream(indexPath, { encoding: 'utf-8' }),
input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
for await (const line of lines) {
@@ -151,8 +155,9 @@ async function readCodexSessionIndexTitlesFromDisk(
titleBySessionId.set(sessionId, title)
}
}
} catch {
} catch (error) {
// Codex creates the index opportunistically; older homes may only have raw transcripts.
return { titles: titleBySessionId, refused: error instanceof WslTranscriptFsError }
}
return titleBySessionId
return { titles: titleBySessionId, refused: false }
}
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -35,7 +35,7 @@ export async function parseCopilotSessionFile(
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCopilotSessionLines({ file, lines, platform })
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type * as NodePathModule from 'node:path'
import type { SessionFileCandidate } from './session-scanner-types'
const mocks = vi.hoisted(() => ({
open: vi.fn(),
readFile: vi.fn(),
readdir: vi.fn(),
stat: vi.fn()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
open: mocks.open,
readFile: mocks.readFile,
readdir: mocks.readdir,
stat: mocks.stat
}))
// Derived sibling paths must retain Windows separators in this cross-platform test.
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof NodePathModule>()
return { ...actual.win32, default: actual.win32 }
})
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from './session-scanner-parse-cache'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
function uncPath(distro: string, ...segments: string[]): string {
return `\\\\wsl.localhost\\${distro}\\home\\ada\\${segments.join('\\')}`
}
const KIMI_HOME = ['.kimi-code', 'sessions', 'wd_app_9f2', 'session_abc']
const GROK_DIR = ['.grok', 'sessions', 'ses-1']
const OPENCODE_ROOT = ['.local', 'share', 'opencode', 'storage']
const KIMI_STATE = JSON.stringify({
title: 'Kimi session',
createdAt: '2026-06-01T10:00:00.000Z',
updatedAt: '2026-06-01T10:05:00.000Z',
agents: { main: { type: 'main', parentAgentId: null } }
})
const KIMI_WIRE = `${JSON.stringify({
type: 'context.append_message',
message: { role: 'user', origin: { kind: 'user' }, content: 'ship it' }
})}\n`
const GROK_SESSION = JSON.stringify({
info: { id: 'ses-1', cwd: '/repo/app' },
generated_title: 'Grok session',
created_at: '2026-06-01T10:00:00.000Z'
})
const GROK_HISTORY = `${JSON.stringify({
type: 'user',
content: '<user_query>ship it</user_query>'
})}\n`
const OPENCODE_SESSION = JSON.stringify({
id: 'ses-1',
title: 'OpenCode session',
directory: '/repo/app',
time: { created: 1780000000000 }
})
const OPENCODE_MESSAGE = JSON.stringify({
role: 'user',
content: [{ type: 'text', text: 'ship it' }],
time: { created: 1780000000000 }
})
type ReadResult = { bytesRead: number; buffer: Buffer }
let releaseStall: (() => void) | undefined
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) } as T)
})
}
function stallingHandle() {
return { read: vi.fn(stalls<ReadResult>), close: vi.fn(async () => {}) }
}
function servingHandle(body: string) {
const bytes = Buffer.from(body)
return {
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = bytes.subarray(position, Math.min(position + length, bytes.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}),
close: vi.fn(async () => {})
}
}
function missing(): Error {
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
function dirent(name: string) {
return { name, isFile: () => true }
}
function candidate(agent: SessionFileCandidate['agent'], path: string): SessionFileCandidate {
return {
agent,
file: {
path,
mtimeMs: 1,
modifiedAt: '2026-06-01T10:05:00.000Z',
sizeBytes: 128
},
codexHome: null
}
}
async function expectRefusal(target: SessionFileCandidate): Promise<void> {
const refusal = expect(parseAgentSessionFileCached(target, 'linux')).rejects.toBeInstanceOf(
WslTranscriptFsError
)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await refusal
}
async function releaseAndSettle(): Promise<void> {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
resetSessionParseCacheForTests()
mocks.open.mockReset()
mocks.readFile.mockReset()
mocks.readdir.mockReset()
mocks.stat.mockReset()
releaseStall = undefined
mocks.stat.mockRejectedValue(missing())
vi.useFakeTimers()
})
afterEach(async () => {
await releaseAndSettle()
vi.useRealTimers()
})
describe('Kimi session parse against a stalled WSL transcript', () => {
it('refuses a stalled state.json read instead of reporting no session', async () => {
const state = uncPath('KimiState', ...KIMI_HOME, 'state.json')
mocks.readFile.mockImplementation(stalls<string>)
await expectRefusal(candidate('kimi', state))
})
it('refuses a stalled wire read, then caches the full session once it recovers', async () => {
const state = uncPath('KimiWire', ...KIMI_HOME, 'state.json')
mocks.readFile.mockResolvedValue(KIMI_STATE)
mocks.open.mockResolvedValue(stallingHandle())
await expectRefusal(candidate('kimi', state))
await releaseAndSettle()
mocks.open.mockResolvedValue(servingHandle(KIMI_WIRE))
const recovered = await parseAgentSessionFileCached(candidate('kimi', state), 'linux')
expect(recovered?.messageCount).toBe(1)
})
// Pins the narrowness of the parser's `instanceof WslTranscriptFsError`
// rethrow: widening it to every error turns this into a hard scan issue.
it('still returns a metadata-only session when the wire file is simply missing', async () => {
const state = uncPath('KimiMissing', ...KIMI_HOME, 'state.json')
mocks.readFile.mockResolvedValue(KIMI_STATE)
mocks.open.mockRejectedValue(missing())
const session = await parseAgentSessionFileCached(candidate('kimi', state), 'linux')
expect(session).toMatchObject({ agent: 'kimi', title: 'Kimi session', messageCount: 0 })
})
})
describe('Grok session parse against a stalled WSL transcript', () => {
it('refuses a stalled chat_history read instead of reporting a summary-only session', async () => {
const file = uncPath('GrokHistory', ...GROK_DIR, 'session.json')
mocks.readFile.mockResolvedValue(GROK_SESSION)
mocks.open.mockResolvedValue(stallingHandle())
await expectRefusal(candidate('grok', file))
await releaseAndSettle()
mocks.open.mockResolvedValue(servingHandle(GROK_HISTORY))
const recovered = await parseAgentSessionFileCached(candidate('grok', file), 'linux')
expect(recovered?.previewMessages).toEqual([
expect.objectContaining({ role: 'user', text: 'ship it' })
])
})
it('still returns the summary-only session when chat_history is missing', async () => {
const file = uncPath('GrokMissing', ...GROK_DIR, 'session.json')
mocks.readFile.mockResolvedValue(GROK_SESSION)
mocks.open.mockRejectedValue(missing())
const session = await parseAgentSessionFileCached(candidate('grok', file), 'linux')
expect(session).toMatchObject({
agent: 'grok',
title: 'Grok session',
previewMessages: []
})
})
})
describe('OpenCode session parse against a stalled WSL transcript', () => {
function sessionPath(distro: string): string {
return uncPath(distro, ...OPENCODE_ROOT, 'session', 'prj', 'ses-1.json')
}
it('refuses a stalled message-directory listing instead of an empty transcript', async () => {
mocks.readFile.mockResolvedValue(OPENCODE_SESSION)
mocks.readdir.mockImplementation(stalls<unknown[]>)
await expectRefusal(candidate('opencode', sessionPath('OpenCodeDir')))
})
it('refuses a stalled per-message read instead of a partial transcript', async () => {
const file = sessionPath('OpenCodeMessage')
mocks.readdir.mockResolvedValue([dirent('msg-1.json')])
mocks.readFile.mockImplementation((path: string) =>
path === file ? Promise.resolve(OPENCODE_SESSION) : stalls<string>()
)
await expectRefusal(candidate('opencode', file))
await releaseAndSettle()
mocks.readFile.mockImplementation((path: string) =>
Promise.resolve(path === file ? OPENCODE_SESSION : OPENCODE_MESSAGE)
)
const recovered = await parseAgentSessionFileCached(candidate('opencode', file), 'linux')
expect(recovered?.messageCount).toBe(1)
})
// Same guard as Kimi's: a live process mid-write must stay a parseable
// session, not a refusal.
it('still returns the session when a live process left a half-written message', async () => {
const file = sessionPath('OpenCodeHalfWritten')
mocks.readdir.mockResolvedValue([dirent('msg-1.json')])
mocks.readFile.mockImplementation((path: string) =>
Promise.resolve(path === file ? OPENCODE_SESSION : '{"role":"user",')
)
const session = await parseAgentSessionFileCached(candidate('opencode', file), 'linux')
expect(session).toMatchObject({ agent: 'opencode', title: 'OpenCode session', messageCount: 0 })
})
})
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -33,7 +33,7 @@ export async function parseCursorSessionFile(
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCursorSessionLines({ file, lines, platform })
@@ -1,4 +1,4 @@
import { readFile } from 'node:fs/promises'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
@@ -27,7 +27,11 @@ export async function parseDevinSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
return parseDevinSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
)
}
export function parseDevinSessionContent(
@@ -1,6 +1,54 @@
import { describe, expect, it } from 'vitest'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { walkSessionFiles } from './session-scanner-discovery'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Dirent } from 'node:fs'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
readdir: fsMocks.readdir,
stat: fsMocks.stat
}))
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
import { discoverFiles, walkSessionFiles } from './session-scanner-discovery'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
function dirent(name: string): Dirent {
return { name, isDirectory: () => false, isFile: () => true } as Dirent
}
let releaseStall: (() => void) | undefined
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve(undefined as T)
})
}
beforeEach(() => {
fsMocks.readdir.mockReset()
fsMocks.stat.mockReset()
fsMocks.readdir.mockResolvedValue([])
releaseStall = undefined
})
// A stalled task holds its gate permit until the call settles; releasing it
// keeps the next case from fast-failing on an already-exhausted gate.
afterEach(async () => {
if (!vi.isFakeTimers()) {
return
}
releaseStall?.()
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
})
describe('walkSessionFiles WSL gate refusals', () => {
it('rethrows gate refusals instead of reporting an empty tree', async () => {
@@ -26,3 +74,64 @@ describe('walkSessionFiles WSL gate refusals', () => {
).resolves.toEqual([])
})
})
describe('discoverFiles containment for a stalled WSL root', () => {
const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions'
it('contains a stalled readdir to one root so a Promise.all fan-out still resolves', async () => {
vi.useFakeTimers()
{
fsMocks.readdir.mockImplementation((dir: string) =>
dir === UNC_ROOT ? stalls<Dirent[]>() : Promise.resolve([])
)
const issues: AiVaultScanIssue[] = []
const discovery = discoverFiles({
rootDir: UNC_ROOT,
limit: 10,
agent: 'codex',
issues,
extensions: ['.jsonl']
})
const healthy = discoverFiles({
rootDir: '/home/ada/.codex/sessions',
limit: 10,
agent: 'codex',
issues: [],
extensions: ['.jsonl']
})
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await expect(Promise.all([discovery, healthy])).resolves.toEqual([
{ agent: 'codex', rootDir: UNC_ROOT, files: [] },
{ agent: 'codex', rootDir: '/home/ada/.codex/sessions', files: [] }
])
expect(issues).toEqual([{ agent: 'codex', path: UNC_ROOT, message: SLOW_MESSAGE }])
}
})
it('reports every stalled file without spending one deadline per file', async () => {
vi.useFakeTimers()
{
const names = ['a', 'b', 'c', 'd', 'e'].map((name) => dirent(`${name}.jsonl`))
fsMocks.readdir.mockResolvedValue(names)
fsMocks.stat.mockImplementation(stalls)
const issues: AiVaultScanIssue[] = []
const discovery = discoverFiles({
rootDir: UNC_ROOT,
limit: 10,
agent: 'codex',
issues,
extensions: ['.jsonl']
})
// Two deadlines total, not five: once the first stalled stat is marked
// stuck, every later file fast-fails on the route instead of queueing.
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
await expect(discovery).resolves.toEqual({ agent: 'codex', rootDir: UNC_ROOT, files: [] })
expect(issues).toHaveLength(names.length)
expect(issues.every((issue) => issue.path.startsWith(UNC_ROOT))).toBe(true)
}
})
})
+30 -9
View File
@@ -1,8 +1,9 @@
import type { Dirent } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import { extname, join } from 'node:path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
@@ -15,15 +16,31 @@ export async function discoverFiles(args: {
filePredicate?: (path: string) => boolean
directoryPredicate?: (name: string, depth: number) => boolean
}): Promise<SessionFileDiscovery> {
const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
})
let paths: string[]
try {
paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
})
} catch (err) {
// Why: discoverAiVaultSessionSources fans out with Promise.all, so one
// stalled distro would otherwise reject the whole vault scan — including
// every healthy local agent. Contain it to this root.
if (!(err instanceof WslTranscriptFsError)) {
throw err
}
recordSessionScanIssue(args.issues, {
agent: args.agent,
path: args.rootDir,
message: err.message
})
return { agent: args.agent, rootDir: args.rootDir, files: [] }
}
const files: FileWithMtime[] = []
for (const path of paths) {
try {
const fileStat = await stat(path)
const fileStat = await wslGatedStat(path, 'scan')
files.push({
path,
mtimeMs: fileStat.mtimeMs,
@@ -34,7 +51,11 @@ export async function discoverFiles(args: {
nlink: fileStat.nlink
})
} catch (err) {
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
recordSessionScanIssue(args.issues, {
agent: args.agent,
path,
message: errorMessage(err)
})
}
}
return {
@@ -64,7 +85,7 @@ export async function walkSessionFiles(
try {
entries = options.readDirectory
? await options.readDirectory(dirPath)
: await readdir(dirPath, { withFileTypes: true })
: await wslGatedReaddir(dirPath, 'scan', options.signal)
} catch (error) {
options.signal?.throwIfAborted()
// Why: a gate refusal means the scan could not run, not that the tree is
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -34,11 +34,16 @@ export async function parseDroidSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseDroidSessionLines({ file, lines, platform })
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseDroidSessionLines({ file, lines, platform })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
export async function parseDroidSessionContent(
@@ -0,0 +1,81 @@
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const AI_VAULT_DIR = join(import.meta.dirname, '.')
const NATIVE_CHAT_DIR = join(import.meta.dirname, '..', 'native-chat')
// Transcript I/O in these modules must go through the WSL admission gate, so a
// stalled 9P mount cannot hang a scan or a Native Chat load (STA-4049).
const NATIVE_CHAT_MODULES = [
'transcript-read-cache.ts',
'transcript-reader.ts',
'transcript-tail-reader.ts',
'transcript-file-version.ts',
'transcript-incremental-reader.ts',
'transcript-watch-engine.ts'
]
// Adding to this list must be a reviewed act: each entry names why the file
// touches a path that can never be a WSL UNC transcript.
const ALLOWLIST = new Set([
// `existsSync` on bundled module paths inside the app dir.
'session-scanner-service-entry-path.ts',
'session-scanner-service-spawn.ts',
'session-scanner-worker-spawn.ts',
'session-scanner-opencode-sqlite-worker-spawn.ts',
// On-demand IPC readers, gated in the STA-4049 follow-up.
'session-scanner-claude-subagents.ts',
'session-scanner-omp-subagent-listing.ts',
// Test-only fixture builder.
'session-scanner-test-fixtures.ts'
])
const FS_IMPORT = /import\s+([\s\S]*?)\s+from\s+['"]node:fs(?:\/promises)?['"]/g
function valueImportsOfNodeFs(source: string): string[] {
return [...source.matchAll(FS_IMPORT)]
.map((match) => match[1]!.trim())
.filter((clause) => !clause.startsWith('type'))
}
async function guardedFiles(): Promise<{ name: string; path: string }[]> {
const aiVault = (await readdir(AI_VAULT_DIR))
.filter((name) => name.startsWith('session-scanner') || name === 'session-title-file-reader.ts')
.filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts'))
.filter((name) => !ALLOWLIST.has(name))
.map((name) => ({ name, path: join(AI_VAULT_DIR, name) }))
return [
...aiVault,
...NATIVE_CHAT_MODULES.map((name) => ({ name, path: join(NATIVE_CHAT_DIR, name) }))
]
}
describe('WSL transcript gate import guard', () => {
it('leaves no raw node:fs value import in a gated transcript module', async () => {
const files = await guardedFiles()
const offenders: string[] = []
for (const file of files) {
for (const clause of valueImportsOfNodeFs(await readFile(file.path, 'utf-8'))) {
offenders.push(`${file.name}: import ${clause} from 'node:fs…'`)
}
}
expect(offenders).toEqual([])
})
it('actually covers the modules it claims to', async () => {
const names = (await guardedFiles()).map((file) => file.name)
expect(names).toEqual(expect.arrayContaining(NATIVE_CHAT_MODULES))
// The V12–V17 sites the previous `*parser*` wording silently skipped.
expect(names).toEqual(
expect.arrayContaining([
'session-title-file-reader.ts',
'session-scanner.ts',
'session-scanner-values.ts',
'session-scanner-codex-title-index.ts',
'session-scanner-kimi-paths.ts',
'session-scanner-opencode-sources.ts'
])
)
})
})
@@ -1,6 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type {
@@ -34,7 +33,11 @@ export async function parseGeminiSessionFile(
return parseGeminiJsonlSessionFile(file, platform)
}
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
return parseGeminiJsonSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
)
}
export async function parseGeminiSessionContent(
@@ -83,7 +86,7 @@ export async function parseGeminiJsonlSessionFile(
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
@@ -1,6 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@@ -41,7 +40,9 @@ export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
const metadata = asRecord(
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
)
if (!metadata) {
return null
}
@@ -175,11 +176,16 @@ export async function parseMessageGraphSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseMessageGraphSessionLines({ agent, file, lines, platform })
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseMessageGraphSessionLines({ agent, file, lines, platform })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
export async function parseMessageGraphSessionContent(
@@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@@ -36,7 +36,7 @@ export async function parseGrokSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
return null
}
@@ -62,12 +62,13 @@ async function consumeGrokChatHistory(
accumulator: SessionAccumulator,
sessionDir: string
): Promise<void> {
const input = openTranscriptReadStream(
join(sessionDir, 'chat_history.jsonl'),
{ encoding: 'utf-8' },
'scan'
)
const lines = createInterface({ input, crlfDelay: Infinity })
try {
const lines = createInterface({
input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
@@ -111,8 +112,18 @@ async function consumeGrokChatHistory(
seedFirstUserPrompt: false
})
}
} catch {
// Summary-only sessions still provide enough metadata for the Vault list.
} catch (error) {
// Summary-only sessions still provide enough metadata for the Vault list. A
// gate refusal is a different thing — the history exists but is unreachable,
// so a message-less session must not be cached under the summary's mtime.
if (error instanceof WslTranscriptFsError) {
throw error
}
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-read failure cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
@@ -1,4 +1,4 @@
import { readFile } from 'node:fs/promises'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
@@ -26,7 +26,11 @@ export async function parseHermesSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
return parseHermesSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
)
}
export async function parseHermesSessionContent(
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const CODEX_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex'
const CODEX_SESSION_FILE = `${CODEX_HOME}\\sessions\\2026\\01\\01\\rollout-1.jsonl`
const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const KIMI_INDEX = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.kimi\\session_index.jsonl'
const mocks = vi.hoisted(() => ({ stat: vi.fn(), open: vi.fn(), readFile: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
stat: mocks.stat,
open: mocks.open,
readFile: mocks.readFile
}))
import {
readCodexSessionIndexTitle,
resetCodexSessionIndexTitleCacheForTests,
_hasCodexSessionIndexTitleCacheEntryForTest
} from './session-scanner-codex-title-index'
import {
clearKimiSessionIndexCache,
hasKimiSessionIndexCacheEntryForTests,
readKimiWorkDirBySessionId
} from './session-scanner-kimi-paths'
import { readJsonObjectIfExists } from './session-scanner-values'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
// Identity must not change between the refused and the recovered read, or the
// cache would miss for that reason instead of because the entry was evicted.
const INDEX_STATS = { size: 128, mtimeMs: 7, ctimeMs: 7 }
let releaseStall: (() => void) | undefined
function stalls(): Promise<never> {
return new Promise<never>((resolve) => {
releaseStall = () => resolve(undefined as never)
})
}
function servingHandle(body: string) {
const bytes = Buffer.from(body)
return {
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = bytes.subarray(position, Math.min(position + length, bytes.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}),
close: vi.fn(async () => {})
}
}
async function releaseAndSettle(): Promise<void> {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
resetCodexSessionIndexTitleCacheForTests()
clearKimiSessionIndexCache()
mocks.stat.mockReset()
mocks.open.mockReset()
mocks.readFile.mockReset()
mocks.stat.mockResolvedValue(INDEX_STATS)
releaseStall = undefined
vi.useFakeTimers()
})
afterEach(async () => {
await releaseAndSettle()
vi.useRealTimers()
})
describe('memoized WSL session indexes under a stalled mount', () => {
it('evicts the Codex title index instead of pinning "no titles"', async () => {
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
const refused = readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
expect(await refused).toBeNull()
expect(_hasCodexSessionIndexTitleCacheEntryForTest(CODEX_HOME)).toBe(false)
await releaseAndSettle()
mocks.open.mockResolvedValue(
servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Ship the gate' })}\n`)
)
// Same size/mtime as the refused read: only the eviction can produce a hit.
expect(await readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)).toBe(
'Ship the gate'
)
})
it('evicts the Kimi work-dir index instead of pinning "no cwd"', async () => {
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
const refused = readKimiWorkDirBySessionId(KIMI_INDEX)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
expect(await refused).toEqual(new Map())
expect(hasKimiSessionIndexCacheEntryForTests(KIMI_INDEX)).toBe(false)
await releaseAndSettle()
mocks.open.mockResolvedValue(
servingHandle(`${JSON.stringify({ sessionId: SESSION_ID, workDir: '/repo/app' })}\n`)
)
expect(await readKimiWorkDirBySessionId(KIMI_INDEX)).toEqual(
new Map([[SESSION_ID, '/repo/app']])
)
})
it('surfaces a refused optional JSON enrichment instead of caching it as absent', async () => {
mocks.readFile.mockImplementation(stalls)
const pending = readJsonObjectIfExists(`${CODEX_HOME}\\history.json`).catch(
(error: unknown) => error
)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
// A throw, not `null`: `parseSessionCandidate` turns it into a scan issue,
// where `null` would cache an un-enriched session that never re-reads.
expect(await pending).toBeInstanceOf(WslTranscriptFsError)
})
it('still degrades a missing optional JSON file to null', async () => {
mocks.readFile.mockRejectedValue(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' as const })
)
await expect(readJsonObjectIfExists(`${CODEX_HOME}\\missing.json`)).resolves.toBeNull()
})
})
@@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import {
@@ -36,8 +36,16 @@ export async function parseKimiSessionFile(
): Promise<AiVaultSession | null> {
let stateRecord: Record<string, unknown> | null
try {
stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
} catch {
stateRecord = asRecord(
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
)
} catch (error) {
// Why: a missing/half-written state file is genuinely "no session", but a
// gate refusal is not — returning null would let the parse cache store that
// degraded answer under the file's unchanged mtime and never retry.
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
if (!stateRecord) {
@@ -83,11 +91,9 @@ async function consumeKimiWireTranscript(
}
}
const input = openTranscriptReadStream(wirePath, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
const lines = createInterface({
input: createReadStream(wirePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
@@ -111,9 +117,18 @@ async function consumeKimiWireTranscript(
break
}
}
} catch {
} catch (error) {
// No transcript yet (session created but never ran a turn) — metadata-only
// sessions still belong in the panel.
// sessions still belong in the panel. A gate refusal instead means the wire
// bytes exist but are unreachable; a partial session must not be cached.
if (error instanceof WslTranscriptFsError) {
throw error
}
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-read failure cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
flushAssistant()
}
@@ -1,8 +1,8 @@
import { stat } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { asRecord, extractString } from './session-scanner-values'
import {
KimiSessionIndexCache,
@@ -78,9 +78,9 @@ export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolea
export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map<string, string>> {
const generation = workDirCacheByIndexPath.beginRead()
let identity: Awaited<ReturnType<typeof stat>>
let identity: Awaited<ReturnType<typeof wslGatedStat>>
try {
identity = await stat(indexPath)
identity = await wslGatedStat(indexPath, 'scan')
} catch {
// Missing index (e.g. user deleted it): sessions still list, just without cwd.
workDirCacheByIndexPath.delete(indexPath, generation)
@@ -95,18 +95,30 @@ export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map
sizeBytes: identity.size
},
generation,
() => parseKimiSessionIndex(indexPath)
() =>
parseKimiSessionIndex(indexPath).then(({ map, refused }) => {
// Why: a gate refusal is a stalled distro, not a cwd-less index — evict
// so the partial map is not served until the index's identity changes.
// Inside the loader, not at the call site: only the caller that missed
// runs this, so a concurrent hit would otherwise serve and keep it.
if (refused) {
workDirCacheByIndexPath.delete(indexPath, generation)
}
return map
})
)
}
async function parseKimiSessionIndex(indexPath: string): Promise<Map<string, string>> {
async function parseKimiSessionIndex(
indexPath: string
): Promise<{ map: Map<string, string>; refused: boolean }> {
const map = new Map<string, string>()
// Why: never reject. This promise is memoized and shared by every session
// under one Kimi home; a mid-read failure (file deleted after stat, EACCES)
// must degrade to whatever was parsed so the other sessions still list.
try {
const lines = createInterface({
input: createReadStream(indexPath, { encoding: 'utf-8' }),
input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
for await (const line of lines) {
@@ -126,8 +138,9 @@ async function parseKimiSessionIndex(indexPath: string): Promise<Map<string, str
map.set(sessionId, workDir)
}
}
} catch {
} catch (error) {
// Return the partial map gathered before the read error.
return { map, refused: error instanceof WslTranscriptFsError }
}
return map
return { map, refused: false }
}
@@ -1,10 +1,12 @@
import { readdir, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import type {
AiVaultScanIssue,
AiVaultSession,
AiVaultSubagentListResult
} from '../../shared/ai-vault-types'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers'
import {
@@ -16,6 +18,8 @@ import { errorMessage } from './session-scanner-values'
// Match the Claude subagent lister's deliberate parse batching: opening every
// read stream at once stalls over WSL UNC paths.
const OMP_SUBAGENT_PARSE_CONCURRENCY = 8
// Bulk directory work, so 'scan' — same reasoning as the Claude lister.
const OMP_SUBAGENT_FS_PRIORITY = 'scan'
/**
* List the task-subagent transcripts of one OMP session, on demand. The main
@@ -35,8 +39,13 @@ export async function listOmpSubagentSessions(args: {
let entries
try {
entries = await readdir(artifactDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(artifactDir, OMP_SUBAGENT_FS_PRIORITY)
} catch (err) {
// A gate refusal is a stalled distro, not a session without subagents —
// report it so the panel offers a retry instead of showing an empty list.
if (err instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, { agent: 'omp', path: artifactDir, message: err.message })
}
return { sessions: [], issues }
}
@@ -79,7 +88,7 @@ async function parseOmpSubagentTranscript(args: {
}): Promise<AiVaultSession | null> {
const filePath = join(args.artifactDir, args.name)
try {
const fileStat = await stat(filePath)
const fileStat = await wslGatedStat(filePath, OMP_SUBAGENT_FS_PRIORITY)
// The shared OMP parser decorates every parse with an artifact-dir count, so
// a child row carries its own grandchild count. It is accurate but has no
// renderer — subagent rows don't expand — and this lister is local-only, so
@@ -107,7 +116,11 @@ async function parseOmpSubagentTranscript(args: {
}
}
} catch (err) {
args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) })
recordSessionScanIssue(args.issues, {
agent: 'omp',
path: filePath,
message: errorMessage(err)
})
return null
}
}
@@ -1,4 +1,4 @@
import { readdir } from 'node:fs/promises'
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
import { basename, dirname, extname, join } from 'node:path'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts'
@@ -51,7 +51,7 @@ export function isOmpSubagentTranscriptFileName(name: string, isFile: boolean):
export async function countOmpSubagentTranscripts(transcriptFilePath: string): Promise<number> {
let entries
try {
entries = await readdir(ompArtifactDirFor(transcriptFilePath), { withFileTypes: true })
entries = await wslGatedReaddir(ompArtifactDirFor(transcriptFilePath), 'scan')
} catch {
return 0
}
@@ -1,4 +1,5 @@
import { readFile, readdir } from 'node:fs/promises'
import { wslGatedReaddir, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { join } from 'node:path'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
@@ -26,7 +27,7 @@ export async function parseOpenCodeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
return null
}
@@ -47,8 +48,13 @@ export async function parseOpenCodeSessionFile(
async function readOpenCodeMessagesInOrder(messageDir: string): Promise<Record<string, unknown>[]> {
let entries
try {
entries = await readdir(messageDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(messageDir, 'scan')
} catch (error) {
// A session with no message dir yet is empty; a gate refusal is not — an
// empty transcript would otherwise be cached under the session's mtime.
if (error instanceof WslTranscriptFsError) {
throw error
}
return []
}
const messages: { name: string; createdMs: number; message: Record<string, unknown> }[] = []
@@ -59,11 +65,15 @@ async function readOpenCodeMessagesInOrder(messageDir: string): Promise<Record<s
let message: Record<string, unknown> | null = null
try {
message = asRecord(
JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown
JSON.parse(await wslGatedReadFile(join(messageDir, entry.name), 'utf-8', 'scan')) as unknown
)
} catch {
} catch (error) {
// A live OpenCode process can leave a half-written file; skip it rather
// than discard the whole session.
// than discard the whole session. A refused read is not a bad file — the
// partial transcript it would produce must not become the cached answer.
if (error instanceof WslTranscriptFsError) {
throw error
}
continue
}
if (!message) {
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Dirent } from 'node:fs'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
const WSL_DATA_DIR = `${WSL_HOME}/.local/share/opencode`
const mocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
readdir: mocks.readdir,
stat: mocks.stat
}))
// The SQLite leg spawns a real worker thread, which fake timers cannot drive.
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({
listOpenCodeSqliteSessionsViaWorker: async () => []
}))
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate'
let releaseStall: (() => void) | undefined
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
function stalls(): Promise<never> {
return new Promise<never>((resolve) => {
releaseStall = () => resolve(undefined as never)
})
}
beforeEach(() => {
mocks.readdir.mockReset()
mocks.stat.mockReset()
releaseStall = undefined
vi.useFakeTimers()
})
afterEach(async () => {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
})
describe('OpenCode source discovery with a stalled WSL data directory', () => {
it('reports the stalled home as an issue and still resolves every discovery', async () => {
mocks.readdir.mockImplementation((dir: string) =>
dir === WSL_DATA_DIR ? stalls() : Promise.resolve([] as Dirent[])
)
const issues: AiVaultScanIssue[] = []
const discoveries = Promise.all(
opencodeDiscoveries(
{ opencodeStorageDir: '/home/ada/.local/share/opencode/storage' },
[WSL_HOME],
10,
issues
)
)
// Two deadlines: the data-dir probe, then the storage scan that queued
// behind it before the route was flagged stuck.
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
// Zero databases for that home is the degraded answer; without the issue it
// would be indistinguishable from "OpenCode was never installed there".
const resolved = await discoveries
expect(resolved).toHaveLength(2)
expect(resolved.every((discovery) => discovery.files.length === 0)).toBe(true)
expect(issues.some((issue) => issue.path === WSL_DATA_DIR)).toBe(true)
expect(issues.every((issue) => issue.agent === 'opencode')).toBe(true)
})
it('reports a refused primary data directory instead of scanning clean', async () => {
const previousXdg = process.env.XDG_DATA_HOME
const previousDb = process.env.OPENCODE_DB
process.env.XDG_DATA_HOME = WSL_HOME
delete process.env.OPENCODE_DB
try {
mocks.readdir.mockImplementation(stalls)
const issues: AiVaultScanIssue[] = []
const discoveries = Promise.all(opencodeDiscoveries({}, [], 10, issues))
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
// The primary source is the one per-root containment cannot reach, so a
// silent [] here reads as "no OpenCode sessions" on a clean scan.
await expect(discoveries).resolves.toHaveLength(1)
expect(
issues.some((issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode`)
).toBe(true)
} finally {
restoreEnv('XDG_DATA_HOME', previousXdg)
restoreEnv('OPENCODE_DB', previousDb)
}
})
})
@@ -1,8 +1,10 @@
import { readdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory'
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
@@ -16,7 +18,7 @@ export function opencodeDiscoveries(
return storageDirs.map(async (storageDir, index) =>
discoverOpenCodeSessions({
storageDir,
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index),
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues),
limitPerAgent: limit,
issues
})
@@ -37,32 +39,47 @@ async function opencodeDbPathsForSource(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
storageDir: string,
sourceIndex: number
sourceIndex: number,
issues: AiVaultScanIssue[]
): Promise<readonly string[]> {
if (options.opencodeDbPaths) {
return sourceIndex === 0 ? options.opencodeDbPaths : []
}
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
if (sourceIndex === 0 && options.opencodeStorageDir) {
return listOpenCodeDatabasesInDirectory(dirname(storageDir))
return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues)
}
if (sourceIndex === 0) {
return listOpenCodeDatabases()
return listOpenCodeDatabases((path, error) => {
recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message })
})
}
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
return wslHomeDir
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'))
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues)
: []
}
async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise<string[]> {
async function listOpenCodeDatabasesInDirectory(
dataDir: string,
issues: AiVaultScanIssue[]
): Promise<string[]> {
try {
const entries = await readdir(dataDir, { withFileTypes: true })
const entries = await wslGatedReaddir(dataDir, 'scan')
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDir, entry.name))
.sort()
} catch {
} catch (error) {
// A stalled WSL data dir still degrades to "no databases", but the gap has
// to be reportable — an empty list otherwise reads as "OpenCode not used".
if (error instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, {
agent: 'opencode',
path: dataDir,
message: error.message
})
}
return []
}
}
@@ -1,5 +1,7 @@
import { createReadStream } from 'node:fs'
import { open } from 'node:fs/promises'
import {
openTranscriptReadStream,
readTranscriptSlice
} from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
@@ -305,13 +307,8 @@ async function parseResumableCandidate(args: {
// agent transcripts are append-only so that trade is accepted (worst case is
// a stale vault row until the file is next truncated or the app restarts).
async function endsWithNewlineAt(path: string, offset: number): Promise<boolean> {
const handle = await open(path, 'r')
try {
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1), 0, 1, offset - 1)
return bytesRead === 1 && buffer[0] === NEWLINE_BYTE
} finally {
await handle.close()
}
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
return slice.length === 1 && slice[0] === NEWLINE_BYTE
}
type JsonlReadResult = {
@@ -336,7 +333,7 @@ async function consumeCompleteJsonlLines(args: {
let remainderParts: Buffer[] = []
let remainderLength = 0
const stream = createReadStream(args.path, { start: args.start })
const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan')
for await (const chunk of stream as AsyncIterable<Buffer>) {
bytesRead += chunk.length
// Why check the chunk alone: the pieces held over are all mid-line, so none
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { SessionFileCandidate } from './session-scanner-types'
const STALLED_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\a.jsonl'
const SIBLING_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\b.jsonl'
const mocks = vi.hoisted(() => ({ open: vi.fn(), readdir: vi.fn() }))
// readdir too: every claude parse counts sibling subagent transcripts, and an
// unmocked one would reach the host UNC path and stall under fake timers.
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
open: mocks.open,
readdir: mocks.readdir
}))
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from './session-scanner-parse-cache'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
type ReadResult = { bytesRead: number; buffer: Buffer }
// A stalled task keeps the gate's single scan slot until the call settles, so
// every case releases its stall before the next one runs.
let releaseStall: (() => void) | undefined
function stalls(): Promise<ReadResult> {
return new Promise<ReadResult>((resolve) => {
releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) })
})
}
function servingHandle(body: Buffer) {
return {
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = body.subarray(position, Math.min(position + length, body.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}),
close: vi.fn(async () => {})
}
}
function record(index: number, text: string): string {
return JSON.stringify({
type: 'user',
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
timestamp: new Date(1740000000000 + index * 60_000).toISOString(),
cwd: '/repo/app',
gitBranch: 'main',
message: { role: 'user', content: text }
})
}
function body(count: number): Buffer {
return Buffer.from(
Array.from({ length: count }, (_, index) => `${record(index, `line ${index}`)}\n`).join('')
)
}
function candidate(path: string, bytes: Buffer, mtimeMs: number): SessionFileCandidate {
return {
agent: 'claude',
file: {
path,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: bytes.length
},
codexHome: null
}
}
async function releaseAndSettle(): Promise<void> {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
resetSessionParseCacheForTests()
mocks.open.mockReset()
mocks.readdir.mockReset()
mocks.readdir.mockResolvedValue([])
releaseStall = undefined
vi.useFakeTimers()
})
afterEach(async () => {
await releaseAndSettle()
vi.useRealTimers()
})
describe('AI Vault session parse with a stalled WSL transcript body read', () => {
it('refuses the stalled candidate and still parses a sibling on a healthy distro', async () => {
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
// Rejecting (not resolving null) is what lets `parseSessionCandidate`
// report it as an issue instead of silently dropping the session. The
// assertion is attached before the clock moves so the rejection is never
// momentarily unhandled.
const refusal = expect(
parseAgentSessionFileCached(candidate(STALLED_PATH, body(2), 1), 'linux')
).rejects.toBeInstanceOf(WslTranscriptFsError)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await refusal
await releaseAndSettle()
const siblingBytes = body(2)
mocks.open.mockResolvedValue(servingHandle(siblingBytes))
const sibling = await parseAgentSessionFileCached(
candidate(SIBLING_PATH, siblingBytes, 1),
'linux'
)
expect(sibling?.messageCount).toBe(2)
})
it('caches no resume point when the newline probe is refused mid-append', async () => {
const first = body(2)
mocks.open.mockResolvedValue(servingHandle(first))
const seeded = await parseAgentSessionFileCached(candidate(STALLED_PATH, first, 1), 'linux')
expect(seeded?.messageCount).toBe(2)
const grown = body(3)
const serving = servingHandle(grown)
mocks.open.mockResolvedValue({
// Only the 1-byte resume probe stalls; the body read stays healthy.
read: vi.fn((buffer: Buffer, offset: number, length: number, position: number) =>
length === 1 ? stalls() : serving.read(buffer, offset, length, position)
),
close: vi.fn(async () => {})
})
const refused = expect(
parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
).rejects.toBeInstanceOf(WslTranscriptFsError)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await refused
await releaseAndSettle()
mocks.open.mockResolvedValue(servingHandle(grown))
const recovered = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
resetSessionParseCacheForTests()
const cold = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
expect(recovered).toEqual(cold)
expect(recovered?.messageCount).toBe(3)
})
})
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Readable } from 'node:stream'
import type * as NodeReadlineModule from 'node:readline'
import type * as WslFsAccessModule from '../native-chat/wsl-transcript-fs-access'
import type { FileWithMtime } from './session-scanner-types'
// readline.close() leaves its input open, so a parser that stops consuming
// mid-file (a throw, a break) strands the gated transcript handle until the
// gate's deadline and delays every later scan. These pin the finally blocks.
const mocks = vi.hoisted(() => ({
openStream: vi.fn(),
readFile: vi.fn(),
stat: vi.fn(),
createInterface: vi.fn()
}))
vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => ({
...(await importOriginal<typeof WslFsAccessModule>()),
openTranscriptReadStream: mocks.openStream,
wslGatedReadFile: mocks.readFile,
wslGatedStat: mocks.stat
}))
vi.mock('node:readline', async (importOriginal) => ({
...(await importOriginal<typeof NodeReadlineModule>()),
createInterface: mocks.createInterface
}))
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
import { parseDroidSessionFile } from './session-scanner-droid-parser'
import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths'
const PARSE_FAILURE = 'parser failed mid-transcript'
const opened: { path: string; stream: Readable }[] = []
const interfaces: { close: ReturnType<typeof vi.fn> }[] = []
function file(path: string): FileWithMtime {
return { path, mtimeMs: 1, modifiedAt: '2026-06-01T10:05:00.000Z', sizeBytes: 128 }
}
function lastOpened(): { path: string; stream: Readable } {
const entry = opened.at(-1)
if (!entry) {
throw new Error('no transcript stream was opened')
}
return entry
}
function expectStreamTornDown(): void {
expect(lastOpened().stream.destroyed).toBe(true)
expect(interfaces.at(-1)?.close).toHaveBeenCalled()
}
beforeEach(() => {
opened.length = 0
interfaces.length = 0
clearKimiSessionIndexCache()
mocks.openStream.mockReset()
mocks.readFile.mockReset()
mocks.stat.mockReset()
mocks.createInterface.mockReset()
mocks.openStream.mockImplementation((path: string) => {
const stream = new Readable({
read() {
this.push(null)
}
})
opened.push({ path, stream })
return stream
})
// One line, then a consumer-side throw: the parser must still tear the
// stream down on its way out.
mocks.createInterface.mockImplementation(() => {
const lines = {
close: vi.fn(),
[Symbol.asyncIterator]: async function* () {
yield '{}'
throw new Error(PARSE_FAILURE)
}
}
interfaces.push(lines)
return lines
})
})
describe('session parsers that stop consuming a gated transcript early', () => {
it.each([
['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')],
['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')],
['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')]
])('destroys the stream when the %s parse throws', async (_agent, parse) => {
await expect(parse()).rejects.toThrow(PARSE_FAILURE)
expectStreamTornDown()
})
it('destroys the chat_history stream when the Grok parse swallows the failure', async () => {
mocks.readFile.mockResolvedValue(JSON.stringify({ info: { id: 'ses-1' } }))
// Grok degrades to a summary-only session on a non-gate failure, so the
// teardown has no rejection to ride out on.
await expect(
parseGrokSessionFile(file('/w/.grok/sessions/ses-1/session.json'))
).resolves.toBeTruthy()
expect(lastOpened().path).toContain('chat_history.jsonl')
expectStreamTornDown()
})
it('destroys the wire stream when the Kimi parse swallows the failure', async () => {
mocks.readFile.mockResolvedValue(JSON.stringify({ title: 'Kimi session' }))
// No session_index.jsonl, so only the wire transcript opens a stream.
mocks.stat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
await expect(
parseKimiSessionFile(file('/w/.kimi-code/sessions/wd_app/session_abc/state.json'))
).resolves.toBeTruthy()
expect(lastOpened().path).toContain('wire.jsonl')
expectStreamTornDown()
})
})
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
@@ -210,7 +210,7 @@ export async function parseClaudeSessionFile(
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseClaudeSessionLines({ file, lines, platform })
@@ -1,13 +1,18 @@
import { createReadStream } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import { extname, join } from 'node:path'
import {
openTranscriptReadStream,
wslGatedReaddir,
wslGatedStat
} from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import {
isPathInsideOrEqual,
normalizeRuntimePathSeparators
} from '../../shared/cross-platform-path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { recordSessionScanIssue } from './session-scan-issues'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
@@ -26,7 +31,18 @@ export function resetProjectDirCwdCacheForTests(): void {
projectDirCwdCache.clear()
}
async function cachedProjectDirCwd(projectDir: string): Promise<string | null> {
// A gate refusal is a stalled WSL root, not an empty one: keep the existing
// degrade-to-empty containment but make the gap visible in the scan issues.
function recordRefusal(issues: AiVaultScanIssue[], path: string, error: unknown): void {
if (error instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, { agent: 'claude', path, message: error.message })
}
}
async function cachedProjectDirCwd(
projectDir: string,
issues: AiVaultScanIssue[]
): Promise<string | null> {
const cached = projectDirCwdCache.get(projectDir)
if (cached !== undefined) {
// Refresh recency so hot in-scope dirs outlive one-off ones at the cap.
@@ -34,7 +50,7 @@ async function cachedProjectDirCwd(projectDir: string): Promise<string | null> {
projectDirCwdCache.set(projectDir, cached)
return cached
}
const cwd = await readProjectDirCwd(projectDir)
const cwd = await readProjectDirCwd(projectDir, issues)
if (cwd) {
if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) {
const oldest = projectDirCwdCache.keys().next()
@@ -70,8 +86,8 @@ export async function discoverInScopeClaudeFiles(args: {
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
const collected = new Map<string, FileWithMtime>()
for (const rootDir of args.rootDirs) {
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
const cwd = await cachedProjectDirCwd(projectDir)
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes, args.issues)) {
const cwd = await cachedProjectDirCwd(projectDir, args.issues)
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
continue
}
@@ -148,12 +164,14 @@ function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
async function listProjectDirs(
rootDir: string,
scopeProjectPrefixes: ReadonlySet<string>
scopeProjectPrefixes: ReadonlySet<string>,
issues: AiVaultScanIssue[]
): Promise<string[]> {
let entries
try {
entries = await readdir(rootDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(rootDir, 'scan')
} catch (err) {
recordRefusal(issues, rootDir, err)
return []
}
return entries
@@ -163,10 +181,13 @@ async function listProjectDirs(
.map((entry) => join(rootDir, entry.name))
}
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir)
async function readProjectDirCwd(
projectDir: string,
issues: AiVaultScanIssue[]
): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir, issues)
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
const cwd = await readFirstCwd(file)
const cwd = await readFirstCwd(file, issues)
if (cwd) {
return cwd
}
@@ -174,11 +195,15 @@ async function readProjectDirCwd(projectDir: string): Promise<string | null> {
return null
}
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
async function newestClaudeFilesInDir(
projectDir: string,
issues: AiVaultScanIssue[]
): Promise<string[]> {
let entries
try {
entries = await readdir(projectDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(projectDir, 'scan')
} catch (err) {
recordRefusal(issues, projectDir, err)
return []
}
const newest: { path: string; mtimeMs: number }[] = []
@@ -190,18 +215,19 @@ async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
try {
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
path,
mtimeMs: (await stat(path)).mtimeMs
mtimeMs: (await wslGatedStat(path, 'scan')).mtimeMs
})
} catch {
} catch (err) {
// Best effort: unreadable candidates are ignored here and reported during
// full collection if the project directory proves in-scope.
recordRefusal(issues, path, err)
}
}
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
}
async function readFirstCwd(filePath: string): Promise<string | null> {
const input = createReadStream(filePath, { encoding: 'utf-8' })
async function readFirstCwd(filePath: string, issues: AiVaultScanIssue[]): Promise<string | null> {
const input = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
let read = 0
try {
@@ -214,7 +240,8 @@ async function readFirstCwd(filePath: string): Promise<string | null> {
return cwd
}
}
} catch {
} catch (err) {
recordRefusal(issues, filePath, err)
return null
} finally {
// readline.close() leaves the underlying stream open; destroy it so the early
@@ -234,8 +261,9 @@ async function collectClaudeFiles(args: {
}): Promise<void> {
let entries
try {
entries = await readdir(args.projectDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(args.projectDir, 'scan')
} catch (err) {
recordRefusal(args.issues, args.projectDir, err)
return
}
for (const entry of entries) {
@@ -247,7 +275,7 @@ async function collectClaudeFiles(args: {
continue
}
try {
const fileStat = await stat(path)
const fileStat = await wslGatedStat(path, 'scan')
addBoundedFile(args.collected, args.limit, {
path,
mtimeMs: fileStat.mtimeMs,
@@ -255,7 +283,7 @@ async function collectClaudeFiles(args: {
sizeBytes: fileStat.size
})
} catch (err) {
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
recordSessionScanIssue(args.issues, { agent: 'claude', path, message: errorMessage(err) })
}
}
}
@@ -1,4 +1,4 @@
import { readdir } from 'node:fs/promises'
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
import { basename, dirname, extname, join } from 'node:path'
// Exported so discovery can prune these subtrees using the same literal that
@@ -38,7 +38,7 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
export async function countSubagentTranscripts(transcriptFilePath: string): Promise<number> {
let entries
try {
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true })
entries = await wslGatedReaddir(subagentTranscriptsDirFor(transcriptFilePath), 'scan')
} catch {
return 0
}
+11 -3
View File
@@ -1,6 +1,7 @@
import { homedir } from 'node:os'
import { basename, dirname, isAbsolute, join } from 'node:path'
import { readFile } from 'node:fs/promises'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { asRecord } from './session-scanner-record-value'
export { asRecord }
@@ -76,8 +77,15 @@ export async function readJsonObjectIfExists(
filePath: string
): Promise<Record<string, unknown> | null> {
try {
return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown)
} catch {
return asRecord(JSON.parse(await wslGatedReadFile(filePath, 'utf-8', 'scan')) as unknown)
} catch (error) {
// A missing or malformed file is genuinely "no enrichment", but a gate
// refusal must reach `parseSessionCandidate` as an issue — degrading it to
// null caches the un-enriched session under an unchanged mtime, and the
// non-resumable agents that use this never re-read it.
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
}
+6 -11
View File
@@ -1,4 +1,3 @@
import { readFile } from 'node:fs/promises'
import type {
AiVaultListResult,
AiVaultScanIssue,
@@ -14,6 +13,7 @@ import {
} from './codex-session-root-dedup'
import {
createAntigravityWorkspaceResolver,
readLocalAntigravityHistory,
type AntigravityWorkspaceResolver
} from './session-scanner-antigravity-history'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
@@ -27,6 +27,7 @@ import {
parseAgentSessionFileCached,
type SessionParseStats
} from './session-scanner-parse-cache'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
@@ -71,7 +72,9 @@ export async function scanAiVaultSessions(
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile)
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
throwIfAiVaultScanCancelled(options.signal)
@@ -255,7 +258,7 @@ async function parseSessionCandidates(args: {
for (const result of results) {
if (result.issue) {
args.issues.push(result.issue)
recordSessionScanIssue(args.issues, result.issue)
}
if (result.session) {
sessions.push(result.session)
@@ -305,14 +308,6 @@ async function parseSessionCandidate(
}
}
async function readOptionalTextFile(path: string): Promise<string | null> {
try {
return await readFile(path, 'utf-8')
} catch {
return null
}
}
function withSessionExecutionHost(
session: AiVaultSession,
executionHostId: ExecutionHostId
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\stalled.jsonl'
const STALLED_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const LOCAL_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff'
const mocks = vi.hoisted(() => ({ lstat: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => {
const original = await importOriginal<typeof NodeFsPromisesModule>()
return {
...original,
// Only the WSL request is faked; the local sibling reads a real temp file.
lstat: (path: string, ...rest: unknown[]) =>
path.startsWith('\\\\')
? mocks.lstat(path)
: (original.lstat as (...args: unknown[]) => unknown)(path, ...rest)
}
})
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate'
let releaseStall: (() => void) | undefined
let tempRoot: string | undefined
function stalls(): Promise<never> {
return new Promise<never>((resolve) => {
releaseStall = () => resolve(undefined as never)
})
}
function userRecord(sessionId: string, text: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp: new Date(1740000000000).toISOString(),
cwd: '/repo/app',
gitBranch: 'main',
message: { role: 'user', content: text }
})
}
beforeEach(async () => {
resetSessionParseCacheForTests()
mocks.lstat.mockReset()
releaseStall = undefined
tempRoot = await mkdtemp(join(tmpdir(), 'orca-title-stall-'))
})
afterEach(async () => {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
tempRoot = undefined
}
})
describe('AI Vault session titles with a stalled WSL transcript lstat', () => {
it('drops only the stalled request and still returns the rest of the batch', async () => {
const localPath = join(tempRoot!, `${LOCAL_SESSION_ID}.jsonl`)
await writeFile(localPath, `${userRecord(LOCAL_SESSION_ID, 'hello world')}\n`)
mocks.lstat.mockImplementation(stalls)
vi.useFakeTimers()
const pending = readAiVaultSessionTitlesFromFiles([
{ agent: 'claude', sessionId: STALLED_SESSION_ID, transcriptPath: UNC_PATH },
{ agent: 'claude', sessionId: LOCAL_SESSION_ID, transcriptPath: localPath }
])
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
const { titles } = await pending
expect(titles).toEqual([{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world' }])
})
})
@@ -1,4 +1,4 @@
import { lstat } from 'node:fs/promises'
import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access'
import {
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
type AiVaultSessionTitle,
@@ -27,7 +27,7 @@ async function readOneTitle(
return cache?.get(request) ?? null
}
try {
const stats = await lstat(transcriptPath)
const stats = await wslGatedLstat(transcriptPath, 'scan', signal)
if (!stats.isFile() || signal?.aborted) {
return null
}
+39 -2
View File
@@ -78,6 +78,10 @@ vi.mock('../providers/windows-conpty-process-membership', () => ({
import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
import {
LEGACY_TERMINAL_SHIM_ENV_KEYS,
stripLegacyTerminalShimEnv
} from '../pty/legacy-terminal-shim-dir'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_OPENCODE_CONFIG_DIR',
@@ -1275,6 +1279,35 @@ describe('createPtySubprocess', () => {
expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined()
})
it('does not inherit legacy attribution state from a pre-upgrade daemon', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const saved = Object.fromEntries(
[...LEGACY_TERMINAL_SHIM_ENV_KEYS, 'PATH'].map((key) => [key, process.env[key]])
)
process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1'
process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix'
process.env.PATH = `/tmp/orca-terminal-attribution/posix${delimiter}/usr/bin`
try {
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.PATH).toBe('/usr/bin')
for (const key of LEGACY_TERMINAL_SHIM_ENV_KEYS) {
expect(env[key]).toBeUndefined()
}
})
it('does not inherit NODE_ENV from the daemon process env', () => {
// Why: a dev-mode Orca forks the daemon with NODE_ENV=development; leaking
// Orca's build mode into user shells breaks `next build` and Vitest.
@@ -1295,7 +1328,9 @@ describe('createPtySubprocess', () => {
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.NODE_ENV).toBeUndefined()
expect(env.PATH).toBe(process.env.PATH)
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(env.PATH).toBe(expectedEnv.PATH)
})
it('keeps an explicitly requested NODE_ENV for daemon PTY shells', () => {
@@ -2207,6 +2242,8 @@ describe('createPtySubprocess', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, 'win32')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
@@ -2218,7 +2255,7 @@ describe('createPtySubprocess', () => {
}
const env = spawnMock.mock.calls.at(-1)![2].env
expect(env.PATH).toBe(process.env.PATH)
expect(env.PATH).toBe(expectedEnv.PATH)
})
it('preserves a duplicated path block supplied by main', () => {
+5
View File
@@ -35,6 +35,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { parseWslPath } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
@@ -629,6 +630,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links.
FORCE_HYPERLINK: '1'
} as Record<string, string>
// Why: an older client may not ask a newly upgraded daemon to delete inherited shim state.
stripLegacyTerminalShimEnv(env, process.platform)
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
@@ -834,6 +837,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)]
: undefined
promoteAgentTeamsShimPath(env, requestedPath)
// Why: raw requested PATH promotion runs after the inherited-env scrub.
stripLegacyTerminalShimEnv(env, process.platform)
// Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix.
ensureNodePtySpawnHelperExecutable()
@@ -0,0 +1,37 @@
import type { DiffComment, FolderWorkspace } from '../shared/types'
// Why shape-only: this replaces folder-workspaces.ts's verbatim `Array.isArray(raw.diffComments)` read.
// Filtering members would make the fix itself a new deletion path for user-authored prose.
export function normalizeFolderWorkspaceDiffComments(
value: unknown
): Record<string, DiffComment[]> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const normalized: Record<string, DiffComment[]> = {}
let kept = false
for (const [id, comments] of Object.entries(value)) {
if (!Array.isArray(comments)) {
continue
}
normalized[id] = comments as DiffComment[]
kept = true
}
return kept ? normalized : undefined
}
// Why derive on every write: the map self-GCs, so delete paths need no pruning code.
export function collectFolderWorkspaceDiffComments(
workspaces: readonly FolderWorkspace[] | undefined
): Record<string, DiffComment[]> | undefined {
const collected: Record<string, DiffComment[]> = {}
let kept = false
for (const workspace of workspaces ?? []) {
const comments = workspace.diffComments
if (Array.isArray(comments) && comments.length > 0) {
collected[workspace.id] = comments
kept = true
}
}
return kept ? collected : undefined
}
+2 -2
View File
@@ -185,7 +185,7 @@ import {
logStartupMilestone
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { removeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import {
createServeDesktopActivationGate,
@@ -2172,7 +2172,7 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
removeLegacyTerminalShimDir(app.getPath('userData'))
neutralizeLegacyTerminalShimDir(app.getPath('userData'))
const windowsShellPathHydration = createWindowsShellPathHydration()
configureWindowsHostGitEnvironmentReadiness(
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
@@ -45,6 +45,7 @@ const SNAPSHOT = {
worktreeName: 'Dashboard',
hostKind: 'ssh',
executionHostId: 'ssh:build-box',
hostLabel: 'Build box',
workspaceKind: 'worktree',
workspaceStatusId: 'in-review',
workspaceStatusLabel: 'In review',
@@ -68,6 +69,7 @@ const SNAPSHOT = {
parentWorktreeId: 'parent-worktree-1',
hostKind: 'ssh',
executionHostId: 'ssh:build-box',
hostLabel: 'Build box',
workspaceKind: 'worktree',
workspaceStatusId: 'in-review',
workspaceStatusLabel: 'In review',
@@ -143,6 +145,12 @@ describe('dashboard payload validation', () => {
cards: [{ ...SNAPSHOT.cards[0], executionHostId: `ssh:${'x'.repeat(4_097)}` }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], hostLabel: 'x'.repeat(1_025) }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
@@ -236,6 +244,12 @@ describe('dashboard payload validation', () => {
workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
workspaces: [{ ...SNAPSHOT.workspaces[0], hostLabel: 'x'.repeat(1_025) }]
})
).toBe(false)
})
it('validates bounded launch choices and spawn requests', () => {
@@ -272,6 +272,7 @@ function isDashboardCard(value: unknown): boolean {
(card.executionHostId === undefined ||
(isBoundedString(card.executionHostId, MAX_ID_LENGTH) &&
normalizeExecutionHostId(card.executionHostId) !== null)) &&
isOptionalBoundedString(card.hostLabel, MAX_LABEL_LENGTH) &&
(card.workspaceKind === undefined ||
(typeof card.workspaceKind === 'string' &&
DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) &&
@@ -50,6 +50,7 @@ export function isDashboardWorkspace(value: unknown): value is DashboardWorkspac
HOST_KINDS.has(workspace.hostKind) &&
isString(workspace.executionHostId, MAX_ID_LENGTH) &&
normalizeExecutionHostId(workspace.executionHostId) !== null &&
isOptionalString(workspace.hostLabel, DASHBOARD_MAX_LABEL_LENGTH) &&
typeof workspace.workspaceKind === 'string' &&
WORKSPACE_KINDS.has(workspace.workspaceKind) &&
isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) &&
+53 -2
View File
@@ -248,6 +248,7 @@ import {
type PrepareCodexSessionResume
} from './pty'
import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path'
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader'
import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell'
import {
@@ -4399,6 +4400,16 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('asks surviving pre-upgrade daemons to delete legacy attribution env', async () => {
const spawnOptions = await daemonSpawnAndGetOptions({})
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
)
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
})
it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
@@ -4436,6 +4447,38 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('asks surviving pre-upgrade daemons to delete legacy attribution env for runtime PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} })
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
)
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
})
it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => {
// Why: the runtime controller is the `orca` CLI / automation spawn path and
// assembles envToDelete separately from the renderer's pty:spawn handler;
@@ -6253,7 +6296,7 @@ describe('registerPtyHandlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => {
it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn()
@@ -6296,22 +6339,30 @@ describe('registerPtyHandlers', () => {
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
retireRejectedPty: (ptyId: string) => void
}
try {
expect(controller.kill('remote-pty')).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
controller.retireRejectedPty('remote-pty')
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
})
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
+53 -6
View File
@@ -113,6 +113,7 @@ import {
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
import {
isLegacyTerminalShimPathEntry,
LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
stripLegacyTerminalShimEnv
} from '../pty/legacy-terminal-shim-dir'
import { registerPty, unregisterPty } from '../memory/pty-registry'
@@ -3562,6 +3563,23 @@ export function registerPtyHandlers(
syntheticKillExitPtyIds.set(id, cleanupTimer)
}
// Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late
// synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the
// surface the retirement just removed. Timed like the duplicate window so a reused id is free.
const retiredRejectedPtyIds = new Map<string, NodeJS.Timeout>()
function rememberRetiredRejectedPty(id: string): void {
const existing = retiredRejectedPtyIds.get(id)
if (existing) {
clearTimeout(existing)
}
const cleanupTimer = setTimeout(() => {
retiredRejectedPtyIds.delete(id)
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
cleanupTimer.unref?.()
retiredRejectedPtyIds.set(id, cleanupTimer)
}
function consumeSyntheticKillExit(id: string): boolean {
const cleanupTimer = syntheticKillExitPtyIds.get(id)
if (!cleanupTimer) {
@@ -4715,6 +4733,8 @@ export function registerPtyHandlers(
spawnOptions.envToDelete = mergePtyEnvDeletions(
authEnvToDelete,
args.envToDelete ?? [],
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [],
// Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn.
getInheritedClaudeSessionStampEnvKeysToDelete(env)
@@ -5469,19 +5489,23 @@ export function registerPtyHandlers(
// Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit.
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
.then((providerExitObserved) => {
const retired = retiredRejectedPtyIds.has(ptyId)
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
if (!providerExitObserved && !retired) {
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
})
.catch((err) => {
const retired = retiredRejectedPtyIds.has(ptyId)
if (isPtyAlreadyGoneError(err)) {
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
if (!retired) {
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
return
}
console.warn(
@@ -5489,7 +5513,9 @@ export function registerPtyHandlers(
)
// Why: close runtime tails without clearing provider ownership, so
// a retry can still target a PTY that survived the failed shutdown.
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
if (!retired) {
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
return true
}
@@ -5500,12 +5526,31 @@ export function registerPtyHandlers(
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
if (!retiredRejectedPtyIds.has(ptyId)) {
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
return true
}
return killWithCurrentProvider()
},
retireRejectedPty: (ptyId) => {
rememberRetiredRejectedPty(ptyId)
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
// renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an
// SSH pane retires for good instead of staying preserved by the stop's negative exit.
if (!ptyOwnership.has(ptyId)) {
runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId))
return
}
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
connectionId ??= parsedSshId?.connectionId
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, 0, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: 0 })
},
markReversibleStops: (ptyIds) => {
for (const ptyId of ptyIds) {
reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1)
@@ -6354,6 +6399,8 @@ export function registerPtyHandlers(
envToDelete,
args.envToDelete ?? [],
agentTeamsEnvToDelete ?? [],
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [],
getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [],
@@ -183,6 +183,7 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}
): Promise<RuntimeRpcResponse<unknown>> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
@@ -197,7 +198,8 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
args.method,
args.params,
args.timeoutMs,
args.expectedEnvironmentPairingRevision
args.expectedEnvironmentPairingRevision,
args.expectedRuntimeId ? { expectedRuntimeId: args.expectedRuntimeId } : undefined
)
} catch (error) {
const failure = runtimeEnvironmentCallFailure(environment, args.method, error)
@@ -77,13 +77,15 @@ export function subscribeRemoteRuntimeSharedControlRequest<TResult>(
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
onError: (error: { code: string; message: string }) => void
onClose?: () => void
}
},
envelope?: RuntimeOrchestrationEnvelope
): Promise<RemoteRuntimeSharedSubscription> {
return getSharedControlConnection(environmentId, pairing).subscribe(
method,
params,
timeoutMs,
callbacks
callbacks,
envelope?.expectedRuntimeId
)
}
@@ -193,11 +193,14 @@ export async function subscribeRuntimeEnvironment(
| { type: 'close' }
) => void
onClose: () => void
}
},
expectedRuntimeId?: string
): Promise<RemoteRuntimeSubscription> {
const environment = resolveEnvironment(userDataPath, selector)
const pairing = getPreferredPairingOffer(environment)
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
const envelope: RuntimeOrchestrationEnvelope | undefined =
expectedRuntimeId === undefined ? undefined : { expectedRuntimeId }
let markedUsed = false
const markUsedOnce = (runtimeId: string): void => {
if (markedUsed) {
@@ -240,7 +243,8 @@ export async function subscribeRuntimeEnvironment(
method,
params,
effectiveTimeoutMs,
callbacksWithMarkUsed
callbacksWithMarkUsed,
envelope
)
}
return await subscribeRemoteRuntimeRequest(
@@ -248,7 +252,9 @@ export async function subscribeRuntimeEnvironment(
method,
params,
effectiveTimeoutMs,
callbacksWithMarkUsed
callbacksWithMarkUsed,
undefined,
envelope
)
} catch (error) {
if (error instanceof Error) {
+39 -9
View File
@@ -1094,7 +1094,13 @@ describe('registerRuntimeEnvironmentHandlers', () => {
await add(null, { name: 'desk', pairingCode: pairingCode() })
const subscribe = handler<
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
{
selector: string
method: string
params?: unknown
subscriptionId?: string
expectedRuntimeId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
await subscribe(
@@ -1107,7 +1113,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
removeListener: vi.fn()
}
},
{ selector: 'desk', method: 'browser.screencast', params: { pageId: 'page-1' } }
{
selector: 'desk',
method: 'browser.screencast',
params: { pageId: 'page-1' },
expectedRuntimeId: 'runtime-1'
}
)
await subscribe(
{
@@ -1127,14 +1138,18 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'browser.screencast',
{ pageId: 'page-1' },
15_000,
expect.any(Object)
expect.any(Object),
undefined,
{ expectedRuntimeId: 'runtime-1' }
)
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith(
expect.any(Object),
'terminal.multiplex',
{ client: { id: 'client-1' } },
15_000,
expect.any(Object)
expect.any(Object),
undefined,
undefined
)
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
})
@@ -1163,7 +1178,13 @@ describe('registerRuntimeEnvironmentHandlers', () => {
await add(null, { name: 'desk', pairingCode: pairingCode() })
const subscribe = handler<
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
{
selector: string
method: string
params?: unknown
subscriptionId?: string
expectedRuntimeId?: string
},
{ subscriptionId: string; requestId: string }
>('runtimeEnvironments:subscribe')
await expect(
@@ -1177,7 +1198,11 @@ describe('registerRuntimeEnvironmentHandlers', () => {
removeListener: vi.fn()
}
},
{ selector: 'desk', method: 'session.tabs.subscribeAll' }
{
selector: 'desk',
method: 'session.tabs.subscribeAll',
expectedRuntimeId: 'runtime-1'
}
)
).resolves.toMatchObject({ requestId: 'tabs-shared' })
@@ -1187,7 +1212,8 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'session.tabs.subscribeAll',
undefined,
15_000,
expect.any(Object)
expect.any(Object),
{ expectedRuntimeId: 'runtime-1' }
)
expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled()
})
@@ -1315,7 +1341,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'session.tabs.subscribeAll',
undefined,
15_000,
expect.any(Object)
expect.any(Object),
undefined,
undefined
)
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
})
@@ -1659,7 +1687,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'terminal.subscribe',
{ terminal: 't1' },
25,
expect.any(Object)
expect.any(Object),
undefined,
undefined
)
expect(sent).toEqual([
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
+3 -1
View File
@@ -90,6 +90,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
timeoutMs?: number
subscriptionId?: string
expectedEnvironmentPairingRevision?: number
expectedRuntimeId?: string
}
): Promise<{ subscriptionId: string; requestId: string }> => {
const subscriptionId =
@@ -180,7 +181,8 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
retained?.removeDestroyedListener()
remoteRuntimeSubscriptions.delete(subscriptionId)
}
}
},
args.expectedRuntimeId
)
} catch (error) {
removeDestroyedListener()
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import type { RuntimeMobileSessionClientTab } from '../../shared/runtime-types'
import { nativeChatTranscriptIncludesPath } from './native-chat-file-provenance'
function terminalTab(): RuntimeMobileSessionClientTab {
return {
type: 'terminal',
id: 'tab-1',
title: 'Codex',
parentTabId: 'parent-1',
leafId: 'leaf-1',
launchAgent: 'codex',
agentStatus: {
state: 'done',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
paneKey: 'parent-1:leaf-1',
stateHistory: [],
agentType: 'codex',
providerSession: {
key: 'session_id',
id: 'session-1',
transcriptPath: '/host/transcripts/session-1.jsonl'
}
},
isActive: true,
status: 'ready',
terminal: 'term-1'
}
}
function message(role: NativeChatMessage['role'], text: string): NativeChatMessage {
return {
id: `${role}-1`,
role,
blocks: [{ type: 'text', text }],
timestamp: 1,
source: 'transcript'
}
}
describe('nativeChatTranscriptIncludesPath', () => {
it('accepts a path in recent assistant output from the host-bound session', async () => {
const readTranscript = vi.fn(async () => ({
messages: [message('assistant', 'Open ~/orca-plans/result.html to review it.')]
}))
await expect(
nativeChatTranscriptIncludesPath({
tabs: [terminalTab()],
context: { tabId: 'tab-1', sessionId: 'session-1' },
pathText: '~/orca-plans/result.html',
absolutePath: '/Users/ada/orca-plans/result.html',
readTranscript
})
).resolves.toBe(true)
expect(readTranscript).toHaveBeenCalledWith({
agent: 'codex',
sessionId: 'session-1',
transcriptPath: '/host/transcripts/session-1.jsonl',
limit: 2000
})
})
it('rejects a client session identity that does not match the host tab', async () => {
const readTranscript = vi.fn()
await expect(
nativeChatTranscriptIncludesPath({
tabs: [terminalTab()],
context: { tabId: 'tab-1', sessionId: 'forged-session' },
pathText: '/etc/passwd',
absolutePath: '/etc/passwd',
readTranscript
})
).resolves.toBe(false)
expect(readTranscript).not.toHaveBeenCalled()
})
it('accepts a path followed by a sentence-final period', async () => {
const readTranscript = vi.fn(async () => ({
messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.')]
}))
await expect(
nativeChatTranscriptIncludesPath({
tabs: [terminalTab()],
context: { tabId: 'tab-1', sessionId: 'session-1' },
pathText: '/tmp/orca-pr14166-external.txt',
absolutePath: '/tmp/orca-pr14166-external.txt',
readTranscript
})
).resolves.toBe(true)
})
it('does not accept a longer filename sharing the requested path prefix', async () => {
const readTranscript = vi.fn(async () => ({
messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.backup')]
}))
await expect(
nativeChatTranscriptIncludesPath({
tabs: [terminalTab()],
context: { tabId: 'tab-1', sessionId: 'session-1' },
pathText: '/tmp/orca-pr14166-external.txt',
absolutePath: '/tmp/orca-pr14166-external.txt',
readTranscript
})
).resolves.toBe(false)
})
it('does not treat user-authored transcript text as agent provenance', async () => {
const readTranscript = vi.fn(async () => ({
messages: [message('user', 'Please open /etc/passwd')]
}))
await expect(
nativeChatTranscriptIncludesPath({
tabs: [terminalTab()],
context: { tabId: 'tab-1', sessionId: 'session-1' },
pathText: '/etc/passwd',
absolutePath: '/etc/passwd',
readTranscript
})
).resolves.toBe(false)
})
})
@@ -0,0 +1,74 @@
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import type {
RuntimeMobileSessionClientTab,
RuntimeMobileSessionTerminalClientTab,
RuntimeNativeChatFileContext
} from '../../shared/runtime-types'
import { recentTerminalOutputIncludesPath } from '../runtime/terminal-output-path-candidates'
import { readNativeChatTranscriptTail } from './transcript-tail-reader'
// Match the largest transcript window mobile can render so any visible citation remains provable.
const NATIVE_CHAT_FILE_PROVENANCE_WINDOW = 2000
type TranscriptRead = (args: {
agent: AgentType
sessionId: string
transcriptPath?: string
limit: number
}) => Promise<{ messages: NativeChatMessage[] } | { error: string }>
function transcriptTextIncludesPath(text: string, pathText: string, absolutePath: string): boolean {
if (recentTerminalOutputIncludesPath(text, pathText, absolutePath)) {
return true
}
// Mobile excludes a sentence-final period from the tappable path span.
return recentTerminalOutputIncludesPath(text, `${pathText}.`, `${absolutePath}.`)
}
export async function nativeChatTranscriptIncludesPath(args: {
tabs: readonly RuntimeMobileSessionClientTab[]
context: RuntimeNativeChatFileContext
pathText: string
absolutePath: string
readTranscript?: TranscriptRead
}): Promise<boolean> {
const tab = args.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalClientTab =>
candidate.type === 'terminal' && candidate.id === args.context.tabId
)
const providerSession = tab?.agentStatus?.providerSession
const agent = tab?.agentStatus?.agentType ?? tab?.launchAgent
if (
!tab ||
!providerSession ||
providerSession.id !== args.context.sessionId ||
!agent ||
!resolveNativeChatTranscriptAgent(agent)
) {
return false
}
try {
const result = await (args.readTranscript ?? readNativeChatTranscriptTail)({
agent,
sessionId: providerSession.id,
...(providerSession.transcriptPath ? { transcriptPath: providerSession.transcriptPath } : {}),
limit: NATIVE_CHAT_FILE_PROVENANCE_WINDOW
})
if (!('messages' in result)) {
return false
}
return result.messages.some(
(message) =>
message.role === 'assistant' &&
message.blocks.some(
(block) =>
block.type === 'text' &&
transcriptTextIncludesPath(block.text, args.pathText, args.absolutePath)
)
)
} catch {
return false
}
}
@@ -1,4 +1,6 @@
import { stat } from 'node:fs/promises'
import { readTranscriptSlice, wslGatedStat } from './wsl-transcript-fs-access'
const BOUNDARY_FINGERPRINT_BYTES = 64
export type TranscriptFileVersion = {
identity: string
@@ -7,8 +9,11 @@ export type TranscriptFileVersion = {
ctimeMs: number
}
export async function readTranscriptFileVersion(filePath: string): Promise<TranscriptFileVersion> {
const value = await stat(filePath)
export async function readTranscriptFileVersion(
filePath: string,
signal?: AbortSignal
): Promise<TranscriptFileVersion> {
const value = await wslGatedStat(filePath, 'exact', signal)
return {
identity: `${value.dev}:${value.ino}`,
size: value.size,
@@ -17,6 +22,19 @@ export async function readTranscriptFileVersion(filePath: string): Promise<Trans
}
}
export async function boundaryFingerprint(
filePath: string,
offset: number,
signal?: AbortSignal
): Promise<string> {
if (offset <= 0) {
return ''
}
const start = Math.max(0, offset - BOUNDARY_FINGERPRINT_BYTES)
const slice = await readTranscriptSlice(filePath, start, offset - start, 'exact', signal)
return slice.toString('base64')
}
export function transcriptFileVersionChanged(
current: TranscriptFileVersion,
previous: TranscriptFileVersion
@@ -1,10 +1,10 @@
import { open, stat } from 'node:fs/promises'
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
import {
MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES,
type NativeChatLineDecoder
} from './transcript-tail-reader'
import { openTranscriptReadStream, wslGatedStat } from './wsl-transcript-fs-access'
const APPEND_BATCH_MESSAGE_LIMIT = 40
@@ -30,16 +30,21 @@ export async function readIncrementalTranscriptMessages(
decode: NativeChatLineDecoder,
onBatch?: (messages: NativeChatMessage[]) => void,
decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null,
onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void
onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void,
signal?: AbortSignal
): Promise<NativeChatMessage[]> {
const end = (await stat(filePath)).size
const end = (await wslGatedStat(filePath, 'exact', signal)).size
if (end <= state.offset) {
return []
}
const messages: NativeChatMessage[] = []
const handle = await open(filePath, 'r')
const stream = openTranscriptReadStream(
filePath,
{ start: state.offset, end: end - 1 },
'exact',
signal
)
try {
const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false })
let absoluteOffset = state.offset
for await (const rawChunk of stream) {
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk)
@@ -62,7 +67,9 @@ export async function readIncrementalTranscriptMessages(
}
return messages
} finally {
await handle.close()
// Early exits (throw/oversized-record bail) must not leak the fd or, on
// UNC, the gated handle the generator's finally closes.
stream.destroy()
}
function retainPart(part: Buffer): void {
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
const mocks = vi.hoisted(() => ({
resolve: vi.fn<() => Promise<string | null>>(),
stat: vi.fn(),
open: vi.fn()
}))
vi.mock('./session-file-resolver', () => ({
resolveSessionFilePath: mocks.resolve
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
stat: mocks.stat,
open: mocks.open
}))
import {
clearNativeChatTranscriptCache,
readNativeChatTranscriptCached
} from './transcript-read-cache'
import {
WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS,
WSL_TRANSCRIPT_FS_SLOW_MESSAGE
} from './wsl-transcript-fs-gate'
const BODY = Buffer.from(
`${JSON.stringify({
type: 'user',
uuid: 'u-0',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'hello' }
})}\n`
)
let releaseStall: (() => void) | undefined
function stalls<T>(value: T): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve(value)
})
}
function transcriptHandle(body = BODY) {
return {
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = body.subarray(position, Math.min(position + length, body.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}),
close: vi.fn(async () => {})
}
}
beforeEach(() => {
clearNativeChatTranscriptCache()
mocks.resolve.mockReset()
mocks.stat.mockReset()
mocks.open.mockReset()
releaseStall = undefined
mocks.resolve.mockResolvedValue(UNC_PATH)
// The file itself never changes across the refusal and the recovery.
mocks.stat.mockResolvedValue({ mtimeMs: 42, size: BODY.length })
vi.useFakeTimers()
})
afterEach(async () => {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
})
describe('cached native chat transcript read after WSL gate refusals', () => {
it('retries a refused stat instead of caching it as a missing file', async () => {
mocks.stat.mockReturnValueOnce(stalls({ mtimeMs: 42, size: BODY.length }))
const refused = readNativeChatTranscriptCached('claude', 'session-id')
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
expect(await refused).toEqual({ error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE })
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
mocks.open.mockResolvedValue(transcriptHandle())
await expect(readNativeChatTranscriptCached('claude', 'session-id')).resolves.toMatchObject({
messages: [expect.objectContaining({ role: 'user' })]
})
expect(mocks.stat).toHaveBeenCalledTimes(2)
})
it('retries the body on the next call even though the mtime is unchanged', async () => {
mocks.open.mockResolvedValue({
read: vi.fn((buffer: Buffer) => stalls({ bytesRead: 0, buffer })),
close: vi.fn(async () => {})
})
const refused = readNativeChatTranscriptCached('claude', 'session-id')
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
expect(await refused).toEqual({ error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE })
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
mocks.open.mockResolvedValue(transcriptHandle())
const recovered = await readNativeChatTranscriptCached('claude', 'session-id')
expect(mocks.open).toHaveBeenCalledTimes(2)
expect(recovered).toMatchObject({
messages: [expect.objectContaining({ role: 'user' })]
})
})
})
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
const mocks = vi.hoisted(() => ({
resolve: vi.fn<() => Promise<string | null>>(),
stat: vi.fn(),
read: vi.fn()
}))
vi.mock('./session-file-resolver', () => ({
resolveSessionFilePath: mocks.resolve
}))
vi.mock('./transcript-reader', () => ({
readNativeChatTranscript: mocks.read
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
stat: mocks.stat
}))
import {
clearNativeChatTranscriptCache,
readNativeChatTranscriptCached
} from './transcript-read-cache'
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
beforeEach(() => {
mocks.resolve.mockReset()
mocks.stat.mockReset()
mocks.read.mockReset()
mocks.resolve.mockResolvedValue(UNC_PATH)
clearNativeChatTranscriptCache()
})
describe('cached native chat transcript read with a stalled post-resolution stat', () => {
it('reports a retryable error without notFound and leaves nothing cached', async () => {
vi.useFakeTimers()
try {
mocks.stat.mockReturnValueOnce(new Promise(() => {}))
const stalled = readNativeChatTranscriptCached('claude', 'session-id')
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
const result = await stalled
expect(result).toEqual({ error: SLOW_MESSAGE })
expect(result).not.toHaveProperty('notFound')
} finally {
vi.useRealTimers()
}
})
it('re-attempts the stat on the next call instead of serving a poisoned entry', async () => {
// The stalled task above still holds its permit, so this fast-fails on the
// stuck route rather than waiting out a second deadline — but it must still
// reach `stat` again once a different distro answers.
const healthyPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\session.jsonl'
mocks.resolve.mockResolvedValue(healthyPath)
mocks.stat.mockResolvedValue({ mtimeMs: 5, size: 0 })
mocks.read.mockResolvedValue({ messages: [] })
await readNativeChatTranscriptCached('claude', 'session-id')
expect(mocks.stat).toHaveBeenCalledWith(healthyPath)
})
it('serves the cached parse rather than a retry banner once the distro stalls', async () => {
const path = '\\\\wsl.localhost\\Fedora\\home\\ada\\.claude\\projects\\p\\session.jsonl'
mocks.resolve.mockResolvedValue(path)
mocks.stat.mockResolvedValue({ mtimeMs: 9, size: 4 })
mocks.read.mockResolvedValue({ messages: [{ role: 'user', text: 'hi' }] })
const warm = await readNativeChatTranscriptCached('claude', 'session-id')
vi.useFakeTimers()
try {
mocks.stat.mockReturnValueOnce(new Promise(() => {}))
const stalled = readNativeChatTranscriptCached('claude', 'session-id')
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
// The stat is what the stall refuses; the parse it would have validated is
// complete and unchanged, so the chat stays rendered.
await expect(stalled).resolves.toBe(warm)
expect(mocks.read).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
})
+43 -6
View File
@@ -1,8 +1,13 @@
import { stat } from 'node:fs/promises'
import type { AgentType } from '../../shared/native-chat-types'
import { resolveSessionFilePath } from './session-file-resolver'
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
import { wslGatedStat } from './wsl-transcript-fs-access'
import {
WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE,
WSL_TRANSCRIPT_FS_SLOW_MESSAGE,
WslTranscriptFsError,
wslTranscriptFsRefusal
} from './wsl-transcript-fs-gate'
// Why: both the desktop IPC handler and the runtime RPC handler read the same
// host-filesystem transcript, so a single process-global cache keyed by the
@@ -72,9 +77,14 @@ function cacheKey(agent: AgentType, filePath: string): string {
async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> {
try {
const stats = await stat(filePath)
const stats = await wslGatedStat(filePath, 'exact')
return { mtimeMs: stats.mtimeMs, bytes: stats.size }
} catch {
} catch (err) {
// Why: swallowing a gate refusal into an unknown mtime falls through to a
// full read that burns a second deadline. Let the caller report it instead.
if (err instanceof WslTranscriptFsError) {
throw err
}
return { mtimeMs: Number.NaN, bytes: 0 }
}
}
@@ -105,8 +115,22 @@ export async function readNativeChatTranscriptCached(
}
const key = cacheKey(agent, filePath)
const { mtimeMs, bytes } = await fileStat(filePath)
const cached = cache.get(key)
let mtimeMs: number
let bytes: number
try {
;({ mtimeMs, bytes } = await fileStat(filePath))
} catch (err) {
// Why: the refusal says the distro stalled, not that this parse went stale —
// a complete cached transcript beats a retry banner. With nothing cached the
// error stands, and no `notFound`, so the next call re-stats a woken distro.
if (cached) {
// Bump recency so a session read through a stall survives eviction.
setCached(key, cached)
return cached.result
}
return { error: wslTranscriptFsRefusal(err).message }
}
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
// Bump recency so a frequently-read session survives eviction.
setCached(key, cached)
@@ -114,12 +138,25 @@ export async function readNativeChatTranscriptCached(
}
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
if (Number.isFinite(mtimeMs)) {
// Why: a body-read refusal is transient unavailability, but the file's mtime
// is unchanged by it — caching it would serve the retryable error to every
// later call until the transcript itself changes, even after the distro woke.
if (Number.isFinite(mtimeMs) && !isGateRefusal(result)) {
setCached(key, { result, mtimeMs, bytes })
}
return result
}
// The reader flattens a refusal into its message, so that is the only handle
// this layer has on one.
function isGateRefusal(result: ReadTranscriptResult): boolean {
return (
'error' in result &&
(result.error === WSL_TRANSCRIPT_FS_SLOW_MESSAGE ||
result.error === WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE)
)
}
/** Test-only: drop the transcript parse cache between runs. */
export function clearNativeChatTranscriptCache(): void {
cache.clear()
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
const mocks = vi.hoisted(() => ({ open: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
open: mocks.open
}))
import { readNativeChatTranscript } from './transcript-reader'
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
const LINE = JSON.stringify({
type: 'user',
uuid: 'u1',
timestamp: '2026-01-01T00:00:00.000Z',
message: { role: 'user', content: 'hi' }
})
function handleServing(body: Buffer, delayMs = 0) {
return {
read: vi.fn(
(buffer: Buffer, offset: number, length: number, position: number) =>
new Promise((resolve) => {
const slice = body.subarray(position, Math.min(position + length, body.length))
slice.copy(buffer, offset)
const settle = () => resolve({ bytesRead: slice.length, buffer })
if (delayMs > 0) {
setTimeout(settle, delayMs)
} else {
settle()
}
})
),
close: vi.fn(async () => {})
}
}
beforeEach(() => {
mocks.open.mockReset()
})
describe('native chat transcript read with a stalled post-resolution body read', () => {
it('surfaces a retryable error without notFound when the first chunk never settles', async () => {
vi.useFakeTimers()
try {
mocks.open.mockResolvedValue({
read: vi.fn(() => new Promise(() => {})),
close: vi.fn(async () => {})
})
const reading = readNativeChatTranscript('claude', 'session-id', { filePath: UNC_PATH })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
const result = await reading
expect(result).toEqual({ error: SLOW_MESSAGE })
expect(result).not.toHaveProperty('notFound')
} finally {
vi.useRealTimers()
}
})
it('succeeds when every chunk is slow but under the deadline, past the whole-file budget', async () => {
vi.useFakeTimers()
const healthyPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\session.jsonl'
try {
const chunkDelayMs = WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS / 2
mocks.open.mockResolvedValue(handleServing(Buffer.from(`${LINE}\n`), chunkDelayMs))
const reading = readNativeChatTranscript('claude', 'session-id', { filePath: healthyPath })
// Two chunk admissions (body + EOF probe) span more than one exact deadline.
await vi.advanceTimersByTimeAsync(chunkDelayMs * 4)
const result = await reading
expect(result).toMatchObject({ messages: [expect.objectContaining({ id: 'u1' })] })
} finally {
vi.useRealTimers()
}
})
})
+2 -2
View File
@@ -1,4 +1,3 @@
import { createReadStream } from 'node:fs'
import type {
AgentType,
NativeChatMessage,
@@ -7,6 +6,7 @@ import type {
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { errorMessage } from '../ai-vault/session-scanner-values'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import { openTranscriptReadStream } from './wsl-transcript-fs-access'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
import {
decodeClaudeTranscriptLine,
@@ -82,7 +82,7 @@ async function readTranscript(
filePath: string,
decode: (line: string, fallbackId: string) => NativeChatMessage | null
): Promise<NativeChatMessage[]> {
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const stream = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'exact')
const { messages } = await decodeTranscriptStream(stream, filePath, 0, decode, true)
return messages
}
@@ -32,6 +32,23 @@ describe('decodeTranscriptStream', () => {
expect(incremental.messages[0]?.id).toBe(full.messages[1]?.id)
})
it('keeps a codepoint split across two Buffer chunks intact', async () => {
const line = `{"text":"😀é中"}\n`
const bytes = Buffer.from(line, 'utf8')
// Split inside the emoji's 4-byte sequence, as a 1 MiB gated chunk boundary does.
const split = Buffer.from('{"text":"', 'utf8').length + 2
const result = await decodeTranscriptStream(
Readable.from([bytes.subarray(0, split), bytes.subarray(split)]),
'/chat.jsonl',
0,
decode,
false
)
expect(result.messages[0]?.blocks[0]).toEqual({ type: 'text', text: line.slice(0, -1) })
expect(result.consumedBytes).toBe(bytes.length)
})
it('does not consume a partial trailing JSONL record', async () => {
const complete = '{"first":true}\n'
const partial = '{"second"'
@@ -1,4 +1,5 @@
import type { Readable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
@@ -12,11 +13,14 @@ export async function decodeTranscriptStream(
includeTrailingLine: boolean
): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> {
const messages: NativeChatMessage[] = []
// Why: a Buffer chunk can end mid-codepoint, and decoding it standalone would
// both corrupt the line and shift `consumedBytes` (which seeds fallback ids).
const decoder = new StringDecoder('utf8')
let pending = ''
let consumedBytes = 0
for await (const chunk of stream) {
pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
pending += typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk))
let newlineIndex = pending.indexOf('\n')
while (newlineIndex !== -1) {
const segment = pending.slice(0, newlineIndex + 1)
@@ -26,6 +30,7 @@ export async function decodeTranscriptStream(
newlineIndex = pending.indexOf('\n')
}
}
pending += decoder.end()
if (includeTrailingLine && pending.length > 0) {
decodeLine(pending, consumedBytes)
@@ -1,15 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const mocks = vi.hoisted(() => ({
resolve: vi.fn<() => Promise<string | null>>()
resolve: vi.fn<() => Promise<string | null>>(),
stat: vi.fn(),
open: vi.fn()
}))
vi.mock('./session-file-resolver', () => ({
resolveSessionFilePath: mocks.resolve
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
stat: mocks.stat,
open: mocks.open
}))
import { readNativeChatTranscriptTail } from './transcript-tail-reader'
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, WslTranscriptFsError } from './wsl-transcript-fs-gate'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
describe('native chat transcript tail under WSL gate refusals', () => {
beforeEach(() => {
@@ -32,3 +44,69 @@ describe('native chat transcript tail under WSL gate refusals', () => {
).rejects.toThrow('resolver crashed')
})
})
describe('native chat transcript tail with stalled post-resolution UNC I/O', () => {
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl'
// A stalled task holds its gate permit until the underlying call settles, so
// each case releases its stall before the next one runs.
let releaseStall: (() => void) | undefined
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve(undefined as T)
})
}
async function tailAfterStall(): Promise<unknown> {
const pending = readNativeChatTranscriptTail({
agent: 'codex',
sessionId: 'session-id',
filePath: UNC_PATH,
limit: 10
})
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
return pending
}
beforeEach(() => {
mocks.stat.mockReset()
mocks.open.mockReset()
releaseStall = undefined
vi.useFakeTimers()
})
afterEach(async () => {
releaseStall?.()
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
})
it('reports a stalled stat as retryable without notFound', async () => {
mocks.stat.mockImplementation(stalls)
const result = await tailAfterStall()
expect(result).toEqual({ error: SLOW_MESSAGE })
expect(result).not.toHaveProperty('notFound')
})
it('reports a stalled open as retryable without notFound', async () => {
mocks.stat.mockResolvedValue({ size: 4096 })
mocks.open.mockImplementation(stalls)
const result = await tailAfterStall()
expect(result).toEqual({ error: SLOW_MESSAGE })
expect(result).not.toHaveProperty('notFound')
})
it('closes the handle without a second admission when a positional read stalls', async () => {
const close = vi.fn(async () => {})
mocks.stat.mockResolvedValue({ size: 4096 })
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close })
const result = await tailAfterStall()
expect(result).toEqual({ error: SLOW_MESSAGE })
expect(result).not.toHaveProperty('notFound')
// Fire-and-forget close: never gated, never awaited, exactly once.
await vi.advanceTimersByTimeAsync(0)
expect(close).toHaveBeenCalledTimes(1)
})
})
+38 -10
View File
@@ -1,4 +1,4 @@
import { open, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import type {
AgentType,
NativeChatMessage,
@@ -17,6 +17,12 @@ import {
nativeChatTurnLifecycleDecoderForAgent,
type NativeChatTurnLifecycleDecoder
} from './transcript-turn-lifecycle'
import {
closeTranscriptHandle,
wslGatedOpen,
wslGatedRead,
wslGatedStat
} from './wsl-transcript-fs-access'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
@@ -59,12 +65,15 @@ export async function readNativeChatTranscriptTailFile(
oversizedRecordCount?: number
}> {
signal?.throwIfAborted()
const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER)
const end = Math.min(
(await wslGatedStat(filePath, 'exact', signal)).size,
endOffset ?? Number.MAX_SAFE_INTEGER
)
signal?.throwIfAborted()
if (end === 0) {
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
}
const handle = await open(filePath, 'r')
const handle = await wslGatedOpen(filePath, 'exact', signal)
const lineParts: Buffer[] = []
let lineBytes = 0
let lineOversized = false
@@ -76,13 +85,13 @@ export async function readNativeChatTranscriptTailFile(
signal?.throwIfAborted()
const consumedTo = includeTrailingLine
? end
: await findLastCompleteLineEnd(handle, end, signal)
: await findLastCompleteLineEnd(handle, filePath, end, signal)
if (consumedTo === 0) {
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
}
const newestFirst: { message: NativeChatMessage; offset: number }[] = []
const finalByte = Buffer.allocUnsafe(1)
await handle.read(finalByte, 0, 1, consumedTo - 1)
await wslGatedRead(handle, filePath, finalByte, 0, 1, consumedTo - 1, 'exact', signal)
signal?.throwIfAborted()
ignoreNextMalformedRecord = finalByte[0] !== 0x0a
let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0)
@@ -90,7 +99,16 @@ export async function readNativeChatTranscriptTailFile(
signal?.throwIfAborted()
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
const buffer = Buffer.allocUnsafe(cursor - start)
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
const { bytesRead } = await wslGatedRead(
handle,
filePath,
buffer,
0,
buffer.length,
start,
'exact',
signal
)
signal?.throwIfAborted()
let segmentEnd = bytesRead
for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) {
@@ -126,7 +144,7 @@ export async function readNativeChatTranscriptTailFile(
...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {})
}
} finally {
await handle.close()
await closeTranscriptHandle(handle, filePath)
}
function retainPart(part: Buffer): void {
@@ -184,13 +202,14 @@ export async function readNativeChatTranscriptTailFile(
}
async function findLastCompleteLineEnd(
handle: Awaited<ReturnType<typeof open>>,
handle: FileHandle,
filePath: string,
end: number,
signal?: AbortSignal
): Promise<number> {
signal?.throwIfAborted()
const lastByte = Buffer.allocUnsafe(1)
await handle.read(lastByte, 0, 1, end - 1)
await wslGatedRead(handle, filePath, lastByte, 0, 1, end - 1, 'exact', signal)
signal?.throwIfAborted()
if (lastByte[0] === 0x0a) {
return end
@@ -200,7 +219,16 @@ async function findLastCompleteLineEnd(
signal?.throwIfAborted()
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
const buffer = Buffer.allocUnsafe(cursor - start)
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
const { bytesRead } = await wslGatedRead(
handle,
filePath,
buffer,
0,
buffer.length,
start,
'exact',
signal
)
signal?.throwIfAborted()
const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a)
if (newline !== -1) {
+41 -30
View File
@@ -1,6 +1,6 @@
import { open, stat } from 'node:fs/promises'
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import {
boundaryFingerprint,
readTranscriptFileVersion,
transcriptFileVersionChanged,
type TranscriptFileVersion
@@ -18,6 +18,8 @@ import type {
SubscribeNativeChatTranscriptArgs
} from './transcript-watch-contract'
import { createTranscriptWatchScheduler } from './transcript-watch-scheduler'
import { wslGatedStat } from './wsl-transcript-fs-access'
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
const ROTATION_RETRY_MS = 25
const MAX_ROTATION_RETRY_MS = 2_000
@@ -27,21 +29,6 @@ export function getActiveNativeChatWatcherCount(): number {
return activeWatcherCount
}
async function boundaryFingerprint(filePath: string, offset: number): Promise<string> {
if (offset <= 0) {
return ''
}
const start = Math.max(0, offset - 64)
const handle = await open(filePath, 'r')
try {
const buffer = Buffer.allocUnsafe(offset - start)
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
return buffer.subarray(0, bytesRead).toString('base64')
} finally {
await handle.close()
}
}
/**
* Install the live-tail engine on an already-resolved file path. Returns null
* when the file doesn't exist yet, so the caller falls back to resolve-polling.
@@ -51,11 +38,20 @@ async function boundaryFingerprint(filePath: string, offset: number): Promise<st
export async function installTranscriptWatcher(
filePath: string,
decode: (line: string, fallbackId: string) => NativeChatMessage | null,
args: SubscribeNativeChatTranscriptArgs
args: SubscribeNativeChatTranscriptArgs,
/** Cancels the install probe so an unsubscribe during it detaches the gate
* waiter immediately instead of at the 30s deadline. */
signal?: AbortSignal
): Promise<NativeChatTranscriptSubscription | null> {
try {
await stat(filePath)
} catch {
await wslGatedStat(filePath, 'exact', signal)
} catch (error) {
// Why: "not flushed yet" degrades to resolve-polling, but a stalled distro
// must reach the caller so it can surface a retryable message instead of
// stranding the client at `loading`.
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args
@@ -75,6 +71,11 @@ export async function installTranscriptWatcher(
// a persistently-failing retry loop can't spam the subscriber with error frames.
let initialErrorEmitted = false
let closed = false
// Why: every gated call on the drain path must detach the moment we
// unsubscribe, instead of holding a waiter until its 30s deadline, and an
// aborted signal also makes the gate refuse admission for anything the
// in-flight drain would start after teardown.
const gateAbort = new AbortController()
let reading = false
let pendingReadRequested = false
let rotationRetryCount = 0
@@ -106,7 +107,8 @@ export async function installTranscriptWatcher(
decodeLifecycle ?? undefined,
(nextLifecycle) => {
lifecycle = nextLifecycle
}
},
gateAbort.signal
)
if (!closed && (remaining.length > 0 || lifecycle)) {
onAppend(remaining, lifecycle)
@@ -114,8 +116,8 @@ export async function installTranscriptWatcher(
}
async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise<void> {
watchedBoundary = await boundaryFingerprint(filePath, state.offset)
const completedVersion = await readTranscriptFileVersion(filePath)
watchedBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal)
const completedVersion = await readTranscriptFileVersion(filePath, gateAbort.signal)
if (transcriptFileVersionChanged(completedVersion, startVersion)) {
// Why: a write racing this drain needs another pass even when the reader
// happened to reach its new EOF; timestamp-only rewrites may need replace.
@@ -135,8 +137,8 @@ export async function installTranscriptWatcher(
}
async function drainOnce(): Promise<void> {
const current = await readTranscriptFileVersion(filePath)
const currentBoundary = await boundaryFingerprint(filePath, state.offset)
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
const currentBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal)
if (closed) {
return
}
@@ -168,7 +170,8 @@ export async function installTranscriptWatcher(
decode,
false,
undefined,
decodeLifecycle
decodeLifecycle,
gateAbort.signal
)
: null
if (closed) {
@@ -196,7 +199,8 @@ export async function installTranscriptWatcher(
decode,
false,
undefined,
decodeLifecycle
decodeLifecycle,
gateAbort.signal
)
: null
if (closed) {
@@ -225,7 +229,8 @@ export async function installTranscriptWatcher(
decodeLifecycle ?? undefined,
(nextLifecycle) => {
lifecycle = nextLifecycle
}
},
gateAbort.signal
)
if (closed) {
return
@@ -253,7 +258,7 @@ export async function installTranscriptWatcher(
pendingReadRequested = false
try {
await drainOnce()
} catch {
} catch (error) {
// Why: unlink/recreate can detach fs.watch from the pathname. Keep one
// capped-backoff retry alive until a successor appears or we unsubscribe.
// A still-pending initial drain also surfaces one error snapshot so a
@@ -261,7 +266,12 @@ export async function installTranscriptWatcher(
// throwing; initialDrain stays true so a recovered read can still win.
if (!closed && initialDrain && onInitialSnapshot && !initialErrorEmitted) {
initialErrorEmitted = true
onInitialSnapshot([], false, 0, 'Transcript unavailable')
onInitialSnapshot(
[],
false,
0,
error instanceof WslTranscriptFsError ? error.message : 'Transcript unavailable'
)
}
scheduleRotationRetry()
break
@@ -277,7 +287,7 @@ export async function installTranscriptWatcher(
return
}
try {
const current = await readTranscriptFileVersion(filePath)
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
if (closed) {
return
}
@@ -317,6 +327,7 @@ export async function installTranscriptWatcher(
return
}
closed = true
gateAbort.abort(new Error('Native Chat transcript watcher unsubscribed'))
scheduler.dispose()
nativeWatcher.dispose()
activeWatcherCount--
@@ -68,7 +68,12 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => {
})
await vi.advanceTimersByTimeAsync(50)
expect(mocks.install).toHaveBeenCalledWith(ROLLOUT_UNC, expect.anything(), expect.anything())
expect(mocks.install).toHaveBeenCalledWith(
ROLLOUT_UNC,
expect.anything(),
expect.anything(),
expect.any(AbortSignal)
)
expect(mocks.install.mock.calls.every(([path]) => path !== ROLLOUT_LINUX)).toBe(true)
subscription.unsubscribe()
})
@@ -84,7 +89,12 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => {
})
await vi.advanceTimersByTimeAsync(50)
expect(mocks.install).toHaveBeenCalledWith(ROLLOUT_LINUX, expect.anything(), expect.anything())
expect(mocks.install).toHaveBeenCalledWith(
ROLLOUT_LINUX,
expect.anything(),
expect.anything(),
expect.any(AbortSignal)
)
subscription.unsubscribe()
})
})
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { NativeChatMessage } from '../../shared/native-chat-types'
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
const mocks = vi.hoisted(() => ({
resolve: vi.fn<() => Promise<string | null>>(),
stat: vi.fn(),
open: vi.fn()
}))
vi.mock('./session-file-resolver', () => ({ resolveSessionFilePath: mocks.resolve }))
vi.mock('./transcript-native-watcher', () => ({
createTranscriptNativeWatcher: () => ({
bind: () => true,
needsRebind: () => false,
invalidate: () => {},
dispose: () => {}
})
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
stat: mocks.stat,
open: mocks.open
}))
import { subscribeNativeChatTranscript } from './transcript-watch'
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
type Snapshot = [NativeChatMessage[], boolean, number, string | undefined]
let releaseStall: (() => void) | undefined
let unhandled: unknown[] = []
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve(undefined as T)
})
}
function subscribeCollecting(snapshots: Snapshot[]) {
return subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session-id',
onAppend: () => {},
onInitialSnapshot: (messages, hasMore, beforeOffset, error) => {
snapshots.push([messages, hasMore, beforeOffset, error])
},
initialLimit: 10,
resolvePollIntervalMs: 50,
reconciliationIntervalMs: 1_000
})
}
function trackUnhandled(reason: unknown): void {
unhandled.push(reason)
}
beforeEach(() => {
mocks.resolve.mockReset()
mocks.stat.mockReset()
mocks.open.mockReset()
releaseStall = undefined
unhandled = []
process.on('unhandledRejection', trackUnhandled)
vi.useFakeTimers()
})
afterEach(async () => {
releaseStall?.()
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
process.off('unhandledRejection', trackUnhandled)
})
const EMPTY_STATS = { size: 0, mtimeMs: 1, ctimeMs: 1, ino: 1, dev: 1, mtime: new Date(0) }
describe('native chat transcript subscription with a stalled install stat', () => {
it('keeps watching and emits exactly one retryable snapshot, then the real one', async () => {
mocks.resolve.mockResolvedValue(UNC_PATH)
mocks.stat.mockImplementationOnce(stalls).mockResolvedValue(EMPTY_STATS)
const snapshots: Snapshot[] = []
// Not awaited yet: the setup install is itself blocked on the stalled stat.
const subscribing = subscribeCollecting(snapshots)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
const subscription = await subscribing
expect(subscription.watching).toBe(true)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
expect(snapshots).toEqual([[[], false, 0, SLOW_MESSAGE]])
// Later poll ticks fast-fail on the still-stuck route without re-emitting.
await vi.advanceTimersByTimeAsync(500)
expect(snapshots).toHaveLength(1)
// The distro wakes: the same subscription still delivers a real snapshot.
releaseStall?.()
await vi.advanceTimersByTimeAsync(500)
expect(snapshots.length).toBeGreaterThan(1)
expect(snapshots.at(-1)?.[3]).toBeUndefined()
subscription.unsubscribe()
expect(unhandled).toEqual([])
})
it('keeps the poll loop alive on a non-gate resolve failure without emitting a frame', async () => {
// First resolve misses (transcript not flushed) so setup falls through to
// the poll loop; every later tick throws a non-gate error.
mocks.resolve
.mockResolvedValueOnce(null)
.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }))
const snapshots: Snapshot[] = []
const subscription = await subscribeCollecting(snapshots)
const attemptsAfterSetup = mocks.resolve.mock.calls.length
await vi.advanceTimersByTimeAsync(500)
expect(snapshots).toEqual([])
// Still retrying: the rethrow-free branch must not kill `void runAttempt()`.
expect(mocks.resolve.mock.calls.length).toBeGreaterThan(attemptsAfterSetup)
expect(unhandled).toEqual([])
subscription.unsubscribe()
})
it('detaches drain waiters on unsubscribe and starts no further gated work', async () => {
mocks.resolve.mockResolvedValue(UNC_PATH)
mocks.stat.mockResolvedValue({ ...EMPTY_STATS, size: 64 })
let releaseOpen: ((handle: unknown) => void) | undefined
mocks.open.mockReturnValue(
new Promise((resolve) => {
releaseOpen = resolve
})
)
const handle = {
read: vi.fn(async () => ({ bytesRead: 0, buffer: Buffer.alloc(0) })),
close: vi.fn(async () => {})
}
const subscription = await subscribeCollecting([])
await vi.advanceTimersByTimeAsync(100)
expect(mocks.open).toHaveBeenCalledTimes(1)
const statCallsAtTeardown = mocks.stat.mock.calls.length
subscription.unsubscribe()
// No timer advance: the waiter must detach on unsubscribe, not 30s later.
releaseOpen?.(handle)
await vi.advanceTimersByTimeAsync(0)
// The drain that was in flight must not admit anything more.
expect(handle.read).not.toHaveBeenCalled()
expect(mocks.stat.mock.calls.length).toBe(statCallsAtTeardown)
// Nobody is left to own the late handle, so the gate closes it.
expect(handle.close).toHaveBeenCalledTimes(1)
expect(unhandled).toEqual([])
})
it('tears down cleanly while an admission is still stalled', async () => {
mocks.resolve.mockResolvedValue(UNC_PATH)
mocks.stat.mockImplementation(stalls)
const snapshots: Snapshot[] = []
const subscribing = subscribeCollecting(snapshots)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
const subscription = await subscribing
expect(() => subscription.unsubscribe()).not.toThrow()
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
expect(unhandled).toEqual([])
})
})
+16 -4
View File
@@ -11,7 +11,7 @@ import type {
SubscribeNativeChatTranscriptArgs
} from './transcript-watch-contract'
import { nativeChatLineDecoderForAgent } from './transcript-tail-reader'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
export { readNativeChatTranscriptTail } from './transcript-tail-reader'
export { getActiveNativeChatWatcherCount } from './transcript-watch-engine'
@@ -33,7 +33,7 @@ async function attemptInstall(
if (!filePath) {
return null
}
const installed = await installTranscriptWatcher(filePath, decode, args)
const installed = await installTranscriptWatcher(filePath, decode, args, signal)
if (signal?.aborted) {
installed?.unsubscribe()
signal.throwIfAborted()
@@ -78,6 +78,9 @@ function subscribeViaResolvePoll(
// the exact-path install doesn't wait on the slower id-glob (#10326).
let hostReadableExactPath: string | null = null
let lastWslTranslateAt = 0
// Latches only once a frame was actually emitted, so a subscriber without the
// callback can't suppress it for a later one.
let gateErrorEmitted = false
const resolveController = new AbortController()
function scheduleAttempt(): void {
@@ -141,9 +144,18 @@ function subscribeViaResolvePoll(
lastFallbackResolveAt = Date.now()
result = await attemptInstall(args, decode, resolveController.signal)
}
} catch {
} catch (error) {
// Why: a transient resolve failure (EACCES/EIO during the glob) must not
// kill the poll loop with an unhandled rejection — retry like a miss.
// kill the poll loop with an unhandled rejection — retry like a miss. A
// stalled WSL distro would otherwise poll silently forever, leaving the
// client at 'loading'; emit its retryable message once and keep polling,
// so a later tick's real snapshot still replaces it. Narrowed with a bare
// instanceof, never wslTranscriptFsRefusal — that helper rethrows, and
// runAttempt is invoked as `void runAttempt()`.
if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) {
gateErrorEmitted = true
args.onInitialSnapshot([], false, 0, error.message)
}
result = null
}
if (closed) {
@@ -0,0 +1,400 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsModule from 'node:fs'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type * as GateModule from './wsl-transcript-fs-gate'
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl'
const OTHER_DISTRO_UNC_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\a.jsonl'
const LEGACY_UNC_PATH = '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl'
const WINDOWS_PATH = 'C:\\Users\\ada\\.codex\\sessions\\a.jsonl'
const POSIX_PATH = '/home/ada/.codex/sessions/a.jsonl'
const mocks = vi.hoisted(() => ({
stat: vi.fn(),
lstat: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
open: vi.fn(),
createReadStream: vi.fn(),
runTask: vi.fn()
}))
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsModule>()),
createReadStream: mocks.createReadStream
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
stat: mocks.stat,
lstat: mocks.lstat,
readdir: mocks.readdir,
readFile: mocks.readFile,
open: mocks.open
}))
vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => {
const original = await importOriginal<typeof GateModule>()
mocks.runTask.mockImplementation(original.runWslTranscriptFsTask)
return { ...original, runWslTranscriptFsTask: mocks.runTask }
})
import {
closeTranscriptHandle,
openTranscriptReadStream,
readTranscriptSlice,
wslGatedLstat,
wslGatedOpen,
wslGatedRead,
wslGatedReaddir,
wslGatedReadFile,
wslGatedStat,
WSL_TRANSCRIPT_READ_CHUNK_BYTES
} from './wsl-transcript-fs-access'
import {
resetWslTranscriptFsGateForTests,
WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS,
WslTranscriptFsError
} from './wsl-transcript-fs-gate'
function fakeHandle() {
return { read: vi.fn(), close: vi.fn(async () => {}) }
}
beforeEach(() => {
// The gate mock delegates to the real implementation, so its module state
// survives between cases — a case that leaves a task stalled would otherwise
// mark that route stuck and fast-fail every later case on it.
resetWslTranscriptFsGateForTests()
// runTask keeps the real gate implementation installed by the mock factory;
// only its call log is cleared.
mocks.runTask.mockClear()
for (const [name, mock] of Object.entries(mocks)) {
if (name !== 'runTask') {
mock.mockReset()
}
}
})
describe('transcript filesystem accessor off WSL UNC', () => {
it.each([
['a posix path', POSIX_PATH],
['a Windows drive path', WINDOWS_PATH]
])('never enters the gate for %s', async (_label, path) => {
mocks.stat.mockResolvedValue({ size: 1 })
mocks.lstat.mockResolvedValue({ size: 1 })
mocks.readdir.mockResolvedValue([])
mocks.readFile.mockResolvedValue('body')
const handle = fakeHandle()
handle.read.mockResolvedValue({ bytesRead: 0, buffer: Buffer.alloc(0) })
mocks.open.mockResolvedValue(handle)
mocks.createReadStream.mockReturnValue('raw-stream')
await wslGatedStat(path, 'exact')
await wslGatedLstat(path, 'scan')
await wslGatedReaddir(path, 'scan')
await wslGatedReadFile(path, 'utf-8', 'scan')
const opened = await wslGatedOpen(path, 'exact')
await wslGatedRead(opened, path, Buffer.alloc(1), 0, 1, 0, 'exact')
const stream = openTranscriptReadStream(path, { encoding: 'utf-8' }, 'scan')
expect(mocks.runTask).not.toHaveBeenCalled()
expect(mocks.stat).toHaveBeenCalledWith(path)
expect(mocks.readdir).toHaveBeenCalledWith(path, { withFileTypes: true })
expect(mocks.readFile).toHaveBeenCalledWith(path, 'utf-8')
expect(mocks.open).toHaveBeenCalledWith(path, 'r')
// Off UNC the raw stream is handed back verbatim, encoding included.
expect(stream).toBe('raw-stream')
expect(mocks.createReadStream).toHaveBeenCalledWith(path, {
encoding: 'utf-8',
signal: undefined
})
})
it('forwards the caller signal so the local stream honours cancellation', () => {
mocks.createReadStream.mockReturnValue('raw-stream')
const controller = new AbortController()
openTranscriptReadStream(POSIX_PATH, { start: 4 }, 'exact', controller.signal)
expect(mocks.createReadStream).toHaveBeenCalledWith(POSIX_PATH, {
start: 4,
signal: controller.signal
})
})
})
describe('transcript filesystem accessor on WSL UNC', () => {
it.each([
['wsl.localhost', UNC_PATH],
['wsl$', LEGACY_UNC_PATH]
])('routes %s paths through the gate', async (_label, path) => {
mocks.stat.mockResolvedValue({ size: 7 })
mocks.lstat.mockResolvedValue({ size: 7 })
mocks.readdir.mockResolvedValue([])
mocks.readFile.mockResolvedValue('body')
await wslGatedStat(path, 'exact')
await wslGatedLstat(path, 'scan')
await wslGatedReaddir(path, 'scan')
await wslGatedReadFile(path, 'utf-8', 'scan')
expect(mocks.runTask.mock.calls.map(([options]) => options.operation)).toEqual([
'stat',
'lstat',
'readdir',
'readfile'
])
})
it('opts positional reads and opens out of coalescing', async () => {
const handle = fakeHandle()
handle.read.mockResolvedValue({ bytesRead: 1, buffer: Buffer.alloc(1) })
mocks.open.mockResolvedValue(handle)
const opened = await wslGatedOpen(UNC_PATH, 'exact')
await wslGatedRead(opened, UNC_PATH, Buffer.alloc(1), 0, 1, 0, 'exact')
for (const call of mocks.runTask.mock.calls) {
expect(call[0]).toMatchObject({ dedupe: false })
}
})
it('reads a slice and closes the handle even when the read rejects', async () => {
const handle = fakeHandle()
handle.read.mockRejectedValue(new Error('EIO'))
mocks.open.mockResolvedValue(handle)
await expect(readTranscriptSlice(UNC_PATH, 4, 8, 'scan')).rejects.toThrow('EIO')
expect(handle.close).toHaveBeenCalledTimes(1)
})
it('yields Buffer chunks and closes the handle when the consumer destroys the stream', async () => {
const handle = fakeHandle()
handle.read.mockImplementation(
async (buffer: Buffer, offset: number, length: number, position: number) => {
const body = Buffer.from('{"a":1}\n{"b":2}\n')
const slice = body.subarray(position, Math.min(position + length, body.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}
)
mocks.open.mockResolvedValue(handle)
const stream = openTranscriptReadStream(UNC_PATH, {}, 'exact')
const chunks: unknown[] = []
for await (const chunk of stream) {
chunks.push(chunk)
break
}
stream.destroy()
await new Promise((resolve) => setImmediate(resolve))
expect(chunks).toHaveLength(1)
expect(Buffer.isBuffer(chunks[0])).toBe(true)
expect((chunks[0] as Buffer).toString('utf-8')).toBe('{"a":1}\n{"b":2}\n')
expect(handle.close).toHaveBeenCalledTimes(1)
})
it('swallows close failures so teardown never rejects', async () => {
const handle = fakeHandle()
handle.close.mockRejectedValue(new Error('stalled close'))
await expect(closeTranscriptHandle(handle as never, UNC_PATH)).resolves.toBeUndefined()
await new Promise((resolve) => setImmediate(resolve))
})
it('drains handle closes one at a time so teardown cannot flood the thread pool', async () => {
let releaseFirst: (() => void) | undefined
const first = fakeHandle()
first.close.mockReturnValue(
new Promise<void>((resolve) => {
releaseFirst = resolve
})
)
const second = fakeHandle()
await closeTranscriptHandle(first as never, UNC_PATH)
await closeTranscriptHandle(second as never, UNC_PATH)
await new Promise((resolve) => setImmediate(resolve))
// A blocked uv_fs_close holds a libuv thread the gate cannot see, so the
// second one must wait rather than occupy a thread of its own.
expect(first.close).toHaveBeenCalledTimes(1)
expect(second.close).not.toHaveBeenCalled()
releaseFirst?.()
await new Promise((resolve) => setImmediate(resolve))
expect(second.close).toHaveBeenCalledTimes(1)
})
it('keeps a blocked close on one distro from stranding teardown on another', async () => {
let releaseStuck: (() => void) | undefined
const stuck = fakeHandle()
stuck.close.mockReturnValue(
new Promise<void>((resolve) => {
releaseStuck = resolve
})
)
const healthy = fakeHandle()
try {
await closeTranscriptHandle(stuck as never, UNC_PATH)
await closeTranscriptHandle(healthy as never, OTHER_DISTRO_UNC_PATH)
await new Promise((resolve) => setImmediate(resolve))
// A close that never settles on a stalled mount would hold a shared lane
// for the process lifetime, leaking every later descriptor with it.
expect(stuck.close).toHaveBeenCalledTimes(1)
expect(healthy.close).toHaveBeenCalledTimes(1)
} finally {
releaseStuck?.()
await new Promise((resolve) => setImmediate(resolve))
}
})
it.each([
['stat', wslGatedStat, mocks.stat],
['lstat', wslGatedLstat, mocks.lstat]
])(
'detaches a cancelled %s waiter without waiting out the deadline',
async (_op, gated, mock) => {
let release: (() => void) | undefined
mock.mockReturnValue(
new Promise((resolve) => {
release = () => resolve({ size: 0 })
})
)
try {
const controller = new AbortController()
const reason = new Error('unsubscribed')
const pending = gated(UNC_PATH, 'exact', controller.signal)
const settled = pending.then(
() => 'resolved',
(error: unknown) => error
)
// Let the gate admit and start the syscall, so this covers the waiter on a
// RUNNING unabortable call rather than a still-queued one.
await new Promise((resolve) => setImmediate(resolve))
expect(mock).toHaveBeenCalledTimes(1)
controller.abort(reason)
// No timer advance: the waiter must be gone the moment the caller cancels,
// not 30s later when its deadline fires.
await expect(settled).resolves.toBe(reason)
} finally {
release?.()
await new Promise((resolve) => setImmediate(resolve))
}
}
)
it('closes an abandoned open whose syscall lands after the caller gave up', async () => {
vi.useFakeTimers()
let release: ((handle: unknown) => void) | undefined
mocks.open.mockReturnValue(
new Promise((resolve) => {
release = resolve
})
)
try {
const handle = fakeHandle()
const refused = wslGatedOpen(UNC_PATH, 'exact').catch((error: unknown) => error)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
await expect(refused).resolves.toBeInstanceOf(WslTranscriptFsError)
release?.(handle)
await vi.advanceTimersByTimeAsync(0)
// Nobody received the handle, so the gate owns closing it.
expect(handle.close).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
describe('transcript handle close off WSL UNC', () => {
it('awaits teardown and surfaces the failure, as the raw close did', async () => {
const handle = fakeHandle()
let closed = false
handle.close.mockImplementation(async () => {
await new Promise((resolve) => setImmediate(resolve))
closed = true
})
await closeTranscriptHandle(handle as never, POSIX_PATH)
expect(closed).toBe(true)
handle.close.mockRejectedValue(new Error('EIO on close'))
await expect(closeTranscriptHandle(handle as never, WINDOWS_PATH)).rejects.toThrow(
'EIO on close'
)
})
})
describe('per-chunk admission', () => {
it('carries a codepoint straddling the 1 MiB chunk boundary across chunks', async () => {
const emoji = Buffer.from('😀', 'utf8')
// Two of the emoji's four bytes land in chunk 1, two in chunk 2 — derived
// from the production constant so the fixture cannot drift off the boundary.
const body = Buffer.concat([
Buffer.alloc(WSL_TRANSCRIPT_READ_CHUNK_BYTES - 2, 0x61),
emoji,
Buffer.from('tail\n', 'utf8')
])
const handle = fakeHandle()
handle.read.mockImplementation(
async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = body.subarray(position, Math.min(position + length, body.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}
)
mocks.open.mockResolvedValue(handle)
const stream = openTranscriptReadStream(UNC_PATH, { encoding: 'utf-8' }, 'exact')
let decoded = ''
for await (const chunk of stream) {
expect(typeof chunk).toBe('string')
decoded += chunk as string
}
expect(decoded).not.toContain('�')
expect(decoded).toBe(body.toString('utf8'))
expect(handle.read.mock.calls.length).toBeGreaterThan(1)
})
it('surfaces a gate refusal mid-stream as an error event', async () => {
vi.useFakeTimers()
try {
let served = 0
const handle = fakeHandle()
handle.read.mockImplementation((buffer: Buffer) => {
if (served++ === 0) {
buffer.write('x')
return Promise.resolve({ bytesRead: 1, buffer })
}
return new Promise(() => {})
})
mocks.open.mockResolvedValue(handle)
const stream = openTranscriptReadStream(UNC_PATH, {}, 'exact')
const drained = (async () => {
for await (const chunk of stream) {
void chunk
}
})()
const settled = drained.then(
() => null,
(error: unknown) => error
)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
await expect(settled).resolves.toBeInstanceOf(WslTranscriptFsError)
expect(handle.close).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,276 @@
import { createReadStream, type Dirent, type Stats } from 'node:fs'
import { lstat, open, readdir, readFile, stat, type FileHandle } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
import { isWslUncPath } from '../../shared/wsl-paths'
import { runWslTranscriptFsTask, type WslTranscriptFsTaskPriority } from './wsl-transcript-fs-gate'
import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route'
/** Never nest a gated call inside another — that deadlocks the scan slot. */
// Why: one deadline per chunk instead of one for the whole file, so a large
// healthy-but-slow transcript is not false-failed by a whole-file timeout.
export const WSL_TRANSCRIPT_READ_CHUNK_BYTES = 1024 * 1024
type Operation = Parameters<typeof runWslTranscriptFsTask>[0]['operation']
function runPathOperation<T>(
operation: Operation,
path: string,
priority: WslTranscriptFsTaskPriority,
signal: AbortSignal | undefined,
task: () => Promise<T>,
options?: { dedupe?: boolean; onAbandonedResult?: (value: T) => void }
): Promise<T> {
return isWslUncPath(path)
? runWslTranscriptFsTask({ operation, path, priority, signal, ...options }, task)
: task()
}
export function wslGatedStat(
path: string,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<Stats> {
return runPathOperation('stat', path, priority, signal, () => stat(path))
}
export function wslGatedLstat(
path: string,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<Stats> {
return runPathOperation('lstat', path, priority, signal, () => lstat(path))
}
export function wslGatedReaddir(
path: string,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<Dirent[]> {
return runPathOperation('readdir', path, priority, signal, () =>
readdir(path, { withFileTypes: true })
)
}
export function wslGatedReadFile(
path: string,
encoding: BufferEncoding,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<string> {
return runPathOperation('readfile', path, priority, signal, () => readFile(path, encoding))
}
// dedupe:false — two joiners would share one FileHandle and both close it.
export function wslGatedOpen(
path: string,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<FileHandle> {
return runPathOperation('open', path, priority, signal, () => open(path, 'r'), {
dedupe: false,
// An unabortable open can still succeed after its caller timed out or
// cancelled; without this the descriptor leaks for the process lifetime.
onAbandonedResult: (handle) => void closeTranscriptHandle(handle, path)
})
}
/**
* `path` is only the gate's route key and UNC guard — the handle carries no
* path and is never re-opened. dedupe:false because `read` fills the CALLER's
* buffer: a joiner would receive the first caller's buffer while its own
* (often `Buffer.allocUnsafe`) stays uninitialized.
*/
export function wslGatedRead(
handle: FileHandle,
path: string,
buffer: Buffer,
offset: number,
length: number,
position: number,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<{ bytesRead: number; buffer: Buffer }> {
return runPathOperation(
'read',
path,
priority,
signal,
() => handle.read(buffer, offset, length, position),
{ dedupe: false }
)
}
// A blocked `uv_fs_close` holds a libuv threadpool thread just like a blocked
// read does, but it is invisible to the gate — so UNC teardown drains one handle
// at a time. Unbounded fire-and-forget closes are what would exhaust the pool the
// gate's two permits are sized against, re-creating the process-wide stall the
// gate exists to prevent; serializing them costs at most one extra busy thread.
// The queue cannot grow without bound: opens on a stuck route already fast-fail.
// Keyed by route like the gate's own admission, because a close that blocks on a
// stalled distro never settles — a shared queue would strand every later close,
// including handles on healthy distros, for the process lifetime.
const MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE = 1
type RouteCloseQueue = { queued: FileHandle[]; active: number }
const closeQueuesByRoute = new Map<string, RouteCloseQueue>()
function drainQueuedCloses(route: string): void {
const lane = closeQueuesByRoute.get(route)
if (!lane) {
return
}
while (lane.active < MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE) {
const handle = lane.queued.shift()
if (!handle) {
if (lane.active === 0) {
closeQueuesByRoute.delete(route)
}
return
}
lane.active += 1
void handle
.close()
.catch(() => {})
.finally(() => {
lane.active -= 1
drainQueuedCloses(route)
})
}
}
/**
* Never gated. Off UNC this is the prior contract verbatim — the caller awaits
* fd teardown and a close failure surfaces. On UNC it is fire-and-forget:
* closing a handle on a stalled mount can itself block, and a gated close would
* burn a permit and a waiter deadline purely for teardown. One leaked fd until
* the OS unblocks beats a second blocked waiter.
*/
export function closeTranscriptHandle(handle: FileHandle, path: string): Promise<void> {
if (!isWslUncPath(path)) {
return handle.close()
}
const route = wslTranscriptFsRouteKey(path)
const lane = closeQueuesByRoute.get(route) ?? { queued: [], active: 0 }
closeQueuesByRoute.set(route, lane)
lane.queued.push(handle)
drainQueuedCloses(route)
return Promise.resolve()
}
/**
* Open, read one slice, close — for callers that want bytes at an offset and
* never touch the handle. Each of the two syscalls is admitted separately, so a
* stalled mount fails at whichever one it blocks on.
*/
export async function readTranscriptSlice(
path: string,
position: number,
length: number,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<Buffer> {
const handle = await wslGatedOpen(path, priority, signal)
try {
const buffer = Buffer.allocUnsafe(length)
const { bytesRead } = await wslGatedRead(
handle,
path,
buffer,
0,
length,
position,
priority,
signal
)
return buffer.subarray(0, bytesRead)
} finally {
await closeTranscriptHandle(handle, path)
}
}
export type TranscriptReadStreamOptions = {
start?: number
/** Inclusive, matching `createReadStream`. */
end?: number
/** Set it to get decoded string chunks on both branches; leave it unset and
* the UNC branch yields `Buffer`s the consumer must decode incrementally
* itself, since a chunk boundary can split a multibyte codepoint. */
encoding?: BufferEncoding
}
async function* gatedChunks(
path: string,
options: TranscriptReadStreamOptions,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): AsyncGenerator<Buffer | string> {
const handle = await wslGatedOpen(path, priority, signal)
// Why: chunk boundaries fall mid-codepoint, so decoding each slice
// independently would emit U+FFFD on both sides of any straddling character.
const decoder = options.encoding ? new StringDecoder(options.encoding) : null
try {
let position = options.start ?? 0
for (;;) {
const length =
options.end === undefined
? WSL_TRANSCRIPT_READ_CHUNK_BYTES
: Math.min(WSL_TRANSCRIPT_READ_CHUNK_BYTES, options.end - position + 1)
if (length <= 0) {
break
}
const buffer = Buffer.allocUnsafe(length)
const { bytesRead } = await wslGatedRead(
handle,
path,
buffer,
0,
length,
position,
priority,
signal
)
if (bytesRead <= 0) {
break
}
position += bytesRead
const chunk = buffer.subarray(0, bytesRead)
if (!decoder) {
yield chunk
continue
}
const decoded = decoder.write(chunk)
if (decoded) {
yield decoded
}
}
// Trailing bytes of an incomplete sequence at EOF, replacement-char'd once.
const trailing = decoder?.end()
if (trailing) {
yield trailing
}
} finally {
// Runs on `.destroy()` too (Readable.from calls the generator's return()),
// so an aborted head-read cannot leak the gated handle.
await closeTranscriptHandle(handle, path)
}
}
/**
* A read stream whose UNC branch admits and deadlines each 1 MiB chunk
* separately. A gate refusal mid-stream surfaces as an `'error'` event carrying
* the `WslTranscriptFsError`, which every existing consumer funnels into its
* `catch`.
*/
export function openTranscriptReadStream(
path: string,
options: TranscriptReadStreamOptions,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Readable {
if (!isWslUncPath(path)) {
// Node destroys the stream with an AbortError on abort, matching how the
// gated branch surfaces cancellation to the same consumers.
return createReadStream(path, { ...options, signal })
}
return Readable.from(gatedChunks(path, options, priority, signal))
}
@@ -139,6 +139,29 @@ describe('WSL transcript filesystem task scheduling', () => {
expect(duplicateTask).not.toHaveBeenCalled()
})
it('never joins an exact probe onto the same file queued as scan work', async () => {
const transcript = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\live.jsonl'
const statTask = (priority: 'exact' | 'scan', task: () => Promise<string>): Promise<string> =>
runWslTranscriptFsTask({ operation: 'stat', path: transcript, priority }, task)
// Hold the single scan slot on another distro so the same-file scan stat
// cannot run — a joiner would inherit exactly that queued position.
const blocked = deferred<string>()
const holderTask = vi.fn(() => blocked.promise)
const holder = run('\\\\wsl.localhost\\Debian\\tree', 'scan', holderTask)
await vi.waitFor(() => expect(holderTask).toHaveBeenCalledOnce())
const scanTask = vi.fn(async () => 'scan')
const scanned = statTask('scan', scanTask)
const exactTask = vi.fn(async () => 'exact')
await expect(statTask('exact', exactTask)).resolves.toBe('exact')
expect(exactTask).toHaveBeenCalledOnce()
expect(scanTask).not.toHaveBeenCalled()
blocked.resolve('holder')
await expect(Promise.all([holder, scanned])).resolves.toEqual(['holder', 'scan'])
})
it('keeps shared work needed by a remaining waiter', async () => {
const pending = deferred<string>()
const task = vi.fn(() => pending.promise)
@@ -632,3 +655,74 @@ describe('WSL transcript filesystem task scheduling', () => {
}
})
})
describe('WSL transcript filesystem task coalescing opt-out', () => {
const READ_PATH = '\\\\wsl.localhost\\Alpine\\home\\ada\\transcript.jsonl'
function gatedRead(task: (signal: AbortSignal) => Promise<Buffer>): Promise<Buffer> {
return runWslTranscriptFsTask(
{ operation: 'read', path: READ_PATH, priority: 'exact', dedupe: false },
task
)
}
it('fills each caller its own buffer for identical positional reads', async () => {
const first = Buffer.alloc(4, 0xa1)
const second = Buffer.alloc(4, 0xb2)
const bodies = [Buffer.from('AAAA'), Buffer.from('BBBB')]
const reads = Promise.all([
gatedRead(async () => {
bodies.shift()!.copy(first)
return first
}),
gatedRead(async () => {
bodies.shift()!.copy(second)
return second
})
])
await expect(reads).resolves.toEqual([first, second])
expect(first.toString()).toBe('AAAA')
expect(second.toString()).toBe('BBBB')
// Neither caller kept its sentinel nor received the other's bytes.
expect(first.equals(second)).toBe(false)
})
it('gives each open call a distinct handle', async () => {
const handles = [
{ id: 1, closed: false },
{ id: 2, closed: false }
]
let served = 0
const opened = await Promise.all(
handles.map(() =>
runWslTranscriptFsTask(
{ operation: 'open', path: READ_PATH, priority: 'exact', dedupe: false },
async () => handles[served++]
)
)
)
expect(opened[0]).not.toBe(opened[1])
opened[0].closed = true
expect(opened[1].closed).toBe(false)
})
it('still coalesces identical work when dedupe is omitted', async () => {
const stalled = deferred<string>()
const task = vi.fn(() => stalled.promise)
const path = '\\\\wsl.localhost\\Alpine\\home\\ada'
const first = run(path, 'scan', task)
await vi.waitFor(() => expect(task).toHaveBeenCalledOnce())
const joiner = run(
path,
'scan',
vi.fn(async () => 'never')
)
stalled.resolve('shared')
await expect(Promise.all([first, joiner])).resolves.toEqual(['shared', 'shared'])
expect(task).toHaveBeenCalledOnce()
})
})
+63 -27
View File
@@ -1,4 +1,4 @@
import { parseWslUncPath } from '../../shared/wsl-paths'
import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route'
const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2
export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000
@@ -6,9 +6,9 @@ export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000
// Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely.
export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64
export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64
const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
export const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
export const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable'
@@ -47,6 +47,8 @@ type ScheduledTask<T> = {
route: string
priority: WslTranscriptFsTaskPriority
operation: (signal: AbortSignal) => Promise<T>
/** Dispose a value no waiter is left to own (see settleTask). */
onAbandonedResult?: (value: T) => void
controller: AbortController
waiters: Set<TaskWaiter<T>>
state: 'queued' | 'running' | 'settled'
@@ -59,6 +61,7 @@ type ScheduledTask<T> = {
type UnknownScheduledTask = ScheduledTask<unknown>
let activeScanCount = 0
let undedupedTaskId = 0
const activeLaneKeys = new Set<string>()
const queuedTasks: UnknownScheduledTask[] = []
const inFlightTasks = new Map<string, UnknownScheduledTask>()
@@ -68,18 +71,6 @@ function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? new Error('WSL transcript filesystem task aborted')
}
// wsl$ and wsl.localhost spellings of one distro stay distinct routes on
// purpose (provider spelling can change behavior), so a stuck spelling never
// fast-fails its twin — the twin is bounded by its own waiter deadlines.
function routeKey(path: string): string {
const normalized = path.replace(/\\/g, '/')
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i)
if (match) {
return `${match[1].toLowerCase()}/${match[2].trim().toLowerCase()}`
}
return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path
}
function removeQueuedTask(task: UnknownScheduledTask): void {
const index = queuedTasks.indexOf(task)
if (index !== -1) {
@@ -159,11 +150,9 @@ function stuckBlocker(
// Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields
// before the waiter is attached, so no aborted-signal recheck is needed.
function attachWaiter<T>(
task: ScheduledTask<T>,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<T> {
// The task's own priority is every waiter's deadline: the dedupe key carries
// priority, so a joiner can only ever join a task scheduled at its own.
function attachWaiter<T>(task: ScheduledTask<T>, signal?: AbortSignal): Promise<T> {
if (task.waiters.size >= WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK) {
return Promise.reject(capacityError())
}
@@ -190,7 +179,7 @@ function attachWaiter<T>(
}
reject(error)
abandonTaskIfUnused(task as UnknownScheduledTask, error)
}, timeoutMs(priority))
}, timeoutMs(task.priority))
waiter.timeout.unref?.()
})
}
@@ -207,6 +196,10 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
activeTasks.delete(task as UnknownScheduledTask)
clearTimeout(task.stuckTimer)
clearTask(task as UnknownScheduledTask)
// Why: an unabortable syscall can still succeed after its last waiter timed
// out or cancelled. A resource-valued result (open's FileHandle) then has no
// owner left to close it, so ownership passes to the task's disposer.
const abandoned = task.waiters.size === 0
for (const waiter of task.waiters) {
removeWaiter(task, waiter)
if ('value' in result) {
@@ -216,6 +209,15 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
}
}
task.waiters.clear()
if (abandoned && 'value' in result) {
// Contained: a disposer that throws must not skip the pump below and wedge
// every queued task behind this one.
try {
task.onAbandonedResult?.(result.value)
} catch {
// Best-effort teardown; nothing left to report it to.
}
}
pumpTasks()
}
@@ -281,13 +283,39 @@ function pumpTasks(): void {
}
}
/**
* Test-only: drop every task and counter so a case that leaves a task stalled
* (holding a permit, marking its route stuck) cannot fast-fail the next one.
* Tasks are marked settled first, so a late resolution from the unabortable
* syscall they are still blocked on cannot decrement counters this just zeroed.
*/
export function resetWslTranscriptFsGateForTests(): void {
for (const task of [...activeTasks, ...queuedTasks]) {
task.state = 'settled'
clearTimeout(task.stuckTimer)
for (const waiter of task.waiters) {
removeWaiter(task, waiter)
}
}
activeTasks.clear()
queuedTasks.length = 0
inFlightTasks.clear()
activeLaneKeys.clear()
activeScanCount = 0
}
/** Bound 9P work without letting scans delay exact transcript probes. */
export function runWslTranscriptFsTask<T>(
options: {
operation: 'access' | 'readdir'
operation: 'access' | 'readdir' | 'stat' | 'lstat' | 'open' | 'read' | 'readfile'
path: string
priority: WslTranscriptFsTaskPriority
signal?: AbortSignal
/** Opt out of coalescing when the result is not the whole answer (`open`,
* positional `read`): a joiner would share the handle or the buffer. */
dedupe?: boolean
/** Release a late result no waiter is left to own (see settleTask). */
onAbandonedResult?: (value: T) => void
},
task: (signal: AbortSignal) => Promise<T>
): Promise<T> {
@@ -295,8 +323,15 @@ export function runWslTranscriptFsTask<T>(
return Promise.reject(abortReason(options.signal))
}
// Why: route spelling and Linux path case can change provider behavior, so
// only byte-identical filesystem requests are safe to share.
const key = JSON.stringify([options.operation, options.path])
// only byte-identical filesystem requests are safe to share. Priority is part
// of the key because it selects the task's lane, scan slot and deadline: an
// exact probe that joined a queued scan task would inherit scan scheduling and
// starve behind the very traffic the two priorities exist to bypass. A counter
// key never collides, so an un-deduped task simply never finds a join target.
const key =
options.dedupe === false
? `undeduped:${++undedupedTaskId}`
: JSON.stringify([options.operation, options.path, options.priority])
const existing = inFlightTasks.get(key) as ScheduledTask<T> | undefined
if (existing) {
// A stuck target — or a queued target that stuck I/O keeps from ever
@@ -308,9 +343,9 @@ export function runWslTranscriptFsTask<T>(
) {
return Promise.reject(unavailableError())
}
return attachWaiter(existing, options.priority, options.signal)
return attachWaiter(existing, options.signal)
}
const route = routeKey(options.path)
const route = wslTranscriptFsRouteKey(options.path)
// Fail fast when the permit, route, or scan slot this task needs is held by
// stuck I/O — queueing would only burn the caller's full deadline.
if (stuckBlocker(route, options.priority)) {
@@ -326,13 +361,14 @@ export function runWslTranscriptFsTask<T>(
route,
priority: options.priority,
operation: task,
onAbandonedResult: options.onAbandonedResult,
controller: new AbortController(),
waiters: new Set(),
state: 'queued',
stuck: false
}
inFlightTasks.set(key, scheduled as UnknownScheduledTask)
const result = attachWaiter(scheduled, options.priority, options.signal)
const result = attachWaiter(scheduled, options.signal)
queuedTasks.push(scheduled as UnknownScheduledTask)
queueMicrotask(pumpTasks)
return result
@@ -0,0 +1,19 @@
import { parseWslUncPath } from '../../shared/wsl-paths'
/**
* The isolation unit for WSL transcript filesystem work: one stalled distro
* must not fast-fail or strand work on any other. Shared by the gate's
* admission and by the ungated close queue so both isolate on the same key.
*
* wsl$ and wsl.localhost spellings of one distro stay distinct routes on
* purpose (provider spelling can change behavior), so a stuck spelling never
* fast-fails its twin — the twin is bounded by its own waiter deadlines.
*/
export function wslTranscriptFsRouteKey(path: string): string {
const normalized = path.replace(/\\/g, '/')
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i)
if (match) {
return `${match[1].toLowerCase()}/${match[2].trim().toLowerCase()}`
}
return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path
}
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const UNC_DATA_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode'
const UNC_DATABASE = `${UNC_DATA_DIR}\\opencode.db`
const mocks = vi.hoisted(() => ({
resolveDataDirectory: vi.fn<() => string>(),
readdir: vi.fn(),
stat: vi.fn()
}))
vi.mock('../opencode/opencode-data-directory', () => ({
resolveOpenCodeDataDirectory: mocks.resolveDataDirectory
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
readdir: mocks.readdir,
stat: mocks.stat
}))
import { listOpenCodeDatabases } from './scanner'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
// A stalled task holds the gate's single scan slot until it settles, so every
// case releases its stall before the next one runs.
let releaseStall: (() => void) | undefined
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve([] as T)
})
}
function dirent(name: string) {
return { name, isFile: () => true }
}
// The point of the gate: an ungated syscall on a stalled 9P mount never returns,
// so the call must still be pending at the deadline and settle just after it.
async function settlesOnlyAtTheScanDeadline(pending: Promise<string[]>): Promise<string[]> {
let settled = false
const tracked = pending.then((value) => {
settled = true
return value
})
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS - 1)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(2)
return await tracked
}
let originalDatabaseOverride: string | undefined
beforeEach(() => {
originalDatabaseOverride = process.env.OPENCODE_DB
delete process.env.OPENCODE_DB
mocks.resolveDataDirectory.mockReset()
mocks.readdir.mockReset()
mocks.stat.mockReset()
releaseStall = undefined
mocks.resolveDataDirectory.mockReturnValue(UNC_DATA_DIR)
vi.useFakeTimers()
})
afterEach(async () => {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
if (originalDatabaseOverride === undefined) {
delete process.env.OPENCODE_DB
} else {
process.env.OPENCODE_DB = originalDatabaseOverride
}
})
// Why this file exists: the AI Vault's primary OpenCode source reaches these
// syscalls transitively, so the import-guard that pins direct `node:fs` use in
// the transcript modules never saw them (STA-4049).
describe('OpenCode database discovery on a stalled WSL data directory', () => {
it('gates the data-directory listing instead of hanging the scan', async () => {
mocks.readdir.mockImplementation(stalls)
// Asserted because an empty list alone also matches a missing data dir; the
// AI Vault's OpenCode source turns this callback into the scan issue.
const onRefusal = vi.fn()
expect(await settlesOnlyAtTheScanDeadline(listOpenCodeDatabases(onRefusal))).toEqual([])
expect(onRefusal).toHaveBeenCalledWith(UNC_DATA_DIR, expect.any(WslTranscriptFsError))
})
it('gates an absolute UNC OPENCODE_DB probe instead of hanging the scan', async () => {
process.env.OPENCODE_DB = UNC_DATABASE
mocks.stat.mockImplementation(stalls)
const onRefusal = vi.fn()
expect(await settlesOnlyAtTheScanDeadline(listOpenCodeDatabases(onRefusal))).toEqual([])
expect(mocks.readdir).not.toHaveBeenCalled()
// Loose on the path: `isAbsolute` is host-flavoured, so a UNC override only
// stays verbatim on Windows. The refusal reaching the caller is the contract.
expect(onRefusal).toHaveBeenCalledWith(
expect.stringContaining('opencode.db'),
expect.any(WslTranscriptFsError)
)
})
it('still lists databases when the distro answers', async () => {
mocks.readdir.mockResolvedValue([dirent('opencode.db'), dirent('notes.txt')])
await expect(listOpenCodeDatabases()).resolves.toEqual([join(UNC_DATA_DIR, 'opencode.db')])
expect(mocks.readdir).toHaveBeenCalledWith(UNC_DATA_DIR, { withFileTypes: true })
})
})
+29 -6
View File
@@ -1,7 +1,9 @@
/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */
import { readdir, realpath, stat } from 'node:fs/promises'
import { realpath, stat } from 'node:fs/promises'
import { basename, isAbsolute, join, posix, win32 } from 'node:path'
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
import Database from '../sqlite/sync-database'
@@ -75,7 +77,14 @@ function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOve
}
}
export async function listOpenCodeDatabases(): Promise<string[]> {
// Why gated: the AI Vault's primary OpenCode source delegates here from inside
// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled
// distro would otherwise hang the whole scan on a raw syscall (STA-4049).
export async function listOpenCodeDatabases(
/** Lets a caller report the refusal; an empty list otherwise reads as
* "OpenCode not used" rather than "we could not look". */
onRefusal?: (path: string, error: WslTranscriptFsError) => void
): Promise<string[]> {
const dataDirectory = resolveOpenCodeDataDirectory()
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
if (databaseOverride.isConfigured) {
@@ -83,23 +92,37 @@ export async function listOpenCodeDatabases(): Promise<string[]> {
return []
}
try {
return (await stat(databaseOverride.path)).isFile() ? [databaseOverride.path] : []
} catch {
return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
? [databaseOverride.path]
: []
} catch (error) {
reportRefusal(databaseOverride.path, error, onRefusal)
return []
}
}
try {
const entries = await readdir(dataDirectory, { withFileTypes: true })
const entries = await wslGatedReaddir(dataDirectory, 'scan')
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDirectory, entry.name))
.sort()
} catch {
} catch (error) {
reportRefusal(dataDirectory, error, onRefusal)
return []
}
}
function reportRefusal(
path: string,
error: unknown,
onRefusal?: (path: string, error: WslTranscriptFsError) => void
): void {
if (error instanceof WslTranscriptFsError) {
onRefusal?.(path, error)
}
}
function compareOpenCodeClaimPriority(left: string, right: string): number {
// Why: the canonical opencode.db is the live database; it must claim
// duplicated sessions ahead of stale sibling copies. Remaining ties use
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
createDefaultLocalOrcaProfile,
DEFAULT_LOCAL_ORCA_PROFILE_ID,
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
@@ -154,6 +155,27 @@ describe('profile index store', () => {
expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile')
})
const posixIt = process.platform === 'win32' ? it.skip : it
posixIt('writes a fresh profile index when umask removes owner-write permission', async () => {
const store = await loadProfileIndexStore()
const indexPath = store.getOrcaProfileIndexPath()
const profile = createDefaultLocalOrcaProfile(1)
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: profile.id,
profiles: [profile]
}
const originalUmask = process.umask(0o200)
try {
expect(() => store.writeProfileIndex(indexPath, index)).not.toThrow()
} finally {
process.umask(originalUmask)
}
expect(readJson(indexPath)).toEqual(index)
})
it('recovers a corrupted profile index from the backup copy', async () => {
const store = await loadProfileIndexStore()
store.ensureActiveOrcaProfile()
+501
View File
@@ -36,6 +36,7 @@ import {
ONBOARDING_FLOW_VERSION
} from '../shared/constants'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope'
import { folderWorkspaceToWorktree } from '../shared/folder-workspace-worktree'
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host'
import { SshConnectionStore } from './ssh/ssh-connection-store'
import { setSourceControlActionDefault } from '../shared/source-control-ai-actions'
@@ -5418,6 +5419,369 @@ describe('Store', () => {
expect(session.browserPagesByWorkspace?.['browser-workspace']).toBeUndefined()
})
// ── 8b. Folder-workspace review notes across a build rollback ──
function makeFolderNote(id: string, body: string, workspaceId = 'fw-1') {
return {
id,
worktreeId: folderWorkspaceKey(workspaceId),
filePath: 'README.md',
source: 'markdown' as const,
lineNumber: 1,
body,
createdAt: 100,
side: 'modified' as const
}
}
function folderWorkspaceRecord(overrides: Record<string, unknown> = {}) {
return {
id: 'fw-1',
projectGroupId: 'root',
name: 'Refund fix',
folderPath: '/workspace/platform',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10,
lastActivityAt: 5,
createdAt: 2,
updatedAt: 3,
...overrides
}
}
function writeFolderWorkspaceProfile(options: {
workspaces: Record<string, unknown>[]
diffCommentsMap?: unknown
connectionId?: string | null
extraTopLevel?: Record<string, unknown>
}): void {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
projectGroups: [
{
id: 'root',
name: 'Platform',
parentPath: '/workspace/platform',
parentGroupId: null,
connectionId: options.connectionId ?? null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
folderWorkspaces: options.workspaces,
...('diffCommentsMap' in options
? { folderWorkspaceDiffComments: options.diffCommentsMap }
: {}),
...options.extraTopLevel
})
}
// The previous build's normalizeFolderWorkspaces has no diffComments projection (the line this
// checkout carries at src/shared/folder-workspaces.ts:107 does not exist on v1.4.179–v1.4.181),
// and its full-state write re-serializes everything else verbatim (the `{ ...defaults, ...parsed }`
// load spread and the omit-style getDurableState()).
function simulatePreviousBuildLoadAndFlush(onDisk: PersistedState): PersistedState {
return {
...onDisk,
folderWorkspaces: onDisk.folderWorkspaces.map(
({ diffComments: _droppedByOldNormalizer, ...rest }) => rest
)
}
}
it('keeps folder-workspace review notes across a previous-build rollback and re-upgrade', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Platform',
parentPath: '/workspace/platform',
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({
projectGroupId: group.id,
name: 'Refund fix'
})
const note = makeFolderNote('note-1', 'Review this paragraph', workspace.id)
store.updateFolderWorkspace(workspace.id, { diffComments: [note] })
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.folderWorkspaceDiffComments?.[workspace.id]).toEqual([note])
expect(persisted.folderWorkspaces[0]).not.toHaveProperty('diffComments')
// The strip lives at the serialization boundary only; live records keep their notes.
expect(store.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
expect(folderWorkspaceToWorktree(store.getFolderWorkspace(workspace.id)!).diffComments).toEqual(
[note]
)
writeDataFile(simulatePreviousBuildLoadAndFlush(persisted))
const restored = await createStore()
expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
restored.flush()
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments?.[workspace.id]).toEqual([
note
])
})
it('survives repeated rollback / re-upgrade hops', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Platform',
parentPath: '/workspace/platform',
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix' })
const note = makeFolderNote('note-1', 'Review this paragraph', workspace.id)
store.updateFolderWorkspace(workspace.id, { diffComments: [note] })
store.flush()
for (let hop = 0; hop < 2; hop++) {
writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState))
const reupgraded = await createStore()
expect(reupgraded.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
reupgraded.flush()
}
})
it('keeps notes and remote provenance for an SSH folder workspace across a rollback', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Platform',
parentPath: '/workspace/platform',
connectionId: 'ssh-1',
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Remote fix' })
expect(workspace.connectionId).toBe('ssh-1')
const note = makeFolderNote('note-remote', 'Remote review', workspace.id)
store.updateFolderWorkspace(workspace.id, { diffComments: [note] })
store.flush()
writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState))
const restored = await createStore()
expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
expect(restored.getFolderWorkspace(workspace.id)?.connectionId).toBe('ssh-1')
})
it('round-trips unknown top-level state keys through load and flush', async () => {
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord({ unknownNestedField: 'from-a-newer-build' })],
extraTopLevel: { unknownTopLevelKey: { kept: 'verbatim' } }
})
const store = await createStore()
store.updateFolderWorkspace('fw-1', { comment: 'touch' })
store.flush()
const persisted = readDataFile() as PersistedState & { unknownTopLevelKey?: unknown }
expect(
persisted.unknownTopLevelKey,
'Unknown top-level keys must survive load + flush. Note preservation relies on this: converting the ' +
'`{ ...defaults, ...parsed }` load spread or the omit-style getDurableState() into an ' +
'allowlist re-opens folder-workspace note loss for the then-previous build.'
).toEqual({ kept: 'verbatim' })
expect(
persisted.folderWorkspaces[0],
'Documents existing behavior, not a guarantee: normalizeFolderWorkspaces rebuilds each ' +
'record field-by-field, so nested unknown fields are dropped. That is why notes moved ' +
'to a top-level key.'
).not.toHaveProperty('unknownNestedField')
})
it('migrates legacy inline folder-workspace notes into the top-level map', async () => {
const note = makeFolderNote('note-1', 'Legacy inline note')
writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord({ diffComments: [note] })] })
const store = await createStore()
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([note])
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.folderWorkspaceDiffComments?.['fw-1']).toEqual([note])
expect(persisted.folderWorkspaces[0]).not.toHaveProperty('diffComments')
})
it('keeps notes across a rollback when the session never edits anything', async () => {
const note = makeFolderNote('note-1', 'Legacy inline note')
writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord({ diffComments: [note] })] })
// Launch and quit on the fixed build, with no mutation: loadNeedsSave must make the
// relocation durable on its own. This is the reported P0 sequence.
const upgraded = await createStore()
upgraded.flush()
writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState))
const restored = await createStore()
expect(restored.getFolderWorkspace('fw-1')?.diffComments).toEqual([note])
})
it('keeps notes authored inline on a rolled-back #14112 build over a staler map entry', async () => {
const mapped = makeFolderNote('note-mapped', 'Note from the fixed build')
const authored = makeFolderNote('note-inline', 'Note authored while rolled back')
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord()],
diffCommentsMap: { 'fw-1': [mapped] }
})
// #14112 persists notes inline and never learned about the top-level map, so a note authored
// there lands inline while the untouched map entry goes stale. Inline is the newer write.
const rolledBack = readDataFile() as PersistedState
writeDataFile({
...rolledBack,
folderWorkspaces: rolledBack.folderWorkspaces.map((workspace) => ({
...workspace,
diffComments: [authored]
}))
})
const store = await createStore()
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([authored])
store.flush()
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
'fw-1': [authored]
})
})
it('never lets an empty or unrelated map entry delete inline notes', async () => {
const inline = makeFolderNote('note-inline', 'Inline note')
const mapped = makeFolderNote('note-mapped', 'Mapped note')
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord({ diffComments: [inline] })],
diffCommentsMap: { 'fw-1': [] }
})
expect((await createStore()).getFolderWorkspace('fw-1')?.diffComments).toEqual([inline])
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord({ diffComments: [inline] })],
diffCommentsMap: { other: [mapped] }
})
const store = await createStore()
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([inline])
store.flush()
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
'fw-1': [inline]
})
})
it('drops orphaned note entries and prunes them when workspaces are deleted', async () => {
const note = makeFolderNote('note-1', 'Kept note')
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord()],
diffCommentsMap: { 'fw-1': [note], ghost: [makeFolderNote('note-ghost', 'Orphan', 'ghost')] }
})
const store = await createStore()
store.flush()
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
'fw-1': [note]
})
const reloaded = await createStore()
reloaded.flush()
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).not.toHaveProperty(
'ghost'
)
// Delete paths carry no pruning code: the map is derived from live workspaces on every write.
expect(reloaded.removeFolderWorkspace('fw-1')).toBe(true)
reloaded.flush()
expect(readDataFile() as PersistedState).not.toHaveProperty('folderWorkspaceDiffComments')
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord()],
diffCommentsMap: { 'fw-1': [note] }
})
const groupDelete = await createStore()
expect(groupDelete.deleteProjectGroup('root')).toBe(true)
groupDelete.flush()
expect(readDataFile() as PersistedState).not.toHaveProperty('folderWorkspaceDiffComments')
})
it('writes no folderWorkspaceDiffComments key for note-free profiles', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Platform',
parentPath: '/workspace/platform',
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'No notes' })
store.flush()
expect('folderWorkspaceDiffComments' in (readDataFile() as object)).toBe(false)
store.updateFolderWorkspace(workspace.id, { diffComments: [] })
store.flush()
expect('folderWorkspaceDiffComments' in (readDataFile() as object)).toBe(false)
})
it.each([
['null root', null, undefined],
['string root', 'oops', undefined],
['array root', [], undefined],
['non-array entry value', { 'fw-1': 'oops' }, undefined],
['array of non-DiffComment members', { 'fw-1': [7] }, [7]]
])(
'tolerates a corrupt folderWorkspaceDiffComments map (%s)',
async (_label, diffCommentsMap, expected) => {
writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord()], diffCommentsMap })
const store = await createStore()
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual(expected)
}
)
it('passes note members through verbatim on load and re-write', async () => {
// Shape-only guard: the moment member filtering is added here, the fix itself becomes a new
// deletion path for user-authored prose.
const noteWithExtras = { ...makeFolderNote('note-1', 'Body'), unknownNoteField: 'kept' }
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord(), folderWorkspaceRecord({ id: 'fw-2', name: 'Second' })],
diffCommentsMap: { 'fw-1': [7], 'fw-2': [noteWithExtras] }
})
const store = await createStore()
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.folderWorkspaceDiffComments).toEqual({
'fw-1': [7],
'fw-2': [noteWithExtras]
})
})
it('derives the written note map from live workspaces, never a stale loaded copy', async () => {
const loadedNote = makeFolderNote('note-loaded', 'Loaded note')
const editedNote = makeFolderNote('note-edited', 'Edited note')
writeFolderWorkspaceProfile({
workspaces: [folderWorkspaceRecord()],
diffCommentsMap: { 'fw-1': [loadedNote] }
})
const store = await createStore()
store.updateFolderWorkspace('fw-1', { diffComments: [editedNote] })
store.flush()
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
'fw-1': [editedNote]
})
})
// ── 9. Settings: get/update ────────────────────────────────────────
it('updateSettings merges partial updates', async () => {
@@ -5659,6 +6023,50 @@ describe('Store', () => {
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
})
it('retires the persisted GitHub attribution setting without dropping unknown settings', async () => {
const settledStore = await createStore()
settledStore.flush()
const settled = readDataFile() as { settings: Record<string, unknown> }
settled.settings.enableGitHubAttribution = true
settled.settings.futureSetting = { enabled: true }
writeDataFile(settled)
vi.useFakeTimers()
try {
const store = await createStore()
expect(store.getSettings()).not.toHaveProperty('enableGitHubAttribution')
expect(store.getSettings()).toHaveProperty('futureSetting', { enabled: true })
vi.advanceTimersByTime(5_000)
await store.waitForPendingWrite()
} finally {
vi.useRealTimers()
}
const persisted = readDataFile() as { settings?: Record<string, unknown> }
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
expect(persisted.settings).toHaveProperty('futureSetting', { enabled: true })
})
it('ignores retired GitHub attribution updates and strips stale in-memory values on save', async () => {
const store = await createStore()
const listener = vi.fn()
store.onSettingsChanged(listener)
const updated = store.updateSettings({ enableGitHubAttribution: true } as never, {
notifyListeners: true
})
expect(updated).not.toHaveProperty('enableGitHubAttribution')
expect(listener).not.toHaveBeenCalled()
const settings = store.getSettings() as GlobalSettings & Record<string, unknown>
settings.enableGitHubAttribution = false
settings.futureSetting = 'kept'
store.flush()
const persisted = readDataFile() as { settings?: Record<string, unknown> }
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
expect(persisted.settings?.futureSetting).toBe('kept')
})
it('normalizes terminal cursor style before persistence and listener broadcasts', async () => {
const store = await createStore()
store.updateSettings({ terminalCursorStyle: 'underline' })
@@ -9357,6 +9765,99 @@ describe('Store', () => {
})
})
it('requires an expected split source incarnation in the owning host partition', async () => {
for (const hostId of [undefined, 'ssh:ssh-1']) {
const store = await createStore()
const sourceSession: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
}
}
}
if (hostId) {
store.setWorkspaceSession(
{
...sourceSession,
terminalPtyIncarnationsByPaneKey: {
[`tab1:${TEST_LEAF_1}`]: 'live-incarnation'
}
},
undefined
)
}
store.setWorkspaceSession(sourceSession, hostId)
expect(
store.persistPtyBinding(
{
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_2,
ptyId: 'pty-split',
expectedSourceBinding: {
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-source',
incarnationId: 'live-incarnation'
}
},
hostId
)
).toBe(false)
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.root).toEqual({
type: 'leaf',
leafId: TEST_LEAF_1
})
}
})
// Why: a session restored without an incarnation map still owns a valid source binding, so the
// split path must be able to fence on the pane alone instead of an id persistence never recorded.
it('admits a split whose source pane has no persisted incarnation', async () => {
const store = await createStore()
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
}
}
},
undefined
)
expect(
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_2,
ptyId: 'pty-split',
expectedSourceBinding: {
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-source'
}
})
).toBe(true)
})
it('rejects competing PTY and incarnation changes during reconciliation', async () => {
const store = await createStore()
const paneKey = `tab1:${TEST_LEAF_1}`
+64 -6
View File
@@ -256,6 +256,10 @@ import { normalizeUiLanguage } from '../shared/ui-language'
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
import { persistedUIValuesEqual } from '../shared/persisted-ui-equality'
import { ActiveViewPreference } from './active-view-preference'
import {
collectFolderWorkspaceDiffComments,
normalizeFolderWorkspaceDiffComments
} from './folder-workspace-diff-comments'
import {
normalizeFolderWorkspaceName,
normalizeFolderWorkspaces
@@ -651,6 +655,11 @@ type LegacyTerminalScrollbackSettings = {
terminalScrollbackBytes?: unknown
}
type RetiredGlobalSettings = {
terminalScrollbackBytes?: unknown
enableGitHubAttribution?: unknown
}
const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3
function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings {
@@ -659,12 +668,16 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal
: {}
}
function stripLegacyTerminalScrollbackBytes(
function stripRetiredGlobalSettings(
settings: Partial<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
{}) as Partial<GlobalSettings> & { terminalScrollbackBytes?: unknown }
const {
terminalScrollbackBytes: _legacyScrollbackBytes,
enableGitHubAttribution: _legacyGitHubAttribution,
...rest
} = (settings ?? {}) as Partial<GlobalSettings> & RetiredGlobalSettings
void _legacyScrollbackBytes
void _legacyGitHubAttribution
return rest
}
@@ -2849,6 +2862,7 @@ export class Store {
// profile avoids serializing the multi-MB recovery store on navigation.
this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView)
const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups()
this.hydrateFolderWorkspaceDiffComments()
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
@@ -2869,6 +2883,33 @@ export class Store {
}
}
// Why: notes live top-level on disk so an older build's field-by-field
// normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here.
private hydrateFolderWorkspaceDiffComments(): void {
const stored = this.state.folderWorkspaceDiffComments
let relocatedInline = false
for (const workspace of this.state.folderWorkspaces ?? []) {
if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) {
// Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the
// older map untouched, so inline is the last notes-aware write. Also makes the relocation
// durable even if the user never edits anything this session.
relocatedInline = true
continue
}
const comments = stored?.[workspace.id]
// Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value.
if (Array.isArray(comments) && comments.length > 0) {
workspace.diffComments = comments
}
}
if (relocatedInline) {
this.loadNeedsSave = true
}
// Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in
// state would make it a stale second source of truth that getDurableState() spreads back out.
delete this.state.folderWorkspaceDiffComments
}
private adaptFlatFolderScanProjectGroups(): boolean {
// Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes.
const groups = this.state.projectGroups ?? []
@@ -3149,6 +3190,13 @@ export class Store {
if (migratedTerminalScrollback.needsSave) {
this.loadNeedsSave = true
}
if (
parsed.settings &&
typeof parsed.settings === 'object' &&
Object.hasOwn(parsed.settings, 'enableGitHubAttribution')
) {
this.loadNeedsSave = true
}
const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault(
parsed.settings
)
@@ -3411,6 +3459,9 @@ export class Store {
parsed.folderWorkspaces,
normalizedProjectGroups
),
folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments(
parsed.folderWorkspaceDiffComments
),
worktreeLineageById: parsed.worktreeLineageById ?? {},
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
parsed.mobileClientTabSelectionsByDeviceId
@@ -3421,7 +3472,7 @@ export class Store {
settings: {
...defaults.settings,
// Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed.
...stripLegacyTerminalScrollbackBytes(parsed.settings),
...stripRetiredGlobalSettings(parsed.settings),
prBotAuthorOverrides: normalizePRBotAuthorOverrides(
parsed.settings?.prBotAuthorOverrides
),
@@ -4005,6 +4056,13 @@ export class Store {
// Why: clone before encrypting secrets so in-memory this.state stays plaintext.
const stateToSave = {
...this.getDurableState(),
// Why both keys unconditionally: the explicit keys always win over the spread, and
// JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk.
// The strip builds a new array here only; this.state records keep their notes in memory.
folderWorkspaces: (this.state.folderWorkspaces ?? []).map(
({ diffComments: _relocated, ...rest }) => rest
),
folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces),
sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({
...record,
ownerLease: encryptToSentinel(
@@ -4013,7 +4071,7 @@ export class Store {
)
})),
settings: {
...this.state.settings,
...stripRetiredGlobalSettings(this.state.settings),
opencodeSessionCookie: encryptToSentinel(
PROTECTED_SECRET_SLOT.opencodeSessionCookie,
this.state.settings.opencodeSessionCookie
@@ -5819,7 +5877,7 @@ export class Store {
updates: Partial<GlobalSettings>,
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): GlobalSettings {
const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates)
const sanitizedUpdates = stripRetiredGlobalSettings(updates)
if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) {
this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie)
}
+17 -1
View File
@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter } from 'node:path'
import type * as MacosTccLoginShell from './macos-tcc-login-shell'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
const {
existsSyncMock,
@@ -576,7 +577,9 @@ describe('LocalPtyProvider', () => {
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.NODE_ENV).toBeUndefined()
expect(spawnCall[2].env.PATH).toBe(process.env.PATH)
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(spawnCall[2].env.PATH).toBe(expectedEnv.PATH)
})
it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => {
@@ -752,6 +755,19 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.ORCA_STALE_TEST_ENV).toBeUndefined()
})
it('does not re-promote a legacy attribution path for Agent Teams', async () => {
await provider.spawn({
cols: 80,
rows: 24,
env: {
PATH: '/tmp/orca-terminal-attribution/posix:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
}
})
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/usr/bin')
})
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
const keys = [
'GIT_CONFIG_COUNT',
+3
View File
@@ -36,6 +36,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
import { SessionNotFoundError } from '../daemon/daemon-errors'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
@@ -829,6 +830,8 @@ export class LocalPtyProvider implements IPtyProvider {
finalEnv,
requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined
)
// Why: raw requested PATH promotion runs after the host-env scrub.
stripLegacyTerminalShimEnv(finalEnv, process.platform)
// Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10).
const worktreeId = args.worktreeId
@@ -62,6 +62,7 @@ import {
unregisterSshPtyProvider
} from '../ipc/pty'
import type { IPtyProvider } from './types'
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
describe('PTY provider dispatch', () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>()
@@ -147,6 +148,7 @@ describe('PTY provider dispatch', () => {
const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0]
expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual(
[
...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_BRIDGE_SESSION_ID'
@@ -0,0 +1,69 @@
const SHELL_DOLLAR = '$'
const POSIX_TOMBSTONE = String.raw`#!/usr/bin/env bash
set -u
command_name="__ORCA_COMMAND__"
wrapper_dir="$(cd -- "$(dirname -- "${SHELL_DOLLAR}{BASH_SOURCE[0]}")" && pwd)"
legacy_wrapper_dir="${SHELL_DOLLAR}{ORCA_ATTRIBUTION_SHIM_DIR:-}"
cleaned_path="${SHELL_DOLLAR}{PATH:-}"
filter_path() {
local legacy_target="$legacy_wrapper_dir"
while [[ "$legacy_target" != "/" && "$legacy_target" == */ ]]; do
legacy_target="${SHELL_DOLLAR}{legacy_target%/}"
done
local remaining="$cleaned_path"
local filtered_path=""
local separator=""
path_entry_kept=0
local entry normalized candidate has_more
while true; do
if [[ "$remaining" == *:* ]]; then
entry="${SHELL_DOLLAR}{remaining%%:*}"
remaining="${SHELL_DOLLAR}{remaining#*:}"
has_more=1
else
entry="$remaining"
has_more=0
fi
normalized="$entry"
while [[ "$normalized" != "/" && "$normalized" == */ ]]; do
normalized="${SHELL_DOLLAR}{normalized%/}"
done
candidate="${SHELL_DOLLAR}{entry:-.}"
if [[ -n "$legacy_target" && "$normalized" == "$legacy_target" ]]; then
:
elif [[ "$candidate" -ef "$wrapper_dir" ]]; then
:
else
filtered_path+="$separator$entry"
separator=":"
path_entry_kept=1
fi
[[ "$has_more" == 1 ]] || break
done
cleaned_path="$filtered_path"
}
filter_path
unset ORCA_ENABLE_GIT_ATTRIBUTION ORCA_GIT_COMMIT_TRAILER ORCA_GH_PR_FOOTER
unset ORCA_GH_ISSUE_FOOTER ORCA_ATTRIBUTION_SHIM_DIR ORCA_REAL_GIT ORCA_REAL_GH ORCA_ATTRIBUTION_BYPASS
real_command=""
if [[ "$path_entry_kept" == 1 ]]; then
real_command="$(PATH="$cleaned_path" type -P "$command_name" || true)"
fi
if [[ -n "$real_command" && "$real_command" -ef "${SHELL_DOLLAR}{BASH_SOURCE[0]}" ]]; then
real_command=""
fi
if [[ -z "$real_command" ]]; then
printf 'Orca compatibility wrapper could not locate %s on PATH.\n' "$command_name" >&2
exit 127
fi
PATH="$cleaned_path" exec "$real_command" "$@"
`
export function renderLegacyTerminalPosixTombstone(command: 'git' | 'gh'): string {
return POSIX_TOMBSTONE.replaceAll('__ORCA_COMMAND__', command)
}
+277 -58
View File
@@ -1,64 +1,209 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__resetLegacyTerminalShimRemovalForTests,
removeLegacyTerminalShimDir,
__resetLegacyTerminalShimNeutralizationForTests,
neutralizeLegacyTerminalShimDir,
stripLegacyTerminalShimEnv
} from './legacy-terminal-shim-dir'
// Why: the failure case is produced with a read-only parent dir, which Windows ignores and root bypasses.
const itOnPosix = process.platform === 'win32' ? it.skip : it
// Why: the failure case uses directory permissions, which Windows ignores and root bypasses.
const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it
describe('legacy terminal shim removal', () => {
describe('legacy terminal shim neutralization', () => {
const tempRoots: string[] = []
const makeUserDataDir = (): string => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
tempRoots.push(userData)
return userData
}
beforeEach(() => {
__resetLegacyTerminalShimRemovalForTests()
__resetLegacyTerminalShimNeutralizationForTests()
})
it('deletes the orphaned wrapper directory left by older installs', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
mkdirSync(shimDir, { recursive: true })
writeFileSync(join(shimDir, 'git'), '#!/usr/bin/env bash\n')
removeLegacyTerminalShimDir(userData)
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
expect(existsSync(userData)).toBe(true)
afterEach(() => {
vi.useRealTimers()
for (const tempRoot of tempRoots.splice(0)) {
rmSync(tempRoot, { recursive: true, force: true })
}
})
it('does not throw when nothing is left to remove', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
expect(() => removeLegacyTerminalShimDir(userData)).not.toThrow()
})
itOnPosixNonRoot('stays retryable when the removal fails, and latches once it succeeds', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
it('atomically replaces the legacy command paths with executable tombstones', () => {
const userData = makeUserDataDir()
const legacyRoot = join(userData, 'orca-terminal-attribution')
mkdirSync(join(legacyRoot, 'posix'), { recursive: true })
writeFileSync(join(legacyRoot, 'posix', 'git'), '#!/usr/bin/env bash\n')
// Why: a read-only parent makes the unlink fail the way a locked Windows wrapper does.
chmodSync(userData, 0o500)
const posixDir = join(legacyRoot, 'posix')
const win32Dir = join(legacyRoot, 'win32')
mkdirSync(posixDir, { recursive: true })
mkdirSync(win32Dir, { recursive: true })
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
writeFileSync(join(win32Dir, 'gh.cmd'), 'legacy attribution wrapper')
neutralizeLegacyTerminalShimDir(userData)
for (const path of [
join(posixDir, 'git'),
join(posixDir, 'gh'),
join(win32Dir, 'git.cmd'),
join(win32Dir, 'gh.cmd')
]) {
expect(existsSync(path)).toBe(true)
expect(readFileSync(path, 'utf8')).not.toContain('Co-authored-by: Orca')
if (process.platform !== 'win32') {
expect(statSync(path).mode & 0o111).not.toBe(0)
}
}
expect(readFileSync(join(legacyRoot, 'VERSION'), 'utf8')).toBe('7\n')
})
it('rejects stale Windows real-command paths inside the wrapper directory', () => {
const userData = makeUserDataDir()
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
neutralizeLegacyTerminalShimDir(userData)
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
expect(cmd).toContain(
'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="'
)
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
expect(powershell).toContain('$realCommand = $null')
expect(powershell.indexOf('[StringComparison]::OrdinalIgnoreCase')).toBeLessThan(
powershell.indexOf('Test-Path -LiteralPath $realCommand')
)
})
it('removes every Windows PATH occurrence of both captured wrapper directories', () => {
const userData = makeUserDataDir()
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
neutralizeLegacyTerminalShimDir(userData)
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"'
expect(cmd.indexOf(cmdCapture)).toBeLessThan(cmd.indexOf('set "ORCA_ATTRIBUTION_SHIM_DIR="'))
expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"')
expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b')
expect(cmd).toContain(
'if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\\" exit /b'
)
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
expect(powershell.indexOf('$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR')).toBeLessThan(
powershell.indexOf('Remove-Item "Env:$_"')
)
expect(powershell).toContain('$wrapperDirs = @($wrapperDir, $legacyWrapperDir)')
expect(powershell).toContain("$env:PATH = (($env:PATH -split ';') | Where-Object {")
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
})
it('does not throw when the legacy directory is absent', () => {
const userData = makeUserDataDir()
expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow()
expect(existsSync(join(userData, 'orca-terminal-attribution', 'posix', 'git'))).toBe(true)
})
itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => {
vi.useFakeTimers()
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
const gitWrapper = join(posixDir, 'git')
mkdirSync(posixDir, { recursive: true })
writeFileSync(gitWrapper, 'legacy attribution wrapper')
chmodSync(posixDir, 0o500)
try {
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(true)
neutralizeLegacyTerminalShimDir(userData)
expect(readFileSync(gitWrapper, 'utf8')).toBe('legacy attribution wrapper')
} finally {
chmodSync(userData, 0o700)
chmodSync(posixDir, 0o700)
}
// The failed attempt must not have latched the guard.
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(false)
try {
await vi.advanceTimersByTimeAsync(1_000)
expect(readFileSync(gitWrapper, 'utf8')).not.toContain('legacy attribution wrapper')
// A success latches it: a directory recreated afterwards is left alone.
mkdirSync(legacyRoot, { recursive: true })
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(true)
writeFileSync(gitWrapper, 'recreated after success')
neutralizeLegacyTerminalShimDir(userData)
expect(readFileSync(gitWrapper, 'utf8')).toBe('recreated after success')
} finally {
vi.useRealTimers()
}
})
itOnPosix('keeps a real Bash command hash working with trailing PATH separators', async () => {
const userData = makeUserDataDir()
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
const shimGit = join(shimDir, 'git')
const realBin = join(userData, 'real-bin')
const realGit = join(realBin, 'git')
mkdirSync(shimDir, { recursive: true })
mkdirSync(realBin, { recursive: true })
writeFileSync(shimGit, '#!/usr/bin/env bash\nexit 99\n', { mode: 0o755 })
writeFileSync(
realGit,
"#!/usr/bin/env bash\nprintf 'arg=<%s>\\n' \"$@\"\ncat\nprintf 'fixture stderr\\n' >&2\nexit 23\n",
{ mode: 0o755 }
)
const child = spawn('bash', ['--noprofile', '--norc'], {
cwd: shimDir,
env: {
...process.env,
PATH: `${shimDir}//::${realBin}:${process.env.PATH ?? ''}`,
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca <help@stably.ai>',
ORCA_ATTRIBUTION_SHIM_DIR: ''
},
stdio: ['pipe', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout += chunk
})
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
const ready = waitForOutput(child.stdout, '__ORCA_HASH_READY__\n')
child.stdin.write(`hash -p ${quoteBash(shimGit)} git\nprintf '__ORCA_HASH_READY__\\n'\n`)
try {
await ready
} catch (error) {
child.kill('SIGKILL')
throw error
}
neutralizeLegacyTerminalShimDir(userData)
const closed = waitForChildClose(child, 2_000)
child.stdin.end("printf 'stdin payload\\n' | git commit -m 'subject with spaces'; exit $?\n")
try {
expect(await closed).toBe(23)
} finally {
child.kill('SIGKILL')
}
expect(stdout).toContain('arg=<commit>\narg=<-m>\narg=<subject with spaces>\nstdin payload\n')
expect(stdout).not.toContain('Co-authored-by: Orca')
expect(stderr).toBe('fixture stderr\n')
})
it('drops inherited shim env and its PATH entry without touching real entries', () => {
// Why: a daemon predating the removal reseeds these from its own process.env.
const env: Record<string, string> = {
PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`,
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
@@ -73,20 +218,50 @@ describe('legacy terminal shim removal', () => {
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
expect(env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(env.ORCA_REAL_GIT).toBeUndefined()
expect(env.ORCA_REAL_GH).toBeUndefined()
expect(env.HOME).toBe('/home/u')
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' })
})
it('strips the Windows shim entry on the inherited Path spelling', () => {
it('uses the captured POSIX shim directory literally when it contains a colon', () => {
const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix'
const env: Record<string, string> = {
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32`
PATH: `/usr/local/bin:${shimDir}:/usr/bin`,
ORCA_ATTRIBUTION_SHIM_DIR: shimDir
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin' })
})
it('treats legacy Windows environment keys case-insensitively', () => {
const shimDir = 'C:\\Users\\orca;user\\orca-terminal-attribution\\win32'
const env: Record<string, string> = {
Path: `${shimDir};C:\\Windows\\System32`,
orca_attribution_shim_dir: shimDir,
Orca_Enable_Git_Attribution: '1',
orca_real_git: 'C:\\Git\\git.exe'
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env).toEqual({ Path: 'C:\\Windows\\System32' })
})
it('strips legacy entries from every Windows PATH spelling', () => {
const env: Record<string, string> = {
PATH: 'C:\\Orca\\orca-terminal-attribution\\win32',
Path: 'C:\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32'
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env.PATH).toBeUndefined()
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('matches a re-cased Windows shim path', () => {
const env: Record<string, string> = {
Path: 'C:\\Orca\\Orca-Terminal-Attribution\\Win32;C:\\Windows\\System32'
}
stripLegacyTerminalShimEnv(env, 'win32')
@@ -94,25 +269,26 @@ describe('legacy terminal shim removal', () => {
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('matches a re-cased shim entry on case-insensitive filesystems', () => {
const env: Record<string, string> = {
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\Orca-Terminal-Attribution\\win32;C:\\Windows\\System32`
}
it('preserves explicit empty PATH values', () => {
const windowsEnv: Record<string, string> = { PATH: '', Path: 'C:\\Windows' }
const posixEnv: Record<string, string> = { PATH: '' }
stripLegacyTerminalShimEnv(env, 'win32')
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
stripLegacyTerminalShimEnv(posixEnv, 'linux')
expect(env.Path).toBe('C:\\Windows\\System32')
expect(windowsEnv).toEqual({ PATH: '', Path: 'C:\\Windows' })
expect(posixEnv).toEqual({ PATH: '' })
})
it('keeps neighbouring directories that merely share the name prefix', () => {
const env: Record<string, string> = {
PATH: '/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
PATH: '/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe(
'/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
'/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
)
})
@@ -122,3 +298,46 @@ describe('legacy terminal shim removal', () => {
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
})
})
function quoteBash(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise<void> {
return new Promise((resolve, reject) => {
let output = ''
const onData = (chunk: string | Buffer): void => {
output += chunk.toString()
if (output.includes(marker)) {
cleanup()
resolve()
}
}
const onEnd = (): void => {
cleanup()
reject(new Error(`Bash exited before emitting ${marker}`))
}
const cleanup = (): void => {
stream.off('data', onData)
stream.off('end', onEnd)
}
stream.on('data', onData)
stream.on('end', onEnd)
})
}
function waitForChildClose(
child: ReturnType<typeof spawn>,
timeoutMs: number
): Promise<number | null> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`Bash did not exit within ${timeoutMs}ms`))
}, timeoutMs)
child.once('close', (exitCode) => {
clearTimeout(timeout)
resolve(exitCode)
})
})
}
+236 -38
View File
@@ -1,71 +1,269 @@
import { rmSync } from 'node:fs'
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { resolvePathEnvKey } from './windows-environment-path'
import {
LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY,
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY
} from '../../shared/legacy-terminal-attribution-env'
import { renderLegacyTerminalPosixTombstone } from './legacy-terminal-posix-tombstone'
const LEGACY_SHIM_ROOT_DIR = 'orca-terminal-attribution'
const LEGACY_SHIM_ENV_KEYS = [
const LEGACY_SHIM_VERSION = '7'
const NEUTRALIZATION_RETRY_DELAYS_MS = [1_000, 5_000, 15_000, 30_000]
export const LEGACY_TERMINAL_SHIM_ENV_KEYS = [
'ORCA_ENABLE_GIT_ATTRIBUTION',
'ORCA_GIT_COMMIT_TRAILER',
'ORCA_GH_PR_FOOTER',
'ORCA_GH_ISSUE_FOOTER',
'ORCA_ATTRIBUTION_SHIM_DIR',
'ORCA_REAL_GIT',
'ORCA_REAL_GH'
'ORCA_REAL_GH',
LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY
] as const
export const LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS = [
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY
] as const
let removed = false
const WIN32_PASSTHROUGH_WRAPPER = String.raw`@echo off
setlocal
set "orca_real=%ORCA_REAL___ORCA_UPPER_COMMAND__%"
set "orca_wrapper_dir=%~dp0"
set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"
set "orca_clean_path="
for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"
set "PATH=%orca_clean_path%"
set "ORCA_ENABLE_GIT_ATTRIBUTION="
set "ORCA_GIT_COMMIT_TRAILER="
set "ORCA_GH_PR_FOOTER="
set "ORCA_GH_ISSUE_FOOTER="
set "ORCA_ATTRIBUTION_SHIM_DIR="
set "ORCA_ATTRIBUTION_BYPASS="
set "ORCA_REAL_GIT="
set "ORCA_REAL_GH="
if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="
if defined orca_real if exist "%orca_real%" goto run
for /f "delims=" %%G in ('where.exe __ORCA_COMMAND__.exe 2^>nul') do if not defined orca_real set "orca_real=%%G"
if not defined orca_real (
echo Orca compatibility wrapper could not locate __ORCA_COMMAND__ on PATH. 1>&2
exit /b 127
)
:run
"%orca_real%" %*
exit /b %ERRORLEVEL%
/** Why: a daemon that outlives an upgrade keeps seeding these from its own process.env
* (pty-subprocess reads process.env as the authoritative base), and the wrappers they
* point at are gated only on inherited env. Deleting the scripts makes both inert. */
export function removeLegacyTerminalShimDir(userDataPath: string): void {
if (removed) {
:orca_append_path
for %%G in ("%~1") do set "orca_path_entry_dir=%%~fG\"
if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b
if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\" exit /b
if defined orca_clean_path (set "orca_clean_path=%orca_clean_path%;%~1") else set "orca_clean_path=%~1"
exit /b
`
const POWERSHELL_PASSTHROUGH_WRAPPER = String.raw`$ErrorActionPreference = 'Stop'
$commandName = '__ORCA_COMMAND__'
$realCommand = [Environment]::GetEnvironmentVariable('ORCA_REAL___ORCA_UPPER_COMMAND__')
$wrapperDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR
$wrapperDirs = @($wrapperDir, $legacyWrapperDir) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }
$env:PATH = (($env:PATH -split ';') | Where-Object {
$pathEntry = $_
$pathEntry -and -not ($wrapperDirs | Where-Object {
[string]::Equals($_, $pathEntry.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)
})
}) -join ';'
'ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_GIT_COMMIT_TRAILER', 'ORCA_GH_PR_FOOTER', 'ORCA_GH_ISSUE_FOOTER', 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_REAL_GIT', 'ORCA_REAL_GH', 'ORCA_ATTRIBUTION_BYPASS' | ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue }
if ($realCommand) {
try {
$capturedDir = Split-Path -Parent ([IO.Path]::GetFullPath($realCommand))
if ([string]::Equals($capturedDir.TrimEnd('\'), $wrapperDir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) {
$realCommand = $null
}
} catch {
$realCommand = $null
}
}
if (-not $realCommand -or -not (Test-Path -LiteralPath $realCommand)) {
$resolved = Get-Command "$commandName.exe" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
$realCommand = if ($resolved) { $resolved.Source } else { $null }
}
if (-not $realCommand) {
[Console]::Error.WriteLine("Orca compatibility wrapper could not locate $commandName on PATH.")
exit 127
}
& $realCommand @args
exit $LASTEXITCODE
`
let neutralized = false
let neutralizationRetryTimer: ReturnType<typeof setTimeout> | null = null
let neutralizationRetryAttempt = 0
export function neutralizeLegacyTerminalShimDir(userDataPath: string): void {
if (neutralized) {
return
}
try {
// Why: a surviving pre-upgrade pane can hold the wrapper open on Windows; retry like
// the other userData removals rather than forfeiting cleanup for the whole run.
rmSync(join(userDataPath, LEGACY_SHIM_ROOT_DIR), {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 50
})
removed = true
const rootDir = join(userDataPath, LEGACY_SHIM_ROOT_DIR)
writeNeutralWrappers(rootDir)
writeFileAtomically(join(rootDir, 'VERSION'), `${LEGACY_SHIM_VERSION}\n`, 0o644)
neutralized = true
clearNeutralizationRetry()
} catch {
// Best-effort: a locked file must not block startup, and the next launch retries.
// Why: Windows can keep a running .cmd open briefly after startup.
scheduleNeutralizationRetry(userDataPath)
}
}
function scheduleNeutralizationRetry(userDataPath: string): void {
if (
neutralizationRetryTimer ||
neutralizationRetryAttempt >= NEUTRALIZATION_RETRY_DELAYS_MS.length
) {
return
}
const delayMs = NEUTRALIZATION_RETRY_DELAYS_MS[neutralizationRetryAttempt]
neutralizationRetryAttempt += 1
neutralizationRetryTimer = setTimeout(() => {
neutralizationRetryTimer = null
neutralizeLegacyTerminalShimDir(userDataPath)
}, delayMs)
neutralizationRetryTimer.unref?.()
}
function clearNeutralizationRetry(): void {
if (neutralizationRetryTimer) {
clearTimeout(neutralizationRetryTimer)
neutralizationRetryTimer = null
}
neutralizationRetryAttempt = 0
}
function writeNeutralWrappers(rootDir: string): void {
const posixDir = join(rootDir, 'posix')
const win32Dir = join(rootDir, 'win32')
mkdirSync(posixDir, { recursive: true })
mkdirSync(win32Dir, { recursive: true })
for (const command of ['git', 'gh'] as const) {
const upperCommand = command.toUpperCase()
writeFileAtomically(join(posixDir, command), renderLegacyTerminalPosixTombstone(command), 0o755)
writeFileAtomically(
join(win32Dir, `${command}.cmd`),
renderWindowsWrapper(WIN32_PASSTHROUGH_WRAPPER, command, upperCommand),
0o755
)
writeFileAtomically(
join(win32Dir, `${command}-wrapper.ps1`),
renderWindowsWrapper(POWERSHELL_PASSTHROUGH_WRAPPER, command, upperCommand),
0o755
)
}
}
function renderWindowsWrapper(template: string, command: string, upperCommand: string): string {
return template
.replaceAll('__ORCA_UPPER_COMMAND__', upperCommand)
.replaceAll('__ORCA_COMMAND__', command)
}
function writeFileAtomically(filePath: string, contents: string, mode: number): void {
const temporaryPath = `${filePath}.orca-neutralizing-${process.pid}`
try {
rmSync(temporaryPath, { force: true, recursive: true })
writeFileSync(temporaryPath, contents, { encoding: 'utf8', flag: 'wx', mode })
chmodSync(temporaryPath, mode)
renameSync(temporaryPath, filePath)
} finally {
rmSync(temporaryPath, { force: true, recursive: true })
}
}
export function isLegacyTerminalShimPathEntry(entry: string): boolean {
return entry.replaceAll('\\', '/').toLowerCase().includes(`/${LEGACY_SHIM_ROOT_DIR}/`)
const normalized = entry.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
return (
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/posix`) ||
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/win32`)
)
}
export function stripLegacyTerminalShimEnv(
env: Record<string, string>,
platform: NodeJS.Platform = process.platform
): void {
for (const key of LEGACY_SHIM_ENV_KEYS) {
delete env[key]
const windows = platform === 'win32'
const legacyKeySet = new Set(
LEGACY_TERMINAL_SHIM_ENV_KEYS.map((key) => (windows ? key.toLowerCase() : key))
)
const shimDirKey = 'ORCA_ATTRIBUTION_SHIM_DIR'.toLowerCase()
const explicitShimDirs = Object.entries(env)
.filter(([key]) =>
windows ? key.toLowerCase() === shimDirKey : key === 'ORCA_ATTRIBUTION_SHIM_DIR'
)
.map(([, value]) => value)
.filter(Boolean)
for (const key of Object.keys(env)) {
if (windows ? legacyKeySet.has(key.toLowerCase()) : legacyKeySet.has(key)) {
delete env[key]
}
}
const pathKey = resolvePathEnvKey(env, platform)
const current = env[pathKey]
if (!current) {
return
const delimiter = windows ? ';' : ':'
const pathKeys = windows
? Object.keys(env).filter((key) => key.toLowerCase() === 'path')
: ['PATH']
for (const pathKey of pathKeys) {
const current = env[pathKey]
if (!current) {
continue
}
const withoutExplicitDirs = explicitShimDirs.reduce(
(pathValue, shimDir) => removeLiteralPathEntry(pathValue, shimDir, delimiter, windows),
current
)
const cleaned = withoutExplicitDirs
.split(delimiter)
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
.join(delimiter)
if (cleaned) {
env[pathKey] = cleaned
} else {
delete env[pathKey]
}
}
const delimiter = platform === 'win32' ? ';' : ':'
const cleaned = current
.split(delimiter)
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
.join(delimiter)
if (cleaned) {
env[pathKey] = cleaned
} else {
delete env[pathKey]
}
function removeLiteralPathEntry(
pathValue: string,
entry: string,
delimiter: string,
caseInsensitive: boolean
): string {
let result = pathValue
const needle = caseInsensitive ? entry.toLowerCase() : entry
let searchStart = 0
for (;;) {
const comparable = caseInsensitive ? result.toLowerCase() : result
const index = comparable.indexOf(needle, searchStart)
if (index === -1) {
return result
}
const end = index + entry.length
const startsAtBoundary = index === 0 || result[index - 1] === delimiter
const endsAtBoundary = end === result.length || result[end] === delimiter
if (!startsAtBoundary || !endsAtBoundary) {
searchStart = index + 1
continue
}
if (result[end] === delimiter) {
result = result.slice(0, index) + result.slice(end + 1)
} else if (index > 0) {
result = result.slice(0, index - 1) + result.slice(end)
} else {
result = ''
}
searchStart = Math.max(0, index - 1)
}
}
/** Test-only: the module-level once-guard would otherwise leak across cases. */
export function __resetLegacyTerminalShimRemovalForTests(): void {
removed = false
export function __resetLegacyTerminalShimNeutralizationForTests(): void {
neutralized = false
clearNeutralizationRetry()
}
+270 -5
View File
@@ -157,6 +157,7 @@ function createRuntimeFileCommands(options?: {
resolveTerminalContext?: ReturnType<typeof vi.fn>
resolveTerminalFileUriHostname?: ReturnType<typeof vi.fn>
hasRecentTerminalOutputPath?: ReturnType<typeof vi.fn>
hasRecentNativeChatOutputPath?: ReturnType<typeof vi.fn>
}) {
const store = {
getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined)
@@ -192,6 +193,9 @@ function createRuntimeFileCommands(options?: {
? { resolveTerminalFileUriHostname: options.resolveTerminalFileUriHostname }
: {}),
hasRecentTerminalOutputPath: options?.hasRecentTerminalOutputPath ?? vi.fn(() => true),
...(options?.hasRecentNativeChatOutputPath
? { hasRecentNativeChatOutputPath: options.hasRecentNativeChatOutputPath }
: {}),
resolveRuntimeGitTarget: options?.resolveRuntimeGitTarget ?? vi.fn(),
openFile: options?.openFile ?? vi.fn(),
...(options?.openDiff ? { openDiff: options.openDiff } : {})
@@ -945,11 +949,18 @@ describe('RuntimeFileCommands', () => {
)
}
function createRemoteTerminalArtifactGrantFixture(artifactPath = '/tmp/result.json') {
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
function createRemoteTerminalArtifactGrantFixture(
artifactPath = '/tmp/result.json',
nativeChat = false
) {
const { commands, store } = createRuntimeFileCommands({
path: '/repo',
...(nativeChat ? { hasRecentNativeChatOutputPath: vi.fn(() => true) } : {})
})
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
let realArtifactPath = artifactPath
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 11, mtime: 3 })
let artifactStat = { type: 'file', size: 11, mtime: 3 }
const stat = vi.fn(async () => artifactStat)
const readTerminalArtifact = vi
.fn()
.mockResolvedValue({ content: '{"ok":true}', isBinary: false })
@@ -967,10 +978,20 @@ describe('RuntimeFileCommands', () => {
writeTerminalArtifact,
moveArtifactTarget: (nextPath: string) => {
realArtifactPath = nextPath
},
replaceArtifact: () => {
artifactStat = { type: 'file', size: 12, mtime: 4 }
}
}
}
function resolveRemoteNativeChatArtifact(commands: RuntimeFileCommands, artifactPath: string) {
return commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a', null, true, {
tabId: 'tab-1',
sessionId: 'session-1'
})
}
it('resolves an absolute path inside the worktree to a relative path', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
statAsFile()
@@ -992,6 +1013,28 @@ describe('RuntimeFileCommands', () => {
})
})
it('keeps in-worktree resolution unchanged when chat provenance is present', async () => {
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands } = createRuntimeFileCommands({
path: '/repo',
hasRecentNativeChatOutputPath
})
statAsFile()
const result = await commands.resolveTerminalPath(
'id:wt-1',
'/repo/src/index.ts',
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(result).toMatchObject({ relativePath: 'src/index.ts', exists: true })
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
})
it('resolves an absolute path through a known sibling workspace', async () => {
const sibling = {
id: 'wt-2',
@@ -1367,9 +1410,13 @@ describe('RuntimeFileCommands', () => {
expect(statMock).not.toHaveBeenCalled()
})
it('does not mint an absolute terminal artifact grant without a source terminal', async () => {
it('keeps old-client behavior without native-chat provenance', async () => {
const artifactPath = await tempFile('result.json', '{}')
const { commands } = createRuntimeFileCommands({ path: '/repo' })
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands } = createRuntimeFileCommands({
path: '/repo',
hasRecentNativeChatOutputPath
})
const result = await commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a')
@@ -1380,6 +1427,139 @@ describe('RuntimeFileCommands', () => {
isDirectory: false
})
expect(result.openTarget).toBeUndefined()
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
})
it('mints an exact-path grant for an out-of-worktree path cited by native chat', async () => {
const artifactPath = await tempFile('chat-result.html', '<h1>Result</h1>')
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands } = createRuntimeFileCommands({
path: '/repo',
hasRecentNativeChatOutputPath
})
const result = await commands.resolveTerminalPath(
'id:wt-1',
artifactPath,
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith(
'wt-1',
{ tabId: 'tab-1', sessionId: 'session-1' },
artifactPath,
artifactPath
)
expect(result).toMatchObject({
worktree: 'wt-1',
relativePath: null,
absolutePath: await realpath(artifactPath),
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
provider: 'local',
absolutePath: await realpath(artifactPath),
readOnly: true
}
})
const target = absoluteFileTarget(result)
await expect(
commands.writeTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'<h1>Changed</h1>',
'client-a'
)
).rejects.toThrow('terminal_file_grant_read_only')
await expect(readFile(artifactPath, 'utf8')).resolves.toBe('<h1>Result</h1>')
})
it('binds a cited symlink alias to its canonical read-only target', async () => {
const artifactPath = await tempFile('chat-target.html', '<h1>Result</h1>')
const citedPath = join(artifactPath, '..', 'chat-citation.html')
await symlink(artifactPath, citedPath)
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands } = createRuntimeFileCommands({
path: '/repo',
hasRecentNativeChatOutputPath
})
const result = await commands.resolveTerminalPath(
'id:wt-1',
citedPath,
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith(
'wt-1',
{ tabId: 'tab-1', sessionId: 'session-1' },
citedPath,
citedPath
)
expect(result).toMatchObject({
absolutePath: await realpath(artifactPath),
exists: true,
openTarget: {
kind: 'absolute-file',
absolutePath: await realpath(artifactPath),
readOnly: true
}
})
})
it('refuses an out-of-worktree chat path without transcript provenance', async () => {
const artifactPath = await tempFile('uncited-result.html', '<h1>Secret</h1>')
const hasRecentNativeChatOutputPath = vi.fn(() => false)
const { commands } = createRuntimeFileCommands({
path: '/repo',
hasRecentNativeChatOutputPath
})
const result = await commands.resolveTerminalPath(
'id:wt-1',
artifactPath,
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(result).toMatchObject({ relativePath: null, exists: false })
expect(result.openTarget).toBeUndefined()
expect(hasRecentNativeChatOutputPath).toHaveBeenCalledTimes(1)
})
it('refuses native-chat tilde paths when the workspace runs in WSL', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands } = createRuntimeFileCommands({
path: String.raw`\\wsl.localhost\Ubuntu\work\repo`,
hasRecentNativeChatOutputPath
})
const result = await commands.resolveTerminalPath(
'id:wt-1',
'~/.ssh/config',
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(result).toMatchObject({ relativePath: null, absolutePath: null, exists: false })
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
})
it('does not mint an absolute terminal artifact grant for an unobserved path', async () => {
@@ -2213,6 +2393,69 @@ describe('RuntimeFileCommands', () => {
expect(writeTerminalArtifact).toHaveBeenCalled()
})
it('reads a remote non-temp artifact cited by native chat', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture(
artifactPath,
true
)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).resolves.toMatchObject({ content: '{"ok":true}' })
expect(readTerminalArtifact).toHaveBeenCalledWith(
artifactPath,
expect.objectContaining({ expectedRealPath: artifactPath })
)
})
it('rejects a retargeted remote native-chat artifact grant', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact, moveArtifactTarget } =
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
moveArtifactTarget('/home/me/private.json')
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).rejects.toThrow('terminal_file_grant_stale')
expect(readTerminalArtifact).not.toHaveBeenCalled()
})
it('rejects a replaced remote native-chat artifact grant', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact, replaceArtifact } =
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
replaceArtifact()
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).rejects.toThrow('terminal_file_grant_stale')
expect(readTerminalArtifact).not.toHaveBeenCalled()
})
it('rejects remote terminal artifact reads when a grant no longer resolves to the granted path', async () => {
const { commands, readTerminalArtifact, moveArtifactTarget } =
createRemoteTerminalArtifactGrantFixture()
@@ -2294,6 +2537,28 @@ describe('RuntimeFileCommands', () => {
expect(stat).not.toHaveBeenCalled()
})
it('still refuses a native-chat ~/ path on a remote worktree', async () => {
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands, store } = createRuntimeFileCommands({
path: '/repo',
hasRecentNativeChatOutputPath
})
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
const result = await commands.resolveTerminalPath(
'id:wt-1',
'~/notes.md',
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(result).toMatchObject({ relativePath: null, exists: false })
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
})
it('reports a missing remote file as not existing', async () => {
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
+123 -42
View File
@@ -45,6 +45,7 @@ import type {
RuntimeFileReadChunkResult,
RuntimeFilePreviewResult,
RuntimeFileReadResult,
RuntimeNativeChatFileContext,
RuntimeTerminalPathResolution
} from '../../shared/runtime-types'
import {
@@ -183,6 +184,8 @@ type TerminalFileGrant = {
clientId?: string
expiresAt: number
statIdentity: string | null
readOnly: boolean
provenance: 'terminal-output' | 'native-chat'
expiryTimer?: ReturnType<typeof setTimeout>
}
@@ -524,6 +527,12 @@ export type RuntimeFileCommandHost = {
pathText: string,
absolutePath: string
): boolean | Promise<boolean>
hasRecentNativeChatOutputPath?(
worktreeId: string,
context: RuntimeNativeChatFileContext,
pathText: string,
absolutePath: string
): boolean | Promise<boolean>
resolveRuntimeGitTarget(
selector: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }>
@@ -726,7 +735,8 @@ export class RuntimeFileCommands {
cwd?: string | null,
clientId?: string,
terminalHandle?: string | null,
crossWorkspace?: boolean
crossWorkspace?: boolean,
nativeChatContext?: RuntimeNativeChatFileContext | null
): Promise<RuntimeTerminalPathResolution> {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
@@ -750,9 +760,9 @@ export class RuntimeFileCommands {
isDirectory: false
}
// Why: remote home is unknown (only local os.homedir), so a tapped ~/… on a remote worktree is not-openable, not guessed.
// Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home.
const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\')
if (isTilde && connectionId) {
if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) {
return empty
}
const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText
@@ -802,6 +812,26 @@ export class RuntimeFileCommands {
}
}
if (
nativeChatContext &&
(await this.host.hasRecentNativeChatOutputPath?.(
worktree.id,
nativeChatContext,
pathText,
absolutePath
))
) {
const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId)
return await this.resolveAbsoluteFileGrant({
worktreeId: worktree.id,
artifactPath,
connectionId,
clientId,
readOnly: true,
provenance: 'native-chat'
})
}
// Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths.
if (!normalizedTerminalHandle || !terminalCwd) {
return { ...empty, relativePath, absolutePath }
@@ -831,38 +861,13 @@ export class RuntimeFileCommands {
) {
return { ...empty, relativePath, absolutePath }
}
const stats = connectionId
? await this.statRemoteTerminalPath(artifactPath, connectionId)
: await this.statLocalTerminalPath(artifactPath)
const isDirectory = stats.isDirectory()
if (!isDirectory && isTerminalArtifactHardLinked(stats)) {
return { ...empty, relativePath, absolutePath }
}
const grant = isDirectory
? null
: this.createTerminalFileGrant({
worktreeId: worktree.id,
absolutePath: artifactPath,
provider: connectionId ? 'ssh' : 'local',
connectionId,
clientId,
stats
})
return {
worktree: worktree.id,
relativePath: null,
absolutePath: artifactPath,
exists: true,
isDirectory,
openTarget: grant
? {
kind: 'absolute-file',
provider: grant.provider,
absolutePath: artifactPath,
grantId: grant.id
}
: undefined
}
return await this.resolveAbsoluteFileGrant({
worktreeId: worktree.id,
artifactPath,
rejectedAbsolutePath: absolutePath,
connectionId,
clientId
})
} catch (error) {
// Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing.
if (
@@ -909,6 +914,72 @@ export class RuntimeFileCommands {
return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath)
}
private async resolveNativeChatArtifactPath(
absolutePath: string,
connectionId?: string
): Promise<string> {
if (!connectionId) {
return canonicalPathForArtifactComparison(absolutePath)
}
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.realpath(absolutePath)
}
private async resolveAbsoluteFileGrant(args: {
worktreeId: string
artifactPath: string
rejectedAbsolutePath?: string
connectionId?: string
clientId?: string
readOnly?: boolean
provenance?: TerminalFileGrant['provenance']
}): Promise<RuntimeTerminalPathResolution> {
const stats = args.connectionId
? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId)
: await this.statLocalTerminalPath(args.artifactPath)
const isDirectory = stats.isDirectory()
if (!isDirectory && isTerminalArtifactHardLinked(stats)) {
return {
worktree: args.worktreeId,
relativePath: null,
absolutePath: args.rejectedAbsolutePath ?? args.artifactPath,
exists: false,
isDirectory: false
}
}
const grant = isDirectory
? null
: this.createTerminalFileGrant({
worktreeId: args.worktreeId,
absolutePath: args.artifactPath,
provider: args.connectionId ? 'ssh' : 'local',
connectionId: args.connectionId,
clientId: args.clientId,
readOnly: args.readOnly === true,
provenance: args.provenance ?? 'terminal-output',
stats
})
return {
worktree: args.worktreeId,
relativePath: null,
absolutePath: args.artifactPath,
exists: true,
isDirectory,
openTarget: grant
? {
kind: 'absolute-file',
provider: grant.provider,
absolutePath: args.artifactPath,
grantId: grant.id,
...(grant.readOnly ? { readOnly: true } : {})
}
: undefined
}
}
private async resolveAllowedRemoteTerminalArtifactPath(
absolutePath: string,
connectionId: string
@@ -953,6 +1024,8 @@ export class RuntimeFileCommands {
provider: 'local' | 'ssh'
connectionId?: string
clientId?: string
readOnly?: boolean
provenance: TerminalFileGrant['provenance']
stats: RuntimeFileStatLike
}): TerminalFileGrant {
assertTerminalArtifactNotHardLinked(args.stats)
@@ -964,7 +1037,9 @@ export class RuntimeFileCommands {
...(args.connectionId ? { connectionId: args.connectionId } : {}),
...(args.clientId ? { clientId: args.clientId } : {}),
expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS,
statIdentity: terminalFileStatIdentity(args.stats)
statIdentity: terminalFileStatIdentity(args.stats),
readOnly: args.readOnly === true,
provenance: args.provenance
}
this.terminalFileGrants.set(grant.id, grant)
this.scheduleTerminalFileGrantExpiry(grant)
@@ -1129,6 +1204,9 @@ export class RuntimeFileCommands {
absolutePath,
clientId
)
if (grant.readOnly) {
throw new Error('terminal_file_grant_read_only')
}
if (isMobileBinaryPath(grant.absolutePath)) {
throw new Error('binary_file')
}
@@ -1280,12 +1358,15 @@ export class RuntimeFileCommands {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const allowedPath = await this.resolveAllowedRemoteTerminalArtifactPath(
grant.absolutePath,
grant.connectionId
)
// Why: relay I/O follows symlinks, so re-canonicalize a remote temp-artifact grant after the process can mutate it.
if (allowedPath !== grant.absolutePath) {
const canonicalPath =
grant.provenance === 'native-chat'
? await provider.realpath(grant.absolutePath)
: await this.resolveAllowedRemoteTerminalArtifactPath(
grant.absolutePath,
grant.connectionId
)
// Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path.
if (canonicalPath !== grant.absolutePath) {
throw new Error('terminal_file_grant_stale')
}
return provider
@@ -69,35 +69,69 @@ function remoteSnapshot(): RuntimeMobileSessionTabsSnapshot {
}
}
function createHarness(includeSource = true) {
function createHarness(
includeSource = true,
options: {
connectionId?: string | null
deferReveal?: boolean
deferSpawn?: boolean
includePairedSnapshot?: boolean
rendererMounted?: boolean
sourceIncarnationId?: string
stopAndWaitResult?: boolean
} = {}
) {
let session = persistedSession(includeSource)
const connectionId = options.connectionId ?? null
const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local'
const requestedSessionHostIds: (string | undefined)[] = []
const repo = {
id: REPO_ID,
path: '/workspace',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
addedAt: 1,
...(connectionId ? { connectionId } : {})
}
const store = {
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getWorkspaceSession: () => session,
getWorkspaceSession: (hostId?: string) => {
requestedSessionHostIds.push(hostId)
return hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession()
},
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
persistPtyBinding: () => true
}
const spawn = vi.fn(async () => ({ id: SPLIT_PTY_ID }))
let resolveSpawn: ((result: { id: string }) => void) | undefined
const spawn = options.deferSpawn
? vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
: vi.fn(async () => ({ id: SPLIT_PTY_ID }))
const kill = vi.fn(() => true)
const revealTerminalSession = vi
.fn()
.mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
const retireRejectedPty = vi.fn()
const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true)
let resolveReveal: ((result: { tabId: string }) => void) | undefined
const revealTerminalSession = options.deferReveal
? vi.fn(
() =>
new Promise<{ tabId: string }>((resolve) => {
resolveReveal = resolve
})
)
: vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
const runtime = new OrcaRuntimeService(store as never)
Object.assign(runtime, {
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
id: WORKTREE_ID,
path: '/workspace',
connectionId: null,
connectionId,
repo,
folderWorkspace: null
}))
@@ -106,35 +140,77 @@ function createHarness(includeSource = true) {
spawn,
write: () => true,
kill,
retireRejectedPty,
...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}),
getForegroundProcess: async () => null
})
runtime.setNotifier({ revealTerminalSession } as never)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: includeSource ? [remoteSnapshot()] : []
tabs:
includeSource && options.rendererMounted
? [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Restored terminal',
activeLeafId: SOURCE_LEAF_ID,
layout: { type: 'leaf', leafId: SOURCE_LEAF_ID } as const
}
]
: [],
leaves:
includeSource && options.rendererMounted
? [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: SOURCE_LEAF_ID,
paneRuntimeId: 1,
ptyId: SOURCE_PTY_ID
}
]
: [],
mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : []
})
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, null, {
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID
leafId: SOURCE_LEAF_ID,
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
})
const internals = runtime as unknown as {
getTerminalHandleForPaneKey: (paneKey: string) => string | null
issuePtyHandle: (pty: unknown) => string
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
ptysById: Map<string, unknown>
}
const handle =
internals.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, SOURCE_LEAF_ID)) ??
internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
return {
runtime,
handle,
spawn,
kill,
retireRejectedPty,
stopAndWait,
revealTerminalSession,
getSession: () => session,
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID)
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
requestedSessionHostIds,
replaceSourceIncarnation: (incarnationId: string) =>
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
incarnationId
}),
replacePersistedSourceIncarnation: (incarnationId: string) => {
session = {
...session,
terminalPtyIncarnationsByPaneKey: {
...session.terminalPtyIncarnationsByPaneKey,
[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]: incarnationId
}
}
},
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID })
}
}
@@ -185,4 +261,118 @@ describe('remote runtime terminal split authority', () => {
expect.soft(harness.kill).not.toHaveBeenCalled()
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it.each([
{ label: 'local', connectionId: null, expectedHostId: 'local' },
{ label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' }
])(
'rejects a same-ID $label source replacement when restored persistence lacks an incarnation',
async ({ connectionId, expectedHostId }) => {
const harness = createHarness(true, {
connectionId,
deferSpawn: true,
includePairedSnapshot: false,
rendererMounted: true,
sourceIncarnationId: 'source-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
expect(
harness.getSession().terminalPtyIncarnationsByPaneKey?.[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]
).toBeUndefined()
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.objectContaining({ ptyId: SOURCE_PTY_ID })
})
)
// Why: persistence never recorded this incarnation, so sending it would make the store
// reject every split from a restored pane; the live id is fenced in-runtime instead.
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
})
)
harness.replaceSourceIncarnation('source-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.requestedSessionHostIds).toContain(expectedHostId)
}
)
it('rejects a same-ID paired-runtime source replacement recovered without an incarnation map', async () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: true,
rendererMounted: false,
sourceIncarnationId: 'remote-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
})
)
harness.replaceSourceIncarnation('remote-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it('rejects a persisted-only source incarnation change during spawn', async () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: false,
stopAndWaitResult: true
})
harness.replacePersistedSourceIncarnation('persisted-before')
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
harness.replacePersistedSourceIncarnation('persisted-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
})
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
stopAndWaitResult: false
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
expect(harness.spawn).toHaveBeenCalledWith(
expect.not.objectContaining({ expectedSourceBinding: expect.anything() })
)
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
})
})
+2
View File
@@ -14898,6 +14898,8 @@ describe('OrcaRuntimeService', () => {
const splitSpawn = spawn.mock.calls[0]?.[0] as
| { expectedSourceBinding?: { incarnationId?: string } }
| undefined
// Why: persistence never recorded an incarnation for this pane, so sending the live-only id
// would make the store's fence reject every split from a restored session.
expect(splitSpawn?.expectedSourceBinding).not.toHaveProperty('incarnationId')
runtime.syncWindowGraph(1, {
+131 -19
View File
@@ -711,6 +711,7 @@ import {
scanLocalRepoWorktreesForResolution,
type RuntimeWorktreeScanResult
} from './repo-worktree-resolution-scan'
import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint'
import {
getLocalWorktreePathAccess,
removeLocalWorktreePath,
@@ -1012,6 +1013,7 @@ import {
recentTerminalOutputIncludesPath,
recentTerminalPathCandidatesIncludePath
} from './terminal-output-path-candidates'
import { nativeChatTranscriptIncludesPath } from '../native-chat/native-chat-file-provenance'
import {
getSelectedReviewBranch,
getSelectedReviewLookupHints,
@@ -1724,6 +1726,7 @@ type RuntimePtyController = {
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
attach?(ptyId: string): Promise<boolean>
kill(ptyId: string): boolean
retireRejectedPty?(ptyId: string): void
stopAndWait?(
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
@@ -1827,6 +1830,9 @@ const BRACKETED_PASTE_END = '\x1b[201~'
const BRACKETED_PASTE_QUIET_MS = 1500
const DRAFT_PASTE_READY_TIMEOUT_MS = 8000
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
// Why: the split already failed; the caller waits on this teardown only to learn whether the
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
const SSH_PANE_RECOVERY_GRACE_MS = 30_000
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
@@ -2531,12 +2537,25 @@ type RuntimeWorktreeScanCache = {
runtimeKey: string
result: RuntimeWorktreeScanResult
expiresAt: number
/**
* Git-admin state read as of the scan's start, kept unresolved so no caller ever waits on the
* probe to receive a scan result. Resolves to `null` when the state could not be read.
*/
adminFingerprint: Promise<string | null>
/** When the Git scan behind `result` actually ran, so reconciliation can be bounded. */
scannedAt: number
}
type RuntimeWorktreeScanInFlight = {
generation: number
runtimeKey: string
promise: Promise<RuntimeWorktreeScanResult>
promise: Promise<RuntimeWorktreeScanRefresh>
}
type RuntimeWorktreeScanRefresh = {
result: RuntimeWorktreeScanResult
adminFingerprint: Promise<string | null>
scannedAt: number
}
type WorktreeLineageCandidate = {
@@ -8764,6 +8783,13 @@ export class OrcaRuntimeService {
this.resolveTerminalFileUriHostname(terminalHandle),
hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) =>
this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath),
hasRecentNativeChatOutputPath: (worktreeId, context, pathText, absolutePath) =>
nativeChatTranscriptIncludesPath({
tabs: this.getMobileSessionTabsForWorktree(worktreeId).tabs,
context,
pathText,
absolutePath
}),
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector),
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => {
if (!this.notifier?.openFile) {
@@ -27362,6 +27388,8 @@ export class OrcaRuntimeService {
if (!sourceAuthority) {
throw new Error('terminal_split_source_not_found')
}
const sourceIncarnationId =
sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId
const leafId = randomUUID()
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
const paneKey = makePaneKey(parentTabId, leafId)
@@ -27388,6 +27416,9 @@ export class OrcaRuntimeService {
tabId: parentTabId,
leafId: parsedPaneKey.leafId,
ptyId: pty.ptyId,
// Why: the store can only match its own persisted map, so a live-only id it never
// recorded would reject every split from a session restored without incarnations.
// The live id is fenced by revalidateSourceAuthority below instead.
...(sourceAuthority.persistedIncarnationId
? { incarnationId: sourceAuthority.persistedIncarnationId }
: {})
@@ -27426,17 +27457,29 @@ export class OrcaRuntimeService {
}
try {
const revalidatedAuthority = this.resolveTerminalSplitSourceAuthority(
workspace.id,
parentTabId,
parsedPaneKey.leafId,
pty.ptyId
)
if (!revalidatedAuthority || (sourceAuthority.persisted && !revalidatedAuthority.persisted)) {
throw new Error('terminal_split_source_not_found')
const revalidateSourceAuthority = (): void => {
const current = this.resolveTerminalSplitSourceAuthority(
workspace.id,
parentTabId,
parsedPaneKey.leafId,
pty.ptyId
)
if (
!current ||
(sourceAuthority.persisted && !current.persisted) ||
(sourceIncarnationId !== null &&
(current.liveIncarnationId ?? current.persistedIncarnationId) !== sourceIncarnationId)
) {
throw new Error('terminal_split_source_not_found')
}
}
revalidateSourceAuthority()
if (!sourceAuthority.persisted) {
await revealSplit()
// Why: rejecting here unmounts the pane the reveal just added only because the retire
// below always emits its exit and the tab still holds the source sibling — the renderer's
// exit handler closes non-final panes. Never close it by tabId: that drops the whole tab.
revalidateSourceAuthority()
}
if (createdPty) {
const persisted = this.persistHeadlessTerminalSplit({
@@ -27460,7 +27503,19 @@ export class OrcaRuntimeService {
}
} catch (error) {
this.setPairedRendererSessionOwnership(result.id, false)
this.ptyController.kill?.(result.id)
let stopped = false
try {
stopped =
(await this.ptyController.stopAndWait?.(result.id, {
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS
})) ?? false
} catch {
// Best-effort fallback below preserves the original split authority error.
}
if (!stopped) {
this.ptyController.kill(result.id)
}
this.ptyController.retireRejectedPty?.(result.id)
throw error
}
const committedSourceAuthority = sourceAuthority.persisted
@@ -27489,6 +27544,7 @@ export class OrcaRuntimeService {
rendererMounted: boolean
persistedWorktreeId: string | null
persistedIncarnationId: string | null
liveIncarnationId: string | null
} | null {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null
@@ -27527,7 +27583,8 @@ export class OrcaRuntimeService {
persisted: true,
rendererMounted,
persistedWorktreeId: sessionWorktreeId,
persistedIncarnationId
persistedIncarnationId,
liveIncarnationId
}
}
// Why: renderer adoption can precede graph sync; this path still requires reveal success before commit.
@@ -27549,7 +27606,8 @@ export class OrcaRuntimeService {
persisted: false,
rendererMounted,
persistedWorktreeId: null,
persistedIncarnationId: null
persistedIncarnationId: null,
liveIncarnationId
}
}
@@ -29255,26 +29313,30 @@ export class OrcaRuntimeService {
}
const inFlight = this.worktreeScanInFlight.get(repo.id)
if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) {
return inFlight.promise
return (await inFlight.promise).result
}
const promise = this.listRepoWorktreesForResolutionUncached(repo, projectRuntime)
const reusableCached =
cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null
const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached)
this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise })
try {
const result = await promise
const refresh = await promise
// Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll.
if (
(result.ok || !repo.connectionId) &&
(refresh.result.ok || !repo.connectionId) &&
generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) &&
this.worktreeScanInFlight.get(repo.id)?.promise === promise
) {
this.worktreeScanCache.set(repo.id, {
generation,
runtimeKey,
result,
expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo)
result: refresh.result,
expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo),
adminFingerprint: refresh.adminFingerprint,
scannedAt: refresh.scannedAt
})
}
return result
return refresh.result
} finally {
if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) {
this.worktreeScanInFlight.delete(repo.id)
@@ -29282,6 +29344,50 @@ export class OrcaRuntimeService {
}
}
/**
* Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap
* Git-admin fingerprint proves nothing changed since the cached scan.
*/
private async refreshRepoWorktreeScan(
repo: Repo,
projectRuntime: ProjectExecutionRuntimeResolution | undefined,
cached: RuntimeWorktreeScanCache | null
): Promise<RuntimeWorktreeScanRefresh> {
const scannedAt = Date.now()
// SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them.
const fingerprintCapable =
!repo.connectionId &&
// Why: a repo whose scan TTL already reaches the reconciliation interval can never reuse a
// fingerprint, so reading one would be pure work. Agent-scratch roots are that case today.
resolveWorktreeScanCacheTtlMs(repo) < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS &&
!getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime).wslDistro
// Why issue it before the scan: a change landing while the scan runs must not be stamped as
// already-observed, or the next probe would mask it until the reconciliation deadline.
const adminFingerprint = fingerprintCapable
? readRepoWorktreeAdminFingerprint(repo.path)
: UNFINGERPRINTED_WORKTREE_SCAN
const reusable =
fingerprintCapable &&
cached?.result.ok === true &&
scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS
? cached
: null
if (reusable) {
// Why await only here: this is the one branch whose decision needs the probe. A scan-bound
// caller must never wait on it, or every cold read pays filesystem latency it cannot use.
const [current, previous] = await Promise.all([adminFingerprint, reusable.adminFingerprint])
if (current !== null && current === previous) {
return {
result: reusable.result,
adminFingerprint: reusable.adminFingerprint,
scannedAt: reusable.scannedAt
}
}
}
const result = await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime)
return { result, adminFingerprint, scannedAt }
}
private async listRepoWorktreesForResolutionUncached(
repo: Repo,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
@@ -35606,6 +35712,12 @@ const WORKTREE_SCAN_CACHE_TTL_MS = 30_000
// fan-out was measured at ~128 git execs/min on real installs, mostly against
// these (crash-cluster diagnostics, 2026-07).
const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000
// Why: the Git-admin fingerprint reads HEAD and its ref tip exactly, but sparse-checkout pattern
// edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size
// stamp, so a real scan still runs on this interval even while the probe reports "unchanged".
export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000
/** Stand-in for a repo the local admin probe cannot or need not describe; never matches a real read. */
const UNFINGERPRINTED_WORKTREE_SCAN: Promise<string | null> = Promise.resolve(null)
const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
export function resolveWorktreeScanCacheTtlMs(repo: Pick<Repo, 'path' | 'connectionId'>): number {
@@ -0,0 +1,152 @@
// Real-binary coverage: the fingerprint's whole job is to predict what `git worktree list` would
// report, so a mocked filesystem would only prove the assumptions, not the Git layout they model.
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint'
const execFileAsync = promisify(execFile)
let scratchDir = ''
let repoPath = ''
let worktreePath = ''
async function git(args: string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
async function fingerprint(path = repoPath): Promise<string | null> {
return await readRepoWorktreeAdminFingerprint(path)
}
beforeEach(async () => {
// realpath: macOS hands out /var/... temp paths while Git records /private/var/... in `gitdir`.
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-fingerprint-')))
repoPath = join(scratchDir, 'repo')
worktreePath = join(scratchDir, 'trees', 'feature')
await mkdir(repoPath, { recursive: true })
await mkdir(join(scratchDir, 'trees'), { recursive: true })
await git(['init', '-q'], repoPath)
await git(['config', 'user.email', 'fingerprint@example.invalid'], repoPath)
await git(['config', 'user.name', 'Fingerprint'], repoPath)
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
await git(['add', '-A'], repoPath)
await git(['commit', '-qm', 'seed'], repoPath)
await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath)
})
afterEach(async () => {
await rm(scratchDir, { recursive: true, force: true })
})
describe('readRepoWorktreeAdminFingerprint', () => {
it('is stable while nothing changes', async () => {
const first = await fingerprint()
expect(first).not.toBeNull()
expect(await fingerprint()).toBe(first)
})
it('changes when a worktree is added', async () => {
const before = await fingerprint()
await git(
['worktree', 'add', '-q', join(scratchDir, 'trees', 'second'), '-b', 'second'],
repoPath
)
expect(await fingerprint()).not.toBe(before)
})
it('changes when a worktree is removed', async () => {
const before = await fingerprint()
await git(['worktree', 'remove', worktreePath], repoPath)
expect(await fingerprint()).not.toBe(before)
})
it('changes when a worktree directory is deleted outside Git', async () => {
// The admin dir is untouched by `rm -rf`, but the row's `prunable` flag flips.
const before = await fingerprint()
await rm(worktreePath, { recursive: true, force: true })
expect(await fingerprint()).not.toBe(before)
})
it('changes when a worktree is moved', async () => {
const before = await fingerprint()
await git(['worktree', 'move', worktreePath, join(scratchDir, 'trees', 'moved')], repoPath)
expect(await fingerprint()).not.toBe(before)
})
it('changes when a worktree is locked', async () => {
const before = await fingerprint()
await git(['worktree', 'lock', worktreePath], repoPath)
expect(await fingerprint()).not.toBe(before)
})
it('changes when a linked worktree switches branch', async () => {
const before = await fingerprint()
// A longer ref name keeps the difference visible even on a coarse mtime clock.
await git(['checkout', '-q', '-b', 'feature-with-a-much-longer-name'], worktreePath)
expect(await fingerprint()).not.toBe(before)
})
it('changes when a linked worktree records a commit', async () => {
// Committing rewrites refs/heads/feature and leaves HEAD alone, but moves the reported HEAD oid.
const before = await fingerprint()
await writeFile(join(worktreePath, 'work.txt'), 'work\n')
await git(['add', '-A'], worktreePath)
await git(['commit', '-qm', 'work'], worktreePath)
expect(await fingerprint()).not.toBe(before)
})
it('changes when the main worktree records a commit', async () => {
const before = await fingerprint()
await writeFile(join(repoPath, 'more.txt'), 'more\n')
await git(['add', '-A'], repoPath)
await git(['commit', '-qm', 'more'], repoPath)
expect(await fingerprint()).not.toBe(before)
})
it('still tracks a tip whose loose ref has been packed away', async () => {
await git(['pack-refs', '--all'], repoPath)
const before = await fingerprint()
await writeFile(join(worktreePath, 'packed.txt'), 'packed\n')
await git(['add', '-A'], worktreePath)
await git(['commit', '-qm', 'packed'], worktreePath)
expect(await fingerprint()).not.toBe(before)
})
it('changes when the main worktree switches branch', async () => {
const before = await fingerprint()
await git(['checkout', '-q', '-b', 'main-with-a-much-longer-name'], repoPath)
expect(await fingerprint()).not.toBe(before)
})
it('resolves the same admin state from a linked worktree path', async () => {
// A linked worktree can itself be the registered repo path; both must describe one common dir.
expect(await fingerprint(worktreePath)).toBe(await fingerprint(repoPath))
})
it('reads a bare repo through its own gitdir', async () => {
const barePath = join(scratchDir, 'bare.git')
await git(['clone', '-q', '--bare', repoPath, barePath], scratchDir)
const before = await fingerprint(barePath)
expect(before).not.toBeNull()
await git(
['worktree', 'add', '-q', join(scratchDir, 'trees', 'from-bare'), '-b', 'bare-tree'],
barePath
)
expect(await fingerprint(barePath)).not.toBe(before)
})
it('returns null for a directory that is not a git repo', async () => {
const plainPath = join(scratchDir, 'plain')
await mkdir(plainPath, { recursive: true })
expect(await fingerprint(plainPath)).toBeNull()
})
it('returns null for a missing path', async () => {
expect(await fingerprint(join(scratchDir, 'absent'))).toBeNull()
})
})

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