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:
Rod Boev
2026-07-04 01:30:04 -07:00
committed by GitHub
co-authored by Jinjing
parent 04046fc4f2
commit 840bedc90d
4 changed files with 93 additions and 1 deletions
+64
View File
@@ -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()
+3 -1
View File
@@ -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()
}
}
+2
View File
@@ -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)
})
})