fix(run-actions): a split pane in the run tab can't end the run; autoRunAfterSetup fires on first open when the host ran setup in an environment build

- Command-finished events carry the pane's leaf; a run learns its pane while its tab has one,
  ignores other panes, and Stop interrupts the run's own pane.
- Workspaces whose setup ran in the environment build (hooks report environmentProvisioned) start
  the autoRunAfterSetup action the first time they are opened, once per workspace.
This commit is contained in:
Neil
2026-11-10 04:09:36 -08:00
parent ceea5240df
commit dc150fa8a5
10 changed files with 229 additions and 17 deletions
@@ -10,6 +10,7 @@ import {
type WorktreeRunActions
} from '@/lib/run-actions/run-action-config'
import { startRunAction, stopRunAction } from '@/lib/run-actions/run-action-controller'
import { autoRunProvisionedWorkspaceOnce } from '@/lib/run-actions/run-action-events'
import { useWorktreeRunActionRuns, type RunActionRun } from '@/lib/run-actions/run-actions-store'
import type { WorkspacePort } from '../../../../shared/workspace-ports'
import { RunActionButtonView } from './RunActionButtonView'
@@ -79,6 +80,11 @@ export function RunActionButton({ worktreeId }: { worktreeId: string }): React.J
// eslint-disable-next-line react-hooks/exhaustive-deps
[config, worktreeId, runs]
)
useEffect(() => {
if (config?.setupProvisioned) {
void autoRunProvisionedWorkspaceOnce(worktreeId, config)
}
}, [config, worktreeId])
if (!config || !primary) {
return null
}
@@ -1,4 +1,5 @@
/** Keep store-level command status while parked; foreground confirmation and input-intent inference remain with mounted panes. */
import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id'
import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types'
import { resolvePaneAgentOwner } from '../../../../../../shared/agent-status/identity/pane-agent-owner'
import { parseAppSshPtyId } from '../../../../../../shared/ssh-pty-id'
@@ -173,7 +174,12 @@ export function createParkedTerminalCommandStatusPolicy(options: {
return
}
// Commands can change HEAD/index; nudge git UI immediately rather than wait for polling.
dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode, tabId)
dispatchTerminalCommandFinishedEvent(
worktreeId,
bestEffortExitCode,
tabId,
parsePaneKey(paneKey)?.leafId
)
void retireForegroundAgentUnlessConfirmed()
// Mirror SSH’s unprobed same-turn drop; local PTYs require mounted foreground confirmation against nested-shell 133;D.
if (parseAppSshPtyId(ptyId) === null) {
@@ -1,3 +1,4 @@
import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id'
import { agentTypeToIconAgent } from '../../../../../../shared/agent-status/presentation/agent-type-icon-agent'
import { dismissRetainedProgramResult } from '@/lib/program-status/result-retention'
import { isRetainedProgramResult } from '@/lib/program-status/row'
@@ -217,7 +218,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
dispatchTerminalCommandFinishedEvent(
session.deps.worktreeId,
bestEffortExitCode,
session.deps.tabId
session.deps.tabId,
parsePaneKey(session.cacheKey)?.leafId
)
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[session.cacheKey]
@@ -6,13 +6,16 @@ export type TerminalCommandFinishedEventDetail = {
exitCode: number | null
/** The pane's tab, when the emitter knows it; consumers tracking one tab's command need it. */
tabId?: string
/** The pane's stable leaf: a split's commands are not the tab's first pane's. */
leafId?: string
}
// Window events let decoupled consumers observe per-pane OSC completion without accessing terminal internals.
export function dispatchTerminalCommandFinishedEvent(
worktreeId: string,
exitCode: number | null,
tabId?: string
tabId?: string,
leafId?: string
): void {
// Why: unit tests and non-DOM renderer shims may expose only the preload API.
if (typeof window.dispatchEvent !== 'function') {
@@ -21,7 +24,7 @@ export function dispatchTerminalCommandFinishedEvent(
window.dispatchEvent(
new CustomEvent<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}) }
detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}) }
})
)
}
@@ -21,6 +21,8 @@ export type WorktreeRunActions = {
scripts: OrcaRunScripts
/** Ids defined by the shared orca.yaml (they need the user's approval before running). */
sharedIds: ReadonlySet<string>
/** The host prepared the checkout itself (setup ran in an environment build, not a Setup tab). */
setupProvisioned?: true
}
export type RunActionTarget = {
@@ -96,13 +98,20 @@ export async function loadWorktreeRunActions(
}
const repo = state.repos.find((candidate) => candidate.id === target.repoId)
let shared: OrcaHooks['scripts'] | undefined
let setupProvisioned = false
try {
const result = await checkRuntimeHooks(target.hostId, target.repoId, options)
shared = result.status === 'error' ? undefined : (result.hooks?.scripts ?? undefined)
setupProvisioned = result.environmentProvisioned === true
} catch {
shared = undefined
}
return mergeRunActions(shared, repo?.hookSettings?.scripts, target.isLocal ? 'local' : 'remote')
const merged = mergeRunActions(
shared,
repo?.hookSettings?.scripts,
target.isLocal ? 'local' : 'remote'
)
return setupProvisioned ? { ...merged, setupProvisioned: true } : merged
}
/** The action the Run button and shortcut act on: the last one still running here, else the default. */
@@ -10,6 +10,7 @@ type MockState = {
{ id: string; entityId: string; contentType: string; groupId: string }[]
>
ptyIdsByTabId: Record<string, string[]>
terminalLayoutsByTabId: Record<string, unknown>
createTab: ReturnType<typeof vi.fn>
closeTab: ReturnType<typeof vi.fn>
setTabCustomTitle: ReturnType<typeof vi.fn>
@@ -69,7 +70,8 @@ vi.stubGlobal('window', new EventTarget())
const { mergeRunActions, loadWorktreeRunActions } = await import('./run-action-config')
const { startRunAction, stopRunAction, relaunchInterruptedRunActions, armRunActionRelaunch } =
await import('./run-action-controller')
const { installRunActionEvents, armAutoRunAfterSetup } = await import('./run-action-events')
const { installRunActionEvents, armAutoRunAfterSetup, autoRunProvisionedWorkspaceOnce } =
await import('./run-action-events')
const { getRunActionRun, resetRunActionRunsForTests } = await import('./run-actions-store')
const { dispatchTerminalCommandFinishedEvent } =
await import('@/hooks/terminal-command-finished-event')
@@ -90,6 +92,7 @@ function makeState(): MockState {
tabsByWorktree: {},
unifiedTabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
createTab: vi.fn((worktreeId: string) => {
const tab = { id: `tab-${++nextTab}` }
state.tabsByWorktree[worktreeId] = [...(state.tabsByWorktree[worktreeId] ?? []), tab]
@@ -322,6 +325,73 @@ describe('run actions', () => {
expect(await startRunAction(WT_B, web, config([web]))).toBe(false)
})
it('a command finishing in a split of the run tab does not end the run', async () => {
await startRunAction(WT_A, web, config([web]))
const { tabId } = getRunActionRun(WT_A, 'web')!
const L1 = '11111111-1111-4111-8111-111111111111'
const L2 = '22222222-2222-4222-8222-222222222222'
const notifyLayouts = (): void => {
for (const listener of mocks.subscribers) {
listener(state, { tabsByWorktree: state.tabsByWorktree, terminalLayoutsByTabId: {} })
}
}
state.terminalLayoutsByTabId = { [tabId]: { root: { type: 'leaf', leafId: L1 } } }
notifyLayouts()
expect(getRunActionRun(WT_A, 'web')?.leafId).toBe(L1)
state.terminalLayoutsByTabId = {
[tabId]: {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: L1 },
second: { type: 'leaf', leafId: L2 }
}
}
}
notifyLayouts()
dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, L2)
expect(getRunActionRun(WT_A, 'web')?.status).toBe('running')
dispatchTerminalCommandFinishedEvent(WT_A, 1, tabId, L1)
expect(getRunActionRun(WT_A, 'web')).toMatchObject({ status: 'failed', exitCode: 1 })
})
it('ignores a pane it cannot attribute while the tab has several panes', async () => {
await startRunAction(WT_A, web, config([web]))
const { tabId } = getRunActionRun(WT_A, 'web')!
state.terminalLayoutsByTabId = {
[tabId]: {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' },
second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' }
}
}
}
dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, '22222222-2222-4222-8222-222222222222')
expect(getRunActionRun(WT_A, 'web')?.status).toBe('running')
})
it('auto-runs once on first open when the host ran setup in the environment build', async () => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => void storage.set(key, value)
})
try {
const provisioned = { ...config([web]), setupProvisioned: true as const }
provisioned.scripts = { ...provisioned.scripts, autoRunAfterSetup: true }
expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(true)
expect(getRunActionRun(WT_A, 'web')?.status).toBe('running')
dispatchTerminalCommandFinishedEvent(WT_A, 0, getRunActionRun(WT_A, 'web')!.tabId)
expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(false)
const notProvisioned = { ...provisioned, setupProvisioned: undefined }
expect(await autoRunProvisionedWorkspaceOnce(WT_B, notProvisioned)).toBe(false)
} finally {
vi.stubGlobal('localStorage', undefined)
}
})
it('auto-runs the default action once setup succeeds, not when it fails', async () => {
mocks.checkRuntimeHooks.mockResolvedValue({
status: 'ok',
@@ -21,7 +21,7 @@ import {
type RunActionTarget,
type WorktreeRunActions
} from './run-action-config'
import { findLiveRunTabId, sameRunTab } from './run-action-tabs'
import { findLiveRunTabId, runPanePtyId, sameRunTab } from './run-action-tabs'
/** Conductor's grace period: an interrupted dev server gets this long before its shell is closed. */
export const RUN_ACTION_STOP_GRACE_MS = 5_000
@@ -101,8 +101,7 @@ async function stopRunActionOnce(worktreeId: string, actionId: string): Promise<
return
}
const state = useAppStore.getState()
const liveTabId = findLiveRunTabId(state, worktreeId, run.tabId)
const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined
const ptyId = runPanePtyId(state, worktreeId, run.tabId, run.leafId)
if (!ptyId) {
deleteRunActionRun(worktreeId, actionId)
return
@@ -13,10 +13,15 @@ import {
findRunActionRunByTab,
getRunActionRun,
listRunActionRuns,
patchRunActionRun
patchRunActionRun,
type RunActionRun
} from './run-actions-store'
import { loadWorktreeRunActions, pickPrimaryRunAction } from './run-action-config'
import { findLiveRunTabId, sameRunTab } from './run-action-tabs'
import {
loadWorktreeRunActions,
pickPrimaryRunAction,
type WorktreeRunActions
} from './run-action-config'
import { findLiveRunTabId, runPanePtyId, runTabLeafIds, sameRunTab } from './run-action-tabs'
import {
isRunActionRelaunchArmed,
notifyRunActionStopped,
@@ -59,6 +64,10 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void
if (!run || (run.status !== 'running' && run.status !== 'stopping')) {
return
}
if (!isRunPane(run, detail.leafId)) {
// A command finished in a split of the run's tab, not the action itself.
return
}
// OSC 133;D may omit the exit code; without one the run is only known to have ended.
const status =
run.status === 'stopping'
@@ -70,6 +79,31 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void
notifyRunActionStopped(tabId)
}
function isRunPane(run: RunActionRun, leafId: string | undefined): boolean {
if (!leafId) {
return true
}
if (run.leafId) {
return run.leafId === leafId
}
// The run's pane isn't known yet: only a tab with one pane proves which pane finished.
const leaves = runTabLeafIds(useAppStore.getState(), run.worktreeId, run.tabId)
return leaves.length <= 1
}
/** A run learns its pane while its tab still has exactly one, before any split exists. */
function learnRunPanes(state: AppState): void {
for (const run of listRunActionRuns()) {
if (run.leafId) {
continue
}
const leaves = runTabLeafIds(state, run.worktreeId, run.tabId)
if (leaves.length === 1) {
patchRunActionRun(run.worktreeId, run.actionId, { leafId: leaves[0] })
}
}
}
function pruneClosedRunTabs(state: AppState): void {
for (const run of listRunActionRuns()) {
// A restarting host rebuilds its tab list; the relaunch owns these runs until it settles.
@@ -91,6 +125,42 @@ function pruneClosedRunTabs(state: AppState): void {
}
}
const AUTO_RAN_KEY = 'orca.runActions.autoRan.v1'
function readAutoRan(): string[] {
try {
const parsed: unknown = JSON.parse(globalThis.localStorage?.getItem(AUTO_RAN_KEY) ?? '[]')
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
return []
}
}
/**
* The host ran setup while building the environment, so no Setup tab will ever finish here:
* honor `autoRunAfterSetup` the first time the workspace is opened, once per workspace.
*/
export async function autoRunProvisionedWorkspaceOnce(
worktreeId: string,
config: WorktreeRunActions
): Promise<boolean> {
const action = config.setupProvisioned ? pickAutoRunAction(config.scripts) : null
const autoRan = readAutoRan()
if (!action || autoRan.includes(worktreeId) || getRunActionRun(worktreeId, action.id)) {
return false
}
try {
// Recorded before starting, so a second open (or window) can't start it twice.
globalThis.localStorage?.setItem(
AUTO_RAN_KEY,
JSON.stringify([...autoRan, worktreeId].slice(-500))
)
} catch {
// Best effort: without storage this still runs once per window session.
}
return startRunAction(worktreeId, action, config, { activate: false })
}
/** Follows run terminals: their exits update status, and closing one forgets its run. */
export function installRunActionEvents(): () => void {
const onFinished = (event: Event): void => {
@@ -104,6 +174,9 @@ export function installRunActionEvents(): () => void {
let pruneTimer: ReturnType<typeof setTimeout> | null = null
const installedAt = Date.now()
const unsubscribe = useAppStore.subscribe((state, previous) => {
if (state.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId) {
learnRunPanes(state)
}
if (state.tabsByWorktree === previous.tabsByWorktree || pruneTimer) {
return
}
@@ -136,8 +209,7 @@ const STARTUP_PRUNE_GRACE_MS = 15_000
async function reconcileLocalRunsAfterStartup(): Promise<void> {
const state = useAppStore.getState()
for (const run of listRunActionRuns()) {
const liveTabId = findLiveRunTabId(state, run.worktreeId, run.tabId)
const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined
const ptyId = runPanePtyId(state, run.worktreeId, run.tabId, run.leafId)
if (run.status !== 'running' || !ptyId || getRemoteRuntimePtyOwner(ptyId)) {
continue
}
@@ -1,4 +1,5 @@
import type { AppState } from '@/store/types'
import type { TerminalPaneLayoutNode } from '../../../../shared/workspace-layout/terminal-tab-types'
/** A remote host adopts a tab the window created under `web-terminal-<id>`; both name one tab. */
const MIRRORED_TAB_PREFIX = 'web-terminal-'
@@ -21,3 +22,43 @@ export function findLiveRunTabId(
(state.tabsByWorktree[worktreeId] ?? []).find((tab) => sameRunTab(tab.id, tabId))?.id ?? null
)
}
function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined, out: string[]): string[] {
if (!node) {
return out
}
if (node.type === 'leaf') {
out.push(node.leafId)
} else {
collectLeafIds(node.first, out)
collectLeafIds(node.second, out)
}
return out
}
/** Every pane of the run's tab, in layout order. */
export function runTabLeafIds(
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
worktreeId: string,
tabId: string
): string[] {
const liveTabId = findLiveRunTabId(state, worktreeId, tabId)
return liveTabId ? collectLeafIds(state.terminalLayoutsByTabId?.[liveTabId]?.root, []) : []
}
/** The PTY of the run's own pane, falling back to the tab's first PTY. */
export function runPanePtyId(
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'ptyIdsByTabId'>,
worktreeId: string,
tabId: string,
leafId: string | undefined
): string | undefined {
const liveTabId = findLiveRunTabId(state, worktreeId, tabId)
if (!liveTabId) {
return undefined
}
const byLeaf = leafId
? state.terminalLayoutsByTabId?.[liveTabId]?.ptyIdsByLeafId?.[leafId]
: undefined
return byLeaf ?? state.ptyIdsByTabId[liveTabId]?.[0]
}
@@ -11,6 +11,8 @@ export type RunActionRun = {
actionName: string
/** The terminal tab showing this run's output. */
tabId: string
/** The tab's pane running the action (learned once the pane exists); splits are other panes. */
leafId?: string
status: RunActionStatus
exitCode: number | null
startedAt: number
@@ -40,7 +42,8 @@ function readRun(value: unknown): RunActionRun | null {
return null
}
const run: Record<string, unknown> = { ...value }
const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt } = run
const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt, leafId } =
run
if (
typeof worktreeId !== 'string' ||
typeof repoId !== 'string' ||
@@ -60,7 +63,8 @@ function readRun(value: unknown): RunActionRun | null {
tabId,
status: STATUSES.find((known) => known === status) ?? 'exited',
exitCode: typeof exitCode === 'number' ? exitCode : null,
startedAt
startedAt,
...(typeof leafId === 'string' ? { leafId } : {})
}
}
@@ -124,7 +128,7 @@ export function setRunActionRun(run: RunActionRun): void {
export function patchRunActionRun(
worktreeId: string,
actionId: string,
patch: Partial<Pick<RunActionRun, 'status' | 'exitCode'>>
patch: Partial<Pick<RunActionRun, 'status' | 'exitCode' | 'leafId'>>
): void {
const current = runs[worktreeId]?.[actionId]
if (!current) {