Keep Mac awake while agents run (#2227)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-18 14:09:21 -07:00 committed by GitHub
parent 9f4d077077
commit ffc7d77dbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1485 additions and 31 deletions

View File

@ -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<number>()
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()
})
})

View File

@ -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<typeof createPowerMonitor> | 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)
})
})

View File

@ -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<Console, 'warn'>
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<Console, 'debug' | 'warn'>
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<typeof setTimeout> | 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

View File

@ -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)
})
})

View File

@ -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<Console, 'debug' | 'warn'>
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<typeof setTimeout> | null = null
private systemdInhibitUnavailable = false
private lastFailureKey: string | null = null
private warnedForLastFailure = false
private readonly intentionalStops = new WeakSet<SystemdInhibitProcess>()
private readonly reportedFailures = new WeakSet<SystemdInhibitProcess>()
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'
)
}

View File

@ -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()
})
})

View File

@ -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<Console, 'debug' | 'warn'>
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<typeof setTimeout> | null = null
private lastFailureKey: string | null = null
private warnedForLastFailure = false
private readonly intentionalStops = new WeakSet<CaffeinateProcess>()
private readonly reportedFailures = new WeakSet<CaffeinateProcess>()
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'
)
}

View File

@ -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 (
<section className="space-y-3">
<SearchableSetting
title="Keep computer awake while agents are working"
description="Keeps this computer awake while agents are working. The display can still turn off."
keywords={['awake', 'sleep', 'power', 'agent', 'running', 'working']}
title={AGENT_AWAKE_TITLE}
description={description}
keywords={getAgentAwakeSearchKeywords()}
className="flex items-start justify-between gap-4 px-1 py-2"
>
<div className="min-w-0 shrink space-y-0.5">
<Label>Keep computer awake while agents are working</Label>
<p className="text-xs text-muted-foreground">
Keeps this computer awake while agents are working. The display can still turn off.
</p>
<Label>{AGENT_AWAKE_TITLE}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<button
role="switch"
aria-label="Keep computer awake while agents are working"
aria-label={AGENT_AWAKE_TITLE}
aria-checked={settings.keepComputerAwakeWhileAgentsRun}
onClick={() =>
updateSettings({

View File

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { getAgentAwakeDescription } from './agent-awake-copy'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane'
import { matchesSettingsSearch } from './settings-search'
@ -65,11 +66,17 @@ describe('AgentsPane', () => {
expect(markup).toContain('Keep computer awake while agents are working')
expect(markup).toContain(
'Keeps this computer awake while agents are working. The display can still turn off.'
'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.'
)
expect(markup).toContain('aria-checked="false"')
})
it('describes Windows lid behavior according to the device', () => {
expect(getAgentAwakeDescription('Windows')).toBe(
"Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings."
)
})
it('toggles the keep-awake setting with the next value', () => {
const updateSettings = vi.fn()
const element = AgentAwakeSetting({
@ -95,5 +102,6 @@ describe('AgentsPane', () => {
it('includes awake and sleep search metadata for the setting', () => {
expect(matchesSettingsSearch('awake', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('sleep', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('lid', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
})
})

View File

@ -0,0 +1,18 @@
export const AGENT_AWAKE_TITLE = 'Keep computer awake while agents are working'
export function getAgentAwakeDescription(
userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent
): string {
if (userAgent.includes('Windows')) {
return "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings."
}
return 'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.'
}
export function getAgentAwakeSearchKeywords(
userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent
): string[] {
const keywords = ['awake', 'sleep', 'power', 'agent', 'running', 'working', 'lid', 'display']
return userAgent.includes('Linux') ? [...keywords, 'linux'] : keywords
}

View File

@ -1,4 +1,9 @@
import type { SettingsSearchEntry } from './settings-search'
import {
AGENT_AWAKE_TITLE,
getAgentAwakeDescription,
getAgentAwakeSearchKeywords
} from './agent-awake-copy'
export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
@ -41,9 +46,8 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
]
},
{
title: 'Keep computer awake while agents are working',
description:
'Keeps this computer awake while agents are working. The display can still turn off.',
keywords: ['awake', 'sleep', 'power', 'agent', 'running', 'working']
title: AGENT_AWAKE_TITLE,
description: getAgentAwakeDescription(),
keywords: getAgentAwakeSearchKeywords()
}
]

View File

@ -1541,7 +1541,7 @@ export type GlobalSettings = {
geminiCliOAuthEnabled: boolean
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
agentCmdOverrides: Partial<Record<TuiAgent, string>>
/** When true, Orca prevents local app suspension while hook-reported agents are working. */
/** When true, Orca requests local awake assertions while hook-reported agents are working. */
keepComputerAwakeWhileAgentsRun: boolean
/** Why: macOS terminals must choose between letting Option compose layout
* characters (@ on German, on French) or treating Option as Meta/Esc for

View File

@ -1,20 +1,188 @@
import { randomUUID } from 'crypto'
import { existsSync, readFileSync } from 'fs'
import path from 'path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type { GlobalSettings } from '../../src/shared/types'
async function getSettings(
page: Parameters<typeof waitForSessionReady>[0]
): Promise<GlobalSettings> {
type AwakeProbeSnapshot = {
starts: { type: string; id: number }[]
stops: { id: number }[]
activeIds: number[]
}
type HookEndpoint = {
port: string
token: string
env: string
version: string
}
async function getSettings(page: Page): Promise<GlobalSettings> {
return page.evaluate(() => window.api.settings.get())
}
async function openSettings(page: Parameters<typeof waitForSessionReady>[0]): Promise<void> {
async function setKeepAwake(page: Page, enabled: boolean): Promise<void> {
await page.evaluate(async (enabled) => {
const nextSettings = await window.api.settings.set({
keepComputerAwakeWhileAgentsRun: enabled
})
window.__store?.setState({ settings: nextSettings as GlobalSettings })
}, enabled)
}
async function openSettings(page: Page): Promise<void> {
await page.evaluate(() => {
window.__store!.getState().openSettingsPage()
})
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
}
async function dismissTransientAnnouncement(page: Page): Promise<void> {
// Why: first-run announcements are independent of this setting and can cover
// the settings pane on fresh CI profiles before the search input is used.
const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' })
const visible = await maybeLaterButton
.isVisible({
timeout: 1_000
})
.catch(() => false)
if (visible) {
await maybeLaterButton.click()
}
}
async function installPowerSaveBlockerProbe(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(({ powerSaveBlocker }) => {
const root = globalThis as typeof globalThis & {
__orcaAwakePowerProbe?: {
starts: { type: string; id: number }[]
stops: { id: number }[]
originalStart: typeof powerSaveBlocker.start
originalStop: typeof powerSaveBlocker.stop
}
}
if (root.__orcaAwakePowerProbe) {
root.__orcaAwakePowerProbe.starts = []
root.__orcaAwakePowerProbe.stops = []
return
}
const originalStart = powerSaveBlocker.start.bind(powerSaveBlocker)
const originalStop = powerSaveBlocker.stop.bind(powerSaveBlocker)
root.__orcaAwakePowerProbe = {
starts: [],
stops: [],
originalStart,
originalStop
}
powerSaveBlocker.start = ((type) => {
const id = originalStart(type)
root.__orcaAwakePowerProbe?.starts.push({ type, id })
return id
}) as typeof powerSaveBlocker.start
powerSaveBlocker.stop = ((id) => {
root.__orcaAwakePowerProbe?.stops.push({ id })
originalStop(id)
}) as typeof powerSaveBlocker.stop
})
}
async function readPowerSaveBlockerProbe(
electronApp: ElectronApplication
): Promise<AwakeProbeSnapshot> {
return electronApp.evaluate(({ powerSaveBlocker }) => {
const probe = (
globalThis as typeof globalThis & {
__orcaAwakePowerProbe?: {
starts: { type: string; id: number }[]
stops: { id: number }[]
}
}
).__orcaAwakePowerProbe
const starts = probe?.starts ?? []
return {
starts: starts.map((start) => ({ ...start })),
stops: (probe?.stops ?? []).map((stop) => ({ ...stop })),
activeIds: starts.map((start) => start.id).filter((id) => powerSaveBlocker.isStarted(id))
}
})
}
function parseEndpointFile(contents: string): HookEndpoint {
const values: Record<string, string> = {}
for (const line of contents.split(/\r?\n/)) {
const normalized = line.startsWith('set ') ? line.slice(4) : line
const separatorIndex = normalized.indexOf('=')
if (separatorIndex <= 0) {
continue
}
values[normalized.slice(0, separatorIndex)] = normalized.slice(separatorIndex + 1)
}
return {
port: values.ORCA_AGENT_HOOK_PORT ?? '',
token: values.ORCA_AGENT_HOOK_TOKEN ?? '',
env: values.ORCA_AGENT_HOOK_ENV ?? '',
version: values.ORCA_AGENT_HOOK_VERSION ?? ''
}
}
async function readAgentHookEndpoint(electronApp: ElectronApplication): Promise<HookEndpoint> {
const userDataPath = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const endpointFilePath = path.join(
userDataPath,
'agent-hooks',
process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env'
)
await expect
.poll(() => existsSync(endpointFilePath), {
timeout: 10_000,
message: 'agent hook endpoint file was not written'
})
.toBe(true)
const endpoint = parseEndpointFile(readFileSync(endpointFilePath, 'utf8'))
expect(endpoint.port).toBeTruthy()
expect(endpoint.token).toBeTruthy()
expect(endpoint.env).toBeTruthy()
expect(endpoint.version).toBeTruthy()
return endpoint
}
async function postCodexHookEvent(
electronApp: ElectronApplication,
options: {
paneKey: string
tabId: string
eventName: 'UserPromptSubmit' | 'Stop'
}
): Promise<void> {
const endpoint = await readAgentHookEndpoint(electronApp)
const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': endpoint.token
},
body: JSON.stringify({
paneKey: options.paneKey,
tabId: options.tabId,
worktreeId: 'e2e-awake-worktree',
env: endpoint.env,
version: endpoint.version,
payload: {
hook_event_name: options.eventName,
prompt: 'e2e keep-awake prompt'
}
})
})
expect(response.status).toBe(204)
}
test.describe('Agent awake setting', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
@ -22,6 +190,7 @@ test.describe('Agent awake setting', () => {
test('can be toggled from Agents settings and persists through IPC', async ({ orcaPage }) => {
await openSettings(orcaPage)
await dismissTransientAnnouncement(orcaPage)
await orcaPage.getByPlaceholder('Search settings').fill('awake')
await expect(
@ -51,4 +220,57 @@ test.describe('Agent awake setting', () => {
})
.toBe(false)
})
test('keeps the OS awake only while a hook-reported agent is working', async ({
electronApp,
orcaPage
}) => {
await installPowerSaveBlockerProbe(electronApp)
await setKeepAwake(orcaPage, true)
const tabId = 'e2e-awake-tab'
const paneKey = `${tabId}:${randomUUID()}`
await postCodexHookEvent(electronApp, {
paneKey,
tabId,
eventName: 'UserPromptSubmit'
})
await expect
.poll(async () => await readPowerSaveBlockerProbe(electronApp), {
timeout: 5_000,
message: 'powerSaveBlocker did not start for the working agent'
})
.toEqual(
expect.objectContaining({
activeIds: expect.arrayContaining([expect.any(Number)]),
starts: expect.arrayContaining([
expect.objectContaining({ type: 'prevent-display-sleep' })
])
})
)
const startedIds = (await readPowerSaveBlockerProbe(electronApp)).starts.map(
(start) => start.id
)
expect(startedIds.length).toBeGreaterThan(0)
await postCodexHookEvent(electronApp, {
paneKey,
tabId,
eventName: 'Stop'
})
await expect
.poll(async () => await readPowerSaveBlockerProbe(electronApp), {
timeout: 5_000,
message: 'powerSaveBlocker stayed active after the agent stopped'
})
.toEqual(
expect.objectContaining({
activeIds: [],
stops: expect.arrayContaining(startedIds.map((id) => expect.objectContaining({ id })))
})
)
})
})