mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
feat(automations): display failed run states
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
|
||||
import { waitForHeadlessAutomationCompletion } from './headless-completion-wait'
|
||||
|
||||
const blocked = {
|
||||
handle: 'terminal-1',
|
||||
condition: 'tui-idle',
|
||||
satisfied: false,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
blockedReason: 'agent-approval-prompt'
|
||||
} as RuntimeTerminalWait
|
||||
|
||||
const completed = {
|
||||
handle: 'terminal-1',
|
||||
condition: 'tui-idle',
|
||||
satisfied: true,
|
||||
status: 'running',
|
||||
exitCode: null
|
||||
} as RuntimeTerminalWait
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('waitForHeadlessAutomationCompletion', () => {
|
||||
it('keeps watching after a recoverable prompt', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const waitForTerminal = vi.fn().mockResolvedValueOnce(blocked).mockResolvedValueOnce(completed)
|
||||
|
||||
const resultPromise = waitForHeadlessAutomationCompletion(
|
||||
{ waitForTerminal },
|
||||
'terminal-1',
|
||||
5_000
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
await expect(resultPromise).resolves.toBe(completed)
|
||||
expect(waitForTerminal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('bounds prompt rechecks by the completion deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const waitForTerminal = vi.fn().mockResolvedValue(blocked)
|
||||
|
||||
const resultPromise = waitForHeadlessAutomationCompletion(
|
||||
{ waitForTerminal },
|
||||
'terminal-1',
|
||||
100
|
||||
)
|
||||
const rejection = expect(resultPromise).rejects.toThrow('timeout')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
await rejection
|
||||
expect(waitForTerminal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
|
||||
|
||||
const HEADLESS_COMPLETION_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const BLOCKED_PROMPT_RECHECK_MS = 1_000
|
||||
|
||||
type TerminalWaitRuntime = {
|
||||
waitForTerminal: (
|
||||
handle: string,
|
||||
options: { condition: 'tui-idle'; timeoutMs: number }
|
||||
) => Promise<RuntimeTerminalWait>
|
||||
}
|
||||
|
||||
export async function waitForHeadlessAutomationCompletion(
|
||||
runtime: TerminalWaitRuntime,
|
||||
terminalHandle: string,
|
||||
timeoutMs = HEADLESS_COMPLETION_TIMEOUT_MS
|
||||
): Promise<RuntimeTerminalWait> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
const remainingMs = deadline - Date.now()
|
||||
if (remainingMs <= 0) {
|
||||
throw new Error('timeout')
|
||||
}
|
||||
const result = await runtime.waitForTerminal(terminalHandle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: remainingMs
|
||||
})
|
||||
if (!result.blockedReason) {
|
||||
return result
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, Math.min(BLOCKED_PROMPT_RECHECK_MS, remainingMs))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
|
||||
import type { AutomationRunTargetResult } from './run-target-resolution'
|
||||
import type { AutomationRunWriter } from './automation-run-writer'
|
||||
import { observeHeadlessAutomationCompletion } from './headless-run-completion'
|
||||
|
||||
export type HeadlessAutomationDispatchContext = {
|
||||
automation: Automation
|
||||
@@ -63,25 +64,14 @@ export async function runHeadlessAutomationDispatch(
|
||||
ctx.watchRun(updated)
|
||||
return updated
|
||||
}
|
||||
void launch.completion
|
||||
.then((completion) =>
|
||||
ctx.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: completion.status,
|
||||
...launchRunTarget,
|
||||
precheckResult,
|
||||
outputSnapshot: completion.outputSnapshot ?? null,
|
||||
error: completion.error ?? null
|
||||
})
|
||||
)
|
||||
.catch((error) =>
|
||||
ctx.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
...launchRunTarget,
|
||||
error: describeDispatchError(error)
|
||||
})
|
||||
)
|
||||
observeHeadlessAutomationCompletion({
|
||||
automation,
|
||||
run,
|
||||
launch,
|
||||
target: launchRunTarget,
|
||||
precheckResult,
|
||||
markDispatchResult: ctx.markDispatchResult
|
||||
})
|
||||
return updated
|
||||
} catch (error) {
|
||||
return runs.updateRun({
|
||||
|
||||
@@ -15,6 +15,7 @@ export type HeadlessAutomationDispatchLaunch = {
|
||||
terminalPtyId?: string | null
|
||||
completion?: Promise<{
|
||||
status: 'completed' | 'dispatch_failed'
|
||||
observationVerdict?: 'unverifiable' | null
|
||||
outputSnapshot?: AutomationRunOutputSnapshot | null
|
||||
error?: string | null
|
||||
}>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDispatchResult,
|
||||
AutomationRun
|
||||
} from '../../shared/automations-types'
|
||||
import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch'
|
||||
import { observeHeadlessAutomationCompletion } from './headless-run-completion'
|
||||
|
||||
const target = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceDisplayName: 'Workspace',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'pane-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
}
|
||||
|
||||
function observe(
|
||||
workspaceMode: Automation['workspaceMode'],
|
||||
completion: HeadlessAutomationDispatchLaunch['completion']
|
||||
) {
|
||||
const markDispatchResult = vi.fn(
|
||||
async (result: AutomationDispatchResult) => result as unknown as AutomationRun
|
||||
)
|
||||
observeHeadlessAutomationCompletion({
|
||||
automation: { workspaceMode } as Automation,
|
||||
run: { id: 'run-1' } as AutomationRun,
|
||||
launch: { ...target, completion },
|
||||
target,
|
||||
precheckResult: null,
|
||||
markDispatchResult
|
||||
})
|
||||
return markDispatchResult
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('observeHeadlessAutomationCompletion', () => {
|
||||
it('keeps an unverifiable existing-workspace run non-final', async () => {
|
||||
const mark = observe(
|
||||
'existing',
|
||||
Promise.resolve({
|
||||
status: 'dispatch_failed',
|
||||
observationVerdict: 'unverifiable'
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(mark).toHaveBeenCalledWith(expect.objectContaining({ status: 'dispatched' }))
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an unverifiable new-per-run failure bounded', async () => {
|
||||
const mark = observe(
|
||||
'new_per_run',
|
||||
Promise.resolve({
|
||||
status: 'dispatch_failed',
|
||||
observationVerdict: 'unverifiable'
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(mark).toHaveBeenCalledWith(expect.objectContaining({ status: 'dispatch_failed' }))
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an observed existing-workspace failure final', async () => {
|
||||
const mark = observe(
|
||||
'existing',
|
||||
Promise.resolve({ status: 'dispatch_failed', error: 'Exited.' })
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(mark).toHaveBeenCalledWith(expect.objectContaining({ status: 'dispatch_failed' }))
|
||||
)
|
||||
})
|
||||
|
||||
it('treats observer rejection as unverifiable without leaking transport tokens', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mark = observe('existing', Promise.reject(new Error('terminal_handle_stale')))
|
||||
await vi.waitFor(() =>
|
||||
expect(mark).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'dispatched',
|
||||
observationVerdict: 'unverifiable',
|
||||
error: 'Orca stopped watching this run before it reported completion.'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps positive terminal exit evidence final', async () => {
|
||||
const mark = observe('existing', Promise.reject(new Error('terminal_exited')))
|
||||
await vi.waitFor(() =>
|
||||
expect(mark).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'dispatch_failed',
|
||||
observationVerdict: null,
|
||||
error: 'Automation terminal exited before the agent reported completion.'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reinterpret persistence failure as observation loss', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const markDispatchResult = vi.fn().mockRejectedValue(new Error('Automation run not found.'))
|
||||
observeHeadlessAutomationCompletion({
|
||||
automation: { workspaceMode: 'existing' } as Automation,
|
||||
run: { id: 'run-1' } as AutomationRun,
|
||||
launch: { ...target, completion: Promise.resolve({ status: 'completed' }) },
|
||||
target,
|
||||
precheckResult: null,
|
||||
markDispatchResult
|
||||
})
|
||||
await vi.waitFor(() => expect(markDispatchResult).toHaveBeenCalledTimes(1))
|
||||
expect(markDispatchResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'completed' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDispatchResult,
|
||||
AutomationPrecheckResult,
|
||||
AutomationRun
|
||||
} from '../../shared/automations-types'
|
||||
import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch'
|
||||
|
||||
export type HeadlessAutomationRunTarget = {
|
||||
workspaceId: string
|
||||
workspaceDisplayName: string | null
|
||||
terminalSessionId: string | null
|
||||
terminalPaneKey: string | null
|
||||
terminalPtyId: string | null
|
||||
}
|
||||
|
||||
function persistHeadlessCompletion(
|
||||
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>,
|
||||
result: AutomationDispatchResult
|
||||
): void {
|
||||
void markDispatchResult(result).catch((error) => {
|
||||
console.error('[automations] failed to persist run completion:', error)
|
||||
})
|
||||
}
|
||||
|
||||
export function observeHeadlessAutomationCompletion({
|
||||
automation,
|
||||
run,
|
||||
launch,
|
||||
target,
|
||||
precheckResult,
|
||||
markDispatchResult
|
||||
}: {
|
||||
automation: Automation
|
||||
run: AutomationRun
|
||||
launch: HeadlessAutomationDispatchLaunch
|
||||
target: HeadlessAutomationRunTarget
|
||||
precheckResult: AutomationPrecheckResult | null
|
||||
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>
|
||||
}): void {
|
||||
if (!launch.completion) {
|
||||
return
|
||||
}
|
||||
void launch.completion.then(
|
||||
(completion) => {
|
||||
const retainPotentiallyLiveRun =
|
||||
automation.workspaceMode === 'existing' && completion.observationVerdict === 'unverifiable'
|
||||
persistHeadlessCompletion(markDispatchResult, {
|
||||
runId: run.id,
|
||||
status: retainPotentiallyLiveRun ? 'dispatched' : completion.status,
|
||||
observationVerdict: completion.observationVerdict ?? null,
|
||||
...target,
|
||||
precheckResult,
|
||||
outputSnapshot: completion.outputSnapshot ?? null,
|
||||
error: completion.error ?? null
|
||||
})
|
||||
},
|
||||
(error) => {
|
||||
const errorCode = error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
const observedExit = errorCode === 'terminal_exited'
|
||||
if (!observedExit) {
|
||||
// Why a fixed sentence: transport tokens belong in logs, not run history.
|
||||
console.error('[automations] run completion observation failed:', error)
|
||||
}
|
||||
persistHeadlessCompletion(markDispatchResult, {
|
||||
runId: run.id,
|
||||
status:
|
||||
!observedExit && automation.workspaceMode === 'existing'
|
||||
? 'dispatched'
|
||||
: 'dispatch_failed',
|
||||
observationVerdict: observedExit ? null : 'unverifiable',
|
||||
...target,
|
||||
precheckResult,
|
||||
error: observedExit
|
||||
? 'Automation terminal exited before the agent reported completion.'
|
||||
: 'Orca stopped watching this run before it reported completion.'
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import { findPotentiallyLiveAutomationRun } from './potentially-live-run'
|
||||
|
||||
const automation = {
|
||||
id: 'automation-2',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'workspace-1'
|
||||
} as Automation
|
||||
|
||||
function run(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
||||
return {
|
||||
id: 'run-1',
|
||||
automationId: 'automation-1',
|
||||
workspaceId: 'workspace-1',
|
||||
status: 'dispatched',
|
||||
...overrides
|
||||
} as AutomationRun
|
||||
}
|
||||
|
||||
describe('findPotentiallyLiveAutomationRun', () => {
|
||||
it('finds an in-flight run from another automation in the same workspace', () => {
|
||||
expect(findPotentiallyLiveAutomationRun(automation, 'current', [run()])?.id).toBe('run-1')
|
||||
})
|
||||
|
||||
it('keeps an additive unverifiable verdict potentially live after a legacy final status', () => {
|
||||
expect(
|
||||
findPotentiallyLiveAutomationRun(automation, 'current', [
|
||||
run({ status: 'dispatch_failed', observationVerdict: 'unverifiable' })
|
||||
])?.id
|
||||
).toBe('run-1')
|
||||
})
|
||||
|
||||
it('does not infer liveness from terminal identity after an observed failure', () => {
|
||||
expect(
|
||||
findPotentiallyLiveAutomationRun(automation, 'current', [
|
||||
run({
|
||||
status: 'dispatch_failed',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
})
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes a legacy completion-observer loss by its transport error', () => {
|
||||
expect(
|
||||
findPotentiallyLiveAutomationRun(automation, 'current', [
|
||||
run({ status: 'dispatch_failed', error: 'terminal_handle_stale' })
|
||||
])?.id
|
||||
).toBe('run-1')
|
||||
})
|
||||
|
||||
it('does not treat a persisted pre-dispatch row as live', () => {
|
||||
expect(
|
||||
findPotentiallyLiveAutomationRun(automation, 'current', [run({ status: 'pending' })])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not block new-per-run workspaces', () => {
|
||||
expect(
|
||||
findPotentiallyLiveAutomationRun({ ...automation, workspaceMode: 'new_per_run' }, 'current', [
|
||||
run({ observationVerdict: 'unverifiable' })
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
isFinalAutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun
|
||||
} from '../../shared/automations-types'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
const LEGACY_UNVERIFIABLE_ERRORS = new Set([
|
||||
'terminal_handle_stale',
|
||||
'terminal_not_found',
|
||||
'timeout'
|
||||
])
|
||||
|
||||
function isLegacyUnverifiableRun(run: AutomationRun): boolean {
|
||||
return (
|
||||
run.status === 'dispatch_failed' &&
|
||||
typeof run.error === 'string' &&
|
||||
LEGACY_UNVERIFIABLE_ERRORS.has(run.error.trim())
|
||||
)
|
||||
}
|
||||
|
||||
export function findPotentiallyLiveAutomationRun(
|
||||
automation: Automation,
|
||||
currentRunId: string,
|
||||
runs: readonly AutomationRun[]
|
||||
): AutomationRun | null {
|
||||
if (automation.workspaceMode !== 'existing' || automation.workspaceId === null) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
runs.find(
|
||||
(run) =>
|
||||
run.id !== currentRunId &&
|
||||
run.workspaceId === automation.workspaceId &&
|
||||
(run.observationVerdict === 'unverifiable' ||
|
||||
run.status === 'dispatching' ||
|
||||
run.status === 'dispatched' ||
|
||||
isLegacyUnverifiableRun(run))
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function pinUnverifiableAutomationRun(store: Store, run: AutomationRun): void {
|
||||
if (
|
||||
(run.observationVerdict !== 'unverifiable' && !isLegacyUnverifiableRun(run)) ||
|
||||
!isFinalAutomationRunStatus(run.status)
|
||||
) {
|
||||
return
|
||||
}
|
||||
store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
observationVerdict: 'unverifiable',
|
||||
workspaceId: run.workspaceId,
|
||||
error: isLegacyUnverifiableRun(run)
|
||||
? 'Orca stopped watching this run before it reported completion.'
|
||||
: run.error
|
||||
})
|
||||
}
|
||||
@@ -132,6 +132,131 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks a manual run when an existing workspace run is unverifiable', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Manual check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const previousRun = store.createAutomationRun(automation, Date.now() - 1_000, 'manual')
|
||||
store.updateAutomationRun({
|
||||
runId: previousRun.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: 'wt1',
|
||||
error: 'terminal_handle_stale'
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({ isDestroyed: () => false, send } as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run).toMatchObject({
|
||||
status: 'skipped_unavailable',
|
||||
error: 'A previous automation run may still be live in this workspace.'
|
||||
})
|
||||
expect(
|
||||
store.listAutomationRuns(automation.id).find((entry) => entry.id === previousRun.id)
|
||||
).toMatchObject({
|
||||
status: 'dispatched',
|
||||
observationVerdict: 'unverifiable',
|
||||
error: 'Orca stopped watching this run before it reported completion.'
|
||||
})
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks a scheduled run when an existing workspace run is unverifiable', async () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const beforeRunAt = new Date(2026, 4, 13, 8, 59).getTime()
|
||||
const scheduledRunAt = new Date(2026, 4, 13, 9, 0).getTime()
|
||||
vi.setSystemTime(beforeRunAt)
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Scheduled check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone,
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date(2026, 4, 12, 0, 0).getTime()
|
||||
})
|
||||
const previousRun = store.createAutomationRun(automation, scheduledRunAt - 1_000, 'manual')
|
||||
store.updateAutomationRun({
|
||||
runId: previousRun.id,
|
||||
status: 'dispatch_failed',
|
||||
observationVerdict: 'unverifiable',
|
||||
workspaceId: 'wt1',
|
||||
error: 'Orca stopped watching this run before it reported completion.'
|
||||
})
|
||||
const headlessDispatcher = vi.fn()
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
headlessDispatcher
|
||||
})
|
||||
|
||||
vi.setSystemTime(new Date(2026, 4, 13, 9, 1).getTime())
|
||||
service.start()
|
||||
await vi.waitFor(() =>
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
status: 'skipped_unavailable',
|
||||
error: 'A previous automation run may still be live in this workspace.'
|
||||
})
|
||||
)
|
||||
service.stop()
|
||||
expect(
|
||||
store.listAutomationRuns(automation.id).find((entry) => entry.id === previousRun.id)
|
||||
).toMatchObject({ status: 'dispatched', observationVerdict: 'unverifiable' })
|
||||
expect(headlessDispatcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks another automation targeting the same workspace', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const base = {
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude' as const,
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing' as const,
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
}
|
||||
const first = store.createAutomation({ ...base, name: 'First check' })
|
||||
const second = store.createAutomation({ ...base, name: 'Second check' })
|
||||
const previousRun = store.createAutomationRun(first, Date.now() - 1_000, 'manual')
|
||||
store.updateAutomationRun({
|
||||
runId: previousRun.id,
|
||||
status: 'dispatched',
|
||||
observationVerdict: 'unverifiable',
|
||||
workspaceId: 'wt1',
|
||||
error: 'Orca stopped watching this run before it reported completion.'
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({ isDestroyed: () => false, send } as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(second.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips dispatch when the selected project host setup is gone', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
@@ -365,6 +490,47 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('records a lost completion watch with an additive unverifiable verdict', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Nightly triage',
|
||||
prompt: 'Triage',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
headlessDispatcher: vi.fn().mockResolvedValue({
|
||||
workspaceId: 'wt-1',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
|
||||
terminalPtyId: 'pty-1',
|
||||
completion: Promise.reject(new Error('terminal_handle_stale'))
|
||||
})
|
||||
})
|
||||
|
||||
await service.runNow(automation.id)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
status: 'dispatch_failed',
|
||||
observationVerdict: 'unverifiable',
|
||||
error: 'Orca stopped watching this run before it reported completion.'
|
||||
})
|
||||
)
|
||||
// Why: the transport token is log material, never user copy.
|
||||
expect(store.listAutomationRuns(automation.id)[0]!.error).not.toContain('terminal_handle_stale')
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('dispatches due scheduled automations headlessly', async () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const beforeRunAt = new Date(2026, 4, 13, 8, 59).getTime()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
AUTOMATION_UNVERIFIABLE_WORKSPACE_ERROR,
|
||||
isFinalAutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationDispatchRequest,
|
||||
@@ -30,6 +31,10 @@ import type {
|
||||
AutomationsChangedPayload,
|
||||
PublishAutomationsChanged
|
||||
} from '../../shared/runtime-client-events'
|
||||
import {
|
||||
findPotentiallyLiveAutomationRun,
|
||||
pinUnverifiableAutomationRun
|
||||
} from './potentially-live-run'
|
||||
|
||||
const DEFAULT_TICK_MS = 60 * 1000
|
||||
|
||||
@@ -297,6 +302,20 @@ export class AutomationService {
|
||||
run: AutomationRun,
|
||||
target: AutomationRunTargetResult
|
||||
): Promise<AutomationRun> {
|
||||
const potentiallyLiveRun = findPotentiallyLiveAutomationRun(
|
||||
automation,
|
||||
run.id,
|
||||
this.store.listAutomationRuns()
|
||||
)
|
||||
if (potentiallyLiveRun) {
|
||||
pinUnverifiableAutomationRun(this.store, potentiallyLiveRun)
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_unavailable',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: AUTOMATION_UNVERIFIABLE_WORKSPACE_ERROR
|
||||
})
|
||||
}
|
||||
if (!target.ok) {
|
||||
return this.runs.updateRun({
|
||||
runId: run.id,
|
||||
|
||||
@@ -173,6 +173,9 @@ export function updateAutomationRun(
|
||||
const updated: AutomationRun = {
|
||||
...current,
|
||||
status: result.status,
|
||||
observationVerdict: Object.hasOwn(result, 'observationVerdict')
|
||||
? (result.observationVerdict ?? null)
|
||||
: (current.observationVerdict ?? null),
|
||||
workspaceId,
|
||||
workspaceDisplayName:
|
||||
workspaceDisplayName ??
|
||||
@@ -196,7 +199,8 @@ export function updateAutomationRun(
|
||||
usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null),
|
||||
error: result.error ?? null,
|
||||
startedAt: current.startedAt ?? now,
|
||||
dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt
|
||||
dispatchedAt:
|
||||
result.status === 'dispatched' ? (current.dispatchedAt ?? now) : current.dispatchedAt
|
||||
}
|
||||
// Replaced, not patched in place: the list projection caches on array identity.
|
||||
operations.state.automationRuns = operations.state.automationRuns.map((run) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AutomationService } from '../automations/service'
|
||||
import { createHeadlessAutomationOutputSnapshotBuffer } from '../automations/headless-dispatch'
|
||||
import { waitForHeadlessAutomationCompletion } from '../automations/headless-completion-wait'
|
||||
import { buildHeadlessAutomationWorktreeCreateArgs } from '../automations/headless-workspace-create'
|
||||
import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
@@ -62,7 +63,13 @@ export function initializeMainProcessAutomations(): AutomationService {
|
||||
workspaceDisplayName = worktree.displayName ?? null
|
||||
}
|
||||
const completion = (async () => {
|
||||
const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle' })
|
||||
const wait = await waitForHeadlessAutomationCompletion(runtime, terminalHandle)
|
||||
if (wait.status === 'exited') {
|
||||
return {
|
||||
status: 'dispatch_failed' as const,
|
||||
error: 'Automation terminal exited before the agent reported completion.'
|
||||
}
|
||||
}
|
||||
const read = await runtime.readTerminal(terminalHandle, {
|
||||
limit: terminalSnapshotLimit
|
||||
})
|
||||
@@ -77,10 +84,9 @@ export function initializeMainProcessAutomations(): AutomationService {
|
||||
}
|
||||
return {
|
||||
status: 'dispatch_failed' as const,
|
||||
observationVerdict: 'unverifiable' as const,
|
||||
outputSnapshot: snapshotBuffer.snapshot(),
|
||||
error: wait.blockedReason
|
||||
? `Automation agent is blocked: ${wait.blockedReason}.`
|
||||
: 'Automation agent did not report completion.'
|
||||
error: 'Orca never saw this run report completion, so its result is unknown.'
|
||||
}
|
||||
})()
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
|
||||
import { AutomationRunPageFrame } from './AutomationRunPageFrame'
|
||||
import { getAutomationRunContent } from './automation-run-content'
|
||||
import { getAutomationRunContent, getAutomationRunNotice } from './automation-run-content'
|
||||
import { AutomationRunNoticeBand } from './AutomationRunNoticeBand'
|
||||
import type { AutomationRunViewState } from './automation-run-view-state'
|
||||
import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ export function AutomationRunDetailsPage({
|
||||
onOpenWorkspace: () => void
|
||||
onBack: () => void
|
||||
}): React.JSX.Element {
|
||||
const notice = getAutomationRunNotice(run)
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 p-5">
|
||||
<AutomationRunPageFrame
|
||||
@@ -56,8 +58,9 @@ export function AutomationRunDetailsPage({
|
||||
)
|
||||
: null
|
||||
}
|
||||
statusLabel={getAutomationRunStatusLabel(run.status)}
|
||||
statusVariant={getAutomationRunStatusVariant(run.status)}
|
||||
statusLabel={getAutomationRunStatusLabel(run.status, run.observationVerdict)}
|
||||
statusVariant={getAutomationRunStatusVariant(run.status, run.observationVerdict)}
|
||||
notice={notice ? <AutomationRunNoticeBand notice={notice} /> : null}
|
||||
actions={
|
||||
<>
|
||||
{canRerun && automation ? (
|
||||
|
||||
@@ -216,8 +216,10 @@ export function AutomationRunHistory({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-start">
|
||||
<Badge variant={getAutomationRunStatusVariant(run.status)}>
|
||||
{getAutomationRunStatusLabel(run.status)}
|
||||
<Badge
|
||||
variant={getAutomationRunStatusVariant(run.status, run.observationVerdict)}
|
||||
>
|
||||
{getAutomationRunStatusLabel(run.status, run.observationVerdict)}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, Info } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AutomationRunNotice } from './automation-run-content'
|
||||
|
||||
export function AutomationRunNoticeBand({
|
||||
notice
|
||||
}: {
|
||||
notice: AutomationRunNotice
|
||||
}): React.JSX.Element {
|
||||
const isError = notice.tone === 'error'
|
||||
const Icon = isError ? AlertTriangle : Info
|
||||
return (
|
||||
<div
|
||||
role={isError ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'flex shrink-0 items-start gap-2 border-b px-4 py-2.5 text-sm',
|
||||
isError
|
||||
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||
: 'border-border/50 bg-muted/40 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">{notice.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AutomationRunPageFrame } from './AutomationRunPageFrame'
|
||||
import { AutomationRunNoticeBand } from './AutomationRunNoticeBand'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('AutomationRunPageFrame notice', () => {
|
||||
it('renders the run reason above the output body', () => {
|
||||
render(
|
||||
<AutomationRunPageFrame
|
||||
title="Linear triage (daily 5pm PT)"
|
||||
breadcrumbs={['Aug 25, 5:00 PM', 'Orca', 'linear-triage']}
|
||||
statusLabel="Unverifiable"
|
||||
statusVariant="outline"
|
||||
notice={
|
||||
<AutomationRunNoticeBand
|
||||
notice={{
|
||||
text: 'Orca stopped watching this run before it reported completion.',
|
||||
tone: 'neutral'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onBack={() => {}}
|
||||
>
|
||||
<pre>{'{"id":"local-status","ok":true}'}</pre>
|
||||
</AutomationRunPageFrame>
|
||||
)
|
||||
|
||||
const reason = screen.getByText('Orca stopped watching this run before it reported completion.')
|
||||
const body = screen.getByText('{"id":"local-status","ok":true}')
|
||||
expect(reason.closest('[role="status"]')).toBe(screen.getByRole('status'))
|
||||
expect(reason).toBeTruthy()
|
||||
// Why: the reason must precede the body in the DOM, not scroll with it.
|
||||
expect(reason.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('adds no empty band when a run ended with no reason', () => {
|
||||
const { container } = render(
|
||||
<AutomationRunPageFrame
|
||||
title="Linear triage (daily 5pm PT)"
|
||||
breadcrumbs={[]}
|
||||
statusLabel="Done"
|
||||
statusVariant="secondary"
|
||||
onBack={() => {}}
|
||||
>
|
||||
<pre>report</pre>
|
||||
</AutomationRunPageFrame>
|
||||
)
|
||||
|
||||
// Why count children: an always-rendered band would add a stray bordered strip
|
||||
// between the header and the body on every healthy run.
|
||||
expect(container.firstElementChild?.children).toHaveLength(2)
|
||||
expect(screen.getByText('report')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('announces observed failures as alerts', () => {
|
||||
render(
|
||||
<AutomationRunNoticeBand
|
||||
notice={{ text: 'Automation process exited with code 1.', tone: 'error' }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain(
|
||||
'Automation process exited with code 1.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,8 @@ type AutomationRunPageFrameProps = {
|
||||
statusVariant: React.ComponentProps<typeof Badge>['variant']
|
||||
detail?: string | null
|
||||
actions?: React.ReactNode
|
||||
/** Pinned under the header so the reason a run ended cannot scroll out of view. */
|
||||
notice?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onBack: () => void
|
||||
}
|
||||
@@ -22,6 +24,7 @@ export function AutomationRunPageFrame({
|
||||
statusVariant,
|
||||
detail,
|
||||
actions,
|
||||
notice,
|
||||
children,
|
||||
onBack
|
||||
}: AutomationRunPageFrameProps): React.JSX.Element {
|
||||
@@ -79,6 +82,7 @@ export function AutomationRunPageFrame({
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
{notice}
|
||||
<div className="scrollbar-sleek min-h-0 flex-1 overflow-auto p-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -104,6 +104,15 @@ describe('automation-list-last-run', () => {
|
||||
expect(getToneForAutomationRunStatus('completed')).toBe('succeeded')
|
||||
expect(getToneForAutomationRunStatus('dispatched')).toBe('running')
|
||||
expect(getToneForAutomationRunStatus('skipped_precheck')).toBe('skipped')
|
||||
expect(getToneForAutomationRunStatus('dispatch_failed', 'unverifiable')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('renders additive unverifiable metadata without changing the legacy status', () => {
|
||||
const snapshot = getLocalAutomationLastRunSnapshot(
|
||||
makeAutomation(),
|
||||
makeRun({ status: 'dispatch_failed', observationVerdict: 'unverifiable' })
|
||||
)
|
||||
expect(snapshot).toMatchObject({ tone: 'unknown', statusLabel: 'Unverifiable' })
|
||||
})
|
||||
|
||||
it('prefers the latest run over lastRunAt-only metadata', () => {
|
||||
|
||||
@@ -52,7 +52,13 @@ export function getAutomationRunLastRunAt(run: AutomationRun): number {
|
||||
return run.dispatchedAt ?? run.startedAt ?? run.createdAt
|
||||
}
|
||||
|
||||
export function getToneForAutomationRunStatus(status: AutomationRunStatus): AutomationLastRunTone {
|
||||
export function getToneForAutomationRunStatus(
|
||||
status: AutomationRunStatus,
|
||||
observationVerdict?: AutomationRun['observationVerdict']
|
||||
): AutomationLastRunTone {
|
||||
if (observationVerdict === 'unverifiable') {
|
||||
return 'unknown'
|
||||
}
|
||||
if (status === 'dispatch_failed') {
|
||||
return 'failed'
|
||||
}
|
||||
@@ -95,8 +101,8 @@ export function getLocalAutomationLastRunSnapshot(
|
||||
if (lastRun) {
|
||||
return {
|
||||
at: getAutomationRunLastRunAt(lastRun),
|
||||
tone: getToneForAutomationRunStatus(lastRun.status),
|
||||
statusLabel: getAutomationRunStatusLabel(lastRun.status)
|
||||
tone: getToneForAutomationRunStatus(lastRun.status, lastRun.observationVerdict),
|
||||
statusLabel: getAutomationRunStatusLabel(lastRun.status, lastRun.observationVerdict)
|
||||
}
|
||||
}
|
||||
if (automation.lastRunAt) {
|
||||
|
||||
@@ -54,8 +54,12 @@ export function formatAutomationDateTimeWithRelative(
|
||||
}
|
||||
|
||||
export function getAutomationRunStatusVariant(
|
||||
status: AutomationRun['status']
|
||||
status: AutomationRun['status'],
|
||||
observationVerdict?: AutomationRun['observationVerdict']
|
||||
): React.ComponentProps<typeof Badge>['variant'] {
|
||||
if (observationVerdict === 'unverifiable') {
|
||||
return 'outline'
|
||||
}
|
||||
if (status === 'dispatched' || status === 'completed') {
|
||||
return 'secondary'
|
||||
}
|
||||
@@ -68,7 +72,13 @@ export function getAutomationRunStatusVariant(
|
||||
return 'dot'
|
||||
}
|
||||
|
||||
export function getAutomationRunStatusLabel(status: AutomationRun['status']): string {
|
||||
export function getAutomationRunStatusLabel(
|
||||
status: AutomationRun['status'],
|
||||
observationVerdict?: AutomationRun['observationVerdict']
|
||||
): string {
|
||||
if (observationVerdict === 'unverifiable') {
|
||||
return 'Unverifiable'
|
||||
}
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Queued'
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AutomationPrecheckResult, AutomationRun } from '../../../../shared/automations-types'
|
||||
import { getAutomationRunContent, getAutomationRunNotice } from './automation-run-content'
|
||||
|
||||
function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
||||
return {
|
||||
id: 'run-1',
|
||||
automationId: 'automation-1',
|
||||
title: 'Run 1',
|
||||
scheduledFor: 1,
|
||||
status: 'completed',
|
||||
trigger: 'scheduled',
|
||||
workspaceId: 'wt-1',
|
||||
sessionKind: 'terminal',
|
||||
chatSessionId: null,
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'tab-1:pane-1',
|
||||
terminalPtyId: 'pty-1',
|
||||
outputSnapshot: null,
|
||||
precheckResult: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
dispatchedAt: 1,
|
||||
createdAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makePassingPrecheck(stdout: string): AutomationPrecheckResult {
|
||||
return {
|
||||
command: 'orca status --json',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
durationMs: 210,
|
||||
stdout,
|
||||
stderr: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
completedAt: 2
|
||||
}
|
||||
}
|
||||
|
||||
describe('getAutomationRunNotice', () => {
|
||||
it('surfaces the run error even when a passing precheck fills the body', () => {
|
||||
const run = makeRun({
|
||||
status: 'dispatched',
|
||||
observationVerdict: 'unverifiable',
|
||||
precheckResult: makePassingPrecheck('{"id":"local-status","ok":true}'),
|
||||
error: 'Orca stopped watching this run before it reported completion.'
|
||||
})
|
||||
// Why this pairing: the precheck stdout used to be the only thing the run page
|
||||
// rendered, so the reason was invisible on exactly the runs that needed it.
|
||||
expect(getAutomationRunContent(run)).toContain('local-status')
|
||||
expect(getAutomationRunNotice(run)).toEqual({
|
||||
text: 'Orca stopped watching this run before it reported completion.',
|
||||
tone: 'neutral'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks only an observed failure with the error tone', () => {
|
||||
expect(
|
||||
getAutomationRunNotice(
|
||||
makeRun({ status: 'dispatch_failed', error: 'Automation process exited with code 1.' })
|
||||
)
|
||||
).toEqual({ text: 'Automation process exited with code 1.', tone: 'error' })
|
||||
})
|
||||
|
||||
it('returns nothing for a run that ended without a reason', () => {
|
||||
expect(getAutomationRunNotice(makeRun({ error: ' ' }))).toBeNull()
|
||||
expect(getAutomationRunNotice(makeRun())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAutomationRunContent', () => {
|
||||
it('prefers the saved output snapshot over the precheck output', () => {
|
||||
expect(
|
||||
getAutomationRunContent(
|
||||
makeRun({
|
||||
outputSnapshot: {
|
||||
format: 'plain_text',
|
||||
content: '# Triage report',
|
||||
capturedAt: 3,
|
||||
truncated: false
|
||||
},
|
||||
precheckResult: makePassingPrecheck('{"ok":true}')
|
||||
})
|
||||
)
|
||||
).toBe('# Triage report')
|
||||
})
|
||||
|
||||
it('no longer repeats the error the notice already carries', () => {
|
||||
expect(getAutomationRunContent(makeRun({ status: 'dispatch_failed', error: 'boom' }))).toBe(
|
||||
'No output content available.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
|
||||
export type AutomationRunNoticeTone = 'error' | 'neutral'
|
||||
|
||||
export type AutomationRunNotice = {
|
||||
text: string
|
||||
tone: AutomationRunNoticeTone
|
||||
}
|
||||
|
||||
export function getAutomationRunContent(run: AutomationRun): string {
|
||||
const savedOutput = run.outputSnapshot?.content.trim()
|
||||
if (savedOutput) {
|
||||
@@ -13,5 +20,21 @@ export function getAutomationRunContent(run: AutomationRun): string {
|
||||
return output
|
||||
}
|
||||
}
|
||||
return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.'
|
||||
return run.usage?.unavailableMessage ?? 'No output content available.'
|
||||
}
|
||||
|
||||
/** Why separate from the body: a passing precheck's stdout outranks `run.error` there,
|
||||
* so the reason a run ended was invisible on exactly the runs that needed it. */
|
||||
export function getAutomationRunNotice(run: AutomationRun): AutomationRunNotice | null {
|
||||
const text = run.error?.trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text,
|
||||
tone:
|
||||
run.status === 'dispatch_failed' && run.observationVerdict !== 'unverifiable'
|
||||
? 'error'
|
||||
: 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,27 @@ describe('canRerunAutomationRun', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('hides rerun while the original run is unverifiable', () => {
|
||||
expect(
|
||||
canRerunAutomationRun({
|
||||
automation: makeAutomation(),
|
||||
run: makeRun({ status: 'dispatch_failed', observationVerdict: 'unverifiable' })
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides rerun for a host-blocked retry row', () => {
|
||||
expect(
|
||||
canRerunAutomationRun({
|
||||
automation: makeAutomation(),
|
||||
run: makeRun({
|
||||
status: 'skipped_unavailable',
|
||||
error: 'A previous automation run may still be live in this workspace.'
|
||||
})
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'pending',
|
||||
'dispatching',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
|
||||
import {
|
||||
AUTOMATION_UNVERIFIABLE_WORKSPACE_ERROR,
|
||||
type Automation,
|
||||
type AutomationRun
|
||||
} from '../../../../shared/automations-types'
|
||||
|
||||
export type AutomationRunViewAvailability = 'terminal' | 'workspace' | 'snapshot' | 'metadata'
|
||||
|
||||
@@ -41,6 +45,12 @@ export function canRerunAutomationRun({
|
||||
if (!automation || run.automationId !== automation.id) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
run.observationVerdict === 'unverifiable' ||
|
||||
run.error === AUTOMATION_UNVERIFIABLE_WORKSPACE_ERROR
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
run.status === 'dispatch_failed' ||
|
||||
run.status === 'skipped_unavailable' ||
|
||||
|
||||
@@ -95,6 +95,19 @@ describe('pruneAutomationRuns', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('retains an unverifiable observation as in-flight history', () => {
|
||||
const unverifiable = run({
|
||||
id: 'unverifiable',
|
||||
automationId: 'a',
|
||||
status: 'dispatched',
|
||||
observationVerdict: 'unverifiable',
|
||||
createdAt: -1
|
||||
})
|
||||
const kept = pruneAutomationRuns([unverifiable, ...makeRuns('a', 120)])
|
||||
expect(kept).toContainEqual(unverifiable)
|
||||
expect(kept.filter((entry) => entry.automationId === 'a')).toHaveLength(101)
|
||||
})
|
||||
|
||||
it('shrinks a realistic runaway history to the cap', () => {
|
||||
const runaway = [
|
||||
...makeRuns('a', 2796),
|
||||
|
||||
@@ -6,6 +6,9 @@ export type AutomationWorkspaceMode = 'existing' | 'new_per_run'
|
||||
export type AutomationExecutionTargetType = 'local' | 'ssh'
|
||||
export type AutomationSchedulerOwner = 'local_host_service' | 'ssh_bridge' | 'remote_host_service'
|
||||
export type AutomationMissedRunPolicy = 'run_once_within_grace'
|
||||
export type AutomationRunObservationVerdict = 'unverifiable'
|
||||
export const AUTOMATION_UNVERIFIABLE_WORKSPACE_ERROR =
|
||||
'A previous automation run may still be live in this workspace.'
|
||||
export type AutomationRunStatus =
|
||||
| 'pending'
|
||||
| 'dispatching'
|
||||
@@ -140,6 +143,8 @@ export type AutomationRun = {
|
||||
title: string
|
||||
scheduledFor: number
|
||||
status: AutomationRunStatus
|
||||
/** Additive refinement for legacy `dispatch_failed`; old builds safely ignore it. */
|
||||
observationVerdict?: AutomationRunObservationVerdict | null
|
||||
trigger: AutomationRunTrigger
|
||||
workspaceId: string | null
|
||||
/** Why: run history must remain understandable after the backing workspace
|
||||
@@ -232,6 +237,7 @@ export type AutomationDispatchRequest = {
|
||||
export type AutomationDispatchResult = {
|
||||
runId: string
|
||||
status: AutomationRunStatus
|
||||
observationVerdict?: AutomationRunObservationVerdict | null
|
||||
workspaceId?: string | null
|
||||
workspaceDisplayName?: string | null
|
||||
terminalSessionId?: string | null
|
||||
|
||||
Reference in New Issue
Block a user