From ffc7d77dbeca43ddfc889535ad7b3fdec91e251b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 18 May 2026 14:09:21 -0700 Subject: [PATCH] Keep Mac awake while agents run (#2227) Co-authored-by: Orca --- ...-awake-service-platform-assertions.test.ts | 127 ++++++++++ src/main/agent-awake-service.test.ts | 119 ++++++++- src/main/agent-awake-service.ts | 106 +++++++- src/main/linux-lid-sleep-assertion.test.ts | 237 ++++++++++++++++++ src/main/linux-lid-sleep-assertion.ts | 232 +++++++++++++++++ src/main/macos-system-sleep-assertion.test.ts | 203 +++++++++++++++ src/main/macos-system-sleep-assertion.ts | 199 +++++++++++++++ .../components/settings/AgentAwakeSetting.tsx | 21 +- .../components/settings/AgentsPane.test.tsx | 10 +- .../components/settings/agent-awake-copy.ts | 18 ++ .../src/components/settings/agents-search.ts | 12 +- src/shared/types.ts | 2 +- tests/e2e/settings-agent-awake.spec.ts | 230 ++++++++++++++++- 13 files changed, 1485 insertions(+), 31 deletions(-) create mode 100644 src/main/agent-awake-service-platform-assertions.test.ts create mode 100644 src/main/linux-lid-sleep-assertion.test.ts create mode 100644 src/main/linux-lid-sleep-assertion.ts create mode 100644 src/main/macos-system-sleep-assertion.test.ts create mode 100644 src/main/macos-system-sleep-assertion.ts create mode 100644 src/renderer/src/components/settings/agent-awake-copy.ts diff --git a/src/main/agent-awake-service-platform-assertions.test.ts b/src/main/agent-awake-service-platform-assertions.test.ts new file mode 100644 index 000000000..9c08064ab --- /dev/null +++ b/src/main/agent-awake-service-platform-assertions.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentAwakeService } from './agent-awake-service' +import type { AgentAwakeStatus } from './agent-awake-service' + +vi.mock('electron', () => ({ + powerMonitor: { + on: vi.fn(), + off: vi.fn() + }, + powerSaveBlocker: { + start: vi.fn(), + stop: vi.fn(), + isStarted: vi.fn() + } +})) + +function workingStatus(): AgentAwakeStatus { + return { + state: 'working', + receivedAt: 1_000, + observedInCurrentRuntime: true + } +} + +function createBlocker() { + const startedIds = new Set() + let nextId = 1 + return { + start: vi.fn(() => { + const id = nextId++ + startedIds.add(id) + return id + }), + stop: vi.fn((id: number) => { + startedIds.delete(id) + }), + isStarted: vi.fn((id: number) => startedIds.has(id)) + } +} + +function createPlatformAssertion() { + return { + start: vi.fn(), + stop: vi.fn(), + dispose: vi.fn() + } +} + +function createService( + blocker = createBlocker(), + macosAssertion = createPlatformAssertion(), + linuxAssertion = createPlatformAssertion() +): AgentAwakeService { + return new AgentAwakeService({ + blocker, + linuxAssertion, + macosAssertion, + now: () => 1_000, + powerMonitor: null, + logger: { + debug: vi.fn(), + warn: vi.fn() + } + }) +} + +describe('AgentAwakeService platform assertions', () => { + it('keeps Electron blocker active when macOS assertion start fails', () => { + const blocker = createBlocker() + const macosAssertion = createPlatformAssertion() + const linuxAssertion = createPlatformAssertion() + macosAssertion.start.mockImplementation(() => { + throw new Error('caffeinate failed') + }) + const service = createService(blocker, macosAssertion, linuxAssertion) + + service.setEnabled(true) + service.setStatuses([workingStatus()]) + service.setEnabled(false) + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + expect(blocker.stop).toHaveBeenCalledWith(1) + expect(macosAssertion.stop).toHaveBeenCalled() + expect(linuxAssertion.start).toHaveBeenCalledTimes(1) + expect(linuxAssertion.stop).toHaveBeenCalled() + }) + + it('keeps Electron blocker active when Linux assertion start fails', () => { + const blocker = createBlocker() + const macosAssertion = createPlatformAssertion() + const linuxAssertion = createPlatformAssertion() + linuxAssertion.start.mockImplementation(() => { + throw new Error('systemd-inhibit failed') + }) + const service = createService(blocker, macosAssertion, linuxAssertion) + + service.setEnabled(true) + service.setStatuses([workingStatus()]) + service.setEnabled(false) + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + expect(blocker.stop).toHaveBeenCalledWith(1) + expect(macosAssertion.start).toHaveBeenCalledTimes(1) + expect(macosAssertion.stop).toHaveBeenCalled() + expect(linuxAssertion.stop).toHaveBeenCalled() + }) + + it('starts platform assertions when Electron blocker start fails', () => { + const blocker = createBlocker() + blocker.start.mockImplementation(() => { + throw new Error('electron failed') + }) + const macosAssertion = createPlatformAssertion() + const linuxAssertion = createPlatformAssertion() + const service = createService(blocker, macosAssertion, linuxAssertion) + + service.setEnabled(true) + service.setStatuses([workingStatus()]) + service.setEnabled(false) + + expect(macosAssertion.start).toHaveBeenCalledTimes(1) + expect(macosAssertion.stop).toHaveBeenCalled() + expect(linuxAssertion.start).toHaveBeenCalledTimes(1) + expect(linuxAssertion.stop).toHaveBeenCalled() + expect(blocker.stop).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index b9105adf3..12ae01f3f 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -3,6 +3,10 @@ import { AgentAwakeService, AGENT_AWAKE_STATUS_STALE_AFTER_MS } from './agent-aw import type { AgentAwakeStatus } from './agent-awake-service' vi.mock('electron', () => ({ + powerMonitor: { + on: vi.fn(), + off: vi.fn() + }, powerSaveBlocker: { start: vi.fn(), stop: vi.fn(), @@ -36,11 +40,54 @@ function createBlocker() { } } -function createService(now: () => number, blocker = createBlocker()): AgentAwakeService { +function createMacosAssertion() { + return { + start: vi.fn(), + stop: vi.fn(), + dispose: vi.fn() + } +} + +function createLinuxAssertion() { + return { + start: vi.fn(), + stop: vi.fn(), + dispose: vi.fn() + } +} + +function createPowerMonitor() { + const listeners = new Set<() => void>() + return { + on: vi.fn((_event: 'resume', listener: () => void) => { + listeners.add(listener) + }), + off: vi.fn((_event: 'resume', listener: () => void) => { + listeners.delete(listener) + }), + emitResume: () => { + for (const listener of listeners) { + listener() + } + } + } +} + +function createService( + now: () => number, + blocker = createBlocker(), + macosAssertion = createMacosAssertion(), + linuxAssertion = createLinuxAssertion(), + powerMonitor: ReturnType | null = null +): AgentAwakeService { return new AgentAwakeService({ blocker, + linuxAssertion, + macosAssertion, now, + powerMonitor, logger: { + debug: vi.fn(), warn: vi.fn() } }) @@ -60,20 +107,26 @@ describe('AgentAwakeService', () => { expect(blocker.start).not.toHaveBeenCalled() }) - it('starts one prevent-app-suspension blocker when enabled with a fresh working status', () => { + it('starts Electron and platform assertions when enabled with a fresh working status', () => { const blocker = createBlocker() - const service = createService(() => 1_000, blocker) + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const service = createService(() => 1_000, blocker, macosAssertion, linuxAssertion) service.setEnabled(true) service.setStatuses([workingStatus()]) expect(blocker.start).toHaveBeenCalledTimes(1) - expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension') + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + expect(macosAssertion.start).toHaveBeenCalledTimes(1) + expect(linuxAssertion.start).toHaveBeenCalledTimes(1) }) it('starts and stops from settings flips around an already-running status', () => { const blocker = createBlocker() - const service = createService(() => 1_000, blocker) + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const service = createService(() => 1_000, blocker, macosAssertion, linuxAssertion) service.setStatuses([workingStatus()]) service.setEnabled(true) @@ -81,6 +134,10 @@ describe('AgentAwakeService', () => { expect(blocker.start).toHaveBeenCalledTimes(1) expect(blocker.stop).toHaveBeenCalledWith(1) + expect(macosAssertion.start).toHaveBeenCalledTimes(1) + expect(macosAssertion.stop).toHaveBeenCalled() + expect(linuxAssertion.start).toHaveBeenCalledTimes(1) + expect(linuxAssertion.stop).toHaveBeenCalled() }) it('ignores startup-hydrated working statuses that were not observed in this runtime', () => { @@ -120,13 +177,17 @@ describe('AgentAwakeService', () => { it('stops when the last running status is dropped', () => { const blocker = createBlocker() - const service = createService(() => 1_000, blocker) + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const service = createService(() => 1_000, blocker, macosAssertion, linuxAssertion) service.setEnabled(true) service.setStatuses([workingStatus()]) service.setStatuses([]) expect(blocker.stop).toHaveBeenCalledWith(1) + expect(macosAssertion.stop).toHaveBeenCalledWith('status-change') + expect(linuxAssertion.stop).toHaveBeenCalledWith('status-change') }) it('does not start for a stale working status', () => { @@ -143,7 +204,9 @@ describe('AgentAwakeService', () => { vi.useFakeTimers() let now = 1_000 const blocker = createBlocker() - const service = createService(() => now, blocker) + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const service = createService(() => now, blocker, macosAssertion, linuxAssertion) service.setEnabled(true) service.setStatuses([workingStatus({ receivedAt: 1_000 })]) @@ -151,6 +214,8 @@ describe('AgentAwakeService', () => { vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS) expect(blocker.stop).toHaveBeenCalledWith(1) + expect(macosAssertion.stop).toHaveBeenCalledWith('stale-expiry') + expect(linuxAssertion.stop).toHaveBeenCalledWith('stale-expiry') service.dispose() }) @@ -158,7 +223,9 @@ describe('AgentAwakeService', () => { vi.useFakeTimers() let now = 1_000 const blocker = createBlocker() - const service = createService(() => now, blocker) + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const service = createService(() => now, blocker, macosAssertion, linuxAssertion) service.setEnabled(true) service.setStatuses([workingStatus({ receivedAt: 1_000 })]) @@ -194,7 +261,9 @@ describe('AgentAwakeService', () => { it('disposes by clearing timers and stopping an active blocker once', () => { vi.useFakeTimers() const blocker = createBlocker() - const service = createService(() => 1_000, blocker) + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const service = createService(() => 1_000, blocker, macosAssertion, linuxAssertion) service.setEnabled(true) service.setStatuses([workingStatus()]) @@ -203,5 +272,37 @@ describe('AgentAwakeService', () => { expect(blocker.stop).toHaveBeenCalledTimes(1) expect(blocker.stop).toHaveBeenCalledWith(1) + expect(macosAssertion.dispose).toHaveBeenCalledTimes(1) + expect(linuxAssertion.dispose).toHaveBeenCalledTimes(1) + }) + + it('reconciles assertions on power resume while work is still eligible', () => { + const blocker = createBlocker() + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const monitor = createPowerMonitor() + const service = createService(() => 1_000, blocker, macosAssertion, linuxAssertion, monitor) + + service.setEnabled(true) + service.setStatuses([workingStatus()]) + blocker.startedIds.clear() + monitor.emitResume() + + expect(blocker.start).toHaveBeenCalledTimes(2) + expect(macosAssertion.start).toHaveBeenCalledTimes(2) + expect(linuxAssertion.start).toHaveBeenCalledTimes(2) + }) + + it('unsubscribes the resume listener on dispose', () => { + const blocker = createBlocker() + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const monitor = createPowerMonitor() + const service = createService(() => 1_000, blocker, macosAssertion, linuxAssertion, monitor) + + service.dispose() + + expect(monitor.on).toHaveBeenCalledTimes(1) + expect(monitor.off).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 6ecdf86d6..a0cffcf27 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,7 @@ -import { powerSaveBlocker } from 'electron' +import { powerMonitor, powerSaveBlocker } from 'electron' import type { AgentStatusState } from '../shared/agent-status-types' +import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' +import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 @@ -10,17 +12,31 @@ export type AgentAwakeStatus = { } type PowerSaveBlocker = { - start: (type: 'prevent-app-suspension') => number + start: (type: 'prevent-app-suspension' | 'prevent-display-sleep') => number stop: (id: number) => void isStarted: (id: number) => boolean } -type Logger = Pick +type PlatformAwakeAssertion = { + start: (reason: string) => void + stop: (reason: string) => void + dispose: () => void +} + +type PowerMonitorEventSource = { + on: (event: 'resume', listener: () => void) => void + off: (event: 'resume', listener: () => void) => void +} + +type Logger = Pick type AgentAwakeServiceOptions = { blocker?: PowerSaveBlocker + linuxAssertion?: PlatformAwakeAssertion logger?: Logger + macosAssertion?: PlatformAwakeAssertion now?: () => number + powerMonitor?: PowerMonitorEventSource | null } export class AgentAwakeService { @@ -29,13 +45,40 @@ export class AgentAwakeService { private blockerId: number | null = null private staleTimer: ReturnType | null = null private readonly blocker: PowerSaveBlocker + private readonly linuxAssertion: PlatformAwakeAssertion private readonly logger: Logger + private readonly macosAssertion: PlatformAwakeAssertion private readonly now: () => number + private readonly unsubscribeResume: (() => void) | null constructor(options: AgentAwakeServiceOptions = {}) { this.blocker = options.blocker ?? powerSaveBlocker this.logger = options.logger ?? console this.now = options.now ?? Date.now + // Windows lid close is intentionally not modeled as an assertion here: + // keeping it awake requires mutating the user's global power plan. + this.linuxAssertion = + options.linuxAssertion ?? + new LinuxLidSleepAssertion({ + logger: this.logger, + now: this.now, + onUnexpectedFailure: (reason) => this.refresh(reason) + }) + this.macosAssertion = + options.macosAssertion ?? + new MacosSystemSleepAssertion({ + logger: this.logger, + now: this.now, + onUnexpectedFailure: (reason) => this.refresh(reason) + }) + const resumeSource = options.powerMonitor === undefined ? powerMonitor : options.powerMonitor + if (resumeSource) { + const onResume = () => this.refresh('power-resume') + resumeSource.on('resume', onResume) + this.unsubscribeResume = () => resumeSource.off('resume', onResume) + } else { + this.unsubscribeResume = null + } } setEnabled(enabled: boolean): void { @@ -53,7 +96,10 @@ export class AgentAwakeService { dispose(): void { this.clearStaleTimer() + this.unsubscribeResume?.() this.stopBlocker('dispose') + this.macosAssertion.dispose() + this.linuxAssertion.dispose() } private refresh(reason: string): void { @@ -62,8 +108,12 @@ export class AgentAwakeService { const shouldBlock = this.enabled && runningStatusCount > 0 if (shouldBlock) { this.startBlocker(reason, runningStatusCount) + this.startMacosAssertion(reason) + this.startLinuxAssertion(reason) } else { this.stopBlocker(reason, runningStatusCount) + this.stopMacosAssertion(reason) + this.stopLinuxAssertion(reason) } } @@ -126,7 +176,7 @@ export class AgentAwakeService { } } try { - const id = this.blocker.start('prevent-app-suspension') + const id = this.blocker.start('prevent-display-sleep') this.blockerId = id this.reconcileBlocker('post-start') } catch (err) { @@ -139,6 +189,54 @@ export class AgentAwakeService { } } + private startMacosAssertion(reason: string): void { + try { + this.macosAssertion.start(reason) + } catch (err) { + this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', { + reason, + enabled: this.enabled, + error: err + }) + } + } + + private startLinuxAssertion(reason: string): void { + try { + this.linuxAssertion.start(reason) + } catch (err) { + this.logger.warn('[agent-awake] failed to start Linux lid sleep assertion', { + reason, + enabled: this.enabled, + error: err + }) + } + } + + private stopMacosAssertion(reason: string): void { + try { + this.macosAssertion.stop(reason) + } catch (err) { + this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', { + reason, + enabled: this.enabled, + error: err + }) + } + } + + private stopLinuxAssertion(reason: string): void { + try { + this.linuxAssertion.stop(reason) + } catch (err) { + this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', { + reason, + enabled: this.enabled, + error: err + }) + } + } + private stopBlocker(reason: string, runningStatusCount = 0): void { if (this.blockerId === null) { return diff --git a/src/main/linux-lid-sleep-assertion.test.ts b/src/main/linux-lid-sleep-assertion.test.ts new file mode 100644 index 000000000..b55aa69aa --- /dev/null +++ b/src/main/linux-lid-sleep-assertion.test.ts @@ -0,0 +1,237 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { + LINUX_LID_SLEEP_ASSERTION_RETRY_MS, + LinuxLidSleepAssertion +} from './linux-lid-sleep-assertion' + +class FakeSystemdInhibitProcess extends EventEmitter { + pid = 123 + kill = vi.fn(() => { + this.emit('exit', null, 'SIGTERM') + return true + }) +} + +function createLogger() { + return { + debug: vi.fn(), + warn: vi.fn() + } +} + +describe('LinuxLidSleepAssertion', () => { + it('spawns systemd-inhibit with sleep and lid-switch inhibitors on Linux', () => { + const child = new FakeSystemdInhibitProcess() + const spawn = vi.fn(() => child) + const assertion = new LinuxLidSleepAssertion({ + logger: createLogger(), + platform: 'linux', + spawn + }) + + assertion.start('status-change') + + expect(spawn).toHaveBeenCalledWith( + 'systemd-inhibit', + [ + '--what=sleep:handle-lid-switch', + '--who=Orca', + '--why=Agents are working', + '--mode=block', + 'sleep', + 'infinity' + ], + { + stdio: 'ignore', + windowsHide: true + } + ) + }) + + it('is a no-op off Linux', () => { + const spawn = vi.fn(() => new FakeSystemdInhibitProcess()) + const assertion = new LinuxLidSleepAssertion({ + logger: createLogger(), + platform: 'darwin', + spawn + }) + + assertion.start('status-change') + + expect(spawn).not.toHaveBeenCalled() + }) + + it('does not start a second inhibitor while one is live', () => { + const spawn = vi.fn(() => new FakeSystemdInhibitProcess()) + const assertion = new LinuxLidSleepAssertion({ + logger: createLogger(), + platform: 'linux', + spawn + }) + + assertion.start('status-change') + assertion.start('power-resume') + + expect(spawn).toHaveBeenCalledTimes(1) + }) + + it('stops only the child process it started', () => { + const child = new FakeSystemdInhibitProcess() + const assertion = new LinuxLidSleepAssertion({ + logger: createLogger(), + platform: 'linux', + spawn: vi.fn(() => child) + }) + + assertion.start('status-change') + assertion.stop('settings-change') + + expect(child.kill).toHaveBeenCalledTimes(1) + }) + + it('does not report an intentional stop as a failed inhibitor', () => { + const logger = createLogger() + const child = new FakeSystemdInhibitProcess() + const assertion = new LinuxLidSleepAssertion({ + logger, + platform: 'linux', + spawn: vi.fn(() => child) + }) + + assertion.start('status-change') + assertion.stop('settings-change') + + expect(logger.warn).not.toHaveBeenCalled() + expect(logger.debug).not.toHaveBeenCalled() + }) + + it('logs missing systemd-inhibit once and degrades to no-op starts', () => { + const logger = createLogger() + const spawn = vi.fn(() => { + const error = new Error('spawn systemd-inhibit ENOENT') as Error & { code: string } + error.code = 'ENOENT' + throw error + }) + const assertion = new LinuxLidSleepAssertion({ + logger, + platform: 'linux', + spawn + }) + + assertion.start('status-change') + assertion.start('power-resume') + + expect(spawn).toHaveBeenCalledTimes(1) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.debug).not.toHaveBeenCalled() + }) + + it('clears the child and notifies the owner after a permission or DBus error', () => { + const firstChild = new FakeSystemdInhibitProcess() + const secondChild = new FakeSystemdInhibitProcess() + const spawn = vi.fn(() => firstChild).mockImplementationOnce(() => firstChild) + spawn.mockImplementationOnce(() => secondChild) + const logger = createLogger() + let now = 1_000 + const onUnexpectedFailure = vi.fn() + const assertion = new LinuxLidSleepAssertion({ + logger, + now: () => now, + onUnexpectedFailure, + platform: 'linux', + spawn + }) + + assertion.start('status-change') + const error = new Error('Access denied') as Error & { code: string } + error.code = 'EACCES' + firstChild.emit('error', error) + now += LINUX_LID_SLEEP_ASSERTION_RETRY_MS + 1 + assertion.start('status-change') + + expect(onUnexpectedFailure).toHaveBeenCalledWith('linux-lid-assertion-failure') + expect(spawn).toHaveBeenCalledTimes(2) + expect(logger.warn).toHaveBeenCalledTimes(1) + }) + + it('suppresses retry attempts until the shared retry gate expires', () => { + vi.useFakeTimers() + let now = 1_000 + const spawn = vi.fn(() => { + throw new Error('dbus unavailable') + }) + const onUnexpectedFailure = vi.fn() + const assertion = new LinuxLidSleepAssertion({ + logger: createLogger(), + now: () => now, + onUnexpectedFailure, + platform: 'linux', + spawn + }) + + assertion.start('status-change') + assertion.start('power-resume') + now += LINUX_LID_SLEEP_ASSERTION_RETRY_MS - 1 + vi.advanceTimersByTime(LINUX_LID_SLEEP_ASSERTION_RETRY_MS - 1) + assertion.start('status-change') + + expect(spawn).toHaveBeenCalledTimes(1) + + now += 1 + vi.advanceTimersByTime(1) + + expect(onUnexpectedFailure).toHaveBeenCalledWith('linux-lid-assertion-retry') + assertion.start('linux-lid-assertion-retry') + + expect(spawn).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + + it('does not retry when systemd-inhibit is missing', () => { + vi.useFakeTimers() + const spawn = vi.fn(() => { + const error = new Error('spawn systemd-inhibit ENOENT') as Error & { code: string } + error.code = 'ENOENT' + throw error + }) + const onUnexpectedFailure = vi.fn() + const assertion = new LinuxLidSleepAssertion({ + logger: createLogger(), + onUnexpectedFailure, + platform: 'linux', + spawn + }) + + assertion.start('status-change') + vi.advanceTimersByTime(LINUX_LID_SLEEP_ASSERTION_RETRY_MS) + assertion.start('power-resume') + + expect(spawn).toHaveBeenCalledTimes(1) + expect(onUnexpectedFailure).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('logs repeated identical failures at debug until reset', () => { + const logger = createLogger() + const spawn = vi.fn(() => { + throw new Error('dbus unavailable') + }) + let now = 1_000 + const assertion = new LinuxLidSleepAssertion({ + logger, + now: () => now, + platform: 'linux', + spawn + }) + + assertion.start('status-change') + now += LINUX_LID_SLEEP_ASSERTION_RETRY_MS + 1 + assertion.start('power-resume') + assertion.stop('settings-change') + assertion.start('status-change') + + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.debug).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/linux-lid-sleep-assertion.ts b/src/main/linux-lid-sleep-assertion.ts new file mode 100644 index 000000000..aef1d68a1 --- /dev/null +++ b/src/main/linux-lid-sleep-assertion.ts @@ -0,0 +1,232 @@ +import { spawn as nodeSpawn } from 'node:child_process' + +export const LINUX_LID_SLEEP_ASSERTION_RETRY_MS = 30_000 + +type Logger = Pick + +type SystemdInhibitProcess = { + kill: () => boolean + on: { + (event: 'error', listener: (error: Error & { code?: string }) => void): void + (event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void + } + pid?: number +} + +type SystemdInhibitSpawn = ( + command: string, + args: string[], + options: { stdio: 'ignore'; windowsHide: true; shell?: false } +) => SystemdInhibitProcess + +type LinuxLidSleepAssertionOptions = { + logger?: Logger + now?: () => number + onUnexpectedFailure?: (reason: string) => void + platform?: NodeJS.Platform + spawn?: SystemdInhibitSpawn +} + +export class LinuxLidSleepAssertion { + private readonly logger: Logger + private readonly now: () => number + private readonly onUnexpectedFailure: (reason: string) => void + private readonly platform: NodeJS.Platform + private readonly spawn: SystemdInhibitSpawn + private child: SystemdInhibitProcess | null = null + private retryNotBefore: number | null = null + private retryTimer: ReturnType | null = null + private systemdInhibitUnavailable = false + private lastFailureKey: string | null = null + private warnedForLastFailure = false + private readonly intentionalStops = new WeakSet() + private readonly reportedFailures = new WeakSet() + + constructor(options: LinuxLidSleepAssertionOptions = {}) { + this.logger = options.logger ?? console + this.now = options.now ?? Date.now + this.onUnexpectedFailure = options.onUnexpectedFailure ?? (() => {}) + this.platform = options.platform ?? process.platform + this.spawn = options.spawn ?? nodeSpawn + } + + start(reason: string): void { + if (this.platform !== 'linux' || this.child || this.systemdInhibitUnavailable) { + return + } + if (this.retryNotBefore !== null && this.now() < this.retryNotBefore) { + this.scheduleRetry() + return + } + + let child: SystemdInhibitProcess + try { + // logind's lid switch handling ignores ordinary sleep inhibitors on many systems, + // so Linux needs both a sleep lock and a handle-lid-switch lock. + child = this.spawn( + 'systemd-inhibit', + [ + '--what=sleep:handle-lid-switch', + '--who=Orca', + '--why=Agents are working', + '--mode=block', + 'sleep', + 'infinity' + ], + { + stdio: 'ignore', + windowsHide: true + } + ) + } catch (error) { + this.handleFailure('spawn-error', reason, error, 'spawn-error') + return + } + + this.child = child + child.on('error', (error) => { + this.handleChildFailure( + child, + `error:${String(error.code ?? error.message)}`, + 'error', + reason, + error + ) + }) + child.on('exit', (code, signal) => { + this.handleChildFailure(child, `exit:${String(code)}:${String(signal)}`, 'exit', reason, { + code, + signal + }) + }) + this.resetRetrySuppression() + this.resetFailureStreak() + } + + stop(_reason: string): void { + this.resetRetrySuppression() + this.resetFailureStreak() + if (!this.child) { + return + } + const child = this.child + this.child = null + this.intentionalStops.add(child) + try { + child.kill() + } catch (error) { + if (!isEsrchError(error)) { + this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', { error }) + } + } + } + + dispose(): void { + this.stop('dispose') + } + + private handleChildFailure( + child: SystemdInhibitProcess, + failureKey: string, + failureType: 'error' | 'exit', + startReason: string, + details: unknown + ): void { + if (this.intentionalStops.has(child)) { + this.intentionalStops.delete(child) + return + } + if (this.reportedFailures.has(child)) { + return + } + this.reportedFailures.add(child) + if (this.child === child) { + this.child = null + } + this.handleFailure(failureKey, startReason, details, failureType) + } + + private handleFailure( + failureKey: string, + reason: string, + details: unknown, + failureType: 'error' | 'exit' | 'spawn-error' + ): void { + if (isMissingSystemdInhibit(details)) { + this.systemdInhibitUnavailable = true + this.resetRetrySuppression() + this.logFailure('systemd-inhibit-missing', reason, details, failureType) + return + } + this.logFailure(failureKey, reason, details, failureType) + this.retryNotBefore = this.now() + LINUX_LID_SLEEP_ASSERTION_RETRY_MS + this.scheduleRetry() + this.onUnexpectedFailure('linux-lid-assertion-failure') + } + + private logFailure( + failureKey: string, + reason: string, + details: unknown, + failureType: 'error' | 'exit' | 'spawn-error' + ): void { + const payload = { + reason, + failureType, + details + } + if (this.lastFailureKey === failureKey && this.warnedForLastFailure) { + this.logger.debug('[agent-awake] Linux lid sleep assertion failed repeatedly', payload) + return + } + this.lastFailureKey = failureKey + this.warnedForLastFailure = true + this.logger.warn('[agent-awake] Linux lid sleep assertion failed', payload) + } + + private resetFailureStreak(): void { + this.lastFailureKey = null + this.warnedForLastFailure = false + } + + private scheduleRetry(): void { + if (this.retryNotBefore === null || this.retryTimer) { + return + } + const retryDelay = Math.max(0, this.retryNotBefore - this.now()) + this.retryTimer = setTimeout(() => { + this.retryTimer = null + this.onUnexpectedFailure('linux-lid-assertion-retry') + }, retryDelay) + if (typeof this.retryTimer.unref === 'function') { + this.retryTimer.unref() + } + } + + private resetRetrySuppression(): void { + this.retryNotBefore = null + if (!this.retryTimer) { + return + } + clearTimeout(this.retryTimer) + this.retryTimer = null + } +} + +function isMissingSystemdInhibit(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ) +} + +function isEsrchError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ESRCH' + ) +} diff --git a/src/main/macos-system-sleep-assertion.test.ts b/src/main/macos-system-sleep-assertion.test.ts new file mode 100644 index 000000000..9baca8c56 --- /dev/null +++ b/src/main/macos-system-sleep-assertion.test.ts @@ -0,0 +1,203 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { + MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS, + MacosSystemSleepAssertion +} from './macos-system-sleep-assertion' + +class FakeCaffeinateProcess extends EventEmitter { + pid = 123 + kill = vi.fn(() => { + this.emit('exit', null, 'SIGTERM') + return true + }) +} + +function createLogger() { + return { + debug: vi.fn(), + warn: vi.fn() + } +} + +describe('MacosSystemSleepAssertion', () => { + it('spawns caffeinate with the system and idle sleep assertions on macOS', () => { + const child = new FakeCaffeinateProcess() + const spawn = vi.fn(() => child) + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + platform: 'darwin', + spawn + }) + + assertion.start('status-change') + + expect(spawn).toHaveBeenCalledWith('/usr/bin/caffeinate', ['-i', '-s'], { + stdio: 'ignore', + windowsHide: true + }) + }) + + it('is a no-op off macOS', () => { + const spawn = vi.fn(() => new FakeCaffeinateProcess()) + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + platform: 'linux', + spawn + }) + + assertion.start('status-change') + + expect(spawn).not.toHaveBeenCalled() + }) + + it('does not start a second caffeinate process while one is live', () => { + const spawn = vi.fn(() => new FakeCaffeinateProcess()) + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + platform: 'darwin', + spawn + }) + + assertion.start('status-change') + assertion.start('status-change') + + expect(spawn).toHaveBeenCalledTimes(1) + }) + + it('stops only the child process it started', () => { + const child = new FakeCaffeinateProcess() + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + platform: 'darwin', + spawn: vi.fn(() => child) + }) + + assertion.start('status-change') + assertion.stop('settings-change') + + expect(child.kill).toHaveBeenCalledTimes(1) + }) + + it('clears the child and notifies the owner on unexpected exit', () => { + const firstChild = new FakeCaffeinateProcess() + const secondChild = new FakeCaffeinateProcess() + const spawn = vi.fn(() => firstChild).mockImplementationOnce(() => firstChild) + spawn.mockImplementationOnce(() => secondChild) + let now = 1_000 + const onUnexpectedFailure = vi.fn() + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + now: () => now, + onUnexpectedFailure, + platform: 'darwin', + spawn + }) + + assertion.start('status-change') + firstChild.emit('exit', 1, null) + now += MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS + 1 + assertion.start('status-change') + + expect(onUnexpectedFailure).toHaveBeenCalledWith('macos-assertion-failure') + expect(spawn).toHaveBeenCalledTimes(2) + }) + + it('does not report an intentional stop as unexpected', () => { + const child = new FakeCaffeinateProcess() + const onUnexpectedFailure = vi.fn() + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + onUnexpectedFailure, + platform: 'darwin', + spawn: vi.fn(() => child) + }) + + assertion.start('status-change') + assertion.stop('settings-change') + + expect(onUnexpectedFailure).not.toHaveBeenCalled() + }) + + it('suppresses retry attempts until the shared retry gate expires', () => { + vi.useFakeTimers() + let now = 1_000 + const spawn = vi.fn(() => { + throw new Error('missing caffeinate') + }) + const onUnexpectedFailure = vi.fn() + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + now: () => now, + onUnexpectedFailure, + platform: 'darwin', + spawn + }) + + assertion.start('status-change') + assertion.start('power-resume') + assertion.start('stale-expiry') + now += MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS - 1 + vi.advanceTimersByTime(MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS - 1) + assertion.start('status-change') + + expect(spawn).toHaveBeenCalledTimes(1) + + now += 1 + vi.advanceTimersByTime(1) + + expect(onUnexpectedFailure).toHaveBeenCalledWith('macos-assertion-retry') + assertion.start('macos-assertion-retry') + + expect(spawn).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + + it('keeps at most one retry timer for repeated failures', () => { + vi.useFakeTimers() + const spawn = vi.fn(() => { + throw new Error('missing caffeinate') + }) + const onUnexpectedFailure = vi.fn() + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + now: () => 1_000, + onUnexpectedFailure, + platform: 'darwin', + spawn + }) + + assertion.start('status-change') + assertion.start('power-resume') + vi.advanceTimersByTime(MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS) + + expect(onUnexpectedFailure).toHaveBeenCalledTimes(2) + expect(onUnexpectedFailure).toHaveBeenNthCalledWith(1, 'macos-assertion-failure') + expect(onUnexpectedFailure).toHaveBeenNthCalledWith(2, 'macos-assertion-retry') + vi.useRealTimers() + }) + + it('logs the first identical failure at warn and repeats at debug until reset', () => { + let now = 1_000 + const logger = createLogger() + const spawn = vi.fn(() => { + throw new Error('missing caffeinate') + }) + const assertion = new MacosSystemSleepAssertion({ + logger, + now: () => now, + platform: 'darwin', + spawn + }) + + assertion.start('status-change') + now += MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS + 1 + assertion.start('status-change') + assertion.stop('settings-change') + assertion.start('status-change') + + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.debug).toHaveBeenCalledTimes(1) + assertion.dispose() + }) +}) diff --git a/src/main/macos-system-sleep-assertion.ts b/src/main/macos-system-sleep-assertion.ts new file mode 100644 index 000000000..dae26880b --- /dev/null +++ b/src/main/macos-system-sleep-assertion.ts @@ -0,0 +1,199 @@ +import { spawn as nodeSpawn } from 'node:child_process' + +export const MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS = 30_000 + +type Logger = Pick + +type CaffeinateProcess = { + kill: () => boolean + on: { + (event: 'error', listener: (error: Error) => void): void + (event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void + } + pid?: number +} + +type CaffeinateSpawn = ( + command: string, + args: string[], + options: { stdio: 'ignore'; windowsHide: true; shell?: false } +) => CaffeinateProcess + +type MacosSystemSleepAssertionOptions = { + logger?: Logger + now?: () => number + onUnexpectedFailure?: (reason: string) => void + platform?: NodeJS.Platform + spawn?: CaffeinateSpawn +} + +export class MacosSystemSleepAssertion { + private readonly logger: Logger + private readonly now: () => number + private readonly onUnexpectedFailure: (reason: string) => void + private readonly platform: NodeJS.Platform + private readonly spawn: CaffeinateSpawn + private child: CaffeinateProcess | null = null + private retryNotBefore: number | null = null + private retryTimer: ReturnType | null = null + private lastFailureKey: string | null = null + private warnedForLastFailure = false + private readonly intentionalStops = new WeakSet() + private readonly reportedFailures = new WeakSet() + + constructor(options: MacosSystemSleepAssertionOptions = {}) { + this.logger = options.logger ?? console + this.now = options.now ?? Date.now + this.onUnexpectedFailure = options.onUnexpectedFailure ?? (() => {}) + this.platform = options.platform ?? process.platform + this.spawn = options.spawn ?? nodeSpawn + } + + start(reason: string): void { + if (this.platform !== 'darwin' || this.child) { + return + } + if (this.retryNotBefore !== null && this.now() < this.retryNotBefore) { + this.scheduleRetry() + return + } + + let child: CaffeinateProcess + try { + child = this.spawn('/usr/bin/caffeinate', ['-i', '-s'], { + stdio: 'ignore', + windowsHide: true + }) + } catch (error) { + this.handleFailure('spawn-error', reason, error) + return + } + + this.child = child + child.on('error', (error) => { + this.handleChildFailure(child, `error:${String(error.message)}`, 'error', reason, error) + }) + child.on('exit', (code, signal) => { + this.handleChildFailure(child, `exit:${String(code)}:${String(signal)}`, 'exit', reason, { + code, + signal + }) + }) + this.resetRetrySuppression() + this.resetFailureStreak() + } + + stop(_reason: string): void { + this.resetRetrySuppression() + this.resetFailureStreak() + if (!this.child) { + return + } + const child = this.child + this.child = null + this.intentionalStops.add(child) + try { + child.kill() + } catch (error) { + if (!isEsrchError(error)) { + this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', { + error + }) + } + } + } + + dispose(): void { + this.stop('dispose') + } + + private handleChildFailure( + child: CaffeinateProcess, + failureKey: string, + failureType: 'error' | 'exit', + startReason: string, + details: unknown + ): void { + if (this.intentionalStops.has(child)) { + this.intentionalStops.delete(child) + return + } + if (this.reportedFailures.has(child)) { + return + } + this.reportedFailures.add(child) + if (this.child === child) { + this.child = null + } + this.handleFailure(failureKey, startReason, details, failureType) + } + + private handleFailure( + failureKey: string, + reason: string, + details: unknown, + failureType: 'error' | 'exit' | 'spawn-error' = 'spawn-error' + ): void { + this.logFailure(failureKey, reason, details, failureType) + this.retryNotBefore = this.now() + MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS + this.scheduleRetry() + this.onUnexpectedFailure('macos-assertion-failure') + } + + private logFailure( + failureKey: string, + reason: string, + details: unknown, + failureType: 'error' | 'exit' | 'spawn-error' + ): void { + const payload = { + reason, + failureType, + details + } + if (this.lastFailureKey === failureKey && this.warnedForLastFailure) { + this.logger.debug('[agent-awake] macOS system sleep assertion failed repeatedly', payload) + return + } + this.lastFailureKey = failureKey + this.warnedForLastFailure = true + this.logger.warn('[agent-awake] macOS system sleep assertion failed', payload) + } + + private scheduleRetry(): void { + if (this.retryNotBefore === null || this.retryTimer) { + return + } + const retryDelay = Math.max(0, this.retryNotBefore - this.now()) + this.retryTimer = setTimeout(() => { + this.retryTimer = null + this.onUnexpectedFailure('macos-assertion-retry') + }, retryDelay) + if (typeof this.retryTimer.unref === 'function') { + this.retryTimer.unref() + } + } + + private resetRetrySuppression(): void { + this.retryNotBefore = null + if (!this.retryTimer) { + return + } + clearTimeout(this.retryTimer) + this.retryTimer = null + } + + private resetFailureStreak(): void { + this.lastFailureKey = null + this.warnedForLastFailure = false + } +} + +function isEsrchError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ESRCH' + ) +} diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx index 649e288c2..9a45192de 100644 --- a/src/renderer/src/components/settings/AgentAwakeSetting.tsx +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -1,5 +1,10 @@ import type { GlobalSettings } from '../../../../shared/types' import { Label } from '../ui/label' +import { + AGENT_AWAKE_TITLE, + getAgentAwakeDescription, + getAgentAwakeSearchKeywords +} from './agent-awake-copy' import { SearchableSetting } from './SearchableSetting' type AgentAwakeSettingProps = { @@ -11,23 +16,23 @@ export function AgentAwakeSetting({ settings, updateSettings }: AgentAwakeSettingProps): React.JSX.Element { + const description = getAgentAwakeDescription() + return (
- -

- Keeps this computer awake while agents are working. The display can still turn off. -

+ +

{description}