From 8d3e32a2ffa4f6a3575da3d090be2d0161839d1b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:53:31 -0700 Subject: [PATCH] Fix setup-provisioned skills missing at agent startup (#17124) * fix(setup): let repos gate agent startup * test(setup): update runner call expectations --- orca.yaml | 1 + src/main/hooks-orca-yaml-parsing.test.ts | 13 +++++++++++ src/main/hooks-setup-runner-script.test.ts | 22 ++++++++++++++++++- src/main/hooks.ts | 1 + src/main/ipc/worktree-remote.ts | 16 +++++++++----- .../ipc/worktrees-local-create-flow.test.ts | 14 +++++++++++- ...trees-setup-launch-sparse-checkout.test.ts | 2 ++ .../ipc/worktrees-ssh-setup-launch.test.ts | 10 ++++++--- src/main/ipc/worktrees-windows.test.ts | 3 ++- .../ipc/worktrees-wsl-runtime-routing.test.ts | 1 + src/main/runtime/orca-runtime.test.ts | 8 +++++-- src/main/runtime/orca-runtime.ts | 4 ++-- src/main/worktree-runner-script.ts | 7 ++++-- src/shared/orca-yaml-hook-types.ts | 1 + src/shared/orca-yaml.ts | 7 ++++++ src/shared/setup-agent-startup-policy.ts | 8 +++---- 16 files changed, 97 insertions(+), 21 deletions(-) diff --git a/orca.yaml b/orca.yaml index 6b7ccd6f1ce..b05497b3905 100644 --- a/orca.yaml +++ b/orca.yaml @@ -1,3 +1,4 @@ +setupAgentStartupPolicy: wait-for-setup scripts: setup: | node config/scripts/run-internal-dev-setup.mjs diff --git a/src/main/hooks-orca-yaml-parsing.test.ts b/src/main/hooks-orca-yaml-parsing.test.ts index bd3236f5c2c..b99e30cdf24 100644 --- a/src/main/hooks-orca-yaml-parsing.test.ts +++ b/src/main/hooks-orca-yaml-parsing.test.ts @@ -22,6 +22,19 @@ describe('parseOrcaYaml', () => { }) }) + it('parses a project requirement to finish setup before agent startup', () => { + const yaml = [ + 'setupAgentStartupPolicy: wait-for-setup', + 'scripts:', + ' setup: node install-project-skills.mjs' + ].join('\n') + + expect(parseOrcaYaml(yaml)).toEqual({ + scripts: { setup: 'node install-project-skills.mjs' }, + setupAgentStartupPolicy: 'wait-for-setup' + }) + }) + it('parses YAML with archive script only', () => { const yaml = `scripts:\n archive: |\n echo "archiving"\n` const result = parseOrcaYaml(yaml) diff --git a/src/main/hooks-setup-runner-script.test.ts b/src/main/hooks-setup-runner-script.test.ts index 0fc9ba6037a..2b3da9d0974 100644 --- a/src/main/hooks-setup-runner-script.test.ts +++ b/src/main/hooks-setup-runner-script.test.ts @@ -302,7 +302,7 @@ describe('createSetupRunnerScript', () => { } }) - it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => { + it('waits when either local or project policy requires completed setup', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') const { createSetupRunnerScript } = await import('./worktree-runner-script') @@ -318,6 +318,26 @@ describe('createSetupRunnerScript', () => { createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup') .waitForAgentStartup ).toBe(true) + expect( + createSetupRunnerScript( + makeRepo('start-immediately'), + '/test/worktree', + 'echo setup', + undefined, + undefined, + 'wait-for-setup' + ).waitForAgentStartup + ).toBe(true) + expect( + createSetupRunnerScript( + makeRepo('wait-for-setup'), + '/test/worktree', + 'echo setup', + undefined, + undefined, + 'start-immediately' + ).waitForAgentStartup + ).toBe(true) }) it('marks setup-runner terminals for the always-on credential guard', async () => { diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 7778132e910..ef65fc28cf0 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -55,6 +55,7 @@ export function hasHooksFile(repoPath: string): boolean { // Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error. const RECOGNIZED_ORCA_YAML_KEYS = new Set([ 'scripts', + 'setupAgentStartupPolicy', 'issueCommand', 'defaultTabs', 'environmentRecipes', diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 977c29d8792..581872540df 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' +import type { SetupAgentStartupPolicy } from '../../shared/orca-yaml-hook-types' import type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion @@ -1259,7 +1260,8 @@ async function createRemoteSetupRunnerScript( worktreePath: string, script: string, gitProvider: SshGitProvider, - fsProvider: IFilesystemProvider + fsProvider: IFilesystemProvider, + projectStartupPolicy?: SetupAgentStartupPolicy ): Promise { const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath) // Why: SSH terminals choose their shell on the remote host; local Windows @@ -1281,7 +1283,10 @@ async function createRemoteSetupRunnerScript( return { runnerScriptPath, envVars: getSetupRunnerEnvVars(repo, worktreePath), - ...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) + ...(shouldWaitForSetupBeforeAgentStartup( + repo.hookSettings?.setupAgentStartupPolicy, + projectStartupPolicy + ) ? { waitForAgentStartup: true } : {}) } @@ -2030,7 +2035,8 @@ export async function createRemoteWorktree( created.path, setupScript, provider, - fsProvider + fsProvider, + yamlHooks?.setupAgentStartupPolicy ) } catch (error) { console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error) @@ -2721,13 +2727,13 @@ export async function createLocalWorktree( try { // Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior. // Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation. - // Why: both trailing args are optional — the shell is undefined off Windows. setup = createSetupRunnerScript( repo, worktreePath, setupScript, localWorktreeGitOptionArgs[0], - resolveSetupRunnerShell(settings) + resolveSetupRunnerShell(settings), + createdYamlHooks?.setupAgentStartupPolicy ) } catch (error) { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 570aa38c09b..a030a60de29 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -514,10 +514,14 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + loadHooksMock.mockReturnValue({ + scripts: { setup: 'pnpm install' }, + setupAgentStartupPolicy: 'wait-for-setup' + }) getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) shouldRunSetupForCreateMock.mockReturnValue(true) + expect(createSetupRunnerScriptMock).not.toHaveBeenCalled() const result = (await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -538,6 +542,14 @@ describe('registerWorktreeHandlers', () => { startupTerminal?: { spawned: boolean; surface?: string } timing?: { phases: { phase: string }[] } } + expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + '/workspace/improve-dashboard', + 'pnpm install', + undefined, + undefined, + 'wait-for-setup' + ) expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( 1, diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index 6eb250af69a..0996df03cb4 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -118,6 +118,7 @@ describe('registerWorktreeHandlers', () => { '/workspace/improve-dashboard', 'pnpm worktree:setup', undefined, + undefined, undefined ) expect(result).toMatchObject({ @@ -169,6 +170,7 @@ describe('registerWorktreeHandlers', () => { '/workspace/improve-dashboard', 'pnpm worktree:setup # worktree', undefined, + undefined, undefined ) expect(result).toEqual( diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts index c43d4d7cb7c..f3a76bdacde 100644 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ b/src/main/ipc/worktrees-ssh-setup-launch.test.ts @@ -137,7 +137,7 @@ describe('registerWorktreeHandlers', () => { } const fsProvider = { readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n setup: pnpm install\n', + content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n', isBinary: false }), createDir: vi.fn().mockResolvedValue(undefined), @@ -153,7 +153,10 @@ describe('registerWorktreeHandlers', () => { getSshFilesystemProviderMock.mockReturnValue(fsProvider) getActiveMultiplexerMock.mockReturnValue(mux) store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + parseOrcaYamlMock.mockReturnValue({ + scripts: { setup: 'pnpm install' }, + setupAgentStartupPolicy: 'wait-for-setup' + }) getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) shouldRunSetupForCreateMock.mockReturnValue(true) @@ -184,7 +187,8 @@ describe('registerWorktreeHandlers', () => { envVars: expect.objectContaining({ ORCA_ROOT_PATH: '/remote/repo', ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard' - }) + }), + waitForAgentStartup: true } }) ) diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 299c3690a1a..b22d7decd84 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -467,7 +467,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => { 'C:\\workspaces\\improve-dashboard', 'pnpm install', undefined, - setupShell + setupShell, + undefined ) expect(result).toMatchObject({ setup: { diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 23758ae5d77..45426a8585a 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -369,6 +369,7 @@ describe('registerWorktreeHandlers', () => { '/workspace/improve-dashboard', 'pnpm worktree:setup', { wslDistro: 'Ubuntu' }, + undefined, undefined ) expect(addWorktreeMock).toHaveBeenCalledWith( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7049620ab27..95bade079aa 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -45042,6 +45042,7 @@ describe('OrcaRuntimeService', () => { '/tmp/workspaces/runtime-hook-test', 'pnpm worktree:setup', undefined, + undefined, undefined ) expect(runHook).not.toHaveBeenCalled() @@ -45197,7 +45198,8 @@ describe('OrcaRuntimeService', () => { 'C:\\workspaces\\runtime-hook-activate', 'pnpm worktree:setup', undefined, - { family: 'posix' } + { family: 'posix' }, + undefined ) expect(result.setup).toMatchObject({ runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', @@ -45277,6 +45279,7 @@ describe('OrcaRuntimeService', () => { '/tmp/workspaces/runtime-hook-skip', 'pnpm worktree:setup', undefined, + undefined, undefined ) expect(runHook).not.toHaveBeenCalled() @@ -45480,7 +45483,8 @@ describe('OrcaRuntimeService', () => { 'C:\\workspaces\\runtime-hook-windowless', 'pnpm worktree:setup', undefined, - { family: 'posix' } + { family: 'posix' }, + undefined ) expect(runHook).not.toHaveBeenCalled() expect(result.setupReceipt).toMatchObject({ state: 'running' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 444664d60a6..41883e05a66 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -27367,13 +27367,13 @@ export class OrcaRuntimeService { // a renderer window, so the startup shell can wait on setup completion // and windowless creates resolve the same Windows setup shell. const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0] - // Why: both trailing args are optional — the shell is undefined off Windows. setup = createSetupRunnerScript( repo, worktreePath, hooks.scripts.setup, runtimeTarget, - resolveSetupRunnerShell(settings) + resolveSetupRunnerShell(settings), + yamlHooks?.setupAgentStartupPolicy ) } catch (error) { // Why: the git worktree is already real at this point. If runner diff --git a/src/main/worktree-runner-script.ts b/src/main/worktree-runner-script.ts index c3a32cc03b3..a301d17ffa3 100644 --- a/src/main/worktree-runner-script.ts +++ b/src/main/worktree-runner-script.ts @@ -13,6 +13,7 @@ import { buildPosixRunnerScript, buildWindowsRunnerScript } from './setup-runner import type { HookRuntimeTarget } from './hook-runtime-target' import type { Repo } from '../shared/repo-types' import type { WorktreeSetupLaunch } from '../shared/worktree/launch-types' +import type { SetupAgentStartupPolicy } from '../shared/orca-yaml-hook-types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { SetupRunnerShell } from '../shared/setup-runner-command' @@ -30,7 +31,8 @@ export function createSetupRunnerScript( worktreePath: string, script: string, projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget, - setupShell?: SetupRunnerShell + setupShell?: SetupRunnerShell, + projectStartupPolicy?: SetupAgentStartupPolicy ): WorktreeSetupLaunch { return createWorktreeRunnerScript({ repo, @@ -39,7 +41,8 @@ export function createSetupRunnerScript( runnerBaseName: 'setup-runner', runtimeTarget: getHookRuntimeTarget(projectRuntime), waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup( - repo.hookSettings?.setupAgentStartupPolicy + repo.hookSettings?.setupAgentStartupPolicy, + projectStartupPolicy ), setupShell }) diff --git a/src/shared/orca-yaml-hook-types.ts b/src/shared/orca-yaml-hook-types.ts index 42122784490..782c9739311 100644 --- a/src/shared/orca-yaml-hook-types.ts +++ b/src/shared/orca-yaml-hook-types.ts @@ -8,6 +8,7 @@ export type OrcaHooks = { setup?: string // Runs after worktree is created archive?: string // Runs before worktree is archived } + setupAgentStartupPolicy?: SetupAgentStartupPolicy issueCommand?: string // Shared default command for linked GitHub issues defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree environmentRecipes?: OrcaVmRecipe[] // Project-scoped per-workspace environment recipes diff --git a/src/shared/orca-yaml.ts b/src/shared/orca-yaml.ts index b2a84104df2..24d77b442aa 100644 --- a/src/shared/orca-yaml.ts +++ b/src/shared/orca-yaml.ts @@ -225,6 +225,11 @@ export function parseOrcaYaml(content: string): OrcaHooks | null { const scriptsRecord = asRecord(record.scripts) const setup = scriptsRecord ? asTrimmedString(scriptsRecord.setup) : undefined const archive = scriptsRecord ? asTrimmedString(scriptsRecord.archive) : undefined + const setupAgentStartupPolicy = + record.setupAgentStartupPolicy === 'start-immediately' || + record.setupAgentStartupPolicy === 'wait-for-setup' + ? record.setupAgentStartupPolicy + : undefined const issueCommand = asTrimmedString(record.issueCommand) const defaultTabs = normalizeDefaultTabs(record.defaultTabs) const environmentRecipeParse = normalizeVmRecipes(record.environmentRecipes) @@ -239,6 +244,7 @@ export function parseOrcaYaml(content: string): OrcaHooks | null { !setup && !archive && !issueCommand && + !setupAgentStartupPolicy && defaultTabs.length === 0 && environmentRecipes.length === 0 && environmentRecipeDiagnostics.length === 0 && @@ -252,6 +258,7 @@ export function parseOrcaYaml(content: string): OrcaHooks | null { ...(setup ? { setup } : {}), ...(archive ? { archive } : {}) }, + ...(setupAgentStartupPolicy ? { setupAgentStartupPolicy } : {}), ...(issueCommand ? { issueCommand } : {}), ...(defaultTabs.length > 0 ? { defaultTabs } : {}), ...(environmentRecipes.length > 0 ? { environmentRecipes } : {}), diff --git a/src/shared/setup-agent-startup-policy.ts b/src/shared/setup-agent-startup-policy.ts index aa77da6eedc..8998b9d68d4 100644 --- a/src/shared/setup-agent-startup-policy.ts +++ b/src/shared/setup-agent-startup-policy.ts @@ -1,11 +1,11 @@ import type { SetupAgentStartupPolicy } from './orca-yaml-hook-types' -// Why: existing repos should keep launching setup and agents side by side unless -// the user explicitly opts into waiting for setup completion. +// Why: existing repos keep launching setup and agents side by side unless the user or +// committed project config requires setup to finish first. export const DEFAULT_SETUP_AGENT_STARTUP_POLICY: SetupAgentStartupPolicy = 'start-immediately' export function shouldWaitForSetupBeforeAgentStartup( - policy: SetupAgentStartupPolicy | undefined + ...policies: (SetupAgentStartupPolicy | undefined)[] ): boolean { - return policy === 'wait-for-setup' + return policies.includes('wait-for-setup') }