mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(automations): start scheduler in headless serve mode (#7296)
* fix(automations): start scheduler in headless serve mode * Trigger initial catch-up check for headless scheduled automations Headless serve never receives a renderer-ready IPC, which previously prevented the initial catch-up pass of due scheduled automations from running on startup. Checking for the presence of a headless dispatcher allows starting the evaluation pass immediately. Additionally, refactor the corresponding test to use fake timers for better reliability and deterministic assertions. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
@@ -348,6 +348,70 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches due scheduled automations headlessly', 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()
|
||||
const afterRunAt = new Date(2026, 4, 13, 9, 1).getTime()
|
||||
const nextRunAt = new Date(2026, 4, 14, 9, 0).getTime()
|
||||
|
||||
vi.setSystemTime(beforeRunAt)
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Morning check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone,
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date(2026, 4, 12, 0, 0).getTime()
|
||||
})
|
||||
|
||||
const headlessDispatcher = vi.fn().mockResolvedValue({
|
||||
workspaceId: 'wt1',
|
||||
workspaceDisplayName: 'Morning check',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'pane-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
})
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher
|
||||
})
|
||||
|
||||
try {
|
||||
vi.setSystemTime(afterRunAt)
|
||||
service.start()
|
||||
await vi.waitFor(() => expect(headlessDispatcher).toHaveBeenCalledTimes(1))
|
||||
const run = store.listAutomationRuns(automation.id)[0]
|
||||
expect(run?.status).toBe('dispatched')
|
||||
expect(run?.scheduledFor).toBe(scheduledRunAt)
|
||||
expect(headlessDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ automation: expect.objectContaining({ id: automation.id }) })
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(store.listAutomations().find((entry) => entry.id === automation.id)?.nextRunAt).toBe(
|
||||
nextRunAt
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
service.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches provider usage when a completed run can be attributed', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -69,7 +69,9 @@ export class AutomationService {
|
||||
this.timer = setInterval(() => {
|
||||
void this.evaluateDueRuns()
|
||||
}, this.tickMs)
|
||||
if (this.rendererReady) {
|
||||
// Why: headless serve never gets a renderer-ready IPC, but due runs still
|
||||
// need the same startup catch-up pass desktop gets after renderer attach.
|
||||
if (this.rendererReady || this.headlessDispatcher) {
|
||||
void this.evaluateDueRuns()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2095,6 +2095,8 @@ app.whenReady().then(async () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
|
||||
automations.start()
|
||||
await printServeReady(serveOptions)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,4 +32,28 @@ describe('desktop startup ordering', () => {
|
||||
expect(attachIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(startIndex).toBeGreaterThan(attachIndex)
|
||||
})
|
||||
|
||||
it('starts the automation scheduler before headless serve reports ready', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
const serveStart = source.indexOf('if (serveOptions) {')
|
||||
const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart)
|
||||
const serveReturn = source.indexOf('return', serveReady)
|
||||
const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart)
|
||||
const automationStart = source.indexOf('automations.start()', serveStart)
|
||||
const desktopSetWebContents = source.indexOf('automations.setWebContents(window.webContents)')
|
||||
const desktopAutomationStart = source.indexOf(
|
||||
'automations.start()',
|
||||
desktopSetWebContents + 1
|
||||
)
|
||||
|
||||
expect(serveStart).toBeGreaterThanOrEqual(0)
|
||||
expect(serveReady).toBeGreaterThan(serveStart)
|
||||
expect(serveReturn).toBeGreaterThan(serveReady)
|
||||
expect(runtimeRpcStart).toBeGreaterThan(serveStart)
|
||||
expect(automationStart).toBeGreaterThan(runtimeRpcStart)
|
||||
expect(automationStart).toBeLessThan(serveReady)
|
||||
expect(automationStart).toBeLessThan(serveReturn)
|
||||
expect(desktopSetWebContents).toBeGreaterThanOrEqual(0)
|
||||
expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user