diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index 6516ae2e07d..19401170c0e 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -42,14 +42,14 @@ jobs: MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - + # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # AWS_REGION: us-east-1 # BEDROCK_MODEL_ID: - + # for Google Vertex AI (https://docs.pullfrog.com/vertex) # VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} # GOOGLE_CLOUD_PROJECT: my-project diff --git a/package.json b/package.json index 80d60b83506..22e17d96b67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.104-rc.3", + "version": "1.4.104-rc.6", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/main/git/git-runtime-options.ts b/src/main/git/git-runtime-options.ts index cf897a7b395..ca0155f07f6 100644 --- a/src/main/git/git-runtime-options.ts +++ b/src/main/git/git-runtime-options.ts @@ -1,10 +1,15 @@ export type GitRuntimeOptions = { wslDistro?: string + signal?: AbortSignal } export function gitOptionsForWorktree( cwd: string, options: GitRuntimeOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +): { cwd: string; wslDistro?: string; signal?: AbortSignal } { + return { + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.signal ? { signal: options.signal } : {}) + } } diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index beef7c5e6be..4f455618569 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -836,6 +836,7 @@ type GitStreamOptions = { cwd: string env?: NodeJS.ProcessEnv wslDistro?: string + signal?: AbortSignal /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ maxBuffer?: number /** @@ -863,6 +864,10 @@ export async function gitStreamStdout( const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } const child = gitSpawn(args, { cwd: options.cwd, env: nonInteractiveGitEnv(options.env), @@ -887,6 +892,7 @@ export async function gitStreamStdout( child.stderr?.off('data', onStderrData) child.off('error', onError) child.off('close', onClose) + options.signal?.removeEventListener('abort', onAbort) // Flush any bytes the decoders were holding for an incomplete sequence. stdoutDecoder.end() stderrDecoder.end() @@ -953,11 +959,19 @@ export async function gitStreamStdout( } finish(new Error(`git exited with ${code}: ${stderr}`)) } + function onAbort(): void { + killSpawnedCommandTree(child) + finish(createAbortError()) + } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) child.on('error', onError) child.on('close', onClose) + options.signal?.addEventListener('abort', onAbort, { once: true }) + if (options.signal?.aborted) { + onAbort() + } }) }) } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 63917212baa..4902a23e59e 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -109,6 +109,9 @@ export async function getStatus( options: GetStatusOptions = {} ): Promise { gitDiffReadDedupe.clear() + if (options.signal) { + return runGetStatus(worktreePath, options) + } // Why: dedupe only concurrent identical reads; after settle, callers must // execute a fresh status read rather than observing a cached result. const cacheKey = getStatusReadKey(worktreePath, options) @@ -190,6 +193,7 @@ async function runGetStatus( // Why: status polling is read-like; avoid refreshing the index and racing // terminal Git commands on `.git/worktrees/*/index.lock`. env: gitOptionalLocksDisabledEnv(), + signal: options.signal, onStdout: (chunk) => parser.update(chunk, limit) }) if (!stoppedEarly) { diff --git a/src/main/git/worktree-separate-git-dir.test.ts b/src/main/git/worktree-separate-git-dir.test.ts new file mode 100644 index 00000000000..2febdb09a76 --- /dev/null +++ b/src/main/git/worktree-separate-git-dir.test.ts @@ -0,0 +1,192 @@ +import { execFileSync } from 'child_process' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import * as path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as GitRunner from './runner' + +// Why: spy on the git runner so we can count rev-parse invocations while still +// running real git, proving the main-entry-equals-repoPath early return skips +// the extra subprocess for an ordinary repo scanned at its own root. +const revParseTopLevelCalls = { count: 0 } +vi.mock('./runner', async () => { + const actual = await vi.importActual('./runner') + return { + ...actual, + gitExecFileAsync: (args: string[], options?: unknown) => { + if (args[0] === 'rev-parse' && args.includes('--show-toplevel')) { + revParseTopLevelCalls.count += 1 + } + return (actual.gitExecFileAsync as (a: string[], o?: unknown) => Promise)( + args, + options + ) + } + } +}) + +// Imported after vi.mock so worktree.ts binds to the spied runner. +const { listWorktrees } = await import('./worktree') + +const tempRoots: string[] = [] + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) +} + +async function createCommittedRepo(root: string, name: string): Promise { + const repoPath = path.join(root, name) + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(path.join(repoPath, 'README.md'), `${name}\n`) + git(repoPath, ['add', 'README.md']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + return realpath(repoPath) +} + +async function createSeparateGitDirRepo(): Promise<{ + gitDirPath: string + worktreePath: string +}> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-separate-git-dir-')) + tempRoots.push(root) + const sourcePath = await createCommittedRepo(root, 'source') + const requestedWorktreePath = path.join(root, 'worktree') + const requestedGitDirPath = path.join(root, 'git-store', 'project.git') + await mkdir(path.dirname(requestedGitDirPath), { recursive: true }) + + execFileSync('git', [ + 'clone', + '--quiet', + `--separate-git-dir=${requestedGitDirPath}`, + sourcePath, + requestedWorktreePath + ]) + + return { + gitDirPath: await realpath(requestedGitDirPath), + worktreePath: await realpath(requestedWorktreePath) + } +} + +async function createNormalRepo(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-normal-worktree-')) + tempRoots.push(root) + return createCommittedRepo(root, 'repo') +} + +async function createBareRepo(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-bare-worktree-')) + tempRoots.push(root) + const repoPath = path.join(root, 'repo.git') + execFileSync('git', ['init', '--bare', '--quiet', repoPath]) + return realpath(repoPath) +} + +afterEach(async () => { + revParseTopLevelCalls.count = 0 + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('git worktree separate git dir paths', () => { + it.skipIf(process.platform === 'win32')( + 'normalizes the main worktree path for a separate-git-dir repo', + async () => { + const { gitDirPath, worktreePath } = await createSeparateGitDirRepo() + + const worktrees = await listWorktrees(worktreePath) + const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree) + + expect(mainWorktree).toMatchObject({ + path: worktreePath, + isMainWorktree: true + }) + expect(mainWorktree?.path).not.toBe(gitDirPath) + } + ) + + it.skipIf(process.platform === 'win32')( + 'fires exactly one rev-parse for a separate-git-dir repo', + async () => { + const { worktreePath } = await createSeparateGitDirRepo() + + await listWorktrees(worktreePath) + + expect(revParseTopLevelCalls.count).toBe(1) + } + ) + + it.skipIf(process.platform === 'win32')('leaves a normal repo main path unchanged', async () => { + const repoPath = await createNormalRepo() + + const worktrees = await listWorktrees(repoPath) + const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree) + + expect(mainWorktree).toMatchObject({ + path: repoPath, + isMainWorktree: true + }) + }) + + it.skipIf(process.platform === 'win32')( + 'leaves an ordinary repo reached via a symlinked path unchanged', + async () => { + // A symlink alias of the checkout root defeats the path-string gate + // (git reports the realpath toplevel), so the git-common-dir gate must + // still skip the rewrite since the main entry is a real working root. + const repoPath = await createNormalRepo() + const linkRoot = await mkdtemp(path.join(tmpdir(), 'orca-symlink-worktree-')) + tempRoots.push(linkRoot) + const linkedRepoPath = path.join(linkRoot, 'linked-repo') + await symlink(repoPath, linkedRepoPath) + + const worktrees = await listWorktrees(linkedRepoPath) + const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree) + + expect(mainWorktree).toMatchObject({ + path: repoPath, + isMainWorktree: true + }) + } + ) + + it.skipIf(process.platform === 'win32')( + 'leaves the main entry unchanged when scanned via a linked worktree', + async () => { + // A linked worktree has a `.git` *pointer file* just like a + // separate-git-dir checkout, but its porcelain main entry is the real + // main working root — never the Git directory. The git-common-dir gate + // must skip the rewrite so the main entry is not overwritten with the + // linked worktree's own toplevel (which would collide / drop the main). + const repoPath = await createNormalRepo() + const linkedWorktreePath = path.join(path.dirname(repoPath), 'linked') + git(repoPath, ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature']) + const resolvedLinked = await realpath(linkedWorktreePath) + + const worktrees = await listWorktrees(resolvedLinked) + const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree) + + expect(mainWorktree).toMatchObject({ + path: repoPath, + isMainWorktree: true + }) + // The main entry must not be rewritten to the linked worktree's path. + expect(mainWorktree?.path).not.toBe(resolvedLinked) + } + ) + + it.skipIf(process.platform === 'win32')('does not throw for a bare repo', async () => { + const repoPath = await createBareRepo() + + const worktrees = await listWorktrees(repoPath) + const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree) + + expect(mainWorktree).toMatchObject({ + path: repoPath, + isBare: true, + isMainWorktree: true + }) + }) +}) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index af1546396c0..d225c380b31 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -14,6 +14,7 @@ import type { RemoveWorktreeResult } from '../../shared/types' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' +import { parseWslUncPath } from '../../shared/wsl-paths' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -29,6 +30,7 @@ type SparseWorktreeCreateError = Error & { export type GitWorktreeExecOptions = { wslDistro?: string + signal?: AbortSignal } export type AddWorktreeOptions = GitWorktreeExecOptions & { @@ -69,8 +71,12 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 function gitExecOptions( cwd: string, options: GitWorktreeExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +): { cwd: string; wslDistro?: string; signal?: AbortSignal } { + return { + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.signal ? { signal: options.signal } : {}) + } } function getErrorCode(error: unknown): string | undefined { @@ -103,6 +109,12 @@ function isUnsupportedWorktreeListZError(error: unknown): boolean { ) } +function isUnsupportedRevParsePathFormatError(error: unknown): boolean { + return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test( + getErrorText(error) + ) +} + function isBranchCheckedOutInWorktreeError(error: unknown): boolean { return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( getErrorText(error) @@ -348,6 +360,103 @@ function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } +function resolveRevParsePath(repoPath: string, value: string): string { + if (posix.isAbsolute(value) || win32.isAbsolute(value)) { + return value + } + // Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir + // must be resolved against the scanned repo path. + return looksLikeWindowsPath(repoPath) + ? win32.resolve(repoPath, value) + : posix.resolve(repoPath, value) +} + +type RepoLocation = { topLevel: string; commonDir: string } + +function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined { + // Old git (pre `--path-format`) echoes the unrecognized flag to stdout and + // exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and + // read the two trailing path lines (toplevel, then git-common-dir). Strip only + // the trailing CR, not surrounding spaces — git paths may legitimately start + // or end with a space. + const lines = output + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .filter((line) => line.length > 0 && !line.startsWith('-')) + if (lines.length < 2) { + return undefined + } + const [topLevel, commonDir] = lines.slice(-2) + return { + topLevel: resolveRevParsePath(repoPath, topLevel), + commonDir: resolveRevParsePath(repoPath, commonDir) + } +} + +async function readRepoLocation( + repoPath: string, + resolveBasePath: string, + options: GitWorktreeExecOptions = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + } catch (error) { + if (!isUnsupportedRevParsePathFormatError(error)) { + return undefined + } + } + + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + } catch { + return undefined + } +} + +async function normalizeMainWorktreePath( + repoPath: string, + worktrees: GitWorktreeInfo[], + options: GitWorktreeExecOptions = {} +): Promise { + const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree) + const mainWorktree = worktrees[mainIndex] + // Compare in the Git-output space: under WSL the porcelain/rev-parse paths are + // Linux while repoPath is a UNC path, so without translating, a UNC repoPath + // never matches the early-return and fires a needless rev-parse on every poll. + // Use the platform-independent UNC parser so the comparison holds regardless + // of host OS; the runner still receives the original repoPath for WSL routing. + const wslRepo = parseWslUncPath(repoPath) + const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath + if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) { + return worktrees + } + + const location = await readRepoLocation(repoPath, comparablePath, options) + if (!location) { + return worktrees + } + + // Why: only a separate-git-dir/submodule main worktree reports the Git + // directory as the main entry — i.e. the main entry equals git-common-dir. + // A linked worktree's main entry is a real working root, so gating on this + // equality avoids overwriting it with the linked worktree's own toplevel. + if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) { + return worktrees + } + + const normalized = [...worktrees] + normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } + return normalized +} + /** * Parse the porcelain output of `git worktree list --porcelain`. */ @@ -441,7 +550,11 @@ async function readWorktreeList( cwd: repoPath, ...options }) - return parseWorktreeList(stdout, { nulDelimited: true }) + return normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }), + options + ) } catch (error) { if (!isUnsupportedWorktreeListZError(error)) { throw error @@ -454,7 +567,7 @@ async function readWorktreeList( cwd: repoPath, ...options }) - return parseWorktreeList(stdout) + return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options) } async function readTranslatedWorktreeGraph( diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 4ead1dc4d2e..87b7b590549 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1020,6 +1020,40 @@ describe('runHook', () => { }) }) +describe('createSetupRunnerScript', () => { + const makeRepo = (setupAgentStartupPolicy?: 'start-immediately' | 'wait-for-setup') => + ({ + id: 'test-id', + path: '/test/repo', + displayName: 'Test Repo', + badgeColor: '#000', + addedAt: Date.now(), + hookSettings: { + mode: 'auto', + setupAgentStartupPolicy, + scripts: { setup: '', archive: '' } + } + }) as unknown as Repo + + it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') + const { createSetupRunnerScript } = await import('./hooks') + + expect( + createSetupRunnerScript(makeRepo(), '/test/worktree', 'echo setup').waitForAgentStartup + ).toBeUndefined() + expect( + createSetupRunnerScript(makeRepo('start-immediately'), '/test/worktree', 'echo setup') + .waitForAgentStartup + ).toBeUndefined() + expect( + createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup') + .waitForAgentStartup + ).toBe(true) + }) +}) + describe('shouldRunSetupForCreate', () => { const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') => ({ diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 17a87757f52..7cb4ba5a1e9 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -6,6 +6,7 @@ import { parse } from 'yaml' import { getDefaultRepoHookSettings } from '../shared/constants' import { getRuntimePathBasename } from '../shared/cross-platform-path' import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy' +import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy' import { gitExecFileSync } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import type { @@ -517,7 +518,8 @@ export function createSetupRunnerScript( worktreePath, script, 'setup-runner', - getHookRuntimeTarget(projectRuntime) + getHookRuntimeTarget(projectRuntime), + shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) ) } @@ -575,7 +577,8 @@ function createWorktreeRunnerScript( worktreePath: string, script: string, runnerBaseName: 'setup-runner' | 'issue-command-runner', - runtimeTarget?: HookRuntimeTarget + runtimeTarget?: HookRuntimeTarget, + waitForAgentStartup?: boolean ): WorktreeSetupLaunch { const envVars = getSetupEnvVars(repo, worktreePath) // Why: WSL worktrees run on a Linux filesystem even though process.platform @@ -618,7 +621,11 @@ function createWorktreeRunnerScript( } } - return { runnerScriptPath, envVars } + return { + runnerScriptPath, + envVars, + ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) + } } /** diff --git a/src/main/index.ts b/src/main/index.ts index c1a4624e079..f8ab73b4aa2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,7 @@ import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/stor import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' +import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { initObservability, shutdownObservability } from './observability' import { startSpan } from './observability/tracer' @@ -986,9 +987,16 @@ function shutdownWatchersOnce(): Promise { if (!watcherShutdownPromise) { // Why: @parcel/watcher tears down native async work during unsubscribe. // Electron must wait for that cleanup before Node's environment exits. - watcherShutdownPromise = closeAllWatchers() - .catch((error) => { - console.error('[filesystem-watcher] shutdown failed:', error) + watcherShutdownPromise = Promise.allSettled([ + closeAllWatchers(), + disposeWorktreeBaseDirectoryWatchers() + ]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') { + console.error('[filesystem-watcher] shutdown failed:', result.reason) + } + } }) .then(() => { watcherShutdownDone = true diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4b9ce9508b7..add52d56301 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -172,6 +172,7 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ })) import { LocalPtyProvider } from '../providers/local-pty-provider' import { makePaneKey } from '../../shared/stable-pane-id' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { registerPtyHandlers, registerSshPtyProvider, @@ -796,6 +797,20 @@ describe('registerPtyHandlers', () => { } ) + it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => { + const env = await spawnAndGetEnv( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' }, + undefined, + undefined, + undefined, + 'bash -lc wait-wrapper' + ) + + expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + }) + it('does not inject MiMo overlay for non-mimo launches', async () => { await spawnAndGetEnv() @@ -897,6 +912,29 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) + it('uses sequenced startup env as the OMP launch hint when command is a wrapper', async () => { + const env = await spawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/tmp/user-omp-agent', + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' + }, + undefined, + undefined, + undefined, + 'powershell wait-wrapper' + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/tmp/user-omp-agent', + 'omp' + ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/tmp/user-omp-agent/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => { const env = await spawnAndGetEnv({ PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', @@ -1347,6 +1385,29 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) + it('uses sequenced startup env as the daemon OMP launch hint when command is a wrapper', async () => { + const env = await daemonSpawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/user/.omp/agent', + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' + }, + undefined, + undefined, + undefined, + { command: 'powershell wait-wrapper' } + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/user/.omp/agent', + 'omp' + ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/user/.omp/agent/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('injects the selected Codex home on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME) expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 65290d01177..09fb8f6906e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -86,6 +86,7 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' +import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { assertFolderWorkspacePathUsable, @@ -751,9 +752,10 @@ export function buildPtyHostEnv( // in lock-step across spawn paths without pushing process.env onto the // IPC wire unnecessarily. const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) - const piAgentKind = detectPiAgentKindFromCommand(opts.launchCommand) + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint) const hasLaunchCommand = - typeof opts.launchCommand === 'string' && opts.launchCommand.trim().length > 0 + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand // Why: source shadows are agent-scoped. Trusting the other kind's source // would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes. @@ -783,7 +785,7 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } } - if (isMimoLaunchCommand(opts.launchCommand)) { + if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) if (baseEnv.MIMOCODE_HOME) { @@ -1880,6 +1882,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.commandDelivery !== undefined) { + spawnOptions.commandDelivery = args.commandDelivery + } if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } @@ -2256,6 +2261,7 @@ export function registerPtyHandlers( env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -2557,6 +2563,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.commandDelivery !== undefined) { + spawnOptions.commandDelivery = args.commandDelivery + } if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index e3959bb48af..df2c13c8646 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -79,6 +79,7 @@ import { getSshGitUsername } from '../git/git-username' import { getActiveMultiplexer } from './ssh' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { track } from '../telemetry/client' +import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' @@ -1926,10 +1927,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'forkSyncMode' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: + | Repo['externalWorktreeDiscoverySuppressedAt'] + | null + } } ) => { // Why: validate the persisted preference string at the IPC boundary @@ -2009,6 +2017,38 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) { delete updates.externalWorktreeVisibilityPromptDismissedAt } + // Why: null is the transport sentinel for clearing discovery suppression. + if ( + 'externalWorktreeDiscoverySuppressedAt' in updates && + updates.externalWorktreeDiscoverySuppressedAt === null + ) { + updates.externalWorktreeDiscoverySuppressedAt = undefined + } else if ( + 'externalWorktreeDiscoverySuppressedAt' in updates && + updates.externalWorktreeDiscoverySuppressedAt !== undefined && + (typeof updates.externalWorktreeDiscoverySuppressedAt !== 'number' || + !Number.isFinite(updates.externalWorktreeDiscoverySuppressedAt)) + ) { + delete updates.externalWorktreeDiscoverySuppressedAt + } + if ( + 'externalWorktreeInboxBaselinePaths' in updates && + updates.externalWorktreeInboxBaselinePaths !== undefined + ) { + const value = updates.externalWorktreeInboxBaselinePaths as unknown + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + delete updates.externalWorktreeInboxBaselinePaths + } + } + if ( + 'importedExternalWorktreePaths' in updates && + updates.importedExternalWorktreePaths !== undefined + ) { + const value = updates.importedExternalWorktreePaths as unknown + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + delete updates.importedExternalWorktreePaths + } + } // Why: null is the transport sentinel for clearing Source Control AI. // Other invalid fields are deleted; this one must flow as undefined. if ('sourceControlAi' in updates && updates.sourceControlAi === null) { @@ -2525,6 +2565,7 @@ function notifyReposChanged(mainWindow: BrowserWindow): void { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('repos:changed') } + scheduleCurrentWorktreeBaseDirectoryWatcherSync() } function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 93a611fe8eb..585a3a6ee3c 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -18,6 +18,7 @@ import { normalizeUiLanguage } from '../../shared/ui-language' import { applyAppIcon } from '../app-icon' import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes' import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation' +import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -114,6 +115,7 @@ export function registerSettingsHandlers( ('nestWorkspaces' in sanitizedArgs && before.nestWorkspaces !== result.nestWorkspaces) ) { void prepareLocalWorktreeRootsForRepos(store) + scheduleCurrentWorktreeBaseDirectoryWatcherSync() } if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) { rebuildAppMenu() diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts new file mode 100644 index 00000000000..e25af90964c --- /dev/null +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -0,0 +1,213 @@ +import type { IGitProvider } from '../providers/types' +import { isFolderRepo } from '../../shared/repo-kind' +import type { Repo, Worktree } from '../../shared/types' +import { + applyWorkspaceCleanupPolicy, + createWorkspaceCleanupFingerprint, + getWorkspaceCleanupInactivityReasons, + isWorkspaceOldForCleanup, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupReason +} from '../../shared/workspace-cleanup' +import { + createEmptyWorkspaceCleanupGitEvidence, + readWorkspaceCleanupGitEvidence +} from './workspace-cleanup-git-evidence' +import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives' + +export async function buildWorkspaceCleanupCandidate(args: { + repo: Repo + worktree: Worktree + scannedAt: number + provider: IGitProvider | null + skipGit: boolean + forceGitCheck: boolean +}): Promise { + const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args + const blockers: WorkspaceCleanupBlocker[] = [] + const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt) + const repoIsFolder = isFolderRepo(repo) + + if (worktree.isMainWorktree) { + blockers.push('main-worktree') + } + if (repoIsFolder) { + blockers.push('folder-repo') + } + if (worktree.isPinned) { + blockers.push('pinned') + } + + const localContext = buildWorkspaceCleanupLocalContext(worktree) + const shouldReadGit = shouldReadWorkspaceCleanupGitEvidence({ + repoIsFolder, + blockers, + worktree, + skipGit, + forceGitCheck + }) + + const gitEvidence = !shouldReadGit + ? createEmptyWorkspaceCleanupGitEvidence() + : await readWorkspaceCleanupGitEvidence(worktree, repo, provider) + appendWorkspaceCleanupItems(blockers, gitEvidence.blockers) + + const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { + worktreeId: worktree.id, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: worktree.displayName, + branch: shortWorkspaceCleanupBranchName(worktree.branch), + path: worktree.path, + tier: 'review', + selectedByDefault: false, + reasons, + blockers: uniqueWorkspaceCleanupBlockers(blockers), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + localContext, + git: { + clean: gitEvidence.clean, + upstreamAhead: gitEvidence.upstreamAhead, + upstreamBehind: gitEvidence.upstreamBehind, + checkedAt: gitEvidence.checkedAt + }, + fingerprint: '' + } + + const fingerprint = createWorkspaceCleanupFingerprint({ + branch: candidateWithoutFingerprint.branch, + head: worktree.head, + gitClean: gitEvidence.clean, + lastActivityAt: worktree.lastActivityAt + }) + + return applyWorkspaceCleanupPolicy({ + ...candidateWithoutFingerprint, + reasons: uniqueWorkspaceCleanupReasons(reasons), + blockers: uniqueWorkspaceCleanupBlockers(blockers), + fingerprint + }) +} + +export function buildWorkspaceCleanupCandidateFromError( + repo: Repo, + worktree: Worktree, + scannedAt: number +): WorkspaceCleanupCandidate { + return applyWorkspaceCleanupPolicy({ + worktreeId: worktree.id, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: worktree.displayName, + branch: shortWorkspaceCleanupBranchName(worktree.branch), + path: worktree.path, + tier: 'protected', + selectedByDefault: false, + reasons: getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt), + blockers: ['git-status-error'], + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + localContext: buildWorkspaceCleanupLocalContext(worktree), + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: scannedAt + }, + fingerprint: createWorkspaceCleanupFingerprint({ + branch: shortWorkspaceCleanupBranchName(worktree.branch), + head: worktree.head, + gitClean: null, + lastActivityAt: worktree.lastActivityAt + }) + }) +} + +export function buildWorkspaceCleanupLocalContext( + worktree: Pick +): WorkspaceCleanupCandidate['localContext'] { + return { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: worktree.diffComments?.length ?? 0, + newestDiffCommentAt: getNewestWorkspaceCleanupDiffCommentAt(worktree.diffComments), + retainedDoneAgentCount: 0 + } +} + +export function getNewestWorkspaceCleanupDiffCommentAt( + diffComments: Worktree['diffComments'] | undefined +): number | null { + if (!diffComments || diffComments.length === 0) { + return null + } + // Why: persisted diff notes can grow large enough for spread-based Math.max + // to exceed the JavaScript argument limit during cleanup scans. + let newest = diffComments[0]?.createdAt ?? null + for (let index = 1; index < diffComments.length; index += 1) { + const createdAt = diffComments[index]?.createdAt + if (createdAt !== undefined && (newest === null || createdAt > newest)) { + newest = createdAt + } + } + return newest +} + +export function isWorkspaceInactiveForCleanup( + workspace: Pick, + scannedAt: number +): boolean { + return isWorkspaceOldForCleanup(workspace, scannedAt) +} + +export function getWorkspaceCleanupInactivityReasonsForWorkspace( + workspace: Pick, + scannedAt: number +): WorkspaceCleanupReason[] { + return getWorkspaceCleanupInactivityReasons(workspace, scannedAt) +} + +export function shortWorkspaceCleanupBranchName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') || 'HEAD' +} + +function shouldReadWorkspaceCleanupGitEvidence(args: { + repoIsFolder: boolean + blockers: WorkspaceCleanupBlocker[] + worktree: Worktree + skipGit: boolean + forceGitCheck: boolean +}): boolean { + const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args + if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) { + return false + } + if ( + blockers.includes('pinned') || + blockers.includes('main-worktree') || + blockers.includes('folder-repo') + ) { + return false + } + + // Why: inactivity is the only recommendation signal now. Git is read only + // to keep the destructive path from deleting dirty or local-only branch work. + return true +} + +function uniqueWorkspaceCleanupBlockers( + blockers: WorkspaceCleanupBlocker[] +): WorkspaceCleanupBlocker[] { + return [...new Set(blockers)] +} + +function uniqueWorkspaceCleanupReasons( + reasons: WorkspaceCleanupReason[] +): WorkspaceCleanupReason[] { + return [...new Set(reasons)] +} diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts new file mode 100644 index 00000000000..bd549a9b6ee --- /dev/null +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -0,0 +1,92 @@ +import { basename } from 'node:path' +import type { Store } from '../persistence' +import type { Repo, WorktreeMeta } from '../../shared/types' +import { + applyWorkspaceCleanupPolicy, + createWorkspaceCleanupFingerprint, + type WorkspaceCleanupCandidate +} from '../../shared/workspace-cleanup' +import { splitWorktreeId } from '../../shared/worktree-id' +import { + getNewestWorkspaceCleanupDiffCommentAt, + getWorkspaceCleanupInactivityReasonsForWorkspace, + isWorkspaceInactiveForCleanup +} from './workspace-cleanup-candidate' + +export function synthesizeDisconnectedSshCleanupCandidates( + store: Store, + repo: Repo, + scannedAt: number, + targetWorktreeId?: string +): WorkspaceCleanupCandidate[] { + const repoWorktreePrefix = `${repo.id}::` + if (targetWorktreeId) { + if (!targetWorktreeId.startsWith(repoWorktreePrefix)) { + return [] + } + // Why: focused delete preflight names one workspace already; walking all + // persisted metadata is unnecessary for disconnected SSH repos. + const meta = store.getWorktreeMeta(targetWorktreeId) + return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : [] + } + + const candidates: WorkspaceCleanupCandidate[] = [] + const allMeta = store.getAllWorktreeMeta() + for (const worktreeId in allMeta) { + if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) { + continue + } + const meta = allMeta[worktreeId] + if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) { + continue + } + candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) + } + return candidates +} + +function createDisconnectedSshCandidate( + repo: Repo, + scannedAt: number, + worktreeId: string, + meta: WorktreeMeta +): WorkspaceCleanupCandidate { + const parsed = splitWorktreeId(worktreeId) + const path = parsed?.worktreePath ?? worktreeId + const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt) + return applyWorkspaceCleanupPolicy({ + worktreeId, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: meta.displayName || basename(path), + branch: basename(path), + path, + tier: 'protected', + selectedByDefault: false, + reasons, + blockers: ['ssh-disconnected'], + lastActivityAt: meta.lastActivityAt, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: meta.diffComments?.length ?? 0, + newestDiffCommentAt: getNewestWorkspaceCleanupDiffCommentAt(meta.diffComments), + retainedDoneAgentCount: 0 + }, + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }, + fingerprint: createWorkspaceCleanupFingerprint({ + branch: basename(path), + head: '', + gitClean: null, + lastActivityAt: meta.lastActivityAt + }) + }) +} diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts new file mode 100644 index 00000000000..90e6058f598 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -0,0 +1,119 @@ +import { getStatus } from '../git/status' +import { gitExecFileAsync } from '../git/runner' +import type { IGitProvider } from '../providers/types' +import type { GitStatusResult, Repo, Worktree } from '../../shared/types' +import type { WorkspaceCleanupBlocker } from '../../shared/workspace-cleanup' +import { + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + withWorkspaceCleanupTimeout +} from './workspace-cleanup-scan-primitives' + +export type WorkspaceCleanupGitEvidence = { + clean: boolean | null + upstreamAhead: number | null + upstreamBehind: number | null + checkedAt: number | null + blockers: WorkspaceCleanupBlocker[] +} + +export function createEmptyWorkspaceCleanupGitEvidence(): WorkspaceCleanupGitEvidence { + return { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null, + blockers: [] + } +} + +export async function readWorkspaceCleanupGitEvidence( + worktree: Worktree, + repo: Repo, + provider: IGitProvider | null +): Promise { + const blockers: WorkspaceCleanupBlocker[] = [] + let status: GitStatusResult + const checkedAt = Date.now() + + try { + status = await withWorkspaceCleanupTimeout( + (signal) => + repo.connectionId + ? provider!.getStatus(worktree.path, { signal }) + : getStatus(worktree.path, { signal }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out reading git status.' + ) + } catch { + return { + ...createEmptyWorkspaceCleanupGitEvidence(), + blockers: ['git-status-error'] + } + } + + if (status.upstreamStatus === undefined) { + return { + ...createEmptyWorkspaceCleanupGitEvidence(), + blockers: ['git-status-error'] + } + } + + const clean = status.entries.length === 0 + if (!clean) { + blockers.push('dirty-files') + } + + const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null + const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null + if (upstreamAhead !== null && upstreamAhead > 0) { + blockers.push('unpushed-commits') + } + if (clean && upstreamAhead === null) { + const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider) + if (unpushedCommitCount === null) { + blockers.push('unknown-base') + } else if (unpushedCommitCount > 0) { + blockers.push('unpushed-commits') + } + } + + return { + clean, + upstreamAhead, + upstreamBehind, + checkedAt, + blockers: uniqueWorkspaceCleanupGitBlockers(blockers) + } +} + +async function readUnpushedCommitCount( + worktree: Worktree, + repo: Repo, + provider: IGitProvider | null +): Promise { + try { + const result = await withWorkspaceCleanupTimeout( + (signal) => + repo.connectionId + ? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path, { + signal + }) + : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { + cwd: worktree.path, + signal + }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out checking unpushed commits.' + ) + const count = Number.parseInt(result.stdout.trim(), 10) + return Number.isFinite(count) ? count : null + } catch { + return null + } +} + +function uniqueWorkspaceCleanupGitBlockers( + blockers: WorkspaceCleanupBlocker[] +): WorkspaceCleanupBlocker[] { + return [...new Set(blockers)] +} diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts new file mode 100644 index 00000000000..4f0a84c8a86 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -0,0 +1,102 @@ +import { basename } from 'node:path' +import type { Repo } from '../../shared/types' +import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup' + +export const WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS = 8_000 + +export function appendWorkspaceCleanupItems(target: T[], entries: readonly T[]): void { + // Why: cleanup can aggregate generated-size worktree batches; spreading + // those batches into push can exceed JavaScript's argument limit. + for (const entry of entries) { + target.push(entry) + } +} + +export async function mapWorkspaceCleanupWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +export async function withWorkspaceCleanupTimeout( + run: (signal: AbortSignal) => Promise, + timeoutMs: number, + message: string +): Promise { + const controller = new AbortController() + let timeoutId: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort() + reject(new Error(message)) + }, timeoutMs) + }) + try { + return await Promise.race([run(controller.signal), timeoutPromise]) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } +} + +export function toWorkspaceCleanupErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function createWorkspaceCleanupScanError( + repo: Repo, + message: string +): WorkspaceCleanupScanError { + return { + repoId: repo.id, + repoName: repo.displayName || basename(repo.path), + message + } +} + +// Why: git errors often include absolute paths or command output. Keep the +// cause useful without leaking raw local/remote filesystem details to the UI. +export function toSafeWorkspaceCleanupRepoScanError(error: unknown): string { + const message = toWorkspaceCleanupErrorMessage(error) + if (message === 'Timed out listing SSH worktrees.') { + return 'Timed out listing remote worktrees.' + } + if (message === 'Timed out listing worktrees.') { + return 'Timed out listing worktrees.' + } + if (message.startsWith('Timed out ')) { + return message.replace(/\.$/, '') + } + + const lower = message.toLowerCase() + if (lower.includes('not a git repository') || lower.includes('not a git worktree')) { + return 'Repository is not a git checkout.' + } + if ( + lower.includes('enoent') || + lower.includes('no such file') || + lower.includes('cannot find') || + lower.includes('does not exist') + ) { + return 'Repository folder was not found.' + } + if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) { + return 'Repository folder is not accessible.' + } + return 'Git could not list worktrees.' +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts new file mode 100644 index 00000000000..33866e6cd73 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -0,0 +1,259 @@ +import type { Store } from '../persistence' +import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { IGitProvider } from '../providers/types' +import { isFolderRepo } from '../../shared/repo-kind' +import type { GitWorktreeInfo, Repo } from '../../shared/types' +import { mergeWorktree } from './worktree-logic' +import { splitWorktreeId } from '../../shared/worktree-id' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanError, + WorkspaceCleanupScanArgs, + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../shared/workspace-cleanup' +import { + buildWorkspaceCleanupCandidate, + buildWorkspaceCleanupCandidateFromError, + isWorkspaceInactiveForCleanup +} from './workspace-cleanup-candidate' +import { synthesizeDisconnectedSshCleanupCandidates } from './workspace-cleanup-disconnected-ssh' +import { + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + appendWorkspaceCleanupItems, + createWorkspaceCleanupScanError, + mapWorkspaceCleanupWithConcurrency, + toSafeWorkspaceCleanupRepoScanError, + withWorkspaceCleanupTimeout +} from './workspace-cleanup-scan-primitives' + +const WORKTREE_SCAN_CONCURRENCY = 3 + +type WorkspaceCleanupScanOptions = { + onProgress?: (progress: WorkspaceCleanupScanProgress) => void +} + +type WorkspaceCleanupScanRepoProgress = { + onWorktreesDiscovered?: (count: number) => void + onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void + onErrors?: (errors: WorkspaceCleanupScanError[]) => void +} + +type WorkspaceCleanupProgressEmitter = { + addDiscovered: (count: number) => void + addCandidate: (candidate: WorkspaceCleanupCandidate) => void + addErrors: (errors: WorkspaceCleanupScanError[]) => void +} + +export async function scanWorkspaceCleanup( + store: Store, + args: WorkspaceCleanupScanArgs = {}, + options: WorkspaceCleanupScanOptions = {} +): Promise { + const scannedAt = Date.now() + const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null + if (args.worktreeId && !parsedTarget) { + return { scannedAt, candidates: [], errors: [] } + } + const repos = parsedTarget + ? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId) + : store.getRepos() + const progress = createProgressEmitter(args.scanId, scannedAt, options) + const errors: WorkspaceCleanupScanResult['errors'] = [] + const candidates: WorkspaceCleanupCandidate[] = [] + + for (const repo of repos) { + const result = await scanRepoWorkspaces({ + store, + repo, + scannedAt, + targetWorktreeId: args.worktreeId, + skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []), + onWorktreesDiscovered: progress.addDiscovered, + onCandidateScanned: progress.addCandidate, + onErrors: progress.addErrors + }) + appendWorkspaceCleanupItems(candidates, result.candidates) + appendWorkspaceCleanupItems(errors, result.errors) + } + + return { scannedAt, candidates, errors } +} + +async function scanRepoWorkspaces( + args: { + store: Store + repo: Repo + scannedAt: number + targetWorktreeId?: string + skipGitWorktreeIds: Set + } & WorkspaceCleanupScanRepoProgress +): Promise { + const { + store, + repo, + scannedAt, + targetWorktreeId, + skipGitWorktreeIds, + onWorktreesDiscovered, + onCandidateScanned, + onErrors + } = args + const errors: WorkspaceCleanupScanResult['errors'] = [] + const repoIsFolder = isFolderRepo(repo) + let provider: IGitProvider | null = null + let gitWorktrees: GitWorktreeInfo[] = [] + + try { + const discovered = await listCleanupGitWorktrees(repo, repoIsFolder) + provider = discovered.provider + gitWorktrees = discovered.gitWorktrees + } catch (error) { + return handleRepoWorktreeListError({ repo, targetWorktreeId, scannedAt, error, onErrors }) + } + + if (repo.connectionId && !provider) { + const candidates = targetWorktreeId + ? synthesizeDisconnectedSshCleanupCandidates(store, repo, scannedAt, targetWorktreeId) + : [] + onWorktreesDiscovered?.(candidates.length) + for (const candidate of candidates) { + onCandidateScanned?.(candidate) + } + return { scannedAt, candidates, errors: [] } + } + + const worktrees = gitWorktrees + .map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + .filter((worktree) => { + if (targetWorktreeId) { + return worktree.id === targetWorktreeId + } + return ( + !repoIsFolder && + !worktree.isMainWorktree && + isWorkspaceInactiveForCleanup(worktree, scannedAt) + ) + }) + + onWorktreesDiscovered?.(worktrees.length) + + const candidates = await mapWorkspaceCleanupWithConcurrency( + worktrees, + WORKTREE_SCAN_CONCURRENCY, + async (worktree) => { + const candidate = await buildWorkspaceCleanupCandidate({ + repo, + worktree, + scannedAt, + provider, + skipGit: skipGitWorktreeIds.has(worktree.id), + forceGitCheck: Boolean(targetWorktreeId) + }).catch((error) => { + console.error('Workspace cleanup candidate scan failed', error) + return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt) + }) + onCandidateScanned?.(candidate) + return candidate + } + ) + + return { scannedAt, candidates, errors } +} + +async function listCleanupGitWorktrees( + repo: Repo, + repoIsFolder: boolean +): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> { + if (repoIsFolder) { + return { provider: null, gitWorktrees: [createFolderWorktree(repo)] } + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) ?? null + if (!provider) { + // Why: cleanup should reflect only workspaces Orca can currently inspect. + return { provider: null, gitWorktrees: [] } + } + return { + provider, + gitWorktrees: await withWorkspaceCleanupTimeout( + (signal) => provider.listWorktrees(repo.path, { signal }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out listing SSH worktrees.' + ) + } + } + return { + provider: null, + gitWorktrees: await withWorkspaceCleanupTimeout( + (signal) => listRepoWorktrees(repo, { signal }), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out listing worktrees.' + ) + } +} + +function handleRepoWorktreeListError(args: { + repo: Repo + targetWorktreeId?: string + scannedAt: number + error: unknown + onErrors?: (errors: WorkspaceCleanupScanError[]) => void +}): WorkspaceCleanupScanResult { + const { repo, targetWorktreeId, scannedAt, error, onErrors } = args + console.error('Workspace cleanup repo scan failed', error) + if (repo.connectionId && !targetWorktreeId) { + // Why: broad cleanup only shows remote workspaces Orca can inspect now. + // A connected SSH repo that fails mid-scan is omitted, not bannered. + return { scannedAt, candidates: [], errors: [] } + } + const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))] + onErrors?.(errors) + return { scannedAt, candidates: [], errors } +} + +function createProgressEmitter( + scanId: string | undefined, + scannedAt: number, + options: WorkspaceCleanupScanOptions +): WorkspaceCleanupProgressEmitter { + const errors: WorkspaceCleanupScanError[] = [] + let totalWorktreeCount = 0 + let scannedWorktreeCount = 0 + const emit = ( + candidates: WorkspaceCleanupCandidate[], + candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot' + ): void => { + if (!scanId) { + return + } + options.onProgress?.({ + scanId, + scannedAt, + totalWorktreeCount, + scannedWorktreeCount, + candidates, + errors: [...errors], + candidateMode + }) + } + return { + addDiscovered: (count) => { + totalWorktreeCount += count + emit([], 'append') + }, + addCandidate: (candidate) => { + scannedWorktreeCount += 1 + emit([candidate], 'append') + }, + addErrors: (newErrors) => { + appendWorkspaceCleanupItems(errors, newErrors) + emit([], 'append') + } + } +} diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 0694ac5e9a5..6fe5320b1b1 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -10,6 +10,7 @@ import type { Repo, WorktreeMeta } from '../../shared/types' +import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { listRepoWorktreesMock, @@ -171,6 +172,58 @@ describe('workspace cleanup scan', () => { }) }) + it('reports cleanup scan progress as inactive workspaces are checked', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + const progress: unknown[] = [] + + const result = await scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + + expect(result.candidates).toHaveLength(2) + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 0, + totalWorktreeCount: 2, + candidates: [] + }) + const candidateProgress = progress.filter( + (event): event is WorkspaceCleanupScanProgress => + (event as WorkspaceCleanupScanProgress).candidateMode === 'append' && + (event as WorkspaceCleanupScanProgress).candidates.length > 0 + ) + expect(candidateProgress).toHaveLength(2) + expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true) + const progressWorktreeIds = candidateProgress.flatMap((event) => + event.candidates.map((candidate) => candidate.worktreeId) + ) + expect(progressWorktreeIds).toEqual( + expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) + ) + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 2, + totalWorktreeCount: 2 + }) + }) + it('keeps raw scan errors out of renderer-facing results', async () => { listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) @@ -185,6 +238,30 @@ describe('workspace cleanup scan', () => { ]) }) + it('aborts a hung worktree list when the cleanup timeout fires', async () => { + let signal: AbortSignal | undefined + listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => { + signal = options?.signal + return new Promise(() => {}) + }) + + const scan = scanWorkspaceCleanup(makeStore()) + await vi.advanceTimersByTimeAsync(8_000) + + await expect(scan).resolves.toEqual({ + scannedAt: NOW, + candidates: [], + errors: [ + { + repoId: 'repo-1', + repoName: 'Repo', + message: 'Timed out listing worktrees.' + } + ] + }) + expect(signal?.aborted).toBe(true) + }) + it('skips disconnected remote workspaces without a scan warning', async () => { const result = await scanWorkspaceCleanup( makeStore({ @@ -263,8 +340,12 @@ describe('workspace cleanup scan', () => { }) ) - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') - expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature') + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { + signal: expect.any(AbortSignal) + }) + expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', { + signal: expect.any(AbortSignal) + }) expect(result.errors).toEqual([]) expect(result.candidates[0]).toMatchObject({ connectionId: 'ssh-1', @@ -287,7 +368,9 @@ describe('workspace cleanup scan', () => { }) ) - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { + signal: expect.any(AbortSignal) + }) expect(result.errors).toEqual([]) expect(result.candidates).toEqual([]) }) @@ -324,6 +407,52 @@ describe('workspace cleanup scan', () => { }) }) + it('only inspects the target repo during focused scans', async () => { + const repoTwo = { ...REPO, id: 'repo-2', path: '/repo-two', displayName: 'Repo Two' } + listRepoWorktreesMock.mockImplementation((repo: Repo) => + Promise.resolve([ + { + path: `${repo.path}-feature`, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + ) + + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [REPO, repoTwo] + }), + { worktreeId: 'repo-2::/repo-two-feature' } + ) + + expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) + expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo, { + signal: expect.any(AbortSignal) + }) + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + worktreeId: 'repo-2::/repo-two-feature', + repoId: 'repo-2' + }) + }) + + it('returns no focused scan rows when the encoded repo id is unknown', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'missing-repo::/repo-feature' + }) + + expect(listRepoWorktreesMock).not.toHaveBeenCalled() + expect(getStatusMock).not.toHaveBeenCalled() + expect(result).toEqual({ + scannedAt: NOW, + candidates: [], + errors: [] + }) + }) + it('honors renderer git deferrals without hiding the workspace', async () => { const result = await scanWorkspaceCleanup(makeStore(), { skipGitWorktreeIds: ['repo-1::/repo-feature'] @@ -353,7 +482,7 @@ describe('workspace cleanup scan', () => { expect(getStatusMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['rev-list', '--count', 'HEAD', '--not', '--remotes'], - { cwd: '/repo-feature' } + { cwd: '/repo-feature', signal: expect.any(AbortSignal) } ) expect(result.candidates[0]).toMatchObject({ tier: 'ready', diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 7a6bdbf2b66..229f4c4177f 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -1,52 +1,20 @@ -/* eslint-disable max-lines */ import { ipcMain } from 'electron' -import { basename } from 'node:path' import type { Store } from '../persistence' -import { getStatus } from '../git/status' -import { gitExecFileAsync } from '../git/runner' -import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import type { IGitProvider, IPtyProvider } from '../providers/types' +import type { IPtyProvider } from '../providers/types' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { listRegisteredPtys } from '../memory/pty-registry' import { getSshPtyProvider } from './pty' -import { isFolderRepo } from '../../shared/repo-kind' -import type { - GitStatusResult, - GitWorktreeInfo, - Repo, - Worktree, - WorktreeMeta -} from '../../shared/types' -import { mergeWorktree } from './worktree-logic' -import { splitWorktreeId } from '../../shared/worktree-id' import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, - applyWorkspaceCleanupPolicy, - createWorkspaceCleanupFingerprint, - getWorkspaceCleanupInactivityReasons, - isWorkspaceOldForCleanup, - type WorkspaceCleanupBlocker, - type WorkspaceCleanupCandidate, type WorkspaceCleanupDismissArgs, type WorkspaceCleanupLocalProcessArgs, type WorkspaceCleanupLocalProcessResult, - type WorkspaceCleanupReason, - type WorkspaceCleanupScanError, type WorkspaceCleanupScanArgs, type WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' +import { scanWorkspaceCleanup } from './workspace-cleanup-scan' -const GIT_READ_TIMEOUT_MS = 8_000 -const WORKTREE_SCAN_CONCURRENCY = 3 - -type GitEvidence = { - clean: boolean | null - upstreamAhead: number | null - upstreamBehind: number | null - checkedAt: number | null - blockers: WorkspaceCleanupBlocker[] -} +export { scanWorkspaceCleanup } type WorkspaceCleanupHandlerDeps = { runtime?: OrcaRuntimeService @@ -64,8 +32,12 @@ export function registerWorkspaceCleanupHandlers( ipcMain.handle( 'workspaceCleanup:scan', - (_event, args?: WorkspaceCleanupScanArgs): Promise => - scanWorkspaceCleanup(store, args ?? {}) + (event, args?: WorkspaceCleanupScanArgs): Promise => + scanWorkspaceCleanup(store, args ?? {}, { + onProgress: args?.scanId + ? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress) + : undefined + }) ) ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => { @@ -187,557 +159,3 @@ function normalizeRemotePath(path: string): string { function isPathWithin(candidatePath: string, parentPath: string): boolean { return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`) } - -export async function scanWorkspaceCleanup( - store: Store, - args: WorkspaceCleanupScanArgs = {} -): Promise { - const scannedAt = Date.now() - const repos = store.getRepos() - const errors: WorkspaceCleanupScanResult['errors'] = [] - const candidates: WorkspaceCleanupCandidate[] = [] - - for (const repo of repos) { - const result = await scanRepoWorkspaces({ - store, - repo, - scannedAt, - targetWorktreeId: args.worktreeId, - skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []) - }) - appendListItems(candidates, result.candidates) - appendListItems(errors, result.errors) - } - - return { scannedAt, candidates, errors } -} - -async function scanRepoWorkspaces(args: { - store: Store - repo: Repo - scannedAt: number - targetWorktreeId?: string - skipGitWorktreeIds: Set -}): Promise { - const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args - const errors: WorkspaceCleanupScanResult['errors'] = [] - let provider: IGitProvider | null = null - let gitWorktrees: GitWorktreeInfo[] = [] - const repoIsFolder = isFolderRepo(repo) - - try { - if (repoIsFolder) { - gitWorktrees = [createFolderWorktree(repo)] - } else if (repo.connectionId) { - provider = getSshGitProvider(repo.connectionId) ?? null - if (!provider) { - // Why: cleanup should reflect only workspaces Orca can currently - // inspect. Disconnected SSH repos are skipped in broad scans. - return { - scannedAt, - candidates: targetWorktreeId - ? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId) - : [], - errors: [] - } - } - gitWorktrees = await withTimeout( - provider.listWorktrees(repo.path), - GIT_READ_TIMEOUT_MS, - 'Timed out listing SSH worktrees.' - ) - } else { - gitWorktrees = await withTimeout( - listRepoWorktrees(repo), - GIT_READ_TIMEOUT_MS, - 'Timed out listing worktrees.' - ) - } - } catch (error) { - console.error('Workspace cleanup repo scan failed', error) - if (repo.connectionId && !targetWorktreeId) { - // Why: broad cleanup only shows remote workspaces Orca can inspect now. - // A connected SSH repo that fails mid-scan is omitted, not bannered. - return { scannedAt, candidates: [], errors: [] } - } - errors.push(createScanError(repo, toSafeRepoScanError(error))) - return { scannedAt, candidates: [], errors } - } - - const worktrees = gitWorktrees - .map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) - .filter((worktree) => { - if (targetWorktreeId) { - return worktree.id === targetWorktreeId - } - return ( - !repoIsFolder && - !worktree.isMainWorktree && - isWorkspaceInactiveForCleanup(worktree, scannedAt) - ) - }) - - const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) => - buildCandidate({ - repo, - worktree, - scannedAt, - provider, - skipGit: skipGitWorktreeIds.has(worktree.id), - forceGitCheck: Boolean(targetWorktreeId) - }).catch((error) => { - console.error('Workspace cleanup candidate scan failed', error) - return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error)) - }) - ) - - return { scannedAt, candidates, errors } -} - -async function buildCandidate(args: { - repo: Repo - worktree: Worktree - scannedAt: number - provider: IGitProvider | null - skipGit: boolean - forceGitCheck: boolean -}): Promise { - const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args - const blockers: WorkspaceCleanupBlocker[] = [] - const reasons = getInactivityReasons(worktree, scannedAt) - const repoIsFolder = isFolderRepo(repo) - - if (worktree.isMainWorktree) { - blockers.push('main-worktree') - } - if (repoIsFolder) { - blockers.push('folder-repo') - } - if (worktree.isPinned) { - blockers.push('pinned') - } - - const localContext = buildLocalContext(worktree) - const shouldReadGit = shouldReadGitEvidence({ - repoIsFolder, - blockers, - worktree, - skipGit, - forceGitCheck - }) - - const gitEvidence = !shouldReadGit - ? createEmptyGitEvidence() - : await readGitEvidence(worktree, repo, provider) - appendListItems(blockers, gitEvidence.blockers) - - const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { - worktreeId: worktree.id, - repoId: repo.id, - repoName: repo.displayName, - connectionId: repo.connectionId ?? null, - displayName: worktree.displayName, - branch: shortBranchName(worktree.branch), - path: worktree.path, - tier: 'review', - selectedByDefault: false, - reasons, - blockers: uniqueBlockers(blockers), - lastActivityAt: worktree.lastActivityAt, - ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), - localContext, - git: { - clean: gitEvidence.clean, - upstreamAhead: gitEvidence.upstreamAhead, - upstreamBehind: gitEvidence.upstreamBehind, - checkedAt: gitEvidence.checkedAt - }, - fingerprint: '' - } - - const fingerprint = createWorkspaceCleanupFingerprint({ - branch: candidateWithoutFingerprint.branch, - head: worktree.head, - gitClean: gitEvidence.clean, - lastActivityAt: worktree.lastActivityAt - }) - - return applyWorkspaceCleanupPolicy({ - ...candidateWithoutFingerprint, - reasons: uniqueReasons(reasons), - blockers: uniqueBlockers(blockers), - fingerprint - }) -} - -function shouldReadGitEvidence(args: { - repoIsFolder: boolean - blockers: WorkspaceCleanupBlocker[] - worktree: Worktree - skipGit: boolean - forceGitCheck: boolean -}): boolean { - const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args - if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) { - return false - } - if ( - blockers.includes('pinned') || - blockers.includes('main-worktree') || - blockers.includes('folder-repo') - ) { - return false - } - - // Why: inactivity is the only recommendation signal now. Git is read only - // to keep the destructive path from deleting dirty or local-only branch work. - return true -} - -function appendListItems(target: T[], entries: readonly T[]): void { - // Why: cleanup can aggregate generated-size worktree batches; spreading - // those batches into push can exceed JavaScript's argument limit. - for (const entry of entries) { - target.push(entry) - } -} - -async function readGitEvidence( - worktree: Worktree, - repo: Repo, - provider: IGitProvider | null -): Promise { - const blockers: WorkspaceCleanupBlocker[] = [] - let status: GitStatusResult - const checkedAt = Date.now() - - try { - status = await withTimeout( - repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path), - GIT_READ_TIMEOUT_MS, - 'Timed out reading git status.' - ) - } catch { - return { - ...createEmptyGitEvidence(), - blockers: ['git-status-error'] - } - } - - if (status.upstreamStatus === undefined) { - return { - ...createEmptyGitEvidence(), - blockers: ['git-status-error'] - } - } - - const clean = status.entries.length === 0 - if (!clean) { - blockers.push('dirty-files') - } - - const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null - const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null - if (upstreamAhead !== null && upstreamAhead > 0) { - blockers.push('unpushed-commits') - } - if (clean && upstreamAhead === null) { - const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider) - if (unpushedCommitCount === null) { - blockers.push('unknown-base') - } else if (unpushedCommitCount > 0) { - blockers.push('unpushed-commits') - } - } - - const evidence: GitEvidence = { - clean, - upstreamAhead, - upstreamBehind, - checkedAt, - blockers - } - - return { ...evidence, blockers: uniqueBlockers(blockers) } -} - -async function readUnpushedCommitCount( - worktree: Worktree, - repo: Repo, - provider: IGitProvider | null -): Promise { - try { - const result = await withTimeout( - repo.connectionId - ? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path) - : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { - cwd: worktree.path - }), - GIT_READ_TIMEOUT_MS, - 'Timed out checking unpushed commits.' - ) - const count = Number.parseInt(result.stdout.trim(), 10) - return Number.isFinite(count) ? count : null - } catch { - return null - } -} - -function buildCandidateFromError( - repo: Repo, - worktree: Worktree, - scannedAt: number, - _message: string -): WorkspaceCleanupCandidate { - return applyWorkspaceCleanupPolicy({ - worktreeId: worktree.id, - repoId: repo.id, - repoName: repo.displayName, - connectionId: repo.connectionId ?? null, - displayName: worktree.displayName, - branch: shortBranchName(worktree.branch), - path: worktree.path, - tier: 'protected', - selectedByDefault: false, - reasons: getInactivityReasons(worktree, scannedAt), - blockers: ['git-status-error'], - lastActivityAt: worktree.lastActivityAt, - ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), - localContext: buildLocalContext(worktree), - git: { - clean: null, - upstreamAhead: null, - upstreamBehind: null, - checkedAt: scannedAt - }, - fingerprint: createWorkspaceCleanupFingerprint({ - branch: shortBranchName(worktree.branch), - head: worktree.head, - gitClean: null, - lastActivityAt: worktree.lastActivityAt - }) - }) -} - -function synthesizeDisconnectedSshCandidates( - store: Store, - repo: Repo, - scannedAt: number, - targetWorktreeId?: string -): WorkspaceCleanupCandidate[] { - const repoWorktreePrefix = `${repo.id}::` - if (targetWorktreeId) { - if (!targetWorktreeId.startsWith(repoWorktreePrefix)) { - return [] - } - // Why: focused delete preflight names one workspace already; walking all - // persisted metadata is unnecessary for disconnected SSH repos. - const meta = store.getWorktreeMeta(targetWorktreeId) - return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : [] - } - - const candidates: WorkspaceCleanupCandidate[] = [] - const allMeta = store.getAllWorktreeMeta() - for (const worktreeId in allMeta) { - if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) { - continue - } - const meta = allMeta[worktreeId] - if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) { - continue - } - candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) - } - return candidates -} - -function createDisconnectedSshCandidate( - repo: Repo, - scannedAt: number, - worktreeId: string, - meta: WorktreeMeta -): WorkspaceCleanupCandidate { - const parsed = splitWorktreeId(worktreeId) - const path = parsed?.worktreePath ?? worktreeId - const reasons = getInactivityReasons(meta, scannedAt) - return applyWorkspaceCleanupPolicy({ - worktreeId, - repoId: repo.id, - repoName: repo.displayName, - connectionId: repo.connectionId ?? null, - displayName: meta.displayName || basename(path), - branch: basename(path), - path, - tier: 'protected', - selectedByDefault: false, - reasons, - blockers: ['ssh-disconnected'], - lastActivityAt: meta.lastActivityAt, - ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - localContext: { - terminalTabCount: 0, - cleanEditorTabCount: 0, - browserTabCount: 0, - diffCommentCount: meta.diffComments?.length ?? 0, - newestDiffCommentAt: getNewestDiffCommentAt(meta.diffComments), - retainedDoneAgentCount: 0 - }, - git: { - clean: null, - upstreamAhead: null, - upstreamBehind: null, - checkedAt: null - }, - fingerprint: createWorkspaceCleanupFingerprint({ - branch: basename(path), - head: '', - gitClean: null, - lastActivityAt: meta.lastActivityAt - }) - }) -} - -function buildLocalContext(worktree: Worktree): WorkspaceCleanupCandidate['localContext'] { - return { - terminalTabCount: 0, - cleanEditorTabCount: 0, - browserTabCount: 0, - diffCommentCount: worktree.diffComments?.length ?? 0, - newestDiffCommentAt: getNewestDiffCommentAt(worktree.diffComments), - retainedDoneAgentCount: 0 - } -} - -function getNewestDiffCommentAt(diffComments: Worktree['diffComments'] | undefined): number | null { - if (!diffComments || diffComments.length === 0) { - return null - } - // Why: persisted diff notes can grow large enough for spread-based Math.max - // to exceed the JavaScript argument limit during cleanup scans. - let newest = diffComments[0]?.createdAt ?? null - for (let index = 1; index < diffComments.length; index += 1) { - const createdAt = diffComments[index]?.createdAt - if (createdAt !== undefined && (newest === null || createdAt > newest)) { - newest = createdAt - } - } - return newest -} - -function createEmptyGitEvidence(): GitEvidence { - return { - clean: null, - upstreamAhead: null, - upstreamBehind: null, - checkedAt: null, - blockers: [] - } -} - -function shortBranchName(branch: string): string { - return branch.replace(/^refs\/heads\//, '') || 'HEAD' -} - -function uniqueBlockers(blockers: WorkspaceCleanupBlocker[]): WorkspaceCleanupBlocker[] { - return [...new Set(blockers)] -} - -function uniqueReasons(reasons: WorkspaceCleanupReason[]): WorkspaceCleanupReason[] { - return [...new Set(reasons)] -} - -function isWorkspaceInactiveForCleanup( - workspace: Pick, - scannedAt: number -): boolean { - return isWorkspaceOldForCleanup(workspace, scannedAt) -} - -function getInactivityReasons( - workspace: Pick, - scannedAt: number -): WorkspaceCleanupReason[] { - return getWorkspaceCleanupInactivityReasons(workspace, scannedAt) -} - -async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workerCount = Math.min(limit, items.length) - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await fn(items[index]) - } - }) - ) - return results -} - -async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { - let timeoutId: NodeJS.Timeout | undefined - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs) - }) - try { - return await Promise.race([promise, timeoutPromise]) - } finally { - if (timeoutId) { - clearTimeout(timeoutId) - } - } -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function createScanError(repo: Repo, message: string): WorkspaceCleanupScanError { - return { - repoId: repo.id, - repoName: repo.displayName || basename(repo.path), - message - } -} - -// Why: git errors often include absolute paths or command output. Keep the -// cause useful without leaking raw local/remote filesystem details to the UI. -function toSafeRepoScanError(error: unknown): string { - const message = toErrorMessage(error) - if (message === 'Timed out listing SSH worktrees.') { - return 'Timed out listing remote worktrees.' - } - if (message === 'Timed out listing worktrees.') { - return 'Timed out listing worktrees.' - } - if (message.startsWith('Timed out ')) { - return message.replace(/\.$/, '') - } - - const lower = message.toLowerCase() - if (lower.includes('not a git repository') || lower.includes('not a git worktree')) { - return 'Repository is not a git checkout.' - } - if ( - lower.includes('enoent') || - lower.includes('no such file') || - lower.includes('cannot find') || - lower.includes('does not exist') - ) { - return 'Repository folder was not found.' - } - if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) { - return 'Repository folder is not accessible.' - } - return 'Git could not list worktrees.' -} diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts new file mode 100644 index 00000000000..4c9d294cf21 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -0,0 +1,102 @@ +import { + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' + +type WorktreeBaseWatcherEvent = { + type: 'create' | 'update' | 'delete' + path: string +} + +export type WorktreeBaseWatchKind = 'base' | 'git-common' + +export type WorktreeBaseRepoWatchConfig = { + repoId: string + repoName: string + nestWorkspaces: boolean +} + +export type WorktreeBaseWatchTarget = { + key: string + kind: WorktreeBaseWatchKind + path: string + connectionId?: string + repos: Map +} + +export function pathRelativeToWorktreeWatchRoot( + rootPath: string, + candidatePath: string +): string[] | null { + const relativePath = relativePathInsideRoot(rootPath, candidatePath) + if (relativePath === null) { + return null + } + return relativePath.split(/[\\/]+/).filter(Boolean) +} + +function isRootCompletionEvent(parts: string[], config: WorktreeBaseRepoWatchConfig): boolean { + if (config.nestWorkspaces) { + return ( + parts.length === 2 && + normalizeRuntimePathForComparison(parts[0]) === + normalizeRuntimePathForComparison(config.repoName) + ) + } + return parts.length === 1 +} + +// Why: root creation can arrive before Git finishes registration; the `.git` +// marker is the checkout-complete signal, while deeper file churn is ignored. +function isGitMarkerCompletionEvent(parts: string[], config: WorktreeBaseRepoWatchConfig): boolean { + if (config.nestWorkspaces) { + return ( + parts.length === 3 && + normalizeRuntimePathForComparison(parts[0]) === + normalizeRuntimePathForComparison(config.repoName) && + parts[2] === '.git' + ) + } + return parts.length === 2 && parts[1] === '.git' +} + +function matchingBaseRepoIds( + target: WorktreeBaseWatchTarget, + eventPath: string, + eventType: string +): string[] { + const repoIds: string[] = [] + const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath) + if (!parts) { + return repoIds + } + + for (const config of target.repos.values()) { + if ( + isGitMarkerCompletionEvent(parts, config) || + (eventType === 'delete' && isRootCompletionEvent(parts, config)) + ) { + repoIds.push(config.repoId) + } + } + return repoIds +} + +// Why: Git records linked worktrees under the common dir's `worktrees` +// metadata, which is lower churn than watching checkout contents. +function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] { + const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath) + if (!parts || parts[0] !== 'worktrees') { + return [] + } + return [...target.repos.keys()] +} + +export function matchingWorktreeBaseRepoIds( + target: WorktreeBaseWatchTarget, + event: WorktreeBaseWatcherEvent +): string[] { + return target.kind === 'git-common' + ? matchingGitCommonRepoIds(target, event.path) + : matchingBaseRepoIds(target, event.path, event.type) +} diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts new file mode 100644 index 00000000000..22cf8da8540 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -0,0 +1,202 @@ +import { normalize } from 'path' +import { realpath, stat } from 'fs/promises' +import type { Stats } from 'fs' +import type { Store } from '../persistence' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { GlobalSettings, Repo } from '../../shared/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isFolderRepo } from '../../shared/repo-kind' +import { + isRuntimePathAbsolute, + isWindowsAbsolutePathLike, + getRuntimePathBasename, + normalizeRuntimePathForComparison, + resolveRuntimePath +} from '../../shared/cross-platform-path' +import { isWslUncPath } from '../../shared/wsl-paths' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + computeWorkspaceRoot, + getWorktreePathSettings, + hasRepoWorktreeBasePath +} from './worktree-logic' +import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchKind, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +const missingRootWarnings = new Set() +const skippedWslWarnings = new Set() + +function normalizeWatchKey(pathValue: string): string { + return normalizeRuntimePathForComparison(normalize(pathValue)) +} + +async function canonicalizeExistingPath( + pathValue: string, + connectionId: string | undefined +): Promise { + if (connectionId) { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return normalize(pathValue) + } + try { + return await provider.realpath(pathValue) + } catch { + return normalize(pathValue) + } + } + try { + return await realpath(pathValue) + } catch { + return normalize(pathValue) + } +} + +function isDirectoryStat(value: Stats | FileStat | undefined): boolean { + if (!value) { + return false + } + return 'type' in value ? value.type === 'directory' : value.isDirectory() +} + +async function addTarget( + targets: Map, + kind: WorktreeBaseWatchKind, + pathValue: string, + config: WorktreeBaseRepoWatchConfig, + connectionId?: string +): Promise { + const watchedPath = await canonicalizeExistingPath(pathValue, connectionId) + const key = `${kind}:${connectionId ?? 'local'}:${normalizeWatchKey(watchedPath)}` + const existing = targets.get(key) + if (existing) { + existing.repos.set(config.repoId, config) + return + } + targets.set(key, { + key, + kind, + path: watchedPath, + ...(connectionId ? { connectionId } : {}), + repos: new Map([[config.repoId, config]]) + }) +} + +function getRemoteProvider(connectionId: string | undefined): IFilesystemProvider | undefined { + return connectionId ? getSshFilesystemProvider(connectionId) : undefined +} + +function isRuntimePathAbsoluteForRepo(repoPath: string, pathValue: string): boolean { + const pathFlavor = + isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(pathValue) + ? 'windows' + : 'posix' + return isRuntimePathAbsolute(pathValue, pathFlavor) +} + +function getBaseWatchLayout( + repo: Repo, + pathSettings: Pick, + connectionId: string | undefined +): { workspaceRoot: string; nestWorkspaces: boolean } { + if ( + connectionId && + !hasRepoWorktreeBasePath(repo) && + isRuntimePathAbsoluteForRepo(repo.path, pathSettings.workspaceDir) + ) { + // Why: SSH creates default worktrees beside the remote repo when the + // global workspace dir is a desktop-local absolute path. + return { workspaceRoot: resolveRuntimePath(repo.path, '..'), nestWorkspaces: false } + } + + return { + workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), + nestWorkspaces: pathSettings.nestWorkspaces + } +} + +async function maybeAddBaseTarget( + targets: Map, + repo: Repo, + settings: GlobalSettings, + connectionId?: string +): Promise { + const pathSettings = getWorktreePathSettings(repo, settings) + const { workspaceRoot, nestWorkspaces } = getBaseWatchLayout(repo, pathSettings, connectionId) + // Why: WSL UNC roots are unreliable for native watching; avoid project-level polling. + if (isWslUncPath(workspaceRoot) || isWslUncPath(repo.path)) { + const key = `${repo.id}:${workspaceRoot}` + if (!skippedWslWarnings.has(key)) { + skippedWslWarnings.add(key) + console.warn( + `[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}` + ) + } + return + } + + const config = { + repoId: repo.id, + repoName: getRuntimePathBasename(repo.path).replace(/\.git$/, ''), + nestWorkspaces + } + const remoteProvider = getRemoteProvider(connectionId) + if (connectionId && !remoteProvider) { + return + } + try { + const rootStat = remoteProvider + ? await remoteProvider.stat(workspaceRoot) + : await stat(workspaceRoot) + if (isDirectoryStat(rootStat)) { + await addTarget(targets, 'base', workspaceRoot, config, connectionId) + } + } catch { + const key = normalizeWatchKey(workspaceRoot) + if (!missingRootWarnings.has(key)) { + missingRootWarnings.add(key) + console.warn(`[worktree-base-watcher] worktree root unavailable: ${workspaceRoot}`) + } + } + + const commonDir = await resolveWorktreeCommonGitDirectory( + repo, + remoteProvider + ? { + stat: (path) => remoteProvider.stat(path), + readFile: async (path) => (await remoteProvider.readFile(path)).content + } + : undefined + ) + if (commonDir) { + await addTarget(targets, 'git-common', commonDir, config, connectionId) + } +} + +export async function buildWorktreeBaseDirectoryWatchTargets( + store: Store +): Promise> { + const settings = store.getSettings() + const targets = new Map() + for (const repo of store.getRepos()) { + if (isFolderRepo(repo)) { + continue + } + const executionHostId = getRepoExecutionHostId(repo) + if (executionHostId === LOCAL_EXECUTION_HOST_ID) { + await maybeAddBaseTarget(targets, repo, settings) + } else if (repo.connectionId) { + await maybeAddBaseTarget(targets, repo, settings, repo.connectionId) + } + } + return targets +} + +export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void { + missingRootWarnings.clear() + skippedWslWarnings.clear() +} diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts new file mode 100644 index 00000000000..6b052603fb3 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join, sep } from 'path' +import type { Event as WatcherEvent, SubscribeCallback } from '@parcel/watcher' +import type { GlobalSettings, Repo } from '../../shared/types' + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(async () => ''), + realpath: vi.fn(async (path: string) => path), + stat: vi.fn(async () => ({ isDirectory: () => true })) +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./worktree-remote', () => ({ + notifyWorktreesChanged: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { subscribe } from '@parcel/watcher' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { notifyWorktreesChanged } from './worktree-remote' +import { + disposeWorktreeBaseDirectoryWatchers, + syncWorktreeBaseDirectoryWatchers +} from './worktree-base-directory-watcher' +import { matchingWorktreeBaseRepoIds } from './worktree-base-directory-event-filter' + +type WatcherCallback = SubscribeCallback + +const watcherCallbacks = new Map() +const unsubscribeMocks = new Map>() +const absolutePath = (...parts: string[]): string => join(sep, ...parts) +const WORKTREE_ROOT = absolutePath('workspace', 'worktrees') +const PROJECT_ROOT = absolutePath('workspace', 'projects', 'project') +const PROJECT_GIT_COMMON_DIR = join(PROJECT_ROOT, '.git') + +const settings = { + workspaceDir: WORKTREE_ROOT, + nestWorkspaces: true +} as GlobalSettings + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: PROJECT_ROOT, + displayName: 'Project', + badgeColor: '#000000', + addedAt: 1, + ...overrides + } as Repo +} + +function makeStore(repos: Repo[]) { + return { + getSettings: () => settings, + getRepos: () => repos + } +} + +function makeWindow(options: { destroyed?: () => boolean } = {}) { + return { + isDestroyed: () => options.destroyed?.() ?? false, + webContents: { send: vi.fn() } + } +} + +function emit(root: string, events: WatcherEvent[]): void { + const callback = watcherCallbacks.get(root) + if (!callback) { + throw new Error(`No watcher callback for ${root}`) + } + callback(null, events) +} + +describe('worktree base directory watcher', () => { + beforeEach(() => { + vi.useFakeTimers() + watcherCallbacks.clear() + unsubscribeMocks.clear() + vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined) + vi.mocked(subscribe).mockImplementation(async (root, callback) => { + const unsubscribe = vi.fn(async () => {}) + watcherCallbacks.set(root, callback) + unsubscribeMocks.set(root, unsubscribe) + return { unsubscribe } + }) + }) + + afterEach(async () => { + await disposeWorktreeBaseDirectoryWatchers() + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('coalesces nested worktree completion events into one targeted notification', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(WORKTREE_ROOT, [ + { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') }, + { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + ] as WatcherEvent[]) + + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + }) + + it('drops pending worktree notifications after the window is destroyed', async () => { + let destroyed = false + await syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo()]) as never, + makeWindow({ destroyed: () => destroyed }) as never + ) + + emit(WORKTREE_ROOT, [ + { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + ] as WatcherEvent[]) + destroyed = true + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('ignores deep checkout churn below candidate roots', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(WORKTREE_ROOT, [ + { type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') } + ] as WatcherEvent[]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('uses Git common-dir worktree metadata as a low-churn completion signal', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + emit(PROJECT_GIT_COMMON_DIR, [ + { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') } + ] as WatcherEvent[]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + }) + + it('does not install local desktop watchers for runtime or folder repos', async () => { + await syncWorktreeBaseDirectoryWatchers( + makeStore([ + makeRepo({ id: 'runtime', executionHostId: 'runtime:dev' }), + makeRepo({ id: 'folder', kind: 'folder' }) + ]) as never, + makeWindow() as never + ) + + expect(subscribe).not.toHaveBeenCalled() + }) + + it('uses the remote sibling root for default SSH worktree roots', async () => { + const remoteCallbacks = new Map void>() + const remoteUnwatch = vi.fn() + const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => { + remoteCallbacks.set(root, callback) + return remoteUnwatch + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })), + realpath: vi.fn(async (path: string) => path), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + watch: remoteWatch + } as never) + + await syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never, + makeWindow() as never + ) + + expect(subscribe).not.toHaveBeenCalled() + expect(remoteWatch).toHaveBeenCalledWith('/home/alice', expect.any(Function)) + remoteCallbacks.get('/home/alice')?.([ + { + kind: 'create', + absolutePath: '/home/alice/external-5104/.git' + } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + await disposeWorktreeBaseDirectoryWatchers() + expect(remoteUnwatch).toHaveBeenCalled() + }) + + it('unsubscribes roots that disappear after repo settings change', async () => { + const repo = makeRepo() + const store = makeStore([repo]) + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + + const otherRoot = absolutePath('workspace', 'other-worktrees') + repo.worktreeBasePath = otherRoot + await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never) + + expect(unsubscribeMocks.get(WORKTREE_ROOT)).toHaveBeenCalled() + expect(watcherCallbacks.has(otherRoot)).toBe(true) + }) + + it('unsubscribes a watcher that finishes installing after disposal starts', async () => { + let resolveSubscribe: (subscription: { unsubscribe: () => Promise }) => void = () => {} + const unsubscribe = vi.fn(async () => {}) + vi.mocked(subscribe).mockImplementationOnce( + async () => + new Promise((resolve) => { + resolveSubscribe = resolve + }) + ) + + const syncPromise = syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo()]) as never, + makeWindow() as never + ) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalled()) + const disposePromise = disposeWorktreeBaseDirectoryWatchers() + resolveSubscribe({ unsubscribe }) + await syncPromise + await disposePromise + + expect(unsubscribe).toHaveBeenCalled() + }) + + it('matches flat workspace .git marker events without matching sibling churn', () => { + const target = { + key: `base:${WORKTREE_ROOT}`, + kind: 'base' as const, + path: WORKTREE_ROOT, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'create', + path: join(WORKTREE_ROOT, 'external-5104', '.git') + } as WatcherEvent) + ).toEqual(['repo-1']) + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'update', + path: join(WORKTREE_ROOT, 'external-5104', 'src', 'file.ts') + } as WatcherEvent) + ).toEqual([]) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts new file mode 100644 index 00000000000..f7b23103d2b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -0,0 +1,286 @@ +import type { BrowserWindow } from 'electron' +import type { AsyncSubscription } from '@parcel/watcher' +import type { FsChangeEvent } from '../../shared/types' +import type { Store } from '../persistence' +import { notifyWorktreesChanged } from './worktree-remote' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + matchingWorktreeBaseRepoIds, + type WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import { + buildWorktreeBaseDirectoryWatchTargets, + clearWorktreeBaseDirectoryWatchTargetWarnings +} from './worktree-base-directory-watch-targets' + +type ActiveWatch = WorktreeBaseWatchTarget & { + mainWindow: BrowserWindow + subscription: Pick + notifyTimer: ReturnType | null + pendingRepoIds: Set + disposed: boolean +} + +const WATCH_DEBOUNCE_MS = 250 +const activeWatches = new Map() +let syncGeneration = 0 +let scheduledSync: ReturnType | null = null +let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null + +function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + watch.pendingRepoIds.clear() + return + } + for (const repoId of repoIds) { + watch.pendingRepoIds.add(repoId) + } + if (watch.notifyTimer) { + clearTimeout(watch.notifyTimer) + } + watch.notifyTimer = setTimeout(() => { + watch.notifyTimer = null + if (watch.disposed || watch.mainWindow.isDestroyed()) { + watch.pendingRepoIds.clear() + return + } + const pending = [...watch.pendingRepoIds] + watch.pendingRepoIds.clear() + for (const repoId of pending) { + notifyWorktreesChanged(watch.mainWindow, repoId) + } + }, WATCH_DEBOUNCE_MS) +} + +function collectMatchingRepoIds( + watch: ActiveWatch, + eventType: 'create' | 'update' | 'delete', + eventPath: string, + repoIds: Set +): void { + for (const repoId of matchingWorktreeBaseRepoIds(watch, { type: eventType, path: eventPath })) { + repoIds.add(repoId) + } +} + +function handleLocalWatchEvents( + watch: ActiveWatch, + error: Error | null, + events: { type: 'create' | 'update' | 'delete'; path: string }[] +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + if (error) { + console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) + scheduleNotification(watch, [...watch.repos.keys()]) + return + } + const repoIds = new Set() + for (const event of events) { + collectMatchingRepoIds(watch, event.type, event.path, repoIds) + } + if (repoIds.size > 0) { + scheduleNotification(watch, [...repoIds]) + } +} + +function handleRemoteWatchEvents(watch: ActiveWatch, events: FsChangeEvent[]): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + const repoIds = new Set() + for (const event of events) { + if (event.kind === 'overflow') { + scheduleNotification(watch, [...watch.repos.keys()]) + return + } + if (event.kind === 'rename') { + if (event.oldAbsolutePath) { + collectMatchingRepoIds(watch, 'delete', event.oldAbsolutePath, repoIds) + } + collectMatchingRepoIds(watch, 'create', event.absolutePath, repoIds) + continue + } + collectMatchingRepoIds(watch, event.kind, event.absolutePath, repoIds) + } + if (repoIds.size > 0) { + scheduleNotification(watch, [...repoIds]) + } +} + +async function subscribeTarget( + target: WorktreeBaseWatchTarget, + mainWindow: BrowserWindow +): Promise { + let activeWatch: ActiveWatch | null = null + if (target.connectionId) { + const provider = getSshFilesystemProvider(target.connectionId) + if (!provider) { + throw new Error(`SSH filesystem provider unavailable for ${target.connectionId}`) + } + const unwatch = await provider.watch(target.path, (events) => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (!currentWatch || currentWatch.disposed) { + return + } + handleRemoteWatchEvents(currentWatch, events) + }) + activeWatch = { + ...target, + mainWindow, + subscription: { unsubscribe: async () => unwatch() }, + notifyTimer: null, + pendingRepoIds: new Set(), + disposed: false + } + return activeWatch + } + + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + target.path, + (error, events) => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (!currentWatch || currentWatch.disposed) { + return + } + handleLocalWatchEvents(currentWatch, error, events) + }, + { + ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.cache/**'], + ...(process.platform === 'win32' ? { backend: 'windows' as const } : {}) + } + ) + activeWatch = { + ...target, + mainWindow, + subscription, + notifyTimer: null, + pendingRepoIds: new Set(), + disposed: false + } + return activeWatch +} + +async function replaceWatch( + target: WorktreeBaseWatchTarget, + mainWindow: BrowserWindow, + generation: number +): Promise { + const previous = activeWatches.get(target.key) + if (previous) { + previous.repos = target.repos + previous.mainWindow = mainWindow + return + } + try { + const activeWatch = await subscribeTarget(target, mainWindow) + if (generation !== syncGeneration) { + activeWatch.disposed = true + await activeWatch.subscription.unsubscribe().catch((error) => { + console.warn(`[worktree-base-watcher] failed to unwatch stale ${target.path}:`, error) + }) + return + } + activeWatches.set(target.key, activeWatch) + } catch (error) { + console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error) + } +} + +async function removeWatch(key: string): Promise { + const watch = activeWatches.get(key) + if (!watch) { + return + } + activeWatches.delete(key) + watch.disposed = true + if (watch.notifyTimer) { + clearTimeout(watch.notifyTimer) + } + await watch.subscription.unsubscribe().catch((error) => { + console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) + }) +} + +export async function syncWorktreeBaseDirectoryWatchers( + store: Store, + mainWindow: BrowserWindow +): Promise { + const generation = ++syncGeneration + const targets = await buildWorktreeBaseDirectoryWatchTargets(store) + if (generation !== syncGeneration) { + return + } + for (const key of activeWatches.keys()) { + if (generation !== syncGeneration) { + return + } + if (!targets.has(key)) { + await removeWatch(key) + if (generation !== syncGeneration) { + return + } + } + } + for (const target of targets.values()) { + if (generation !== syncGeneration) { + return + } + await replaceWatch(target, mainWindow, generation) + if (generation !== syncGeneration) { + return + } + } +} + +export function setWorktreeBaseDirectoryWatcherSyncContext( + store: Store, + mainWindow: BrowserWindow +): void { + latestSyncContext = { store, mainWindow } + // Why: older integration tests use lean BrowserWindow stubs; real windows still + // clear this context on close so stale watcher syncs cannot target dead chrome. + if (typeof mainWindow.once === 'function') { + mainWindow.once('closed', () => { + if (latestSyncContext?.mainWindow === mainWindow) { + latestSyncContext = null + } + }) + } +} + +export function scheduleWorktreeBaseDirectoryWatcherSync( + store: Store, + mainWindow: BrowserWindow +): void { + if (scheduledSync) { + clearTimeout(scheduledSync) + } + scheduledSync = setTimeout(() => { + scheduledSync = null + if (mainWindow.isDestroyed()) { + return + } + void syncWorktreeBaseDirectoryWatchers(store, mainWindow) + }, 100) +} + +export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void { + if (!latestSyncContext || latestSyncContext.mainWindow.isDestroyed()) { + return + } + scheduleWorktreeBaseDirectoryWatcherSync(latestSyncContext.store, latestSyncContext.mainWindow) +} + +export async function disposeWorktreeBaseDirectoryWatchers(): Promise { + syncGeneration++ + latestSyncContext = null + if (scheduledSync) { + clearTimeout(scheduledSync) + scheduledSync = null + } + await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key))) + clearWorktreeBaseDirectoryWatchTargetWarnings() +} diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts new file mode 100644 index 00000000000..2ba706a0734 --- /dev/null +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -0,0 +1,65 @@ +import { readFile, stat } from 'fs/promises' +import type { Repo } from '../../shared/types' +import { + getRuntimePathBasename, + normalizeRuntimePathSeparators, + resolveRuntimePath +} from '../../shared/cross-platform-path' +import type { FileStat } from '../providers/types' + +type GitDirectoryStat = Awaited> | FileStat + +type GitDirectoryAccess = { + stat?: (path: string) => Promise + readFile?: (path: string) => Promise +} + +function isDirectoryStat(value: GitDirectoryStat): boolean { + return 'type' in value ? value.type === 'directory' : value.isDirectory() +} + +function isFileStat(value: GitDirectoryStat): boolean { + return 'type' in value ? value.type === 'file' : value.isFile() +} + +function runtimeDirname(pathValue: string): string { + const normalized = normalizeRuntimePathSeparators(pathValue).replace(/\/+$/, '') + const index = normalized.lastIndexOf('/') + if (index < 0) { + return '.' + } + if (index === 0) { + return '/' + } + return normalized.slice(0, index) +} + +export async function resolveWorktreeCommonGitDirectory( + repo: Repo, + access: GitDirectoryAccess = {} +): Promise { + const dotGitPath = resolveRuntimePath(repo.path, '.git') + const statPath = access.stat ?? stat + const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8')) + try { + const dotGitStat = await statPath(dotGitPath) + if (isDirectoryStat(dotGitStat)) { + return dotGitPath + } + if (!isFileStat(dotGitStat)) { + return null + } + const content = await readText(dotGitPath) + const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim() + if (!gitDir) { + return null + } + const resolvedGitDir = resolveRuntimePath(repo.path, gitDir) + return getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees' + ? runtimeDirname(runtimeDirname(resolvedGitDir)) + : resolvedGitDir + } catch (error) { + console.warn(`[worktree-base-watcher] cannot resolve git common dir for ${repo.id}:`, error) + return null + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 74dfb7db9f8..ae445927837 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -93,7 +93,12 @@ import { createWorktreeLinkedPaths } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' -import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../shared/setup-runner-command' +import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' +import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy' import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' import { markCodexProjectTrusted, @@ -122,6 +127,7 @@ type RemoteWorktreeCreateBasePlan = { type StagedStartupResult = { startupTerminal?: CreateWorktreeResult['startupTerminal'] + activationSetup?: CreateWorktreeResult['setup'] didSpawnSetup: boolean warning?: string } @@ -230,6 +236,26 @@ async function spawnLocalStartupAndSetupTerminals(args: { let startupTerminalHandle: string | null = null let startupTerminal: CreateWorktreeResult['startupTerminal'] + let sequencedStartup = startup + let wrappedSetupCommandStr: string | undefined + if (startup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: startup.command, + platform + }) + sequencedStartup = { + ...startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + try { // Why: after `git worktree add` and metadata registration, a runtime-owned // PTY can begin booting the selected agent while setup runs in a sibling @@ -249,12 +275,13 @@ async function spawnLocalStartupAndSetupTerminals(args: { } } const terminal = await runtime.createTerminal(`id:${worktree.id}`, { - command: startup.command, - env: startup.env, - ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + command: sequencedStartup.command, + ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), - startupCommandDelivery: startup.startupCommandDelivery, - telemetry: startup.telemetry, + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry, activate: true }) startupTerminalHandle = terminal.handle @@ -272,10 +299,15 @@ async function spawnLocalStartupAndSetupTerminals(args: { let didSpawnSetup = false if (setup) { try { - const setupCommand = buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + ) const setupLaunchMode = (settings as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -307,6 +339,16 @@ async function spawnLocalStartupAndSetupTerminals(args: { } return { + ...(setup && !didSpawnSetup + ? { + activationSetup: { + ...setup, + ...(startupTerminalHandle && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + } + : {}), ...(startupTerminal ? { startupTerminal } : {}), didSpawnSetup, ...(warning ? { warning } : {}) @@ -1072,7 +1114,10 @@ async function createRemoteSetupRunnerScript( ) return { runnerScriptPath, - envVars: getSetupRunnerEnvVars(repo, worktreePath) + envVars: getSetupRunnerEnvVars(repo, worktreePath), + ...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) + ? { waitForAgentStartup: true } + : {}) } } @@ -2390,7 +2435,11 @@ export async function createLocalWorktree( return { worktree: { ...worktree, workspaceLineage }, ...(workspaceLineage ? { workspaceLineage } : {}), - ...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}), + ...(stagedStartup.activationSetup + ? { setup: stagedStartup.activationSetup } + : setup && !stagedStartup.didSpawnSetup + ? { setup } + : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 51324c45c89..5ce5d0baba6 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -940,6 +940,7 @@ describe('registerWorktreeHandlers', () => { 1, 'id:repo-1::/workspace/improve-dashboard', { + claudeAgentTeamsSourceCommand: 'claude --prefill test', command: 'claude --prefill test', env: { ORCA_AGENT_MODE: 'direct' }, launchAgent: 'claude', @@ -957,7 +958,7 @@ describe('registerWorktreeHandlers', () => { 'id:repo-1::/workspace/improve-dashboard', { title: 'Setup', - command: 'bash /workspace/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh'), env: { ORCA_ROOT_PATH: '/workspace/repo', ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' @@ -965,6 +966,15 @@ describe('registerWorktreeHandlers', () => { activate: false } ) + const startupCreateCall = runtimeStub.createTerminal.mock.calls[0] + const setupCreateCall = runtimeStub.createTerminal.mock.calls[1] + if (!startupCreateCall || !setupCreateCall) { + throw new Error('expected startup and setup terminal calls') + } + const startupCommand = (startupCreateCall[1] as { command: string }).command + const setupCommand = (setupCreateCall[1] as { command: string }).command + expect(startupCommand).toBe('claude --prefill test') + expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh') expect(result.setup).toBeUndefined() expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' }) expect(result.timing?.phases.map((phase) => phase.phase)).toEqual( @@ -977,6 +987,57 @@ describe('registerWorktreeHandlers', () => { ) }) + it('returns the wrapped setup command when startup spawned but setup creation failed', async () => { + addWorktreeMock.mockResolvedValue({}) + listWorktreesMock.mockResolvedValueOnce([ + { + path: '/workspace/improve-dashboard', + head: 'def', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + createSetupRunnerScriptMock.mockReturnValueOnce({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/workspace/repo', + ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' + }, + waitForAgentStartup: true + }) + runtimeStub.createTerminal + .mockResolvedValueOnce({ handle: 'term-startup', surface: 'visible' }) + .mockRejectedValueOnce(new Error('setup creation failed')) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + createdWithAgent: 'claude', + startup: { + command: 'claude --prefill test', + env: { ORCA_AGENT_MODE: 'direct' }, + telemetry: { + agent_kind: 'claude', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + })) as { setup?: { command?: string; runnerScriptPath: string } } + + expect(result.setup).toEqual( + expect.objectContaining({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh') + }) + ) + expect(result.setup?.command).toContain('printf') + }) + it('checks out a selected existing local branch exactly', async () => { listWorktreesMock .mockResolvedValueOnce([ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 1baddaff16f..e6bca9b3119 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3790,11 +3790,16 @@ export class Store { | 'forkSyncMode' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' | 'projectHostSetupMethod' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } ): Repo | null { const repo = this.state.repos.find((r) => r.id === id) if (!repo) { @@ -3844,6 +3849,14 @@ export class Store { // time visibility changes so later hide/show choices keep legacy safety. repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy } + if ( + 'externalWorktreeDiscoverySuppressedAt' in sanitizedUpdates && + (sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === undefined || + sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === null) + ) { + delete repo.externalWorktreeDiscoverySuppressedAt + delete sanitizedUpdates.externalWorktreeDiscoverySuppressedAt + } if ( 'sourceControlAi' in sanitizedUpdates && (sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 751ebc1ef7c..563f3e60b59 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -103,11 +103,14 @@ export class SshGitProvider implements IGitProvider { const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} - return (await this.mux.request('git.status', { + const request = { worktreePath, ...includeIgnoredArgs, ...upstreamCacheBypassArgs - })) as GitStatusResult + } + return (await (options?.signal + ? this.mux.request('git.status', request, { signal: options.signal }) + : this.mux.request('git.status', request))) as GitStatusResult } async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 5adfdecf23b..7a6af3da39a 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -102,6 +102,28 @@ describe('SshPtyProvider', () => { }) }) + it('forwards provider command delivery to the relay', async () => { + mux.request.mockResolvedValue({ id: 'pty-provider-command' }) + + await provider.spawn({ + cols: 120, + rows: 40, + command: 'echo from-runtime', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }, + command: 'echo from-runtime', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + }) + it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => { mux.request.mockResolvedValue({ id: 'pty-bridge' }) provider = new SshPtyProvider('conn-1', mux as never, { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 54c2ce5824f..c33910bad95 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -133,12 +133,10 @@ export class SshPtyProvider implements IPtyProvider { cwd: opts.cwd, env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete), // Why: the relay's plugin-overlay env augmenter needs to know which - // Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors - // the right `~/./agent` source dir on the remote disk. The - // relay does not execute `command` itself — the user types it into - // the shell — but receiving it as a hint lets overlay resolution be - // per-launch instead of always-Pi. + // Pi-compatible agent is being launched, while commandDelivery tells it + // whether to submit the command itself for runtime-owned background PTYs. ...(opts.command ? { command: opts.command } : {}), + ...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}), ...(opts.startupCommandDelivery ? { startupCommandDelivery: opts.startupCommandDelivery } : {}) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index c8a090e59fa..6561d120240 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -30,6 +30,7 @@ export type PtySpawnOptions = { env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ @@ -168,6 +169,7 @@ export type IFilesystemProvider = { export type GitProviderStatusOptions = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + signal?: AbortSignal } export type IGitProvider = { diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index ca3bcf359c9..e02ba35f9c7 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -6,6 +6,7 @@ import { areWorktreePathsEqual } from './ipc/worktree-logic' type LocalRepoWorktreeListOptions = { wslDistro?: string + signal?: AbortSignal } export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2a4f84e7d86..edc10ea87f2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -66,6 +66,7 @@ import { } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { makePaneKey } from '../../shared/stable-pane-id' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' @@ -2911,7 +2912,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const parent = { path: '/remote/repo-parent', @@ -3078,7 +3085,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const metaById: Record = {} const remoteStore = { @@ -3288,7 +3301,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const metaById: Record = {} const remoteStore = { @@ -3375,15 +3394,16 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) - expect(result.setup).toMatchObject({ - runnerScriptPath: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' - }) + // Why: runtime now provisions setup itself (fire-and-forget) and omits it + // from the RPC result so the caller does not double-spawn. The async spawn + // means we wait for both the agent and setup PTYs before asserting. + expect(result.setup).toBeUndefined() await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ cwd: '/remote/mobile-setup', - command: 'claude', + command: expect.stringContaining('exec claude'), worktreeId: result.worktree.id }) ) @@ -3391,10 +3411,21 @@ describe('OrcaRuntimeService', () => { 2, expect.objectContaining({ cwd: '/remote/mobile-setup', - command: 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh', + command: expect.stringContaining( + 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' + ), worktreeId: result.worktree.id }) ) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done` + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(startupCommand).toContain(markerPath) + expect(setupCommand).toContain(markerPath) expect(revealTerminalSession).toHaveBeenLastCalledWith( result.worktree.id, expect.objectContaining({ @@ -6424,6 +6455,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ cwd: TEST_WORKTREE_PATH, command: 'codex', + commandDelivery: 'provider', worktreeId: TEST_WORKTREE_ID, preAllocatedHandle: expect.stringMatching(/^term_/) }) @@ -6704,6 +6736,49 @@ describe('OrcaRuntimeService', () => { }) }) + it('preserves Claude Agent Teams for sequenced Claude launches', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + claudeAgentTeamsMode: 'in-process' as const + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: + 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'', + claudeAgentTeamsSourceCommand: 'claude "hello"', + launchAgent: 'claude', + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { CLAUDE_PROFILE: 'captured' } + } + }) + + const sequencedClaude = spawn.mock.calls[0]?.[0] as { + command?: string + env?: Record + } + + expect(sequencedClaude.command).toBe( + 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'' + ) + expect(sequencedClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(sequencedClaude.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe( + 'claude --teammate-mode in-process "hello"' + ) + }) + it('restores captured native Claude Agent Teams mode with fresh service env', async () => { setPlatform('linux') const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) @@ -18704,15 +18779,9 @@ describe('OrcaRuntimeService', () => { repoId: 'repo-1', path: '/tmp/workspaces/runtime-hook-skip', branch: 'runtime-hook-skip' - }), - setup: { - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - envVars: { - ORCA_ROOT_PATH: '/tmp/repo', - ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' - } - } + }) }) + expect(result.setup).toBeUndefined() expect(activateWorktree).not.toHaveBeenCalled() await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( @@ -18754,6 +18823,166 @@ describe('OrcaRuntimeService', () => { }) }) + it('sequences setup before startup for opted-in local headless worktree creates', async () => { + const waitRepo = { + ...store.getRepo('repo-1')!, + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } + } + const runtimeStore = { + ...store, + getRepos: () => [waitRepo], + getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-headless-startup' }) + .mockResolvedValueOnce({ id: 'pty-headless-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup' + }, + waitForAgentStartup: true + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-headless-startup-setup', + head: 'def', + branch: 'runtime-headless-startup-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-headless-startup-setup', + setupDecision: 'run', + startup: { command: 'claude' } + }) + + expect(createSetupRunnerScript).toHaveBeenCalled() + expect(runHook).not.toHaveBeenCalled() + // Why: runtime now provisions setup fire-and-forget, so the setup PTY spawns + // on a later tick. The wait-for-setup guarantee is enforced by the shell + // nonce/marker in the wrapped commands below, not by JS spawn ordering. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + expect(startupCommand).toContain('exec claude') + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(result.setup).toBeUndefined() + }) + + it('starts setup and startup side by side by default for local headless worktree creates', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-parallel' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-headless-parallel-startup' }) + .mockResolvedValueOnce({ id: 'pty-headless-parallel-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-parallel' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-headless-parallel', + head: 'def', + branch: 'runtime-headless-parallel', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-headless-parallel', + setupDecision: 'run', + startup: { command: 'claude' } + }) + + // Why: setup now spawns fire-and-forget on a later tick; wait for both PTYs. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' })) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh' }) + ) + }) + it('creates the first terminal for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -19449,7 +19678,7 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) - it('honors split setup placement for local startup-draft worktrees', async () => { + it('honors split setup placement for opted-in local startup-draft worktrees', async () => { const metaById: Record = {} const runtimeStore = { ...store, @@ -19506,7 +19735,8 @@ describe('OrcaRuntimeService', () => { envVars: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split' - } + }, + waitForAgentStartup: true }) vi.mocked(listWorktrees).mockResolvedValue([ { @@ -19531,7 +19761,7 @@ describe('OrcaRuntimeService', () => { 1, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: expect.stringContaining('codex'), worktreeId: result.worktree.id }) ) @@ -19539,7 +19769,7 @@ describe('OrcaRuntimeService', () => { 2, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), env: expect.objectContaining({ ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split', @@ -19548,8 +19778,19 @@ describe('OrcaRuntimeService', () => { worktreeId: result.worktree.id }) ) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done` + expect(startupCommand).toContain('--dangerously-bypass-approvals-and-sandbox') + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(startupCommand).toContain(markerPath) + expect(setupCommand).toContain(markerPath) const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} + expect(result.setup).toBeUndefined() expect(mainEnv.ORCA_TAB_ID).toBeDefined() expect(mainEnv.ORCA_PANE_KEY).toBeDefined() expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) @@ -19566,6 +19807,84 @@ describe('OrcaRuntimeService', () => { ) }) + it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => { + const runtime = new OrcaRuntimeService(store) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-startup-main' }) + .mockRejectedValueOnce(new Error('setup spawn failed')) + const activateWorktree = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-startup-main' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry' + }, + waitForAgentStartup: true + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-startup-setup-retry', + head: 'def', + branch: 'runtime-startup-setup-retry', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-startup-setup-retry', + setupDecision: 'run', + activate: true, + startup: { command: 'claude' } + }) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + expect.objectContaining({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }), + undefined, + undefined + ) + const activationSetup = activateWorktree.mock.calls[0]?.[2] as { command?: string } | undefined + expect(activationSetup?.command).toContain('printf') + }) + it('lets explicit startup draft agents override the desktop default', async () => { detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) const metaById: Record = {} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 42d79e24660..03c7ea12bfa 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -154,7 +154,14 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../shared/setup-runner-command' +import { + createSequencedSetupAgentCommands, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from '../../shared/setup-agent-sequencing' import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' @@ -991,6 +998,7 @@ type RuntimePtyController = { rows: number cwd?: string command?: string + commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] @@ -9821,10 +9829,15 @@ export class OrcaRuntimeService { | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } ): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -9834,6 +9847,12 @@ export class OrcaRuntimeService { if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { sanitizedUpdates.worktreeBasePath = undefined } + if ( + 'externalWorktreeDiscoverySuppressedAt' in updates && + updates.externalWorktreeDiscoverySuppressedAt === null + ) { + sanitizedUpdates.externalWorktreeDiscoverySuppressedAt = undefined + } if ('sourceControlAi' in updates && updates.sourceControlAi === null) { sanitizedUpdates.sourceControlAi = null } @@ -11961,10 +11980,16 @@ export class OrcaRuntimeService { primaryTerminalHandle?: string | null hasStartupTerminal: boolean setupCommandPlatform: 'windows' | 'posix' - }): Promise { + // Why: when the agent startup is sequenced to wait for setup + // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds + // the setup command. Pass that wrapped command through so the Setup tab runs + // the same script the agent is waiting on instead of a bare runner. + wrappedSetupCommand?: string + }): Promise<{ setupSpawned: boolean }> { if (!this.ptyController?.spawn) { - return + return { setupSpawned: false } } + let setupSpawned = false try { const defaultTabHandles = await this.createDefaultTabTerminals( args.worktreeSelector, @@ -11983,10 +12008,9 @@ export class OrcaRuntimeService { primaryTerminalHandle = terminal.handle } if (args.setup) { - const setupCommand = buildSetupRunnerCommand( - args.setup.runnerScriptPath, - args.setupCommandPlatform - ) + const setupCommand = + args.wrappedSetupCommand ?? + buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform) const shouldSplitSetup = primaryTerminalHandle && (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') @@ -12002,6 +12026,7 @@ export class OrcaRuntimeService { command: setupCommand, env: args.setup.envVars })) + setupSpawned = true } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -12009,6 +12034,7 @@ export class OrcaRuntimeService { `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` ) } + return { setupSpawned } } private async waitForStartupFollowupReady( @@ -12799,12 +12825,11 @@ export class OrcaRuntimeService { } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) if (shouldRunSetup && hooks?.scripts.setup) { - if (this.authoritativeWindowId !== null) { + const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup) + if (shouldUseSetupRunner) { try { - // Why: CLI-created worktrees must use the same runner-script path as the - // renderer create flow so repo-committed `orca.yaml` setup hooks run in - // the visible first terminal instead of a hidden background shell with - // different failure and prompt behavior. + // Why: setup+startup must share the terminal runner path even without + // a renderer window, so the startup shell can wait on setup completion. setup = createSetupRunnerScript( repo, worktreePath, @@ -12847,11 +12872,39 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false + // Why: tracks whether runtime itself launched the setup script (via + // provisionManagedWorktreeTerminals). When true, renderer activation and the + // RPC return value must omit setup so the client does not spawn it a second + // time. Mirrors the wait-for-agent setup contract from #6298. + let didSpawnSetup = false let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - if (effectiveStartup && this.ptyController?.spawn) { + + let sequencedStartup = effectiveStartup + let wrappedSetupCommandStr: string | undefined + if (effectiveStartup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: effectiveStartup.command, + platform + }) + sequencedStartup = { + ...effectiveStartup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv + ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } } + : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane // mounting. Runtime-spawned PTYs run immediately and the UI adopts the @@ -12861,12 +12914,15 @@ export class OrcaRuntimeService { this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) } const terminal = await this.createTerminal(`id:${worktree.id}`, { - command: effectiveStartup.command, - env: effectiveStartup.env, - ...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}), + command: sequencedStartup.command, + ...(setup && effectiveStartup + ? { claudeAgentTeamsSourceCommand: effectiveStartup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), - startupCommandDelivery: effectiveStartup.startupCommandDelivery, - telemetry: effectiveStartup.telemetry + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -12894,9 +12950,11 @@ export class OrcaRuntimeService { const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs) if (runtimeWillProvisionTerminals) { // Why: once runtime spawned the startup PTY, renderer activation may see - // an existing terminal and skip setup/default tabs. Keep those side - // effects best-effort so they cannot delay the startup agent. - void this.provisionManagedWorktreeTerminals({ + // an existing terminal and skip setup/default tabs. Await provisioning so + // a failed setup spawn falls back to renderer activation (which still + // carries the wrapped command for retry); #6298's wait-for-setup + // guarantee is enforced by the shell marker, not by spawn timing. + const provisioned = await this.provisionManagedWorktreeTerminals({ worktreeSelector: `id:${worktree.id}`, worktreeId: worktree.id, worktreePath, @@ -12908,10 +12966,26 @@ export class OrcaRuntimeService { ? isWindowsAbsolutePathLike(setup.runnerScriptPath) ? 'windows' : 'posix' - : 'posix' + : 'posix', + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) + didSpawnSetup = provisioned.setupSpawned } - const activationSetup = runtimeWillProvisionTerminals ? undefined : setup + // Why: when runtime spawned setup, omit it from activation. When setup + // spawn failed, fall through with the wrapped command so renderer + // activation retries it. + const activationSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs if (effectiveStartup && !didSpawnStartup) { this.notifyActivateWorktree( @@ -12945,8 +13019,14 @@ export class OrcaRuntimeService { ? isWindowsAbsolutePathLike(setup.runnerScriptPath) ? 'windows' : 'posix' - : 'posix' + : 'posix', + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) + // Why: runtime owns setup spawning here, so the RPC result must omit setup + // to keep the headless/mobile caller from launching it a second time. + if (setup) { + didSpawnSetup = true + } } else if (this.ptyController?.spawn) { try { await this.createTerminal(`id:${worktree.id}`) @@ -12958,6 +13038,16 @@ export class OrcaRuntimeService { console.warn(`[worktree-create] ${warning}`) } } + const returnedSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined return { worktree: { ...worktree, @@ -12968,7 +13058,7 @@ export class OrcaRuntimeService { git: created }, ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), - ...(setup ? { setup } : {}), + ...(returnedSetup ? { setup: returnedSetup } : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh @@ -13089,11 +13179,32 @@ export class OrcaRuntimeService { let warning = result.warning let didSpawnStartup = false + // Why: same no-double-spawn contract as the local path — once runtime + // provisions setup, omit it from activation and the RPC result. + let didSpawnSetup = false let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - if (args.startup && this.ptyController?.spawn) { + + let sequencedStartup = args.startup + let wrappedSetupCommandStr: string | undefined + if (args.startup && result.setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: result.setup.runnerScriptPath, + startupCommand: args.startup.command, + platform + }) + sequencedStartup = { + ...args.startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { try { const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent if (startupTrustAgent) { @@ -13104,12 +13215,15 @@ export class OrcaRuntimeService { ) } const terminal = await this.createTerminal(`path:${result.worktree.path}`, { - command: args.startup.command, - env: args.startup.env, - ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), + command: sequencedStartup.command, + ...(result.setup && args.startup + ? { claudeAgentTeamsSourceCommand: args.startup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), - startupCommandDelivery: args.startup.startupCommandDelivery, - telemetry: args.startup.telemetry + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) @@ -13136,8 +13250,9 @@ export class OrcaRuntimeService { didSpawnStartup && Boolean(result.setup || result.defaultTabs) if (runtimeWillProvisionTerminals) { // Why: remote/mobile task creates spawn the agent terminal in runtime, - // so renderer activation may not materialize setup/default tabs. - void this.provisionManagedWorktreeTerminals({ + // so renderer activation may not materialize setup/default tabs. Await so + // a failed setup spawn falls back to renderer activation for retry. + const provisioned = await this.provisionManagedWorktreeTerminals({ worktreeSelector: `path:${result.worktree.path}`, worktreeId: result.worktree.id, worktreePath: result.worktree.path, @@ -13149,10 +13264,25 @@ export class OrcaRuntimeService { ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' - : 'posix' + : 'posix', + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // remote Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) + didSpawnSetup = provisioned.setupSpawned } - const activationSetup = runtimeWillProvisionTerminals ? undefined : result.setup + // Why: omit setup from activation when runtime spawned it; on spawn + // failure fall through with the wrapped command so renderer retries. + const activationSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs if (args.startup && !didSpawnStartup) { this.notifyActivateWorktree( @@ -13192,8 +13322,14 @@ export class OrcaRuntimeService { ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' - : 'posix' + : 'posix', + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) + // Why: runtime owns setup spawning here, so omit setup from the RPC result + // to keep the headless/mobile caller from launching it a second time. + if (result.setup) { + didSpawnSetup = true + } } else if (!shouldActivate && this.ptyController?.spawn) { try { await this.createTerminal(`path:${result.worktree.path}`) @@ -13205,10 +13341,27 @@ export class OrcaRuntimeService { } } + const returnedSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined + const resultForRenderer = returnedSetup + ? { ...result, setup: returnedSetup } + : (() => { + const { setup: _setup, ...resultWithoutSetup } = result + return resultWithoutSetup + })() + const resultWithStartupTerminal = didSpawnStartup && startupTerminalHandle ? { - ...result, + ...resultForRenderer, startupTerminal: { spawned: true, handle: startupTerminalHandle, @@ -13218,7 +13371,7 @@ export class OrcaRuntimeService { surface: 'background' as const } } - : result + : resultForRenderer return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal } @@ -14717,6 +14870,7 @@ export class OrcaRuntimeService { worktreeSelector?: string, opts: { command?: string + claudeAgentTeamsSourceCommand?: string env?: Record launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string @@ -14778,14 +14932,16 @@ export class OrcaRuntimeService { ...opts.env, ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) } + const claudeAgentTeamsSourceCommand = + opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( opts.launchConfig, - opts.command, + claudeAgentTeamsSourceCommand, claudeAgentTeamsMode ) const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({ - command: opts.command, + command: claudeAgentTeamsSourceCommand, mode: effectiveClaudeAgentTeamsMode, baseEnv: { ...process.env, @@ -14802,6 +14958,13 @@ export class OrcaRuntimeService { shimBin }).env }) + const sequencedStartupCommand = + agentTeamsPlan && + claudeAgentTeamsSourceCommand && + opts.command && + claudeAgentTeamsSourceCommand !== opts.command + ? agentTeamsPlan.command + : undefined const effectiveLaunchConfig = opts.launchConfig && agentTeamsPlan ? { @@ -14817,9 +14980,17 @@ export class OrcaRuntimeService { } } : opts.launchConfig + // Why: setup/agent sequencing wraps the PTY launch in a wait shell before + // Claude Agent Teams runs. Preserve the direct Claude command separately + // so the wrapper can exec the teammate-mode variant after setup completes. const env = this.buildTerminalWorkspaceEnv( workspace, - baseEnv, + { + ...baseEnv, + ...(sequencedStartupCommand + ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } + : {}) + }, paneKey, tabId, agentTeamsPlan?.env @@ -14828,7 +14999,8 @@ export class OrcaRuntimeService { cols: 120, rows: 40, cwd: workspace.path, - command: agentTeamsPlan?.command ?? opts.command, + command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command), + commandDelivery: 'provider', startupCommandDelivery: opts.startupCommandDelivery, env, envToDelete: agentTeamsPlan?.envToDelete, @@ -15673,6 +15845,7 @@ export class OrcaRuntimeService { rows: 40, cwd: workspace.path, command: opts.command, + commandDelivery: 'provider', env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), envToDelete: opts.envToDelete, connectionId: workspace.connectionId, diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts new file mode 100644 index 00000000000..6fdb59c3ae9 --- /dev/null +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString } from '../schemas' +import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' + +export const RepoSourceControlAiOverrides = z + .unknown() + .optional() + .transform((value) => + value === undefined + ? undefined + : value === null + ? null + : normalizeRepoSourceControlAiOverrides(value) + ) + +const RepoBadgeColor = z + .unknown() + .optional() + .transform((value) => + value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) + ) + +const RepoUpstream = z + .object({ + owner: z.string().min(1), + repo: z.string().min(1) + }) + .nullable() + .optional() + +export function createRepoUpdateSchema( + selectorShape: T +): z.ZodObject }> { + return z.object({ + ...selectorShape, + updates: z.object({ + displayName: OptionalString, + badgeColor: RepoBadgeColor, + repoIcon: z + .unknown() + .transform((value) => sanitizeRepoIcon(value)) + .optional(), + upstream: RepoUpstream, + hookSettings: z.unknown().optional(), + worktreeBaseRef: OptionalString, + worktreeBasePath: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + symlinkPaths: z.array(z.string()).optional(), + issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), + forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), + externalWorktreeVisibility: z.enum(['hide', 'show']).optional(), + externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), + externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), + importedExternalWorktreePaths: z.array(z.string()).optional(), + externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), + projectGroupId: OptionalString.nullable().optional(), + projectGroupOrder: OptionalFiniteNumber, + sourceControlAi: RepoSourceControlAiOverrides + }) + }) as z.ZodObject }> +} diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 0fcf5226444..455192a8c11 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -1,11 +1,9 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import { sanitizeRepoIcon } from '../../../../shared/repo-icon' -import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' +import { createRepoUpdateSchema } from './repo-update-schema' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -32,55 +30,7 @@ const RepoSetBaseRef = z.object({ ref: requiredString('Missing base ref') }) -const RepoSourceControlAiOverrides = z - .unknown() - .optional() - .transform((value) => - value === undefined - ? undefined - : value === null - ? null - : normalizeRepoSourceControlAiOverrides(value) - ) - -const RepoBadgeColor = z - .unknown() - .optional() - .transform((value) => - value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) - ) - -const RepoUpstream = z - .object({ - owner: z.string().min(1), - repo: z.string().min(1) - }) - .nullable() - .optional() - -const RepoUpdate = RepoSelector.extend({ - updates: z.object({ - displayName: OptionalString, - badgeColor: RepoBadgeColor, - repoIcon: z - .unknown() - .transform((value) => sanitizeRepoIcon(value)) - .optional(), - upstream: RepoUpstream, - hookSettings: z.unknown().optional(), - worktreeBaseRef: OptionalString, - worktreeBasePath: OptionalString, - kind: z.enum(['git', 'folder']).optional(), - symlinkPaths: z.array(z.string()).optional(), - issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), - forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), - externalWorktreeVisibility: z.enum(['hide', 'show']).optional(), - externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), - projectGroupId: OptionalString.nullable().optional(), - projectGroupOrder: OptionalFiniteNumber, - sourceControlAi: RepoSourceControlAiOverrides - }) -}) +const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape) const RepoSearchRefs = z.object({ repo: requiredString('Missing repo selector'), diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index eace514bcc9..4b4a0e4c89b 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -37,6 +37,10 @@ import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-rel import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' +import { + scheduleWorktreeBaseDirectoryWatcherSync, + setWorktreeBaseDirectoryWatcherSyncContext +} from '../ipc/worktree-base-directory-watcher' let appReloadHandlerTokenCounter = 0 let activeAppReloadHandlerToken: number | null = null @@ -60,6 +64,9 @@ export function attachMainWindowServices( registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store, runtime) + // Why: repo/settings mutations resync watchers through this attached main-window context. + setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow) + scheduleWorktreeBaseDirectoryWatcherSync(store, mainWindow) registerWorkspaceCleanupHandlers(store, { runtime, getLocalPtyProvider }) registerPtyHandlers( mainWindow, diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 4b4693ed534..dd3fef92c74 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -2551,10 +2551,12 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) - it('ignores duplicate ready-to-show events after startup maximize has already run', () => { + function createStartupRevealWindowFixture() { const windowHandlers: Record void> = {} const webContents = { - on: vi.fn(), + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), invalidate: vi.fn(), @@ -2581,6 +2583,23 @@ describe('createMainWindow', () => { return browserWindowInstance }) + return { browserWindowInstance, windowHandlers } + } + + function createStartupRevealStore(savedMaximized: boolean) { + return { + getUI: () => + ({ + windowMaximized: savedMaximized + }) as never, + getSettings: () => ({ windowBackgroundBlur: false }) as never, + updateUI: vi.fn() + } + } + + it('ignores duplicate ready-to-show events after startup maximize has already run', () => { + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + createMainWindow({ getUI: () => ({ @@ -2597,6 +2616,98 @@ describe('createMainWindow', () => { expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) }) + it('reveals the startup window on Windows when ready-to-show never fires', () => { + vi.useFakeTimers() + const { browserWindowInstance } = createStartupRevealWindowFixture() + + withPlatform('win32', () => { + createMainWindow(null) + vi.advanceTimersByTime(9_999) + expect(browserWindowInstance.show).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + }) + + it('cancels the Windows startup reveal fallback after ready-to-show', () => { + vi.useFakeTimers() + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + withPlatform('win32', () => { + createMainWindow(null) + windowHandlers['ready-to-show']() + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + }) + + it('does not install the startup reveal fallback off Windows', () => { + vi.useFakeTimers() + const { browserWindowInstance } = createStartupRevealWindowFixture() + + withPlatform('linux', () => { + createMainWindow(null) + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + }) + + it('keeps the headless E2E window hidden when the Windows fallback fires', () => { + vi.useFakeTimers() + const previousHeadless = process.env.ORCA_E2E_HEADLESS + process.env.ORCA_E2E_HEADLESS = '1' + const { browserWindowInstance } = createStartupRevealWindowFixture() + + try { + withPlatform('win32', () => { + createMainWindow(createStartupRevealStore(true) as never) + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + } finally { + if (previousHeadless === undefined) { + delete process.env.ORCA_E2E_HEADLESS + } else { + process.env.ORCA_E2E_HEADLESS = previousHeadless + } + } + }) + + it('clears the Windows startup reveal fallback when the window is closed', () => { + vi.useFakeTimers() + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + withPlatform('win32', () => { + createMainWindow(createStartupRevealStore(true) as never) + windowHandlers.closed() + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + }) + + it('does not show or maximize a destroyed window when the Windows fallback fires', () => { + vi.useFakeTimers() + const { browserWindowInstance } = createStartupRevealWindowFixture() + + withPlatform('win32', () => { + createMainWindow(createStartupRevealStore(true) as never) + browserWindowInstance.isDestroyed.mockReturnValue(true) + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + }) + describe('minimize to tray on close (win32)', () => { const originalPlatform = process.platform diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 863392bf155..8d6d2ba81dc 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -330,11 +330,34 @@ export function createMainWindow( // handler re-runs maximize() from the persisted savedMaximized flag, snapping // the window back to full-screen after the user already resized it (#591). let handledInitialReadyToShow = false - mainWindow.on('ready-to-show', () => { + let initialRevealFallbackTimer: ReturnType | null = + process.platform === 'win32' + ? setTimeout(() => { + // Why: GPU/driver failures on Windows can prevent ready-to-show forever, + // leaving the only app window hidden while the main process stays alive. + initialRevealFallbackTimer = null + revealInitialWindow() + }, 10_000) + : null + initialRevealFallbackTimer?.unref?.() + + const clearInitialRevealFallbackTimer = (): void => { + if (initialRevealFallbackTimer) { + clearTimeout(initialRevealFallbackTimer) + initialRevealFallbackTimer = null + } + } + + const revealInitialWindow = (): void => { + if (mainWindow.isDestroyed()) { + clearInitialRevealFallbackTimer() + return + } if (handledInitialReadyToShow) { return } handledInitialReadyToShow = true + clearInitialRevealFallbackTimer() // Why: in E2E headless mode, the window stays hidden to avoid stealing // focus and screen real estate during test runs. Playwright interacts @@ -347,7 +370,8 @@ export function createMainWindow( mainWindow.maximize() } mainWindow.show() - }) + } + mainWindow.on('ready-to-show', revealInitialWindow) // Why: persist window bounds so the app restores to the user's last // position/size instead of maximizing on every launch. Debounce to avoid @@ -1117,6 +1141,7 @@ export function createMainWindow( ipcMain.on(confirmCloseChannel, onConfirmClose) mainWindow.on('closed', () => { + clearInitialRevealFallbackTimer() // Why: default-deny the Cmd+B carve-out after the window is gone so a // stale-true flag can't leak past subsequent state transitions. Paired // with the webContents lifecycle resets above. diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 17945e471d8..7a485016fe6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -391,6 +391,7 @@ import type { WorkspaceCleanupLocalProcessArgs, WorkspaceCleanupLocalProcessResult, WorkspaceCleanupScanArgs, + WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../shared/workspace-cleanup' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' @@ -788,11 +789,16 @@ export type PreloadApi = { | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' | 'forkSyncMode' > - > & { sourceControlAi?: Repo['sourceControlAi'] | null } + > & { + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } }) => Promise pickFolder: () => Promise pickFolders: () => Promise @@ -989,7 +995,10 @@ export type PreloadApi = { ) => () => void } workspaceCleanup: { - scan: (args?: WorkspaceCleanupScanArgs) => Promise + scan: ( + args?: WorkspaceCleanupScanArgs, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => Promise dismiss: (args: WorkspaceCleanupDismissArgs) => Promise clearDismissals: () => Promise hasKillableLocalProcesses: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 96f6797ffb8..d29a1b701c7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -79,6 +79,7 @@ import type { RateLimitState } from '../shared/rate-limit-types' import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' +import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup' import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { TaskSourceContext } from '../shared/task-source-context' @@ -662,7 +663,24 @@ const api = { } satisfies PreloadApi['worktrees'], workspaceCleanup: { - scan: (args) => ipcRenderer.invoke('workspaceCleanup:scan', args), + scan: (args, onProgress) => { + if (!onProgress) { + return ipcRenderer.invoke('workspaceCleanup:scan', args) + } + const scanId = args?.scanId ?? crypto.randomUUID() + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceCleanupScanProgress + ): void => { + if (progress.scanId === scanId) { + onProgress(progress) + } + } + ipcRenderer.on('workspaceCleanup:scanProgress', listener) + return ipcRenderer + .invoke('workspaceCleanup:scan', { ...args, scanId }) + .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) + }, dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), hasKillableLocalProcesses: (args) => diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 8e1c3ba65dd..90b516a947e 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -1660,6 +1660,97 @@ describe('GitHandler', () => { expect(result[0].isMainWorktree).toBe(true) }) + it.skipIf(process.platform === 'win32')( + 'normalizes the main worktree path for a separate-git-dir repo', + async () => { + const sourcePath = path.join(tmpDir, 'source') + const worktreePath = path.join(tmpDir, 'worktree') + const gitDirPath = path.join(tmpDir, 'git-store', 'project.git') + mkdirSync(sourcePath) + mkdirSync(path.dirname(gitDirPath), { recursive: true }) + gitInit(sourcePath) + writeFileSync(path.join(sourcePath, 'file.txt'), 'hello') + gitCommit(sourcePath, 'initial') + + execFileSync('git', [ + 'clone', + '--quiet', + `--separate-git-dir=${gitDirPath}`, + sourcePath, + worktreePath + ]) + + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: await fs.realpath(worktreePath) + })) as Record[] + const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true) + + expect(mainWorktree).toMatchObject({ + path: await fs.realpath(worktreePath), + isMainWorktree: true + }) + expect(mainWorktree?.path).not.toBe(await fs.realpath(gitDirPath)) + } + ) + + it.skipIf(process.platform === 'win32')( + 'leaves an ordinary repo reached via a symlinked path unchanged', + async () => { + // A symlink alias defeats the path-string gate (git reports the + // realpath toplevel); the git-common-dir gate must still skip the + // rewrite for an ordinary repo so the reported path is untouched. + const repoPath = path.join(tmpDir, 'plain-repo') + mkdirSync(repoPath) + gitInit(repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'hello') + gitCommit(repoPath, 'initial') + const linkedRepoPath = path.join(tmpDir, 'linked-repo') + symlinkSync(repoPath, linkedRepoPath) + + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: linkedRepoPath + })) as Record[] + const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true) + + expect(mainWorktree).toMatchObject({ + path: await fs.realpath(repoPath), + isMainWorktree: true + }) + } + ) + + it.skipIf(process.platform === 'win32')( + 'leaves the main entry unchanged when scanned via a linked worktree', + async () => { + // A linked worktree has a `.git` pointer file like a separate-git-dir + // checkout, but its porcelain main entry is the real main working root. + // The git-common-dir gate must skip the rewrite so the main entry is + // not overwritten with the linked worktree's own toplevel. + const repoPath = path.join(tmpDir, 'main-repo') + mkdirSync(repoPath) + gitInit(repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'hello') + gitCommit(repoPath, 'initial') + const linkedWorktreePath = path.join(tmpDir, 'linked-wt') + execFileSync('git', ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'], { + cwd: repoPath, + stdio: 'pipe' + }) + const resolvedLinked = await fs.realpath(linkedWorktreePath) + + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: resolvedLinked + })) as Record[] + const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true) + + expect(mainWorktree).toMatchObject({ + path: await fs.realpath(repoPath), + isMainWorktree: true + }) + expect(mainWorktree?.path).not.toBe(resolvedLinked) + } + ) + it.skipIf(process.platform === 'win32')( 'lists worktrees whose paths contain newlines', async () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index e3310f969bc..31c796418f3 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -20,6 +20,7 @@ import { } from './git-handler-ops' import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops' import { + areRelayWorktreePathsEqual, commitChangesRelay, addWorktreeOp, removeWorktreeOp, @@ -52,6 +53,64 @@ const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 const BULK_CHUNK_SIZE = 100 +function getErrorText(error: unknown): string { + if (typeof error === 'object' && error !== null) { + const parts: string[] = [] + if ('message' in error && typeof error.message === 'string') { + parts.push(error.message) + } + if ('stderr' in error && typeof error.stderr === 'string') { + parts.push(error.stderr) + } + return parts.join('\n') + } + return String(error) +} + +function isUnsupportedRevParsePathFormatError(error: unknown): boolean { + return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test( + getErrorText(error) + ) +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') +} + +function resolveRelayPath(repoPath: string, value: string): string { + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { + return value + } + // Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir + // must be resolved against the scanned repo path. Mirror worktree.ts's + // resolveRevParsePath: pick the win32/posix resolver from the repoPath shape. + return isWindowsAbsolutePath(repoPath) + ? path.win32.resolve(repoPath, value) + : path.posix.resolve(repoPath, value) +} + +type RelayRepoLocation = { topLevel: string; commonDir: string } + +function parseRelayRepoLocation(repoPath: string, output: string): RelayRepoLocation | undefined { + // Old git (pre `--path-format`) echoes the unrecognized flag to stdout and + // exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and + // read the two trailing path lines (toplevel, then git-common-dir). Strip only + // the trailing CR, not surrounding spaces — git paths may legitimately start + // or end with a space. + const lines = output + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .filter((line) => line.length > 0 && !line.startsWith('-')) + if (lines.length < 2) { + return undefined + } + const [topLevel, commonDir] = lines.slice(-2) + return { + topLevel: resolveRelayPath(repoPath, topLevel), + commonDir: resolveRelayPath(repoPath, commonDir) + } +} + function execFileWithStdin( command: string, args: string[], @@ -998,11 +1057,70 @@ export class GitHandler { } } + private async readRepoLocation(repoPath: string): Promise { + try { + const { stdout } = await this.git( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + repoPath + ) + return parseRelayRepoLocation(repoPath, stdout) + } catch (error) { + if (!isUnsupportedRevParsePathFormatError(error)) { + return undefined + } + } + + try { + const { stdout } = await this.git( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + repoPath + ) + return parseRelayRepoLocation(repoPath, stdout) + } catch { + return undefined + } + } + + private async normalizeMainWorktreePath( + repoPath: string, + worktrees: Record[] + ): Promise[]> { + const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree === true) + const mainWorktree = worktrees[mainIndex] + const mainPath = typeof mainWorktree?.path === 'string' ? mainWorktree.path : '' + // Expand `~` so the early-return matches git's absolute porcelain path for + // legacy SSH repos stored with a tilde, sparing them a rev-parse per poll. + const resolvedRepoPath = expandTilde(repoPath) + if (!mainPath || areRelayWorktreePathsEqual(mainPath, resolvedRepoPath)) { + return worktrees + } + + const location = await this.readRepoLocation(resolvedRepoPath) + if (!location) { + return worktrees + } + + // Why: only a separate-git-dir/submodule main worktree reports the Git + // directory as the main entry — i.e. the main entry equals git-common-dir. + // A linked worktree's main entry is a real working root, so gating on this + // equality avoids overwriting it with the linked worktree's own toplevel. + if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) { + return worktrees + } + + const normalized = [...worktrees] + normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } + return normalized + } + private async listWorktrees(params: Record) { const repoPath = params.repoPath as string try { const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath) - return parseWorktreeList(stdout, { nulDelimited: true }) + return this.normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }) + ) } catch (error) { if (!isUnsupportedWorktreeListZError(error)) { return [] @@ -1013,7 +1131,7 @@ export class GitHandler { // Git rejects it. Fall back to the original line-block parser there. try { const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) - return parseWorktreeList(stdout) + return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) } catch { return [] } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 983ac234848..3c9b0597030 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -4,6 +4,10 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { + resolveSetupAgentSequenceLaunchCommand, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from '../shared/setup-agent-sequencing' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -165,8 +169,34 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => { + await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' }) + + vi.advanceTimersByTime(50) + + const term = mockPtySpawn.mock.results[0]?.value + expect(term.write).not.toHaveBeenCalled() + }) + + it('submits provider-delivered spawn commands to the relay shell', async () => { + await dispatcher.callRequest('pty.spawn', { + command: 'echo provider-owned', + commandDelivery: 'provider' + }) + + vi.advanceTimersByTime(49) + const term = mockPtySpawn.mock.results[0]?.value + expect(handler.retainedStartupCommandCount).toBe(1) + expect(term.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + const submit = process.platform === 'win32' ? '\r' : '\n' + expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`) + expect(handler.retainedStartupCommandCount).toBe(0) + }) + it.skipIf(process.platform === 'win32')( - 'enables shell-ready marker env for delivery-hinted startup commands', + 'emits shell-ready markers for renderer-delivered startup commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME @@ -177,6 +207,7 @@ describe('PtyHandler', () => { try { await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir }, + command: 'echo renderer-owned', startupCommandDelivery: 'shell-ready' }) } finally { @@ -197,11 +228,12 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(handler.retainedStartupCommandCount).toBe(0) } ) it.skipIf(process.platform === 'win32')( - 'enables shell-ready marker env for Codex native prefill commands', + 'emits shell-ready markers for renderer-delivered Codex native prefill commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME @@ -232,6 +264,197 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32')( + 'enables shell-ready marker env for provider-delivered startup commands', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo provider-owned', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the sequenced startup command hint for provider shell-ready detection', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { + HOME: homeDir, + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + }, + command: 'bash -lc wait-for-setup-wrapper', + commandDelivery: 'provider' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + + it.skipIf(process.platform === 'win32')( + 'waits for the shell-ready marker before provider-delivered startup commands', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo after-ready', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + vi.advanceTimersByTime(1499) + expect(term.write).not.toHaveBeenCalled() + + dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ') + vi.advanceTimersByTime(49) + expect(term.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + + expect(term.write).toHaveBeenCalledWith('echo after-ready\n') + expect(handler.retainedStartupCommandCount).toBe(0) + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: 'user@remote $ ' + }) + } + ) + + it.skipIf(process.platform === 'win32')( + 'flushes held shell-ready marker bytes when provider delivery falls back', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo fallback', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + dataCallback?.('\x1b]777;orca-shell-ready') + vi.advanceTimersByTime(1500) + + expect(term.write).toHaveBeenCalledWith('echo fallback\n') + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]777;orca-shell-ready' + }) + + const result = await dispatcher.callRequest('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' }) } ) @@ -252,6 +475,42 @@ describe('PtyHandler', () => { expect(killSpy).toHaveBeenCalledWith('SIGKILL') }) + it('does not submit provider-delivered commands for stale spawn responses', async () => { + const killSpy = vi.fn() + const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + mockPtySpawn.mockReturnValue(term) + + await dispatcher.callRequest( + 'pty.spawn', + { command: 'echo stale', commandDelivery: 'provider' }, + { isStale: () => true } + ) + + vi.advanceTimersByTime(50) + expect(term.write).not.toHaveBeenCalled() + expect(handler.retainedStartupCommandCount).toBe(0) + expect(killSpy).toHaveBeenCalledWith('SIGTERM') + }) + + it('releases pending provider-delivered commands on shutdown before delivery', async () => { + const killSpy = vi.fn() + const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + mockPtySpawn.mockReturnValue(term) + + await dispatcher.callRequest('pty.spawn', { + command: 'echo stop-before-run', + commandDelivery: 'provider' + }) + expect(handler.retainedStartupCommandCount).toBe(1) + + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + vi.advanceTimersByTime(50) + + expect(handler.retainedStartupCommandCount).toBe(0) + expect(term.write).not.toHaveBeenCalled() + expect(killSpy).toHaveBeenCalledWith('SIGKILL') + }) + it('increments PTY ids on each spawn', async () => { const r1 = await dispatcher.callRequest('pty.spawn', {}) const r2 = await dispatcher.callRequest('pty.spawn', {}) @@ -759,6 +1018,20 @@ describe('PtyHandler', () => { expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi') }) + it('lets relay env augmenters resolve the original sequenced startup command hint', async () => { + handler.addEnvAugmenter((ctx) => ({ + SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? '' + })) + + await dispatcher.callRequest('pty.spawn', { + command: 'powershell wait-wrapper', + env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' } + }) + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume') + }) + it.skipIf(process.platform === 'win32')( 'wraps bash spawns to restore overlay env after remote startup files', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b1419e2facb..8ac4259d3ee 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -13,6 +13,13 @@ import { import { getRelayShellLaunchConfig } from './pty-shell-launch' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { + createShellReadyScanState, + drainShellReadyHeldBytes, + scanForShellReady, + type ShellReadyScanState +} from '../main/shell-ready-marker-scanner' // Why: node-pty is a native addon that may not be installed on the remote. // Dynamic import keeps the require() lazy so loadPty() returns null gracefully @@ -53,12 +60,21 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + startupCommand?: ManagedStartupCommand } type PendingPtyOutput = { data: string } +type ManagedStartupCommand = { + command: string + delivered: boolean + waitForShellReady: boolean + scanState: ShellReadyScanState | null + timer: ReturnType | null +} + function disposeManagedPty(managed: ManagedPty): void { if (managed.disposed) { return @@ -98,6 +114,8 @@ const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 +const STARTUP_COMMAND_WRITE_DELAY_MS = 50 +const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 const ALLOWED_SIGNALS = new Set([ 'SIGINT', 'SIGTERM', @@ -222,14 +240,71 @@ export class PtyHandler { return { ...baseEnv, ...augmented } } + private clearStartupCommandTimer(managed: ManagedPty): void { + if (managed.startupCommand?.timer) { + clearTimeout(managed.startupCommand.timer) + managed.startupCommand.timer = null + } + } + + private appendReplayBuffer(managed: ManagedPty, data: string): void { + managed.buffered += data + if (managed.buffered.length > REPLAY_BUFFER_MAX) { + managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) + } + } + + private releaseStartupCommand(managed: ManagedPty): void { + this.clearStartupCommandTimer(managed) + managed.startupCommand = undefined + } + + private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void { + const startup = managed.startupCommand + if (!startup || startup.delivered || managed.disposed) { + return + } + this.clearStartupCommandTimer(managed) + startup.timer = setTimeout(() => { + startup.timer = null + this.deliverStartupCommand(managed) + }, delayMs) + } + + private deliverStartupCommand(managed: ManagedPty): void { + const startup = managed.startupCommand + if (!startup || startup.delivered || managed.disposed) { + return + } + startup.delivered = true + this.clearStartupCommandTimer(managed) + if (startup.scanState) { + const heldBytes = drainShellReadyHeldBytes(startup.scanState) + if (heldBytes) { + this.appendReplayBuffer(managed, heldBytes) + this.enqueuePtyOutput(managed.id, heldBytes) + } + } + const submit = process.platform === 'win32' ? '\r' : '\n' + const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n') + const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}` + managed.startupCommand = undefined + managed.pty.write(payload) + } + /** Wire onData/onExit listeners for a managed PTY and store it. */ private wireAndStore(managed: ManagedPty): void { this.ptys.set(managed.id, managed) managed.pty.onData((data: string) => { - managed.buffered += data - if (managed.buffered.length > REPLAY_BUFFER_MAX) { - managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) + const startup = managed.startupCommand + if (startup?.waitForShellReady && startup.scanState && !startup.delivered) { + const scanned = scanForShellReady(startup.scanState, data) + data = scanned.output + if (scanned.matched) { + this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS) + } } + this.appendReplayBuffer(managed, data) this.enqueuePtyOutput(managed.id, data) }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { @@ -253,6 +328,7 @@ export class PtyHandler { clearTimeout(managed.killTimer) managed.killTimer = undefined } + this.clearStartupCommandTimer(managed) this.flushPtyOutput(managed.id) this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) this.notifyExitListener(managed) @@ -428,20 +504,26 @@ export class PtyHandler { // dirs) override renderer-supplied env so live remote paths and hook coords // win over local userData paths. The context lets overlay augmenters derive // per-PTY OpenCode/Pi directories from the stable paneKey when present. - // `command` is forwarded by ssh-pty-provider.ts only as a hint for - // overlay resolution — the relay still launches a login shell and the - // command is typed in via pty.data writes. + // `command` is usually forwarded by ssh-pty-provider.ts only as a hint + // for overlay resolution; runtime-owned PTYs opt into relay delivery + // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined const command = typeof params.command === 'string' ? params.command : undefined + const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' + const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) - // Why: only explicit shell-ready hints are trusted here; native Codex - // prefill detection still auto-enables readiness through the predicate. - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { - emitReadyMarker: shouldUseShellReadyStartupDelivery({ - command, + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) + const shouldEmitShellReadyMarker = + launchCommandHint !== undefined && + shouldUseShellReadyStartupDelivery({ + command: launchCommandHint, startupCommandDelivery: params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined }) + // Why: renderer- and provider-delivered startup commands both use this + // marker; the side responsible for delivery also strips it from output. + const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { + emitReadyMarker: shouldEmitShellReadyMarker }) // Why: SSH exec channels give the relay a minimal environment without @@ -454,7 +536,9 @@ export class PtyHandler { cols, rows, cwd, - env: { ...spawnEnv, ...shellLaunch.env } + // Why: relay shells inherit process.env; never let an ambient Orca marker + // enable shell-ready behavior unless this spawn explicitly requested it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } }) // Why: capture the renderer-supplied paneKey on the managed entry so the @@ -470,13 +554,28 @@ export class PtyHandler { buffered: '', paneKey, tabId, - worktreeId + worktreeId, + ...(shouldProviderDeliverCommand + ? { + startupCommand: { + command, + delivered: false, + waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1', + scanState: + shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' + ? createShellReadyScanState() + : null, + timer: null + } + } + : {}) } this.wireAndStore(managed) if (context?.isStale()) { // Why: if the client reconnected while pty.spawn was in flight, the // response is discarded and no renderer can own this PTY. Shut it down // immediately so it does not linger as an unreachable remote shell. + this.releaseStartupCommand(managed) term.kill('SIGTERM') managed.killTimer = setTimeout(() => { const still = this.ptys.get(id) @@ -491,6 +590,13 @@ export class PtyHandler { this.ptys.delete(id) } }, 5000) + } else if (managed.startupCommand) { + this.scheduleStartupCommandDelivery( + managed, + managed.startupCommand.waitForShellReady + ? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS + : STARTUP_COMMAND_WRITE_DELAY_MS + ) } return { id } } @@ -562,6 +668,7 @@ export class PtyHandler { } if (immediate) { + this.releaseStartupCommand(managed) this.flushPtyOutput(id) managed.pty.kill('SIGKILL') // Why: SIGKILL has already reaped the child; release the ptmx fd on the @@ -581,6 +688,7 @@ export class PtyHandler { this.ptys.delete(id) this.clearPtyFlowState(id) } else { + this.releaseStartupCommand(managed) managed.pty.kill('SIGTERM') // Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal @@ -761,7 +869,9 @@ export class PtyHandler { cols: entry.cols, rows: entry.rows, cwd: entry.cwd, - env: { ...spawnEnv, ...shellLaunch.env } + // Why: revived shells should not inherit an ambient shell-ready marker + // because no provider-delivered startup command is waiting on it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } }) this.wireAndStore({ id: entry.id, @@ -821,6 +931,7 @@ export class PtyHandler { clearTimeout(managed.killTimer) managed.killTimer = undefined } + this.clearStartupCommandTimer(managed) // Why: SIGKILL (not SIGTERM) before destroy. The relay process is // exiting; any SIGTERM-ignoring remote shell (editor with unsaved // buffers, a hung child with a bad handler, a process in @@ -843,6 +954,16 @@ export class PtyHandler { return this.ptys.size } + get retainedStartupCommandCount(): number { + let count = 0 + for (const managed of this.ptys.values()) { + if (managed.startupCommand) { + count += 1 + } + } + return count + } + get graceTimerActive(): boolean { return this.graceTimer !== null } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 356d778125b..7f1e6212019 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -57,6 +57,7 @@ import { import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -536,8 +537,10 @@ async function main(): Promise { // Why: source-dir defaulting is keyed on which Pi-compatible agent is // being launched (Pi vs OMP). Install Orca's guarded extension into that // real remote agent dir without redirecting PI_CODING_AGENT_DIR. - const kind = detectPiAgentKindFromCommand(ctx.command) - const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0 + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) + const kind = detectPiAgentKindFromCommand(launchCommandHint) + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand if (kind === 'pi') { const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi') diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 899524a11dc..43d07dc285a 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -150,6 +150,8 @@ function renderCard( requiresExplicitSetupChoice={false} setupDecision={null} onSetupDecisionChange={() => {}} + setupAgentStartupPolicy="start-immediately" + onSetupAgentStartupPolicyChange={() => {}} shouldWaitForSetupCheck={false} resolvedSetupDecision={null} createError={null} @@ -266,6 +268,58 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(onChanges).toEqual([true]) }) + it('shows the setup startup policy toggle only when setup is available', () => { + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: '# defaultTabs[1]\npnpm dev', + kind: 'default-tabs' + } + }) + expect(current.container.textContent).not.toContain( + 'Wait for setup to complete before starting agent' + ) + + act(() => current?.root.unmount()) + current?.container.remove() + + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: 'pnpm install', + kind: 'setup' + } + }) + expect(current.container.textContent).toContain( + 'Wait for setup to complete before starting agent' + ) + }) + + it('emits the setup startup policy toggle value', () => { + const changes: string[] = [] + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: 'pnpm install', + kind: 'setup' + }, + onSetupAgentStartupPolicyChange: (next) => changes.push(next) + }) + + const waitSwitch = current.container.querySelector( + '[role="switch"][aria-label="Wait for setup to complete before starting agent"]' + ) + expect(waitSwitch).toBeTruthy() + act(() => waitSwitch?.click()) + expect(changes).toEqual(['wait-for-setup']) + }) + it('does not disable folder workspace creation when only source lookup needs SSH', () => { current = renderCard({ eligibleRepos: [ diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6f68d168bb2..aa3bd8a60c2 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' @@ -35,6 +36,7 @@ import type { GitHubWorkItem, GitLabWorkItem, LinearIssue, + SetupAgentStartupPolicy, SparsePreset, TuiAgent } from '../../../shared/types' @@ -116,6 +118,8 @@ type NewWorkspaceComposerCardProps = { requiresExplicitSetupChoice: boolean setupDecision: 'run' | 'skip' | null onSetupDecisionChange: (value: 'run' | 'skip') => void + setupAgentStartupPolicy: SetupAgentStartupPolicy + onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: WorkspaceCreateErrorDisplay | null @@ -354,6 +358,8 @@ export default function NewWorkspaceComposerCard({ requiresExplicitSetupChoice, setupDecision, onSetupDecisionChange, + setupAgentStartupPolicy, + onSetupAgentStartupPolicyChange, shouldWaitForSetupCheck, resolvedSetupDecision, createError, @@ -421,6 +427,10 @@ export default function NewWorkspaceComposerCard({ ? 'Run commands now' : 'Run setup now' const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands' + // Why: defaultTabs launch commands can be long-running too, but they are not + // the setup command this setting gates agent startup on. + const showSetupAgentStartupPolicy = + setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs' const handleSetDefaultAgent = React.useCallback( (next: TuiAgent | 'blank' | null) => { @@ -1004,6 +1014,39 @@ export default function NewWorkspaceComposerCard({ ) : null} ) : null} + + {showSetupAgentStartupPolicy ? ( +
+ + + {translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + + + {translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp', + 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' + )} + + + + onSetupAgentStartupPolicyChange( + setupAgentStartupPolicy === 'wait-for-setup' + ? 'start-immediately' + : 'wait-for-setup' + ) + } + ariaLabel={translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + /> +
+ ) : null} ) : null} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index eab39c0f2d3..c6b209b02f3 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -17,6 +17,7 @@ import { useRepoById } from '@/store/selectors' import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { + AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, writeAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' @@ -82,8 +83,13 @@ export function VaultSessionRow({ ? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details') : translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details') const startResumeDrag = useCallback( - (event: React.DragEvent): void => { + (event: React.DragEvent): void => { event.stopPropagation() + const target = event.target + if (target instanceof Element && target.closest('[data-ai-vault-session-actions]')) { + event.preventDefault() + return + } if (resumeDisabled) { event.preventDefault() return @@ -106,12 +112,20 @@ export function VaultSessionRow({