diff --git a/docs/site/content/docs/model/orca-yaml.mdx b/docs/site/content/docs/model/orca-yaml.mdx index 49bf4c2ca5a..3ce562086f9 100644 --- a/docs/site/content/docs/model/orca-yaml.mdx +++ b/docs/site/content/docs/model/orca-yaml.mdx @@ -129,11 +129,11 @@ scripts: - Ids may use letters, numbers, spaces, dots, underscores, and hyphens. Hyphens show as spaces when `name` is omitted. - `cwd` is relative to the worktree. Absolute paths and `..` are ignored. - **Stop** sends an interrupt (Ctrl+C). If the command is still running after 5 seconds, Orca closes its terminal, which ends every process the shell started. **Restart** stops the action and starts it again in a fresh terminal. -- `runMode: concurrent` lets the same action run in several worktrees at once. `nonconcurrent` stops every other running action of the project first; use it when worktrees share one fixed port, database, or container stack. An action's own `runMode` overrides the project setting. +- `runMode: concurrent` lets the same action run in several worktrees at once. `nonconcurrent` first stops that action in the project's other worktrees (the project's other actions keep running); use it when worktrees share one fixed port, database, or container stack. An action's own `runMode` overrides the project setting. - `autoRunAfterSetup` starts the action once the Setup terminal's script succeeds. - Shared `orca.yaml` actions go through the same approval prompt as setup scripts. Actions imported into local settings are treated as your own. -Every terminal in a worktree gets a stable block of 10 ports: `ORCA_PORT` through `ORCA_PORT+9` (`ORCA_PORT_COUNT` is `10`). Two worktrees on the same host never share a block, so the same dev server can run in each without port collisions. `CONDUCTOR_PORT` is set to the same value, so imported Conductor scripts work unchanged. Ports a run opens appear in the Run menu and the **Ports** panel. +Every terminal in a worktree gets a stable block of 10 ports: `ORCA_PORT` through `ORCA_PORT+9` (`ORCA_PORT_COUNT` is `10`). Two worktrees on the same host never share a block (when a host launcher already sets `ORCA_PORT`, the first workspace keeps that block), so the same dev server can run in each without port collisions. `CONDUCTOR_PORT` is set to the same value, so imported Conductor scripts work unchanged. Ports a run opens appear in the Run menu and the **Ports** panel. ### Terminal defaults and issue commands diff --git a/src/main/daemon/pty-subprocess/spawn-environment-ports.test.ts b/src/main/daemon/pty-subprocess/spawn-environment-ports.test.ts new file mode 100644 index 00000000000..1dc815a64ed --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment-ports.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDaemonPtyEnvironment } from './spawn-environment' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('daemon PTY env and the host launcher port block', () => { + it("keeps the pane's own workspace block over the host's ORCA_PORT", () => { + vi.stubEnv('ORCA_PORT', '3000') + vi.stubEnv('CONDUCTOR_PORT', '3000') + const env = createDaemonPtyEnvironment({ + sessionId: 's', + cols: 80, + rows: 24, + env: { ORCA_PORT: '21010', ORCA_PORT_COUNT: '10', CONDUCTOR_PORT: '21010' } + }) + expect([env.ORCA_PORT, env.ORCA_PORT_COUNT, env.CONDUCTOR_PORT]).toEqual([ + '21010', + '10', + '21010' + ]) + }) + + it("doesn't hand the host's block to a pane that has no workspace block", () => { + vi.stubEnv('ORCA_PORT', '3000') + vi.stubEnv('CONDUCTOR_PORT', '3000') + const env = createDaemonPtyEnvironment({ sessionId: 's', cols: 80, rows: 24, env: {} }) + expect(env.ORCA_PORT).toBeUndefined() + expect(env.CONDUCTOR_PORT).toBeUndefined() + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 8170166e150..5da6296bc0c 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -30,6 +30,7 @@ import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' import type { PtySubprocessOptions } from './pty-subprocess' import { REMOTE_VIEWER_CLIPBOARD_ENV } from '../../../shared/remote-viewer-clipboard-env' import { ORCAD_SERVER_LAUNCH_ENV_KEYS } from '../../../shared/orcad-server-launch-env' +import { WORKSPACE_PORT_ENV_KEYS } from '../../../shared/workspace-port-block' import { stripInheritedClaudeSessionEnv } from '../../../shared/agent-launch/startup/claude-parent-session-env' const PANE_IDENTITY_ENV_KEYS = [ @@ -40,6 +41,8 @@ const PANE_IDENTITY_ENV_KEYS = [ // Not identity but equally per-spawn: an inherited copy names another launch's CLI. 'ORCA_WSL_CLI_DIR', 'JCODE_RUNTIME_DIR', + // The host's own port block (a VM launcher sets one) is not this pane's workspace block. + ...WORKSPACE_PORT_ENV_KEYS, // Per-viewer: an Orca started from a remote viewer's pane must not mark its own local panes. REMOTE_VIEWER_CLIPBOARD_ENV, // A server's own launch env; a user's `orcad` in this shell is not that server. diff --git a/src/main/workspace-hooks/hooks.ts b/src/main/workspace-hooks/hooks.ts index 94a74cc949d..9b1400d6ebd 100644 --- a/src/main/workspace-hooks/hooks.ts +++ b/src/main/workspace-hooks/hooks.ts @@ -5,6 +5,7 @@ import { resolveHookCommandSourcePolicy } from '../../shared/hook-command-source import { getEffectiveHooksFromConfig } from './effective-hook-config' import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target' import { getSetupEnvVars } from './setup-hook-env-vars' +import { getWorkspacePortBlockForPath } from '../workspace-ports/workspace-port-block-registry' import { iterateLfScriptLines } from './setup-runner-script-text' import { promptGuardShellEnv } from '../git/command-runner/git-process-env' import { dropIncoherentCondaActivationEnv } from '../pty/shell-startup/conda-activation-env' @@ -179,7 +180,15 @@ export function getSetupCommandSource( return null } -export function runHook( +export async function runHook( + ...args: Parameters +): ReturnType { + // Hooks see the workspace's own port block, which may not exist yet if no terminal opened. + await getWorkspacePortBlockForPath(args[1]).catch(() => null) + return runHookWithKnownPorts(...args) +} + +function runHookWithKnownPorts( hookName: 'setup' | 'archive', cwd: string, repo: Repo, diff --git a/src/main/workspace-ports/workspace-port-block-registry.test.ts b/src/main/workspace-ports/workspace-port-block-registry.test.ts index 973187f1601..cf26796da1f 100644 --- a/src/main/workspace-ports/workspace-port-block-registry.test.ts +++ b/src/main/workspace-ports/workspace-port-block-registry.test.ts @@ -6,7 +6,8 @@ import { WORKSPACE_PORT_BLOCK_SIZE, WORKSPACE_PORT_RANGE_END, WORKSPACE_PORT_RANGE_START, - buildWorkspacePortEnv + buildWorkspacePortEnv, + hostLauncherPortBase } from '../../shared/workspace-port-block' import { WorkspacePortBlockRegistry } from './workspace-port-block-registry' @@ -124,3 +125,40 @@ describe('peekBlock', () => { expect(new Set(blocks.map((block) => block.base)).size).toBe(20) }) }) + +describe('a host launcher block (VM launcher sets ORCA_PORT=3000)', () => { + it('goes to the first workspace; later ones get registry blocks that never overlap it', async () => { + const file = registryFile() + const registry = new WorkspacePortBlockRegistry(file, Date.now, 3000) + expect(await registry.getBlock('/vercel/orca/workspaces/project/main')).toEqual({ + base: 3000, + count: 10 + }) + const second = await registry.getBlock('/vercel/orca/workspaces/project/feature') + expect(second.base).toBeGreaterThanOrEqual(WORKSPACE_PORT_RANGE_START) + // After a wake the VM's registry file is read again: the first workspace keeps 3000. + const reloaded = new WorkspacePortBlockRegistry(file, Date.now, 3000) + expect(await reloaded.getBlock('/vercel/orca/workspaces/project/main')).toEqual({ + base: 3000, + count: 10 + }) + expect(await reloaded.getBlock('/vercel/orca/workspaces/project/feature')).toEqual(second) + }) + + it('skips registry slots that overlap a pinned block inside the registry range', async () => { + const registry = new WorkspacePortBlockRegistry(registryFile(), Date.now, 25005) + expect((await registry.getBlock('/first')).base).toBe(25005) + for (let i = 0; i < 200; i++) { + const { base } = await registry.getBlock(`/w-${i}`) + expect(base + 10 <= 25005 || base >= 25015).toBe(true) + } + }) + + it('reads the launcher block from the host env, never from a parent Orca pane', () => { + expect(hostLauncherPortBase({ ORCA_PORT: '3000' })).toBe(3000) + expect(hostLauncherPortBase({ ORCA_PORT: '3000', ORCA_WORKTREE_ID: 'r::/w' })).toBeNull() + expect(hostLauncherPortBase({ ORCA_PORT: '3000', ORCA_PANE_KEY: 't:l' })).toBeNull() + expect(hostLauncherPortBase({ ORCA_PORT: 'x' })).toBeNull() + expect(hostLauncherPortBase({})).toBeNull() + }) +}) diff --git a/src/main/workspace-ports/workspace-port-block-registry.ts b/src/main/workspace-ports/workspace-port-block-registry.ts index 4229e159d53..3d96854db71 100644 --- a/src/main/workspace-ports/workspace-port-block-registry.ts +++ b/src/main/workspace-ports/workspace-port-block-registry.ts @@ -5,6 +5,7 @@ import { WORKSPACE_PORT_BLOCK_SIZE, WORKSPACE_PORT_RANGE_END, WORKSPACE_PORT_RANGE_START, + hostLauncherPortBase, type WorkspacePortBlock } from '../../shared/workspace-port-block' import { splitWorktreeId } from '../../shared/worktree/id' @@ -32,11 +33,14 @@ function slotBase(slot: number): number { return WORKSPACE_PORT_RANGE_START + slot * WORKSPACE_PORT_BLOCK_SIZE } -function readBlock(value: unknown): PersistedBlock | null { +function readBlock(value: unknown, pinnedBase: number | null): PersistedBlock | null { if (typeof value !== 'object' || value === null) { return null } const { base, allocatedAt }: Record = { ...value } + if (typeof base === 'number' && base === pinnedBase && typeof allocatedAt === 'number') { + return { base, allocatedAt } + } if ( typeof base !== 'number' || !Number.isInteger(base) || @@ -50,7 +54,7 @@ function readBlock(value: unknown): PersistedBlock | null { return { base, allocatedAt } } -function parseRegistry(text: string): Map { +function parseRegistry(text: string, pinnedBase: number | null): Map { const blocks = new Map() const parsed: unknown = JSON.parse(text) const entries = @@ -60,7 +64,7 @@ function parseRegistry(text: string): Map { } const seenBases = new Set() for (const [key, value] of Object.entries(entries)) { - const block = readBlock(value) + const block = readBlock(value, pinnedBase) // A hand-edited duplicate keeps its first owner; the second re-allocates on next use. if (block && !seenBases.has(block.base)) { seenBases.add(block.base) @@ -85,7 +89,13 @@ export class WorkspacePortBlockRegistry { constructor( private readonly filePath: string, - private readonly now: () => number = Date.now + private readonly now: () => number = Date.now, + /** + * A host launcher's own block (a VM's launcher sets ORCA_PORT=3000 for everything it starts). + * The first workspace to ask keeps it, so its agents, terminals and setup agree; every later + * workspace gets a registry block, so none collide. + */ + private readonly pinnedBase: number | null = null ) {} /** The block already known in memory, without touching disk (null until first allocation). */ @@ -119,10 +129,18 @@ export class WorkspacePortBlockRegistry { private allocate(key: string, blocks: Map): number { const taken = new Set([...blocks.values()].map((block) => block.base)) + const pinned = this.pinnedBase + if (pinned !== null && !taken.has(pinned)) { + return pinned + } const preferred = hashKey(key) % SLOT_COUNT for (let offset = 0; offset < SLOT_COUNT; offset++) { const base = slotBase((preferred + offset) % SLOT_COUNT) - if (!taken.has(base)) { + const overlapsPinned = + pinned !== null && + base < pinned + WORKSPACE_PORT_BLOCK_SIZE && + pinned < base + WORKSPACE_PORT_BLOCK_SIZE + if (!taken.has(base) && !overlapsPinned) { return base } } @@ -141,7 +159,7 @@ export class WorkspacePortBlockRegistry { private async load(): Promise> { try { - return parseRegistry(await readFile(this.filePath, 'utf8')) + return parseRegistry(await readFile(this.filePath, 'utf8'), this.pinnedBase) } catch { // Missing or unreadable: keep what this process knows; allocations re-derive from the hash. return new Map(this.blocks ?? []) @@ -167,7 +185,9 @@ function getDefaultRegistry(): WorkspacePortBlockRegistry | null { if (!defaultRegistry) { try { defaultRegistry = new WorkspacePortBlockRegistry( - join(getCanonicalUserDataPath(), 'workspace-port-blocks.json') + join(getCanonicalUserDataPath(), 'workspace-port-blocks.json'), + Date.now, + hostLauncherPortBase(process.env) ) } catch { return null @@ -185,6 +205,14 @@ export async function getWorkspacePortBlockForWorktreeId( return registry && path ? registry.getBlock(path) : null } +/** The block for a checkout path (setup and archive hooks run before any terminal may exist). */ +export async function getWorkspacePortBlockForPath( + worktreePath: string +): Promise { + const registry = worktreePath ? getDefaultRegistry() : null + return registry ? registry.getBlock(worktreePath) : null +} + /** For synchronous callers (setup/archive env): known once any terminal in the workspace opened. */ export function peekWorkspacePortBlock(worktreePath: string): WorkspacePortBlock | null { return worktreePath ? (getDefaultRegistry()?.peekBlock(worktreePath) ?? null) : null diff --git a/src/renderer/src/lib/run-actions/run-action-controller.test.ts b/src/renderer/src/lib/run-actions/run-action-controller.test.ts index d6fc27a7354..1d1d6d27bba 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.test.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.test.ts @@ -226,6 +226,25 @@ describe('run actions', () => { expect(getRunActionRun(WT_B, 'web')?.status).toBe('running') }) + it('nonconcurrent leaves the project’s other actions running, here and elsewhere', async () => { + const worker: OrcaRunAction = { id: 'worker', name: 'Worker', command: 'pnpm worker' } + mocks.checkRuntimeHooks.mockResolvedValue({ + status: 'ok', + hooks: { scripts: { run: [web, worker], runMode: 'nonconcurrent' } } + }) + await startRunAction(WT_A, web, config([web, worker], 'nonconcurrent')) + await startRunAction(WT_B, worker, config([web, worker], 'nonconcurrent')) + const starting = startRunAction(WT_A, worker, config([web, worker], 'nonconcurrent')) + await vi.advanceTimersByTimeAsync(0) + // Only the same action in the other workspace is interrupted. + expect(mocks.sendRuntimePtyInput).toHaveBeenCalledTimes(1) + expect(getRunActionRun(WT_B, 'worker')?.status).toBe('stopping') + dispatchTerminalCommandFinishedEvent(WT_B, 130, getRunActionRun(WT_B, 'worker')!.tabId) + await starting + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + expect(getRunActionRun(WT_A, 'worker')?.status).toBe('running') + }) + it('concurrent leaves other workspaces running', async () => { await startRunAction(WT_A, web, config([web])) await startRunAction(WT_B, web, config([web])) diff --git a/src/renderer/src/lib/run-actions/run-action-controller.ts b/src/renderer/src/lib/run-actions/run-action-controller.ts index 2d515fe0acb..a2dc704c290 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.ts @@ -124,12 +124,14 @@ async function stopConflictingRuns( if (effectiveRunMode(action, scripts) !== 'nonconcurrent') { return } - // nonconcurrent: the project shares one port/database/stack, so any other live action must stop first. + // nonconcurrent (Conductor's run_mode): the same action can't run in two workspaces of the project + // at once (one fixed port, database or stack). Other actions keep running. const conflicting = listRunActionRuns().filter( (run) => run.repoId === target.repoId && - (run.status === 'running' || run.status === 'stopping') && - !(run.worktreeId === target.worktreeId && run.actionId === action.id) + run.actionId === action.id && + run.worktreeId !== target.worktreeId && + (run.status === 'running' || run.status === 'stopping') ) await Promise.all(conflicting.map((run) => stopRunAction(run.worktreeId, run.actionId))) } diff --git a/src/shared/orca-yaml-hook-types.ts b/src/shared/orca-yaml-hook-types.ts index 8d8479be1ec..c628eb39f53 100644 --- a/src/shared/orca-yaml-hook-types.ts +++ b/src/shared/orca-yaml-hook-types.ts @@ -26,8 +26,8 @@ export type OrcaWorktreeDefaults = { /** * `concurrent`: the action may run in several workspaces at once. - * `nonconcurrent`: starting it first stops every other running action of the same project, - * for projects that share one fixed port, database or container stack. + * `nonconcurrent`: starting it first stops the same action running in the project's other + * workspaces, for projects that share one fixed port, database or container stack. */ export type OrcaRunMode = 'concurrent' | 'nonconcurrent' diff --git a/src/shared/workspace-port-block.ts b/src/shared/workspace-port-block.ts index 761370ec365..b427a7e296d 100644 --- a/src/shared/workspace-port-block.ts +++ b/src/shared/workspace-port-block.ts @@ -24,3 +24,18 @@ export function buildWorkspacePortEnv(block: WorkspacePortBlock): Record= block.base && port < block.base + block.count } + +/** + * The block a host launcher set for everything it starts (ORCA_PORT in the host process env), or + * null. A copy inherited from a parent Orca pane carries pane identity and names that pane's + * workspace instead, so it doesn't count. + */ +export function hostLauncherPortBase(env: NodeJS.ProcessEnv): number | null { + if (env.ORCA_PANE_KEY || env.ORCA_WORKTREE_ID) { + return null + } + const base = Number(env.ORCA_PORT) + return Number.isInteger(base) && base >= 1024 && base <= 65535 - WORKSPACE_PORT_BLOCK_SIZE + ? base + : null +}