mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user