Keep Mac awake while agents run (#2227)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9f4d077077
commit
ffc7d77dbe
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })))
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue