diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 9ab1ed45d57..786ff017bb2 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -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() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 7a192153d1f..dc22550e7ec 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -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() } } diff --git a/src/main/index.ts b/src/main/index.ts index 9537dc98b38..ff4b249f8dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index cd1faf35c60..276242040dc 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -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) + }) })