diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index 12ae01f3fc7..a56738f6c60 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentAwakeService, AGENT_AWAKE_STATUS_STALE_AFTER_MS } from './agent-awake-service' +import { + AgentAwakeService, + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS, + AGENT_AWAKE_STATUS_STALE_AFTER_MS +} from './agent-awake-service' import type { AgentAwakeStatus } from './agent-awake-service' vi.mock('electron', () => ({ @@ -122,6 +126,53 @@ describe('AgentAwakeService', () => { expect(linuxAssertion.start).toHaveBeenCalledTimes(1) }) + it('keeps the system awake but lets the display sleep for a status quiet past the display window', () => { + const blocker = createBlocker() + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + // Working status last refreshed longer ago than the display-keep window but + // still within the 2h system window (e.g. a long silent tool call, or an + // agent that died with its shell still open). + const now = 1_000 + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + 1 + const service = createService(() => now, blocker, macosAssertion, linuxAssertion) + + service.setEnabled(true) + service.setStatuses([workingStatus({ receivedAt: 1_000 })]) + + expect(blocker.start).toHaveBeenCalledTimes(1) + expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension') + expect(macosAssertion.start).toHaveBeenCalledTimes(1) + expect(linuxAssertion.start).toHaveBeenCalledTimes(1) + }) + + it('downgrades the display assertion to system-only when a working status stops refreshing', () => { + vi.useFakeTimers() + let now = 1_000 + const blocker = createBlocker() + const macosAssertion = createMacosAssertion() + const linuxAssertion = createLinuxAssertion() + const service = createService(() => now, blocker, macosAssertion, linuxAssertion) + + service.setEnabled(true) + service.setStatuses([workingStatus({ receivedAt: 1_000 })]) + expect(blocker.start).toHaveBeenLastCalledWith('prevent-display-sleep') + // Ignore the setup-time stop that fires while enabling before a status exists. + macosAssertion.stop.mockClear() + linuxAssertion.stop.mockClear() + + // Cross the display-keep boundary with no newer status. + now = 1_000 + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + 1 + vi.advanceTimersByTime(AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + 1) + + // The display assertion is swapped for a system-only one; the platform + // (system) assertions are never stopped, so the agent keeps running. + expect(blocker.stop).toHaveBeenCalledWith(1) + expect(blocker.start).toHaveBeenLastCalledWith('prevent-app-suspension') + expect(macosAssertion.stop).not.toHaveBeenCalled() + expect(linuxAssertion.stop).not.toHaveBeenCalled() + service.dispose() + }) + it('starts and stops from settings flips around an already-running status', () => { const blocker = createBlocker() const macosAssertion = createMacosAssertion() @@ -231,14 +282,21 @@ describe('AgentAwakeService', () => { service.setStatuses([workingStatus({ receivedAt: 1_000 })]) now = 2_000 service.setStatuses([workingStatus({ receivedAt: 2_000 })]) + // Ignore the setup-time stop that fires while enabling before a status exists. + macosAssertion.stop.mockClear() + linuxAssertion.stop.mockClear() now = 1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1 vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS) - expect(blocker.stop).not.toHaveBeenCalled() + // The system is still kept awake because the newer status is within its own + // 2h window. (The Electron blocker may have swapped display->system type by + // now; the platform assertions are the true "still awake" signal.) + expect(macosAssertion.stop).not.toHaveBeenCalled() + expect(linuxAssertion.stop).not.toHaveBeenCalled() now = 2_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1 vi.advanceTimersByTime(1_000) - expect(blocker.stop).toHaveBeenCalledWith(1) + expect(macosAssertion.stop).toHaveBeenCalledWith('stale-expiry') service.dispose() }) diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index a0cffcf27c4..3fad2c0b629 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -5,6 +5,18 @@ import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 +// Why: the display is the single biggest battery draw and only needs to stay +// lit while an agent is ACTIVELY producing status. Keeping the screen on during +// a long quiet stretch — or after an agent has silently died with its shell +// still open (no terminating hook, so the 2h backstop above is what releases +// it) — wastes power for up to two hours. Once no working status has been +// refreshed within this shorter window, downgrade to a system-only assertion: +// the machine (and the agent) stays awake for the full 2h window, but the +// display is allowed to sleep normally. +export const AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS = 15 * 60 * 1000 + +type AgentAwakeBlockerType = 'prevent-app-suspension' | 'prevent-display-sleep' + export type AgentAwakeStatus = { state: AgentStatusState receivedAt: number @@ -43,6 +55,7 @@ export class AgentAwakeService { private enabled = false private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null + private blockerType: AgentAwakeBlockerType | null = null private staleTimer: ReturnType | null = null private readonly blocker: PowerSaveBlocker private readonly linuxAssertion: PlatformAwakeAssertion @@ -104,10 +117,17 @@ export class AgentAwakeService { private refresh(reason: string): void { this.scheduleStaleTimer() + const now = this.now() const runningStatusCount = this.getEligibleRunningStatusCount() const shouldBlock = this.enabled && runningStatusCount > 0 if (shouldBlock) { - this.startBlocker(reason, runningStatusCount) + // Keep the screen lit only while an agent is actively working; once every + // working status has gone quiet past the display window, keep the system + // (and the agent) awake but let the display sleep. + const displayEligibleCount = this.getDisplayEligibleCount(now) + const desiredType: AgentAwakeBlockerType = + displayEligibleCount > 0 ? 'prevent-display-sleep' : 'prevent-app-suspension' + this.startBlocker(reason, runningStatusCount, desiredType) this.startMacosAssertion(reason) this.startLinuxAssertion(reason) } else { @@ -122,6 +142,14 @@ export class AgentAwakeService { return this.statuses.filter((status) => this.isWakeEligible(status, now)).length } + private getDisplayEligibleCount(now: number): number { + return this.statuses.filter( + (status) => + this.isWakeEligible(status, now) && + now - status.receivedAt <= AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS + ).length + } + private isWakeEligible(status: AgentAwakeStatus, now: number): boolean { return ( status.observedInCurrentRuntime && @@ -143,11 +171,17 @@ export class AgentAwakeService { ) { continue } - const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS - if (expiry <= now) { - continue + // Wake at BOTH boundaries: the display-keep expiry (downgrade the screen + // to system-only) and the full stale expiry (release entirely). + for (const expiry of [ + status.receivedAt + AGENT_AWAKE_DISPLAY_KEEP_AFTER_MS, + status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS + ]) { + if (expiry <= now) { + continue + } + earliestExpiry = earliestExpiry === null ? expiry : Math.min(earliestExpiry, expiry) } - earliestExpiry = earliestExpiry === null ? expiry : Math.min(earliestExpiry, expiry) } if (earliestExpiry === null) { return @@ -169,21 +203,34 @@ export class AgentAwakeService { this.staleTimer = null } - private startBlocker(reason: string, runningStatusCount: number): void { - if (this.blockerId !== null) { + private startBlocker( + reason: string, + runningStatusCount: number, + desiredType: AgentAwakeBlockerType + ): void { + // Keep an already-running blocker only if it is the type we want; a + // display<->system transition must swap the assertion. + if (this.blockerId !== null && this.blockerType === desiredType) { if (this.reconcileBlocker('start-reconcile')) { return } } + // Stop any existing (wrong-type or dead) blocker before starting the new + // type so a display->system downgrade never leaks the display assertion. + if (this.blockerId !== null) { + this.stopBlocker('blocker-type-change', runningStatusCount) + } try { - const id = this.blocker.start('prevent-display-sleep') + const id = this.blocker.start(desiredType) this.blockerId = id + this.blockerType = desiredType this.reconcileBlocker('post-start') } catch (err) { this.logger.warn('[agent-awake] failed to start blocker', { reason, enabled: this.enabled, runningStatusCount, + blockerType: desiredType, error: err }) } @@ -254,6 +301,9 @@ export class AgentAwakeService { }) } this.reconcileBlocker('post-stop') + if (this.blockerId === null) { + this.blockerType = null + } } private reconcileBlocker(reason: string): boolean { diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 2d69afaffee..b349474f5fa 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -158,7 +158,7 @@ describe('AgentsPane', () => { expect(markup).not.toContain('aria-label="Agent runtime"') expect(markup).toContain('Keep computer awake while agents are working') expect(markup).toContain( - '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.' + 'Keeps this computer awake so agents keep running. The display stays on while agents are actively working and is allowed to sleep during long quiet stretches. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) expect(markup).toContain('aria-checked="false"') }) @@ -217,7 +217,7 @@ describe('AgentsPane', () => { 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." + "Keeps this computer awake so agents keep running. The display stays on while agents are actively working and is allowed to sleep during long quiet stretches. Lid-close behavior follows this device's power settings." ) }) diff --git a/src/renderer/src/components/settings/agent-awake-copy.ts b/src/renderer/src/components/settings/agent-awake-copy.ts index 10d629b58d1..94b0d67bc3b 100644 --- a/src/renderer/src/components/settings/agent-awake-copy.ts +++ b/src/renderer/src/components/settings/agent-awake-copy.ts @@ -15,13 +15,13 @@ export function getAgentAwakeDescription( if (userAgent.includes('Windows')) { return translate( AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY, - "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings." + "Keeps this computer awake so agents keep running. The display stays on while agents are actively working and is allowed to sleep during long quiet stretches. Lid-close behavior follows this device's power settings." ) } return translate( AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY, - '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.' + 'Keeps this computer awake so agents keep running. The display stays on while agents are actively working and is allowed to sleep during long quiet stretches. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d6d8725c2d8..3da1b252461 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8611,8 +8611,8 @@ }, "agent-awake-copy": { "e5995ce268": "Keep computer awake while agents are working", - "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", - "a42f6fbdd8": "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." + "95d3031db2": "Keeps this computer awake so agents keep running. The display stays on while agents are actively working and is allowed to sleep during long quiet stretches. Lid-close behavior follows this device's power settings.", + "a42f6fbdd8": "Keeps this computer awake so agents keep running. The display stays on while agents are actively working and is allowed to sleep during long quiet stretches. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." }, "agent-status-hooks-copy": { "7707c15abb": "Agent status hooks", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a2bbc411665..6aeca5806af 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8566,8 +8566,8 @@ }, "agent-awake-copy": { "e5995ce268": "Mantener la computadora activa mientras los agentes están trabajando", - "95d3031db2": "Mantiene esta computadora y la pantalla activas mientras los agentes están trabajando. El comportamiento al cerrar la tapa sigue la configuración de energía de este dispositivo.", - "a42f6fbdd8": "Mantiene esta computadora y la pantalla activas mientras los agentes están trabajando. Orca también le pide a este dispositivo que permanezca activo cuando la tapa está cerrada, sujeto a su política de energía." + "95d3031db2": "Mantiene este equipo activo para que los agentes sigan ejecutándose. La pantalla permanece encendida mientras los agentes trabajan activamente y puede apagarse durante periodos largos de inactividad. El comportamiento al cerrar la tapa depende de la configuración de energía del dispositivo.", + "a42f6fbdd8": "Mantiene este equipo activo para que los agentes sigan ejecutándose. La pantalla permanece encendida mientras los agentes trabajan activamente y puede apagarse durante periodos largos de inactividad. Orca también solicita que el dispositivo permanezca activo con la tapa cerrada, según su política de energía." }, "agent-status-hooks-copy": { "7707c15abb": "Hooks de estado del agente", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8ce317e4ae4..ea624f3390c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8588,8 +8588,8 @@ }, "agent-awake-copy": { "e5995ce268": "agents 作業中はコンピューターを起きたままにする", - "95d3031db2": "agents が作業している間、このコンピューターとディスプレイを起きたままにします。蓋を閉じたときの動作は、このデバイスの電源設定に従います。", - "a42f6fbdd8": "agents が作業している間、このコンピューターとディスプレイを起きたままにします。Orca は電源ポリシーに従い、蓋が閉じているときもこのデバイスを起きたままにするよう要求します。" + "95d3031db2": "エージェントの実行を継続できるよう、このコンピューターをスリープさせません。ディスプレイはエージェントがアクティブに作業している間は点灯し、長時間操作がない場合はスリープします。ふたを閉じたときの動作はデバイスの電源設定に従います。", + "a42f6fbdd8": "エージェントの実行を継続できるよう、このコンピューターをスリープさせません。ディスプレイはエージェントがアクティブに作業している間は点灯し、長時間操作がない場合はスリープします。Orca はデバイスの電源ポリシーに従い、ふたを閉じた状態でもスリープしないよう要求します。" }, "agent-status-hooks-copy": { "7707c15abb": "Agent 状態フック", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 37326de084a..b2496dd29c2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8551,8 +8551,8 @@ }, "agent-awake-copy": { "e5995ce268": "agents 작업 중 컴퓨터를 깨어 있게 유지", - "95d3031db2": "agents가 작업하는 동안 이 컴퓨터와 디스플레이를 깨어 있게 유지합니다. 뚜껑을 닫을 때의 동작은 이 기기의 전원 설정을 따릅니다.", - "a42f6fbdd8": "agents가 작업하는 동안 이 컴퓨터와 디스플레이를 깨어 있게 유지합니다. Orca는 전원 정책에 따라 뚜껑이 닫혀 있을 때도 이 기기를 깨어 있게 유지하도록 요청합니다." + "95d3031db2": "에이전트가 계속 실행되도록 이 컴퓨터를 켜 둡니다. 디스플레이는 에이전트가 활발히 작업하는 동안 켜져 있으며, 오랫동안 활동이 없으면 절전될 수 있습니다. 덮개를 닫을 때의 동작은 기기의 전원 설정을 따릅니다.", + "a42f6fbdd8": "에이전트가 계속 실행되도록 이 컴퓨터를 켜 둡니다. 디스플레이는 에이전트가 활발히 작업하는 동안 켜져 있으며, 오랫동안 활동이 없으면 절전될 수 있습니다. Orca는 기기의 전원 정책에 따라 덮개를 닫아도 절전되지 않도록 요청합니다." }, "agent-status-hooks-copy": { "7707c15abb": "Agent 상태 훅", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 060903dce0e..7276811fb93 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8551,8 +8551,8 @@ }, "agent-awake-copy": { "e5995ce268": "智能体工作时保持电脑唤醒", - "95d3031db2": "在智能体工作时保持此电脑和显示器唤醒。合盖行为遵循此设备的电源设置。", - "a42f6fbdd8": "在智能体工作时保持此电脑和显示器唤醒。Orca 还会根据电源策略,在合盖时请求此设备保持唤醒。" + "95d3031db2": "保持此电脑唤醒,以便代理持续运行。显示器在代理活跃工作时保持点亮,在长时间无活动时可以进入睡眠。合盖行为取决于设备的电源设置。", + "a42f6fbdd8": "保持此电脑唤醒,以便代理持续运行。显示器在代理活跃工作时保持点亮,在长时间无活动时可以进入睡眠。Orca 还会根据设备的电源策略,在合盖时请求设备保持唤醒。" }, "agent-status-hooks-copy": { "7707c15abb": "智能体状态钩子",