Split automation dispatch event handling (#17155)

* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Fix F3-speech for #17123

* Fix F1-cycle for #17131
This commit is contained in:
Neil
2026-08-29 20:12:21 -07:00
committed by GitHub
parent 8ff8e8a5f3
commit ca6401a08f
5 changed files with 776 additions and 694 deletions
-1
View File
@@ -56,7 +56,6 @@ inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.ts
@@ -0,0 +1,266 @@
import {
createAutomationRunOutputSnapshotBuffer,
selectAutomationRunOutputSnapshot
} from '@/components/automations/automation-run-output-snapshot'
import { useAppStore } from '@/store'
import type {
AutomationDispatchResult,
AutomationPrecheckResult,
AutomationRun
} from '../../../shared/automations-types'
import type { AgentStateHistoryEntry, AgentStatusEntry } from '../../../shared/agent-status-types'
import {
selectAutomationAgentStatusEntryChange,
UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY
} from './automation-agent-status-entry-change'
import type { Worktree } from '../../../shared/worktree/types'
type MarkDispatchResult = (result: AutomationDispatchResult) => Promise<void>
export function createAutomationDispatchCompletion(args: {
run: AutomationRun
worktree: Worktree
precheckResult: AutomationPrecheckResult | null
markDispatchResult: MarkDispatchResult
releaseTerminalOwnership: () => void
finalizeTerminalOwnership: () => boolean
}) {
const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer()
let latestAssistantMessage: string | null = null
const getOutputSnapshot = () =>
selectAutomationRunOutputSnapshot(latestAssistantMessage, outputSnapshotBuffer.snapshot())
let dispatchMarked = false
let pendingExitCode: number | null = null
let pendingDone = false
let completionMarked = false
let unsubscribeAgentStatus = (): void => {}
let unsubscribeSessionObserver = (): void => {}
let releaseReuseDispatchTab = (): void => {}
const cleanupRunObservers = (): void => {
unsubscribeAgentStatus()
unsubscribeSessionObserver()
releaseReuseDispatchTab()
unsubscribeAgentStatus = (): void => {}
unsubscribeSessionObserver = (): void => {}
releaseReuseDispatchTab = (): void => {}
}
const markCompletionResult = async (): Promise<void> => {
if (completionMarked) {
return
}
completionMarked = true
cleanupRunObservers()
try {
await args.markDispatchResult({
runId: args.run.id,
status: 'completed',
workspaceId: args.worktree.id,
workspaceDisplayName: args.worktree.displayName,
outputSnapshot: getOutputSnapshot(),
precheckResult: args.precheckResult,
error: null
})
} catch (error) {
args.releaseTerminalOwnership()
throw error
}
if (args.finalizeTerminalOwnership()) {
await clearRetiredRunTerminalIdentity()
}
}
const clearRetiredRunTerminalIdentity = async (): Promise<void> => {
// Why: the owned terminal was just retired, so the run's pane/pty
// pointers now reference a closed tab. Drop them (best-effort) so
// "View run" resolves to the workspace/snapshot instead of dead-ending
// on an unavailable terminal.
try {
await args.markDispatchResult({
runId: args.run.id,
status: 'completed',
terminalSessionId: null,
terminalPaneKey: null,
terminalPtyId: null
})
} catch (error) {
console.error('[automations] Failed to clear retired terminal identity:', error)
}
}
const markExitResult = async (code: number): Promise<void> => {
if (completionMarked) {
return
}
completionMarked = true
cleanupRunObservers()
try {
await args.markDispatchResult({
runId: args.run.id,
status: code === 0 ? 'completed' : 'dispatch_failed',
workspaceId: args.worktree.id,
workspaceDisplayName: args.worktree.displayName,
outputSnapshot: getOutputSnapshot(),
precheckResult: args.precheckResult,
error: code === 0 ? null : `Automation process exited with code ${code}.`
})
} catch (error) {
args.releaseTerminalOwnership()
throw error
}
if (code === 0) {
if (args.finalizeTerminalOwnership()) {
await clearRetiredRunTerminalIdentity()
}
} else {
args.releaseTerminalOwnership()
}
}
const settleLateResult = (result: Promise<void>): void => {
// Why: status/exit callbacks have no awaitable caller; the result
// path already releases ownership before propagating persistence errors.
void result.catch((error) => {
console.error('[automations] Failed to persist late automation result:', error)
})
}
const handleAgentDone = (): void => {
if (completionMarked) {
return
}
if (!dispatchMarked) {
pendingDone = true
return
}
settleLateResult(markCompletionResult())
}
const handleExit = (code: number): void => {
if (completionMarked) {
return
}
if (!dispatchMarked) {
pendingExitCode = code
return
}
settleLateResult(markExitResult(code))
}
const observeAgentStatus = (
targetPaneKey: string,
startedAfter: number,
options?: { requireWorkingAfterStart?: boolean }
): void => {
let sawWorkingAfterStart = false
let observedStateHistory: AgentStateHistoryEntry[] = []
let observedEntry: AgentStatusEntry | undefined
const checkCurrentStatus = (): void => {
const entryChange = selectAutomationAgentStatusEntryChange(
useAppStore.getState().agentStatusByPaneKey,
targetPaneKey,
observedEntry
)
if (entryChange === UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) {
return
}
const entry = entryChange
observedEntry = entry
if (!entry || entry.updatedAt < startedAfter) {
return
}
const historyOverlap = getAgentStateHistoryOverlap(observedStateHistory, entry.stateHistory)
// Why: sawWorkingAfterStart stays monotonic — a recreated entry
// (transport loss, PTY exit, cap eviction) arrives with an empty
// stateHistory, so clearing it here would strand reuseSession runs
// with nothing left to re-derive the working edge from.
for (const historicalState of entry.stateHistory.slice(historyOverlap)) {
if (historicalState.startedAt < startedAfter) {
continue
}
if (historicalState.state === 'working') {
sawWorkingAfterStart = true
}
if (
historicalState.state === 'done' &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
// Why: this `done` already rolled out of the live entry, so its output
// survives only in the entry-level completed slot.
latestAssistantMessage =
entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
return
}
}
observedStateHistory = [...entry.stateHistory]
if (entry.state === 'working') {
sawWorkingAfterStart = true
}
if (
entry.state === 'done' &&
// Why: a session-boundary done is the agent CONNECTING (Claude SessionStart
// fires at launch, before the argv prompt submits) — completing here would
// close the tab and record an empty run result.
entry.sessionBoundary !== true &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
latestAssistantMessage = entry.lastAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
}
}
// Why: Codex/Claude completion normally arrives through the global
// hook IPC listener, not the hidden PTY OSC fallback.
unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus)
checkCurrentStatus()
}
return {
appendOutput: (chunk: string) => outputSnapshotBuffer.append(chunk),
captureAssistantMessage: (message: string | null | undefined) => {
latestAssistantMessage = message?.trim() || latestAssistantMessage
},
cleanupRunObservers,
handleAgentDone,
handleExit,
observeAgentStatus,
setReuseDispatchTabRelease: (release: () => void) => {
releaseReuseDispatchTab = release
},
setSessionObserver: (unsubscribe: () => void) => {
unsubscribeSessionObserver = unsubscribe
},
settlePendingAfterDispatch: async () => {
dispatchMarked = true
if (pendingDone) {
await markCompletionResult()
} else if (pendingExitCode !== null) {
await markExitResult(pendingExitCode)
}
}
}
}
function agentStateHistoryEntriesEqual(
left: AgentStateHistoryEntry,
right: AgentStateHistoryEntry
): boolean {
return (
left.state === right.state &&
left.prompt === right.prompt &&
left.startedAt === right.startedAt &&
left.interrupted === right.interrupted
)
}
function getAgentStateHistoryOverlap(
previous: AgentStateHistoryEntry[],
current: AgentStateHistoryEntry[]
): number {
for (let overlap = Math.min(previous.length, current.length); overlap > 0; overlap -= 1) {
const previousOffset = previous.length - overlap
if (
current
.slice(0, overlap)
.every((entry, index) =>
agentStateHistoryEntriesEqual(entry, previous[previousOffset + index])
)
) {
return overlap
}
}
return 0
}
@@ -0,0 +1,239 @@
import { listAutomationRunsForTarget } from '@/components/automations/automation-host-client'
import { translate } from '@/i18n/i18n'
import { submitPromptToAgentPty } from '@/lib/agent-paste-draft'
import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session'
import { observeExistingAutomationSession } from '@/lib/automation-session-observer'
import { findReusableAutomationSession } from '@/lib/automation-session-reuse'
import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership'
import { useAppStore } from '@/store'
import type {
AutomationDispatchRequest,
AutomationDispatchResult
} from '../../../shared/automations-types'
import { createAutomationDispatchCompletion } from './automation-dispatch-completion'
import {
prepareAutomationDispatchWorkspace,
resolveAutomationDispatchWorkspace
} from './automation-dispatch-workspace'
const activeReuseDispatchTabIds = new Set<string>()
function acquireReuseDispatchTab(tabId: string): (() => void) | null {
if (activeReuseDispatchTabIds.has(tabId)) {
return null
}
activeReuseDispatchTabIds.add(tabId)
return () => activeReuseDispatchTabIds.delete(tabId)
}
export async function handleAutomationDispatchRequest({
automation,
run,
dispatchToken
}: AutomationDispatchRequest): Promise<void> {
const markDispatchResult = async (result: AutomationDispatchResult): Promise<void> => {
// Deliberately no local emit: the write publishes its own host-scoped event
// (automation-run-writer.ts) before this reply returns, and an unscoped one
// here would arrive second and invalidate every host in the catalog.
await window.api.automations.markDispatchResult(result)
}
const state = useAppStore.getState()
const focusBeforeDispatch = {
activeView: state.activeView,
activeWorktreeId: state.activeWorktreeId,
activeTabId: state.activeTabId,
activeTabType: state.activeTabType
}
const resolved = resolveAutomationDispatchWorkspace(state, automation, run)
let terminalOwnership: AutomationTerminalOwnership | null = null
const releaseTerminalOwnership = (): void => {
const ownership = terminalOwnership
terminalOwnership = null
ownership?.release()
}
const finalizeTerminalOwnership = (): boolean => {
const ownership = terminalOwnership
terminalOwnership = null
return ownership?.finalize() ?? false
}
if (!resolved.repo) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: run.workspaceId,
workspaceDisplayName: run.workspaceDisplayName ?? null,
error: translate(
'auto.hooks.useAutomationDispatchEvents.386db94f3e',
'The target project is no longer available.'
)
})
return
}
try {
const worktree = await prepareAutomationDispatchWorkspace({
state,
automation,
run,
dispatchToken,
resolved: { ...resolved, repo: resolved.repo },
markDispatchResult
})
if (!worktree) {
return
}
const completion = createAutomationDispatchCompletion({
run,
worktree,
precheckResult: resolved.context.precheckResult,
markDispatchResult,
releaseTerminalOwnership,
finalizeTerminalOwnership
})
const dispatchStartedAt = Date.now()
if (automation.reuseSession) {
const reusableSession = findReusableAutomationSession({
automationId: automation.id,
agentId: automation.agentId,
worktreeId: worktree.id,
currentRunId: run.id,
// Why: the dispatch loop only ever executes for the desktop authority,
// so its history read addresses the local runtime explicitly.
runs: await listAutomationRunsForTarget({ kind: 'local' }, automation.id),
state: useAppStore.getState()
})
if (reusableSession) {
const releaseTab = acquireReuseDispatchTab(reusableSession.tabId)
if (releaseTab) {
completion.setReuseDispatchTabRelease(releaseTab)
try {
const submitted = await submitPromptToAgentPty({
tabId: reusableSession.tabId,
ptyId: reusableSession.ptyId,
content: automation.prompt
})
if (!submitted) {
completion.cleanupRunObservers()
} else {
let reuseSawWorking = false
const handleReusableAgentStatus = (payload: { state: string }): void => {
if (payload.state === 'working') {
reuseSawWorking = true
return
}
if (payload.state === 'done' && reuseSawWorking) {
completion.handleAgentDone()
}
}
const reuseCompletionStartedAt = Date.now()
completion.setSessionObserver(
await observeExistingAutomationSession({
ptyId: reusableSession.ptyId,
paneKey: reusableSession.paneKey,
runId: run.id,
onData: completion.appendOutput,
onAgentStatus: (payload) => {
completion.captureAssistantMessage(payload.lastAssistantMessage)
handleReusableAgentStatus(payload)
},
onExit: completion.handleExit
})
)
completion.observeAgentStatus(reusableSession.paneKey, reuseCompletionStartedAt, {
requireWorkingAfterStart: true
})
await markDispatchResult({
runId: run.id,
status: 'dispatched',
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
terminalSessionId: reusableSession.tabId,
terminalPaneKey: reusableSession.paneKey,
terminalPtyId: reusableSession.ptyId,
precheckResult: resolved.context.precheckResult,
error: null
})
await completion.settlePendingAfterDispatch()
return
}
} catch (error) {
completion.cleanupRunObservers()
throw error
}
}
}
}
const result = await launchAgentBackgroundSession({
agent: automation.agentId,
worktreeId: worktree.id,
prompt: automation.prompt,
launchSource: 'unknown',
title: run.title,
onData: completion.appendOutput,
onAgentStatus: (payload) => {
completion.captureAssistantMessage(payload.lastAssistantMessage)
// Why: session-boundary done = launch connect, not run completion (see observeAgentStatus).
if (payload.state !== 'done' || payload.sessionBoundary === true) {
return
}
completion.handleAgentDone()
},
onExit: (_ptyId, code) => {
completion.handleExit(code)
}
})
if (!result) {
throw new Error('Unable to build an agent launch plan.')
}
terminalOwnership = result.terminalOwnership
if (automation.reuseSession) {
// Why: the first fresh launch is the seed for later reuse and must
// survive completion under the same policy as an already-reused tab.
releaseTerminalOwnership()
}
const launchedTabId = result.tabId
completion.observeAgentStatus(result.paneKey, dispatchStartedAt)
try {
await markDispatchResult({
runId: run.id,
status: 'dispatched',
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
terminalSessionId: launchedTabId,
terminalPaneKey: result.paneKey,
terminalPtyId: result.ptyId,
precheckResult: resolved.context.precheckResult,
error: null
})
await completion.settlePendingAfterDispatch()
} catch (error) {
completion.cleanupRunObservers()
throw error
}
const currentState = useAppStore.getState()
// Why: Run Now and scheduled dispatches should create workspaces/tabs in
// the background; only an explicit row click should navigate there.
if (
focusBeforeDispatch.activeWorktreeId !== worktree.id &&
currentState.activeWorktreeId === worktree.id
) {
currentState.setActiveView(focusBeforeDispatch.activeView)
currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId)
if (focusBeforeDispatch.activeTabId) {
currentState.setActiveTab(focusBeforeDispatch.activeTabId)
}
currentState.setActiveTabType(focusBeforeDispatch.activeTabType)
}
} catch (error) {
releaseTerminalOwnership()
await markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
workspaceId: resolved.context.workspaceId,
workspaceDisplayName: resolved.context.workspaceDisplayName,
precheckResult: resolved.context.precheckResult,
error: error instanceof Error ? error.message : String(error)
})
}
}
@@ -0,0 +1,269 @@
import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host'
import type {
Automation,
AutomationDispatchResult,
AutomationPrecheckResult,
AutomationRun
} from '../../../shared/automations-types'
import { getAutomationRunRepoId } from '../../../shared/automation-run-identity'
import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
} from '../../../shared/automation-precheck'
import {
getRepoExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId
} from '../../../shared/execution-host'
import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host'
import type { Worktree } from '../../../shared/worktree/types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
type AutomationDispatchStoreState = ReturnType<typeof useAppStore.getState>
type MarkDispatchResult = (result: AutomationDispatchResult) => Promise<void>
export type AutomationDispatchWorkspaceContext = {
workspaceId: string | null
workspaceDisplayName: string | null
precheckResult: AutomationPrecheckResult | null
}
export function resolveAutomationDispatchWorkspace(
state: AutomationDispatchStoreState,
automation: Automation,
run: AutomationRun
) {
const runRepoId = getAutomationRunRepoId(automation)
const repo = state.repos.find((entry) => entry.id === runRepoId)
const automationWorkspaceScope = parseWorkspaceKey(automation.workspaceId ?? '')
const automationWorktree = automation.workspaceId
? automationWorkspaceScope?.type === 'folder'
? state.getKnownWorktreeById(automation.workspaceId)
: state.allWorktrees().find((entry) => entry.id === automation.workspaceId)
: null
const context: AutomationDispatchWorkspaceContext = {
workspaceId: automation.workspaceId,
workspaceDisplayName: automationWorktree?.displayName ?? run.workspaceDisplayName ?? null,
precheckResult: null
}
return { runRepoId, repo, automationWorkspaceScope, automationWorktree, context }
}
type ResolvedAutomationDispatchWorkspace = ReturnType<typeof resolveAutomationDispatchWorkspace>
export async function prepareAutomationDispatchWorkspace(args: {
state: AutomationDispatchStoreState
automation: Automation
run: AutomationRun
dispatchToken: string
resolved: ResolvedAutomationDispatchWorkspace & {
repo: NonNullable<ResolvedAutomationDispatchWorkspace['repo']>
}
markDispatchResult: MarkDispatchResult
}): Promise<Worktree | null> {
const { state, automation, run, dispatchToken, resolved, markDispatchResult } = args
const { repo, runRepoId, automationWorkspaceScope, automationWorktree, context } = resolved
const folderWorkspaceHost =
automationWorkspaceScope?.type === 'folder'
? resolveFolderWorkspaceHost(state, automationWorkspaceScope.folderWorkspaceId)
: null
const folderWorkspaceConnectionId =
folderWorkspaceHost?.kind === 'ssh' ? folderWorkspaceHost.targetId : null
// A workspace whose host does not resolve to one place is refused, not guessed at.
const folderWorkspaceHostUnresolved =
folderWorkspaceHost !== null && folderWorkspaceHost.kind === 'ambiguous'
const folderWorkspaceHostId =
folderWorkspaceHost && automationWorktree
? folderWorkspaceConnectionId
? toSshExecutionHostId(folderWorkspaceConnectionId)
: folderWorkspaceHost.kind === 'local'
? getResolvedExecutionHostIdForWorktree(state, automationWorktree.id)
: null
: null
const runHostId =
parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo)
const workspaceMatchesRunTarget =
automationWorkspaceScope?.type === 'folder'
? folderWorkspaceHostId !== null && folderWorkspaceHostId === runHostId
: !automation.runContext?.repoId ||
automationWorktree?.repoId === automation.runContext.repoId
if (automation.workspaceMode === 'existing' && automationWorktree && !workspaceMatchesRunTarget) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
workspaceDisplayName: context.workspaceDisplayName,
error: folderWorkspaceHostUnresolved
? translate(
'auto.hooks.useAutomationDispatchEvents.workspaceHostUnresolved',
'The target workspace spans more than one host, so this run has no single host to use.'
)
: translate(
'auto.hooks.useAutomationDispatchEvents.3ad7d77f57',
'The target workspace is on a different host than this automation run target.'
)
})
return null
}
const sshTargetId =
automationWorkspaceScope?.type === 'folder'
? (folderWorkspaceConnectionId ?? null)
: (repo.connectionId ?? null)
if (sshTargetId) {
const needsPrompt = await window.api.ssh.needsPassphrasePrompt({
targetId: sshTargetId
})
if (needsPrompt) {
await markDispatchResult({
runId: run.id,
status: 'skipped_needs_interactive_auth',
workspaceId: context.workspaceId,
workspaceDisplayName: context.workspaceDisplayName,
error: translate(
'auto.hooks.useAutomationDispatchEvents.16a21d6413',
'SSH reconnect requires interactive credentials.'
)
})
return null
}
const sshState = await window.api.ssh.getState({ targetId: sshTargetId })
if (sshState?.status !== 'connected') {
try {
const connected = await window.api.ssh.connect({ targetId: sshTargetId })
if (connected?.status !== 'connected') {
throw new Error('SSH target is unavailable.')
}
} catch (error) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: context.workspaceId,
workspaceDisplayName: context.workspaceDisplayName,
error: error instanceof Error ? error.message : String(error)
})
return null
}
}
}
if (automation.workspaceMode === 'existing' && !automationWorktree) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
workspaceDisplayName: context.workspaceDisplayName,
error: translate(
'auto.hooks.useAutomationDispatchEvents.59718b120b',
'The target workspace is no longer available.'
)
})
return null
}
if (run.trigger === 'scheduled' && automation.precheck) {
context.precheckResult = await window.api.automations.runPrecheck({
automationId: automation.id,
runId: run.id
})
if (context.precheckResult && !didAutomationPrecheckPass(context.precheckResult)) {
await markDispatchResult({
runId: run.id,
status: 'skipped_precheck',
workspaceId: context.workspaceId,
workspaceDisplayName: context.workspaceDisplayName,
precheckResult: context.precheckResult,
error: formatAutomationPrecheckFailure(context.precheckResult)
})
return null
}
}
const automationWorkspaceCreateRequestId = createBrowserUuid()
const createResult =
automation.workspaceMode === 'new_per_run'
? await useAppStore.getState().createWorktree(
runRepoId,
buildAutomationWorkspaceName(run.title, run.scheduledFor),
automation.baseBranch ?? undefined,
automation.setupDecision ?? 'skip',
undefined,
'unknown',
run.title,
undefined,
undefined,
undefined,
// Why: the automation session below owns the prompt-bearing
// agent tab; createdWithAgent would reopen an empty fallback.
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{
automationProvenanceRequest: {
automationId: automation.id,
automationRunId: run.id,
dispatchToken,
createRequestId: automationWorkspaceCreateRequestId
}
}
)
: null
const worktree = createResult
? createResult.worktree
: automation.workspaceId
? automationWorktree
: null
if (!worktree) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
workspaceDisplayName: context.workspaceDisplayName,
error: translate(
'auto.hooks.useAutomationDispatchEvents.59718b120b',
'The target workspace is no longer available.'
)
})
return null
}
context.workspaceId = worktree.id
context.workspaceDisplayName = worktree.displayName
if (createResult?.setup || createResult?.defaultTabs) {
void launchWorktreeBackgroundTerminals({
worktreeId: worktree.id,
setup: createResult.setup,
defaultTabs: createResult.defaultTabs
}).catch((error) => {
// Why: setup/defaultTabs match normal worktree creation: they are
// best-effort terminal work and must not block the automation agent.
console.warn('[automations] Failed to launch workspace setup/default tabs:', error)
})
}
return worktree
}
function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): string {
const slug = runTitle
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13)
return `auto-${slug || 'run'}-${stamp}`
}
@@ -1,700 +1,9 @@
/* eslint-disable max-lines -- Why: automation dispatch is a single renderer lifecycle
* coordinator spanning workspace creation, SSH readiness, terminal launch/reuse,
* completion bookkeeping, and focus restoration. */
import { useEffect } from 'react'
import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session'
import { submitPromptToAgentPty } from '@/lib/agent-paste-draft'
import { findReusableAutomationSession } from '@/lib/automation-session-reuse'
import { observeExistingAutomationSession } from '@/lib/automation-session-observer'
import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals'
import { useAppStore } from '@/store'
import type {
AutomationDispatchResult,
AutomationPrecheckResult
} from '../../../shared/automations-types'
import { getAutomationRunRepoId } from '../../../shared/automation-run-identity'
import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
} from '../../../shared/automation-precheck'
import {
createAutomationRunOutputSnapshotBuffer,
selectAutomationRunOutputSnapshot
} from '@/components/automations/automation-run-output-snapshot'
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from '@/lib/browser-uuid'
import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership'
import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host'
import { listAutomationRunsForTarget } from '@/components/automations/automation-host-client'
import {
getRepoExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId
} from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { AgentStateHistoryEntry, AgentStatusEntry } from '../../../shared/agent-status-types'
import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host'
import {
selectAutomationAgentStatusEntryChange,
UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY
} from './automation-agent-status-entry-change'
const activeReuseDispatchTabIds = new Set<string>()
function acquireReuseDispatchTab(tabId: string): (() => void) | null {
if (activeReuseDispatchTabIds.has(tabId)) {
return null
}
activeReuseDispatchTabIds.add(tabId)
return () => activeReuseDispatchTabIds.delete(tabId)
}
function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): string {
const slug = runTitle
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13)
return `auto-${slug || 'run'}-${stamp}`
}
function agentStateHistoryEntriesEqual(
left: AgentStateHistoryEntry,
right: AgentStateHistoryEntry
): boolean {
return (
left.state === right.state &&
left.prompt === right.prompt &&
left.startedAt === right.startedAt &&
left.interrupted === right.interrupted
)
}
function getAgentStateHistoryOverlap(
previous: AgentStateHistoryEntry[],
current: AgentStateHistoryEntry[]
): number {
for (let overlap = Math.min(previous.length, current.length); overlap > 0; overlap -= 1) {
const previousOffset = previous.length - overlap
if (
current
.slice(0, overlap)
.every((entry, index) =>
agentStateHistoryEntriesEqual(entry, previous[previousOffset + index])
)
) {
return overlap
}
}
return 0
}
import { handleAutomationDispatchRequest } from './automation-dispatch-handler'
export function useAutomationDispatchEvents(): void {
useEffect(() => {
const unsubscribe = window.api.automations.onDispatchRequested(
async ({ automation, run, dispatchToken }) => {
const markDispatchResult = async (result: AutomationDispatchResult): Promise<void> => {
// Deliberately no local emit: the write publishes its own host-scoped event
// (automation-run-writer.ts) before this reply returns, and an unscoped one
// here would arrive second and invalidate every host in the catalog.
await window.api.automations.markDispatchResult(result)
}
const state = useAppStore.getState()
const focusBeforeDispatch = {
activeView: state.activeView,
activeWorktreeId: state.activeWorktreeId,
activeTabId: state.activeTabId,
activeTabType: state.activeTabType
}
const runRepoId = getAutomationRunRepoId(automation)
const repo = state.repos.find((entry) => entry.id === runRepoId)
const automationWorkspaceScope = parseWorkspaceKey(automation.workspaceId ?? '')
const automationWorktree = automation.workspaceId
? automationWorkspaceScope?.type === 'folder'
? state.getKnownWorktreeById(automation.workspaceId)
: state.allWorktrees().find((entry) => entry.id === automation.workspaceId)
: null
let dispatchWorkspaceId = automation.workspaceId
let dispatchWorkspaceDisplayName =
automationWorktree?.displayName ?? run.workspaceDisplayName ?? null
let precheckResult: AutomationPrecheckResult | null = null
let terminalOwnership: AutomationTerminalOwnership | null = null
const releaseTerminalOwnership = (): void => {
const ownership = terminalOwnership
terminalOwnership = null
ownership?.release()
}
const finalizeTerminalOwnership = (): boolean => {
const ownership = terminalOwnership
terminalOwnership = null
return ownership?.finalize() ?? false
}
if (!repo) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: run.workspaceId,
workspaceDisplayName: run.workspaceDisplayName ?? null,
error: translate(
'auto.hooks.useAutomationDispatchEvents.386db94f3e',
'The target project is no longer available.'
)
})
return
}
try {
const folderWorkspaceHost =
automationWorkspaceScope?.type === 'folder'
? resolveFolderWorkspaceHost(state, automationWorkspaceScope.folderWorkspaceId)
: null
const folderWorkspaceConnectionId =
folderWorkspaceHost?.kind === 'ssh' ? folderWorkspaceHost.targetId : null
// A workspace whose host does not resolve to one place is refused, not guessed at.
const folderWorkspaceHostUnresolved =
folderWorkspaceHost !== null && folderWorkspaceHost.kind === 'ambiguous'
const folderWorkspaceHostId =
folderWorkspaceHost && automationWorktree
? folderWorkspaceConnectionId
? toSshExecutionHostId(folderWorkspaceConnectionId)
: folderWorkspaceHost.kind === 'local'
? getResolvedExecutionHostIdForWorktree(state, automationWorktree.id)
: null
: null
const runHostId =
parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo)
const workspaceMatchesRunTarget =
automationWorkspaceScope?.type === 'folder'
? folderWorkspaceHostId !== null && folderWorkspaceHostId === runHostId
: !automation.runContext?.repoId ||
automationWorktree?.repoId === automation.runContext.repoId
if (
automation.workspaceMode === 'existing' &&
automationWorktree &&
!workspaceMatchesRunTarget
) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
error: folderWorkspaceHostUnresolved
? translate(
'auto.hooks.useAutomationDispatchEvents.workspaceHostUnresolved',
'The target workspace spans more than one host, so this run has no single host to use.'
)
: translate(
'auto.hooks.useAutomationDispatchEvents.3ad7d77f57',
'The target workspace is on a different host than this automation run target.'
)
})
return
}
const sshTargetId =
automationWorkspaceScope?.type === 'folder'
? (folderWorkspaceConnectionId ?? null)
: (repo.connectionId ?? null)
if (sshTargetId) {
const needsPrompt = await window.api.ssh.needsPassphrasePrompt({
targetId: sshTargetId
})
if (needsPrompt) {
await markDispatchResult({
runId: run.id,
status: 'skipped_needs_interactive_auth',
workspaceId: dispatchWorkspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
error: translate(
'auto.hooks.useAutomationDispatchEvents.16a21d6413',
'SSH reconnect requires interactive credentials.'
)
})
return
}
const sshState = await window.api.ssh.getState({ targetId: sshTargetId })
if (sshState?.status !== 'connected') {
try {
const connected = await window.api.ssh.connect({ targetId: sshTargetId })
if (connected?.status !== 'connected') {
throw new Error('SSH target is unavailable.')
}
} catch (error) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: dispatchWorkspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
error: error instanceof Error ? error.message : String(error)
})
return
}
}
}
if (automation.workspaceMode === 'existing' && !automationWorktree) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
error: translate(
'auto.hooks.useAutomationDispatchEvents.59718b120b',
'The target workspace is no longer available.'
)
})
return
}
if (run.trigger === 'scheduled' && automation.precheck) {
precheckResult = await window.api.automations.runPrecheck({
automationId: automation.id,
runId: run.id
})
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
await markDispatchResult({
runId: run.id,
status: 'skipped_precheck',
workspaceId: dispatchWorkspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
precheckResult,
error: formatAutomationPrecheckFailure(precheckResult)
})
return
}
}
const automationWorkspaceCreateRequestId = createBrowserUuid()
const createResult =
automation.workspaceMode === 'new_per_run'
? await useAppStore.getState().createWorktree(
runRepoId,
buildAutomationWorkspaceName(run.title, run.scheduledFor),
automation.baseBranch ?? undefined,
automation.setupDecision ?? 'skip',
undefined,
'unknown',
run.title,
undefined,
undefined,
undefined,
// Why: the automation session below owns the prompt-bearing
// agent tab; createdWithAgent would reopen an empty fallback.
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{
automationProvenanceRequest: {
automationId: automation.id,
automationRunId: run.id,
dispatchToken,
createRequestId: automationWorkspaceCreateRequestId
}
}
)
: null
const worktree = createResult
? createResult.worktree
: automation.workspaceId
? automationWorktree
: null
if (!worktree) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
error: translate(
'auto.hooks.useAutomationDispatchEvents.59718b120b',
'The target workspace is no longer available.'
)
})
return
}
dispatchWorkspaceId = worktree.id
dispatchWorkspaceDisplayName = worktree.displayName
if (createResult?.setup || createResult?.defaultTabs) {
void launchWorktreeBackgroundTerminals({
worktreeId: worktree.id,
setup: createResult.setup,
defaultTabs: createResult.defaultTabs
}).catch((error) => {
// Why: setup/defaultTabs match normal worktree creation: they are
// best-effort terminal work and must not block the automation agent.
console.warn('[automations] Failed to launch workspace setup/default tabs:', error)
})
}
const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer()
let latestAssistantMessage: string | null = null
const getOutputSnapshot = () =>
selectAutomationRunOutputSnapshot(
latestAssistantMessage,
outputSnapshotBuffer.snapshot()
)
let dispatchMarked = false
let pendingExitCode: number | null = null
let pendingDone = false
let completionMarked = false
let unsubscribeAgentStatus = (): void => {}
let unsubscribeSessionObserver = (): void => {}
let releaseReuseDispatchTab = (): void => {}
const cleanupRunObservers = (): void => {
unsubscribeAgentStatus()
unsubscribeSessionObserver()
releaseReuseDispatchTab()
unsubscribeAgentStatus = (): void => {}
unsubscribeSessionObserver = (): void => {}
releaseReuseDispatchTab = (): void => {}
}
const markCompletionResult = async (): Promise<void> => {
if (completionMarked) {
return
}
completionMarked = true
cleanupRunObservers()
try {
await markDispatchResult({
runId: run.id,
status: 'completed',
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
outputSnapshot: getOutputSnapshot(),
precheckResult,
error: null
})
} catch (error) {
releaseTerminalOwnership()
throw error
}
if (finalizeTerminalOwnership()) {
await clearRetiredRunTerminalIdentity()
}
}
const clearRetiredRunTerminalIdentity = async (): Promise<void> => {
// Why: the owned terminal was just retired, so the run's pane/pty
// pointers now reference a closed tab. Drop them (best-effort) so
// "View run" resolves to the workspace/snapshot instead of dead-ending
// on an unavailable terminal.
try {
await markDispatchResult({
runId: run.id,
status: 'completed',
terminalSessionId: null,
terminalPaneKey: null,
terminalPtyId: null
})
} catch (error) {
console.error('[automations] Failed to clear retired terminal identity:', error)
}
}
const markExitResult = async (code: number): Promise<void> => {
if (completionMarked) {
return
}
completionMarked = true
cleanupRunObservers()
try {
await markDispatchResult({
runId: run.id,
status: code === 0 ? 'completed' : 'dispatch_failed',
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
outputSnapshot: getOutputSnapshot(),
precheckResult,
error: code === 0 ? null : `Automation process exited with code ${code}.`
})
} catch (error) {
releaseTerminalOwnership()
throw error
}
if (code === 0) {
if (finalizeTerminalOwnership()) {
await clearRetiredRunTerminalIdentity()
}
} else {
releaseTerminalOwnership()
}
}
const settleLateResult = (result: Promise<void>): void => {
// Why: status/exit callbacks have no awaitable caller; the result
// path already releases ownership before propagating persistence errors.
void result.catch((error) => {
console.error('[automations] Failed to persist late automation result:', error)
})
}
const handleAgentDone = (): void => {
if (completionMarked) {
return
}
if (!dispatchMarked) {
pendingDone = true
return
}
settleLateResult(markCompletionResult())
}
const observeAgentStatus = (
targetPaneKey: string,
startedAfter: number,
options?: { requireWorkingAfterStart?: boolean }
): void => {
let sawWorkingAfterStart = false
let observedStateHistory: AgentStateHistoryEntry[] = []
let observedEntry: AgentStatusEntry | undefined
const checkCurrentStatus = (): void => {
const entryChange = selectAutomationAgentStatusEntryChange(
useAppStore.getState().agentStatusByPaneKey,
targetPaneKey,
observedEntry
)
if (entryChange === UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) {
return
}
const entry = entryChange
observedEntry = entry
if (!entry || entry.updatedAt < startedAfter) {
return
}
const historyOverlap = getAgentStateHistoryOverlap(
observedStateHistory,
entry.stateHistory
)
// Why: sawWorkingAfterStart stays monotonic — a recreated entry
// (transport loss, PTY exit, cap eviction) arrives with an empty
// stateHistory, so clearing it here would strand reuseSession runs
// with nothing left to re-derive the working edge from.
for (const historicalState of entry.stateHistory.slice(historyOverlap)) {
if (historicalState.startedAt < startedAfter) {
continue
}
if (historicalState.state === 'working') {
sawWorkingAfterStart = true
}
if (
historicalState.state === 'done' &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
// Why: this `done` already rolled out of the live entry, so its output
// survives only in the entry-level completed slot.
latestAssistantMessage =
entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
return
}
}
observedStateHistory = [...entry.stateHistory]
if (entry.state === 'working') {
sawWorkingAfterStart = true
}
if (
entry.state === 'done' &&
// Why: a session-boundary done is the agent CONNECTING (Claude SessionStart
// fires at launch, before the argv prompt submits) — completing here would
// close the tab and record an empty run result.
entry.sessionBoundary !== true &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
latestAssistantMessage =
entry.lastAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
}
}
// Why: Codex/Claude completion normally arrives through the global
// hook IPC listener, not the hidden PTY OSC fallback.
unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus)
checkCurrentStatus()
}
const dispatchStartedAt = Date.now()
if (automation.reuseSession) {
const reusableSession = findReusableAutomationSession({
automationId: automation.id,
agentId: automation.agentId,
worktreeId: worktree.id,
currentRunId: run.id,
// Why: the dispatch loop only ever executes for the desktop authority,
// so its history read addresses the local runtime explicitly.
runs: await listAutomationRunsForTarget({ kind: 'local' }, automation.id),
state: useAppStore.getState()
})
if (reusableSession) {
const releaseTab = acquireReuseDispatchTab(reusableSession.tabId)
if (releaseTab) {
releaseReuseDispatchTab = releaseTab
try {
const submitted = await submitPromptToAgentPty({
tabId: reusableSession.tabId,
ptyId: reusableSession.ptyId,
content: automation.prompt
})
if (!submitted) {
cleanupRunObservers()
} else {
let reuseSawWorking = false
const handleReusableAgentStatus = (payload: { state: string }): void => {
if (payload.state === 'working') {
reuseSawWorking = true
return
}
if (payload.state === 'done' && reuseSawWorking) {
handleAgentDone()
}
}
const reuseCompletionStartedAt = Date.now()
unsubscribeSessionObserver = await observeExistingAutomationSession({
ptyId: reusableSession.ptyId,
paneKey: reusableSession.paneKey,
runId: run.id,
onData: (chunk) => {
outputSnapshotBuffer.append(chunk)
},
onAgentStatus: (payload) => {
latestAssistantMessage =
payload.lastAssistantMessage?.trim() || latestAssistantMessage
handleReusableAgentStatus(payload)
},
onExit: (code) => {
if (completionMarked) {
return
}
if (!dispatchMarked) {
pendingExitCode = code
return
}
settleLateResult(markExitResult(code))
}
})
observeAgentStatus(reusableSession.paneKey, reuseCompletionStartedAt, {
requireWorkingAfterStart: true
})
await markDispatchResult({
runId: run.id,
status: 'dispatched',
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
terminalSessionId: reusableSession.tabId,
terminalPaneKey: reusableSession.paneKey,
terminalPtyId: reusableSession.ptyId,
precheckResult,
error: null
})
dispatchMarked = true
if (pendingDone) {
await markCompletionResult()
} else if (pendingExitCode !== null) {
await markExitResult(pendingExitCode)
}
return
}
} catch (error) {
cleanupRunObservers()
throw error
}
}
}
}
const result = await launchAgentBackgroundSession({
agent: automation.agentId,
worktreeId: worktree.id,
prompt: automation.prompt,
launchSource: 'unknown',
title: run.title,
onData: (chunk) => {
outputSnapshotBuffer.append(chunk)
},
onAgentStatus: (payload) => {
latestAssistantMessage =
payload.lastAssistantMessage?.trim() || latestAssistantMessage
// Why: session-boundary done = launch connect, not run completion (see observeAgentStatus).
if (payload.state !== 'done' || payload.sessionBoundary === true) {
return
}
handleAgentDone()
},
onExit: (_ptyId, code) => {
if (completionMarked) {
return
}
if (!dispatchMarked) {
pendingExitCode = code
return
}
settleLateResult(markExitResult(code))
}
})
if (!result) {
throw new Error('Unable to build an agent launch plan.')
}
terminalOwnership = result.terminalOwnership
if (automation.reuseSession) {
// Why: the first fresh launch is the seed for later reuse and must
// survive completion under the same policy as an already-reused tab.
releaseTerminalOwnership()
}
const launchedTabId = result.tabId
observeAgentStatus(result.paneKey, dispatchStartedAt)
try {
await markDispatchResult({
runId: run.id,
status: 'dispatched',
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
terminalSessionId: launchedTabId,
terminalPaneKey: result.paneKey,
terminalPtyId: result.ptyId,
precheckResult,
error: null
})
dispatchMarked = true
if (pendingDone) {
await markCompletionResult()
} else if (pendingExitCode !== null) {
await markExitResult(pendingExitCode)
}
} catch (error) {
cleanupRunObservers()
throw error
}
const currentState = useAppStore.getState()
// Why: Run Now and scheduled dispatches should create workspaces/tabs in
// the background; only an explicit row click should navigate there.
if (
focusBeforeDispatch.activeWorktreeId !== worktree.id &&
currentState.activeWorktreeId === worktree.id
) {
currentState.setActiveView(focusBeforeDispatch.activeView)
currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId)
if (focusBeforeDispatch.activeTabId) {
currentState.setActiveTab(focusBeforeDispatch.activeTabId)
}
currentState.setActiveTabType(focusBeforeDispatch.activeTabType)
}
} catch (error) {
releaseTerminalOwnership()
await markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
workspaceId: dispatchWorkspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
precheckResult,
error: error instanceof Error ? error.message : String(error)
})
}
}
)
const unsubscribe = window.api.automations.onDispatchRequested(handleAutomationDispatchRequest)
void window.api.automations.rendererReady()
return unsubscribe
}, [])