fix(automations): restore desktop shell automation creation

This commit is contained in:
Shahar Mor
2026-09-07 11:43:45 +03:00
parent 6c4be28e62
commit 5ca66ad7c8
10 changed files with 116 additions and 42 deletions
@@ -20,6 +20,7 @@ import { RuntimeClientError } from './types'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_SHELL_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
@@ -73,6 +74,7 @@ describe('CLI remote WebSocket transport', () => {
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
AUTOMATION_SHELL_RUNTIME_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
+1 -1
View File
@@ -58,7 +58,7 @@ function createPrecheckResult(args: {
}
}
function failedPrecheckResult(
export function failedPrecheckResult(
precheck: AutomationPrecheck,
startedAt: number,
error: string
@@ -50,12 +50,12 @@ const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({
type TestStore = Awaited<ReturnType<typeof createStore>>
function createAutomation(store: TestStore): Automation {
function createAutomation(store: TestStore, agentId: Automation['agentId'] = 'claude'): Automation {
store.addRepo(makeRepo())
return store.createAutomation({
name: 'Nightly check',
prompt: 'Check the repo',
agentId: 'claude',
agentId,
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
@@ -119,6 +119,40 @@ describe('authority-owned automation run completion', () => {
service.stop()
})
it.each(['renderer', 'headless', 'retained'] as const)(
'keeps %s shell runs out of the agent-idle completion watcher',
async (mode) => {
const store = await createStore()
const automation = createAutomation(store, null)
const observer = createObserver(async () => ({ status: 'completed' }))
const resolveTerminal = vi.spyOn(observer, 'resolveRunTerminal')
const service = new AutomationService(store, {
terminalObserver: observer,
headlessDispatcher: mode === 'headless' ? async () => ({ ...LAUNCH_TARGET }) : undefined
})
try {
let run: AutomationRun
if (mode === 'headless') {
run = await service.runNow(automation.id)
} else {
run = store.createAutomationRun(automation, Date.now(), 'manual')
const result = { runId: run.id, status: 'dispatched' as const, ...LAUNCH_TARGET }
if (mode === 'renderer') {
await service.markDispatchResult(result)
} else {
store.updateAutomationRun(result)
service.start()
}
}
expect(resolveTerminal).not.toHaveBeenCalled()
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
} finally {
service.stop()
}
}
)
it('leaves a headless dispatched run alone when the authority cannot observe it', async () => {
const store = await createStore()
const automation = createAutomation(store)
+13 -3
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import { AutomationService } from './service'
import type * as PrecheckRunnerModule from './precheck-runner'
import { runHeadlessAutomationDispatch } from './headless-dispatch-runner'
import { createAutomationRunWriter } from './automation-run-writer'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
@@ -22,7 +23,8 @@ vi.mock('electron', () => ({
}
}))
vi.mock('./precheck-runner', () => ({
vi.mock('./precheck-runner', async (importOriginal) => ({
...(await importOriginal<typeof PrecheckRunnerModule>()),
runAutomationPrecheck: runAutomationPrecheckMock
}))
@@ -155,10 +157,18 @@ describe('AutomationService prechecks', () => {
const result = await service.runPrecheck(automation.id, run.id)
expect(result).toMatchObject({
expect(result).toEqual({
command: 'test -f ready',
exitCode: null,
error: 'Project path for the selected automation host has changed.'
timedOut: false,
durationMs: 0,
stdout: '',
stderr: '',
stdoutTruncated: false,
stderrTruncated: false,
error: 'Project path for the selected automation host has changed.',
startedAt: Date.now(),
completedAt: Date.now()
})
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
})
+6 -18
View File
@@ -10,7 +10,7 @@ import {
} from '../../shared/automations-types'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import { runAutomationPrecheck } from './precheck-runner'
import { failedPrecheckResult, runAutomationPrecheck } from './precheck-runner'
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
import { collectAutomationRunUsage } from './run-usage-collection'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
@@ -166,19 +166,7 @@ export class AutomationService {
}
const target = this.resolveTarget(automation)
if (!target.ok) {
return {
command: automation.precheck.command,
exitCode: null,
timedOut: false,
durationMs: 0,
stdout: '',
stderr: '',
stdoutTruncated: false,
stderrTruncated: false,
error: target.error,
startedAt: Date.now(),
completedAt: Date.now()
}
return failedPrecheckResult(automation.precheck, Date.now(), target.error)
}
return await runAutomationPrecheck({
precheck: automation.precheck,
@@ -193,8 +181,8 @@ export class AutomationService {
const run = this.runs.updateRun(result)
clearAutomationDispatchTokens(run.automationId, run.id)
if (!isFinalAutomationRunStatus(run.status)) {
if (run.status === 'dispatched' && this.isAgentRun(run)) {
this.completionWatcher?.watch(run)
if (run.status === 'dispatched' && this.completionWatcher && this.isAgentRun(run)) {
this.completionWatcher.watch(run)
}
return run
}
@@ -325,8 +313,8 @@ export class AutomationService {
runPrecheck: () => this.runPrecheck(automation.id, run.id),
markDispatchResult: (result) => this.markDispatchResult(result),
watchRun: (dispatched) => {
if (this.isAgentRun(dispatched)) {
this.completionWatcher?.watch(dispatched)
if (this.completionWatcher && this.isAgentRun(dispatched)) {
this.completionWatcher.watch(dispatched)
}
}
})
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTOMATION_SHELL_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
type StreamRecord = {
clientCapabilities: readonly string[] | undefined
connectionId: string | undefined
emit: (response: string) => void
settled: boolean
@@ -39,9 +41,14 @@ vi.mock('../runtime/rpc/dispatcher', () => ({
dispatchStreaming(
request: { id: string },
emit: (response: string) => void,
options: { connectionId?: string; signal: AbortSignal }
options: {
clientCapabilities?: readonly string[]
connectionId?: string
signal: AbortSignal
}
): Promise<void> {
const record: StreamRecord = {
clientCapabilities: options.clientCapabilities,
connectionId: options.connectionId,
emit,
settled: false,
@@ -157,6 +164,14 @@ describe('runtime:subscribe renderer lifecycle cleanup', () => {
registerRuntimeHandlers({ cleanupSubscriptionsForConnection: vi.fn() } as never)
})
it('advertises shell support on same-version desktop subscriptions', () => {
const harness = createSender(1)
subscribe(harness.sender, 'sub-shell')
expect(streamFor('sub-shell').clientCapabilities).toContain(AUTOMATION_SHELL_RUNTIME_CAPABILITY)
harness.destroy()
})
it('aborts a live stream once its sender commits a navigation', () => {
const harness = createSender(1)
subscribe(harness.sender, 'sub-reload')
+35
View File
@@ -170,6 +170,41 @@ describe('registerRuntimeHandlers', () => {
expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } })
})
it('lets the same-version desktop create and list blank-terminal automations', async () => {
const input = {
name: 'Shell check',
prompt: 'echo ready',
agentId: null,
repo: 'repo-1',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1
}
const automation = { id: 'automation-shell', ...input }
const listing = {
automations: [automation],
items: [{ automationId: automation.id, selector: { kind: 'self' } }]
}
const runtime = {
getRuntimeId: () => 'runtime-1',
createAutomation: vi.fn().mockResolvedValue(automation),
listAutomationsForScope: vi.fn().mockReturnValue(listing)
}
registerRuntimeHandlers(runtime as never)
const handler = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')![1]
const event = runtimeCallEvent()
await expect(
handler(event, { method: 'automation.create', params: input })
).resolves.toMatchObject({ ok: true, result: { automation } })
expect(runtime.createAutomation).toHaveBeenCalledWith(input)
await expect(
handler(event, {
method: 'automation.list',
params: { selector: { kind: 'self' } }
})
).resolves.toMatchObject({ ok: true, result: listing })
})
it('registers project group runtime RPC methods for local desktop callers', async () => {
const runtime = {
syncWindowGraph: vi.fn(),
+5 -2
View File
@@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
import {
AUTOMATION_SHELL_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
@@ -81,7 +82,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
connectionId: desktopSenders.connectionIdFor(event.sender),
clientCapabilities: [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AUTOMATION_SHELL_RUNTIME_CAPABILITY
]
}
)) as RuntimeRpcResponse<unknown>
@@ -129,7 +131,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
connectionId,
clientCapabilities: [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AUTOMATION_SHELL_RUNTIME_CAPABILITY
]
}
)
@@ -784,22 +784,8 @@ describe('useAutomationDispatchEvents setup launch', () => {
it.each(['claude', null] as const)(
'releases ownership on nonzero exit without finalizing the %s tab',
async (agentId) => {
let onExit: ((ptyId: string, code: number) => void) | undefined
mockLaunchAgentBackgroundSession.mockImplementation(async (args) => {
onExit = args.onExit
return {
tabId: 'agent-tab',
paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d',
ptyId: 'agent-pty',
startupPlan: {},
terminalOwnership: {
finalize: mockFinalizeTerminalOwnership,
release: mockReleaseTerminalOwnership
}
}
})
await registerAndDispatch(makeAutomation({ agentId }))
const { onExit } = mockLaunchAgentBackgroundSession.mock.calls[0][0]
if (agentId === null) {
expect(mockStoreSubscribe).not.toHaveBeenCalled()
}
@@ -56,6 +56,7 @@ const INVENTORY: readonly InventoryGroup[] = [
'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx',
2
],
['src/renderer/src/components/automations/AutomationDetail.tsx', 2],
['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2],
'src/renderer/src/components/automations/automation-draft-model.ts',
['src/renderer/src/components/automations/automation-list-search-rows.ts', 2],