Merge pull request #233 from nwparker/cloud/run-actions-semantics

Run actions: nonconcurrent = same action only; host launcher port block goes to the first workspace
This commit is contained in:
Neil
2026-11-10 04:06:10 -08:00
committed by GitHub
10 changed files with 162 additions and 16 deletions
+2 -2
View File
@@ -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
@@ -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()
})
})
@@ -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.
+10 -1
View File
@@ -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<typeof runHookWithKnownPorts>
): ReturnType<typeof runHookWithKnownPorts> {
// 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,
@@ -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()
})
})
@@ -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<string, unknown> = { ...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<string, PersistedBlock> {
function parseRegistry(text: string, pinnedBase: number | null): Map<string, PersistedBlock> {
const blocks = new Map<string, PersistedBlock>()
const parsed: unknown = JSON.parse(text)
const entries =
@@ -60,7 +64,7 @@ function parseRegistry(text: string): Map<string, PersistedBlock> {
}
const seenBases = new Set<number>()
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<string, PersistedBlock>): 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<Map<string, PersistedBlock>> {
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<WorkspacePortBlock | null> {
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
@@ -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]))
@@ -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)))
}
+2 -2
View File
@@ -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'
+15
View File
@@ -24,3 +24,18 @@ export function buildWorkspacePortEnv(block: WorkspacePortBlock): Record<string,
export function isPortInWorkspaceBlock(port: number, block: WorkspacePortBlock): boolean {
return port >= 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
}