diff --git a/Cargo.lock b/Cargo.lock index f7594a2c03..1474d9932c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6379,9 +6379,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", diff --git a/docs/agents.md b/docs/agents.md index 0e14d4efb9..ec0ce1539c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -221,6 +221,39 @@ IDs do not become per-turn tags. Agent identity is evidence, never authority. Names, library rows, local agent rows, archive state and presence do not prove ownership, membership or runtime state. +## Keep awake while local agents work + +**Settings → Buzz → Agent setup → Agent behavior → Keep awake while local agents work** +is off by default and saved on this device. On macOS desktop, it prevents +idle **system** sleep only while this app executes local agent work. The display +can sleep; closing a laptop lid, explicit sleep and running after Quit are not +supported promises. Browser, Windows and Linux show an unavailable control. + +`AgentsService.keepAwake` receives execution demand from the runner, across +communities, including mentions, watches/timers, classifier calls and session +controls. This is independent of the optional Agent Activity plugin, saved +telemetry, typing, and idle harness processes. `workingUntil` retains demand; +Codex also supplies `executingUntil` through actual turn completion so its final +model call remains covered after reply display ends. Pi takes a separate +`AgentHandle.execution()` for each recovered turn, pulses it on harness steps, +and finishes it when the recovered wait settles or the runtime releases it. +Recovered work can outlive the delivery or session control that found it. +Retiring a runner or losing its custody releases its demand without changing +existing turn-stop semantics. + +The native host owns one process-local macOS power assertion and expires it after +one hour without a new execution transition or harness step. New activity can +resume it. Preference-off, completed work, frontend replacement and Quit release +it; a native generation fences old timers and an IPC lease fences old renderer +requests. The trusted main renderer supplies work evidence; this is not an +independent native process monitor or a hostile-plugin boundary. Acquisition, +release and preference-save errors remain visible with explicit Retry. + +Focused coverage: `features/agents/keep-awake.test.ts`, the agent service and +Codex and Pi runtime tests, `app/AgentSettings.test.tsx`, native `keep_awake::tests`, +and the generated native-command webview-isolation test. Fixture tests do not +establish real-provider or laptop power-state acceptance. + ## Local agents `LocalAgents` (`src/features/agents/local.ts`) is a read-only snapshot of the diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 5157c23a53..7e5ac26822 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -111,6 +111,8 @@ fn main() { "media_copy_image", "media_stream_base", "get_os_idle_seconds", + "agent_keep_awake_open", + "agent_keep_awake_set", "workspace_pick_folder", "repositories_folder_validate", "plugin_import_folder", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7ff358c6e9..18a3f2b2bf 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -73,6 +73,8 @@ "allow-media-copy-image", "allow-media-stream-base", "allow-get-os-idle-seconds", + "allow-agent-keep-awake-open", + "allow-agent-keep-awake-set", "allow-workspace-pick-folder", "allow-repositories-folder-validate", "allow-plugin-import-folder", diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index 1f6ab51675..a973495dfa 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -122,6 +122,8 @@ fn native_command_permissions_isolate_webviews() { "media_copy_image", "media_stream_base", "get_os_idle_seconds", + "agent_keep_awake_open", + "agent_keep_awake_set", "plugin_import_folder", "plugin_update_check", "plugin_update_preview", diff --git a/src-tauri/src/keep_awake.rs b/src-tauri/src/keep_awake.rs new file mode 100644 index 0000000000..a1ef44a786 --- /dev/null +++ b/src-tauri/src/keep_awake.rs @@ -0,0 +1,248 @@ +//! One process-local idle-sleep assertion. The main renderer supplies execution +//! demand, not telemetry. A native one-hour cap bounds stale renderer demand. +use serde::Serialize; +use std::sync::{Arc, Mutex}; +use tauri::ipc::Channel; + +#[derive(Clone, Serialize, Debug, Default, PartialEq)] +pub(crate) struct Status { + supported: bool, + active: bool, + expired: bool, + error: Option, +} +#[derive(Default)] +struct State { + lease: u32, + generation: u64, + assertion: Option, + expired: bool, + error: Option, + channel: Option>, + timer: Option>, +} +#[derive(Clone, Default)] +pub(crate) struct KeepAwake(Arc>); +const CAP: std::time::Duration = std::time::Duration::from_secs(60 * 60); + +impl State { + fn status(&self) -> Status { + Status { + supported: cfg!(target_os = "macos"), + active: self.assertion.is_some(), + expired: self.expired, + error: self.error.clone(), + } + } + fn emit(&self) { + if let Some(channel) = &self.channel { + let _ = channel.send(self.status()); + } + } + fn cancel_timer(&mut self) { + self.generation = self.generation.wrapping_add(1); + if let Some(timer) = self.timer.take() { + timer.abort(); + } + } + fn release(&mut self) { + self.cancel_timer(); + self.expired = false; + self.error = None; + if let Some(id) = self.assertion { + match release(id) { + Ok(()) => self.assertion = None, + Err(error) => self.error = Some(error), + } + } + } +} +impl KeepAwake { + pub(crate) fn reset(&self) { + if let Ok(mut state) = self.0.lock() { + state.lease = state.lease.wrapping_add(1); + state.release(); + state.channel = None; + } + } + fn open(&self, channel: Channel) -> Result { + let mut state = self.0.lock().map_err(|_| "Power state unavailable")?; + state.release(); + state.lease = state.lease.wrapping_add(1); + state.channel = Some(channel); + state.emit(); + Ok(state.lease) + } + fn set(&self, lease: u32, active: bool) -> Result { + let mut state = self.0.lock().map_err(|_| "Power state unavailable")?; + if lease != state.lease { + return Err("Keep-awake session changed. Reopen Buzz to try again.".into()); + } + state.cancel_timer(); + state.expired = false; + state.error = None; + if !active { + state.release(); + } else if cfg!(target_os = "macos") { + if state.assertion.is_none() { + match acquire() { + Ok(id) => state.assertion = Some(id), + Err(error) => state.error = Some(error), + } + } + if state.assertion.is_some() { + let generation = state.generation; + let host = self.clone(); + let deadline = tokio::time::Instant::now() + CAP; + state.timer = Some(tokio::spawn(async move { + tokio::time::sleep_until(deadline).await; + host.expire(generation); + })); + } + } + state.emit(); + Ok(state.status()) + } + fn expire(&self, generation: u64) { + let Ok(mut state) = self.0.lock() else { + return; + }; + if generation != state.generation || state.assertion.is_none() { + return; + } + // This is the executing timer: do not abort its own task. + state.timer = None; + state.release(); + state.expired = state.assertion.is_none(); + state.emit(); + } +} +#[tauri::command] +pub(crate) fn agent_keep_awake_open( + state: tauri::State<'_, KeepAwake>, + channel: Channel, +) -> Result { + state.open(channel) +} +#[tauri::command] +pub(crate) async fn agent_keep_awake_set( + state: tauri::State<'_, KeepAwake>, + lease: u32, + active: bool, +) -> Result { + state.set(lease, active) +} + +#[cfg(target_os = "macos")] +mod macos { + use std::ffi::{c_char, c_void}; + #[link(name = "IOKit", kind = "framework")] + extern "C" { + pub fn IOPMAssertionCreateWithName( + kind: *const c_void, + level: u32, + name: *const c_void, + id: *mut u32, + ) -> i32; + pub fn IOPMAssertionRelease(id: u32) -> i32; + } + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + pub fn CFStringCreateWithCString( + allocator: *const c_void, + text: *const c_char, + encoding: u32, + ) -> *const c_void; + pub fn CFRelease(value: *const c_void); + } +} +#[cfg(target_os = "macos")] +fn acquire() -> Result { + unsafe { + let kind = macos::CFStringCreateWithCString( + std::ptr::null(), + c"PreventUserIdleSystemSleep".as_ptr(), + 0x0800_0100, + ); + let reason = macos::CFStringCreateWithCString( + std::ptr::null(), + c"Buzz — local agents are working".as_ptr(), + 0x0800_0100, + ); + let mut id = 0; + let result = if kind.is_null() || reason.is_null() { + -1 + } else { + macos::IOPMAssertionCreateWithName(kind, 255, reason, &mut id) + }; + if !kind.is_null() { + macos::CFRelease(kind); + } + if !reason.is_null() { + macos::CFRelease(reason); + } + if result == 0 { + Ok(id) + } else { + Err(format!( + "Couldn’t prevent automatic sleep (system error {result})." + )) + } + } +} +#[cfg(target_os = "macos")] +fn release(id: u32) -> Result<(), String> { + let result = unsafe { macos::IOPMAssertionRelease(id) }; + if result == 0 { + Ok(()) + } else { + Err(format!( + "Couldn’t release sleep prevention (system error {result})." + )) + } +} +#[cfg(not(target_os = "macos"))] +fn acquire() -> Result { + Err("Sleep prevention is available on macOS only.".into()) +} +#[cfg(not(target_os = "macos"))] +fn release(_: u32) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn leases_fence_old_renderers_and_native_cap_releases_real_assertion() { + let host = KeepAwake::default(); + let first = host.open(Channel::new(|_| Ok(()))).unwrap(); + let lease = host.open(Channel::new(|_| Ok(()))).unwrap(); + assert!(host.set(first, true).is_err()); + let status = host.set(lease, true).unwrap(); + if !cfg!(target_os = "macos") { + assert!(!status.supported); + assert!(!status.active); + return; + } + assert!(status.active, "{status:?}"); + let older = host.0.lock().unwrap().generation; + tokio::time::advance(CAP / 2).await; + host.set(lease, true).unwrap(); + tokio::time::advance(CAP / 2).await; + host.expire(older); // An older timer cannot release the refreshed assertion. + assert!(host.0.lock().unwrap().status().active); + tokio::task::yield_now().await; + tokio::time::advance(CAP).await; + tokio::task::yield_now().await; + assert!(host.0.lock().unwrap().status().expired); + assert!(!host.0.lock().unwrap().status().active); + assert!(host.set(lease, true).unwrap().active); + assert!(!host.set(lease, false).unwrap().active); + assert!(host.set(lease, true).unwrap().active); + host.reset(); + assert!(!host.0.lock().unwrap().status().active); + assert!(host.set(lease, true).is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f04427e09..694c4f800f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,8 +52,10 @@ mod jev; #[cfg(any(not(target_os = "macos"), test))] mod microphone; +mod keep_awake; mod notifications; mod os_idle; +use keep_awake::{agent_keep_awake_open, agent_keep_awake_set, KeepAwake}; mod repositories_folder; mod window_controls; mod window_state; @@ -546,6 +548,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen media_copy_image, media_stream_base, get_os_idle_seconds, + agent_keep_awake_open, + agent_keep_awake_set, workspace_pick_folder, repositories_folder_validate, plugin_import_folder, @@ -823,6 +827,7 @@ pub fn run() { .manage(decisions::DecisionsHost::new()) .manage(pairing::Pairing::default()) .manage(Huddles::default()) + .manage(KeepAwake::default()) .manage(HuddleWindow::default()) .manage(relay::Uploads::default()) .register_asynchronous_uri_scheme_protocol("buzz-media", relay::media_protocol) @@ -862,6 +867,7 @@ pub fn run() { eprintln!("OAuth callback cleanup failed: {error}"); } if webview.label() == "main" && matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + webview.state::().reset(); webview.state::().cancel_all(); webview.state::().document_load(); webview.state::().cancel_all(&webview.state::()); @@ -903,6 +909,7 @@ pub fn run() { /// Best-effort native teardown when Buzz exits, from Quit or a fenced sign-out. pub(crate) fn shut_down(app: &tauri::AppHandle) { + app.state::().reset(); app.state::().close(); app.state::() .cancel_all(&app.state::()); diff --git a/src/app/AgentSettings.test.tsx b/src/app/AgentSettings.test.tsx index df4c7feeaa..123dc78a59 100644 --- a/src/app/AgentSettings.test.tsx +++ b/src/app/AgentSettings.test.tsx @@ -78,3 +78,73 @@ it("does not prefill when storage cannot be read", () => { }); expect(rememberAgentsPreference()).toBe(false); }); + +it("places keep-awake beside tagging, starts off, and reflects native work, expiry and failure recovery", async () => { + const { createKeepAwake } = await import("../features/agents/keep-awake"); + let changed = ( + _state: import("../features/agents/keep-awake").PowerState, + ) => {}; + let fail = false; + const keepAwake = createKeepAwake({ + open: async (listener) => { + changed = listener; + return 1; + }, + set: async (_lease, active) => { + if (fail) throw new Error("System refused sleep prevention"); + return { supported: true, active, expired: false }; + }, + }); + const emptyTypes: never[] = []; + const user = userEvent.setup(); + render( + () => {}, + types: () => emptyTypes, + } as unknown as import("../features/agents/service").Agents + } + />, + { + wrapper: ToastProvider, + }, + ); + const toggle = screen.getByRole("switch", { + name: "Keep awake while local agents work", + }); + expect(toggle).not.toBeChecked(); + expect(toggle).toHaveAccessibleDescription( + "Prevent automatic sleep while local agents work on this computer. Displays can still sleep.", + ); + expect(toggle.closest(".buzz-settings-group")).toContainElement( + screen.getByRole("switch", { name: "Remember mentioned agents" }), + ); + await user.click(toggle); + expect(toggle).toBeChecked(); + await screen.findByText(/Waiting for local agent work/); + let work!: ReturnType; + act(() => { + work = keepAwake.begin(new AbortController().signal); + }); + await screen.findByText(/Sleep prevention is active/); + act(() => changed({ supported: true, active: false, expired: true })); + expect(screen.getByText(/paused after one hour/)).toBeInTheDocument(); + fail = true; + act(() => work.pulse()); + expect(await screen.findByRole("dialog")).toHaveTextContent( + "System refused sleep prevention", + ); + fail = false; + await user.click( + screen.getByRole("button", { name: "Retry keep-awake setting" }), + ); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + await act(async () => { + work.finish(); + await keepAwake.dispose(); + }); +}); diff --git a/src/app/AgentSettings.tsx b/src/app/AgentSettings.tsx index 4ff1962ca7..674b81b641 100644 --- a/src/app/AgentSettings.tsx +++ b/src/app/AgentSettings.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { KeepAwakePreference } from "./KeepAwakePreference"; import type { Agents } from "../features/agents/service"; import { AgentDefaults } from "../bundled/agents/AgentDefaults"; import { Header, InlineHeader } from "../shared/design-system/ui/Header"; @@ -36,6 +37,7 @@ export function AgentSettings({ checked={rememberAgents} onCheckedChange={changeRememberAgents} /> + {active && mentionError && ( diff --git a/src/app/KeepAwakePreference.tsx b/src/app/KeepAwakePreference.tsx new file mode 100644 index 0000000000..bf696a8356 --- /dev/null +++ b/src/app/KeepAwakePreference.tsx @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from "react"; +import type { + KeepAwake, + KeepAwakeSnapshot, +} from "../features/agents/keep-awake"; +import { SwitchPreferenceRow } from "../shared/design-system/ui/SwitchPreferenceRow"; +import { ToastNotice } from "../shared/design-system/ui/Toast"; +import { Button } from "../shared/design-system/ui/Button"; + +const unavailable: KeepAwakeSnapshot = Object.freeze({ + supported: false, + enabled: false, + working: false, + active: false, + expired: false, + pending: false, + saveError: null, +}); +const noSubscription = () => () => {}; +const noSnapshot = () => unavailable; + +export function KeepAwakePreference({ + keepAwake, + active, +}: { + keepAwake: KeepAwake | undefined; + active: boolean; +}) { + const state = useSyncExternalStore( + keepAwake?.subscribe ?? noSubscription, + keepAwake?.snapshot ?? noSnapshot, + ); + let description = + "Prevent automatic sleep while local agents work on this computer. Displays can still sleep."; + if (!state.supported) + description = "Available in the Buzz desktop app on macOS."; + else if (state.expired) + description = + "Sleep prevention paused after one hour without agent activity. It resumes when agents work again."; + else if (state.enabled && !state.pending && !state.error) + description += state.active + ? " Sleep prevention is active." + : " Waiting for local agent work."; + return ( + <> + keepAwake?.setEnabled(enabled)} + /> + {active && (state.error || state.saveError) && ( + + + + )} + + ); +} diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index fb4f4d277e..c003705a48 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -29,7 +29,24 @@ import { communityFixture } from "./agents/community-testing"; import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); -vi.mock("@tauri-apps/api/core", () => native); +vi.mock("@tauri-apps/api/core", () => ({ + ...native, + Channel: class { + onmessage = () => {}; + }, + invoke: (command: string, ...args: unknown[]) => { + // Agents owns the power service; it is unavailable in this login fixture. + // Keep Builderlab's native-call assertions scoped to its own dependencies. + if (command === "agent_keep_awake_open") return Promise.resolve(1); + if (command === "agent_keep_awake_set") + return Promise.resolve({ + supported: false, + active: false, + expired: false, + }); + return native.invoke(command, ...args); + }, +})); stubAvatarBrowserApis(); beforeEach(() => { vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 3d44214901..0c38caad82 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -4075,3 +4075,23 @@ it("updates Codex repository context from the agent’s captured community, incl expect(JSON.stringify(turns.at(-1))).toContain("REPOS in your own workspace"); expect(JSON.stringify(turns.at(-1))).not.toContain("/code/codex"); }); + +it("keeps execution through the actual turn after its final reply clears display activity", async () => { + const f = fixture(); + const executingUntil = vi.fn>(); + await f.runtime.run({ ...f.delivery("work then reply"), executingUntil }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + expect(executingUntil).toHaveBeenCalledOnce(); + let ended = false; + const done = Promise.resolve(executingUntil.mock.calls[0]?.[0]).then(() => { + ended = true; + }); + await f.tool("send", { audience: "everyone", text: "Answer", final: true }); + await expect(f.workingUntil.mock.calls[0]?.[0]).resolves.toEqual({ + ok: true, + }); + expect(ended).toBe(false); + await f.complete(0, ""); + await done; + expect(ended).toBe(true); +}); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 963e74ebe4..9402dc2246 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -85,6 +85,7 @@ type Request = { /** Called once a final send answers this request, or one steered after it, * a steering rejection is posted in its place, or the turn ends normally. */ answered: () => void; + executingUntil?: Delivery["executingUntil"]; step?: (tool: string, target?: string) => void; acknowledged?: () => void; superseded?: () => void; @@ -714,6 +715,9 @@ export class CodexRuntime { team: delivery.team ?? "", ...(project ? { project: `${project}\n` } : {}), answered, + ...(delivery.executingUntil + ? { executingUntil: delivery.executingUntil } + : {}), failActivity, ...(delivery.step ? { step: delivery.step } : {}), ...(delivery.acknowledged ? { acknowledged: delivery.acknowledged } : {}), @@ -797,6 +801,7 @@ export class CodexRuntime { `${context}${request.project ?? ""}${turnInput(request.event, request.conversation, text, true, noteOf(request), request.wake)}`, ), }); + request.executingUntil?.(lane.tail); active.reply = request; active.requests.push(request); entry.waiting.delete(request); @@ -1087,6 +1092,7 @@ export class CodexRuntime { await this.execute(request, text, key, current, entry); }); lane.tail = job; + request.executingUntil?.(job); this.status(request.agent.pubkey, key, "Starting"); // Agents can deliver follow-ups and other conversations immediately. void job diff --git a/src/bundled/pi/runtime.test.ts b/src/bundled/pi/runtime.test.ts index 684442ece3..bf0adc2032 100644 --- a/src/bundled/pi/runtime.test.ts +++ b/src/bundled/pi/runtime.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; +import { createKeepAwake } from "../../features/agents/keep-awake"; import type { AddedTool } from "../../buzz-mcp/session-tools"; import { ownerReads } from "../../features/agents/reads"; import type { MemoryEntry } from "../../features/agents/memory"; @@ -1551,3 +1552,82 @@ it("runs a turn at the agent's saved thinking level, apart from a pick", async ( pick: {}, }); }); + +// The real power owner counts recovered work, not just the operation that +// discovers it. Deferred host replies establish which operation finishes first. +it.each([ + { trigger: "mention", ending: "done" }, + { trigger: "compact", ending: "done" }, + { trigger: "mention", ending: "failed" }, + { trigger: "compact", ending: "dispose" }, + { trigger: "mention", ending: "removed" }, +] as const)( + "keeps recovered work past $trigger and releases it when $ending", + async ({ trigger, ending }) => { + const left = { submission: 41, conversation: 5, request: "d".repeat(64) }; + const recovered = deferred(); + const power = createKeepAwake(); + const lifetime = new AbortController(); + const f = setup({ + unfinished: () => ({ + submissions: [{ ...left, context: { channel, root: "other-root" } }], + }), + wait: ({ submission }) => + submission === left.submission + ? recovered.promise + : { status: "done", answer: 2, text: "New answer" }, + }); + Object.assign(f.agent, { execution: () => power.begin(lifetime.signal) }); + try { + if (trigger === "mention") { + const turn = f.deliver({ + type: "mention", + event: reply("5", "@Pi work"), + }); + await turn.run; + await f.settled(turn); + } else { + await f.runtime.session( + { pubkey: self, config: DEFAULT_CONFIG } as unknown as Agent, + { channelId: channel, root: root.id }, + "compact", + ); + } + expect( + f.host + .requests("wait") + .some(({ params }) => params.submission === left.submission), + ).toBe(true); + expect(power.snapshot().working).toBe(true); + const activity = vi.fn(); + const unsubscribe = power.subscribe(activity); + f.runtime.event({ + type: "step", + agent: self, + conversation: left.conversation, + tool: "bash", + }); + expect(activity).toHaveBeenCalledOnce(); + unsubscribe(); + if (ending === "failed") recovered.reject(new Error("Recovered failure")); + else if (ending === "dispose") f.runtime.dispose(); + else if (ending === "removed") f.runtime.sync([]); + else + recovered.resolve({ + status: "done", + answer: 6, + text: "Recovered answer", + }); + await vi.waitFor(() => expect(power.snapshot().working).toBe(false)); + if (ending === "done" || ending === "failed") + expect(f.published.map(({ content }) => content).join("\n")).toContain( + ending === "done" ? "Recovered answer" : "Recovered failure", + ); + } finally { + recovered.resolve({ status: "closed" }); + lifetime.abort(); + f.runtime.dispose(); + await power.dispose(); + } + }, +); diff --git a/src/bundled/pi/runtime.ts b/src/bundled/pi/runtime.ts index c9217f6bfe..3af81b368c 100644 --- a/src/bundled/pi/runtime.ts +++ b/src/bundled/pi/runtime.ts @@ -114,6 +114,8 @@ type Waited = | { status: "unanswered"; reason?: string; detail?: string } | { status: "closed" }; type Receipt = Pick & { + /** Recovered work has no delivery; the runner still owns its power demand. */ + execution?: ReturnType>; /** Its conversation key, which session controls act on. */ key: string; /** Where its activity shows: a thread, or a Solo chat's channel. */ @@ -214,7 +216,11 @@ export class PiRuntime { for (const pubkey of new Set([...this.running, ...this.agents.keys()])) if (!current.has(pubkey)) { const entry = this.agents.get(pubkey); - if (entry) entry.closed = true; + if (entry) { + entry.closed = true; + for (const receipt of entry.receipts.values()) + receipt.execution?.finish(); + } this.agents.delete(pubkey); void this.pi.request("close", { agent: pubkey }).catch(() => undefined); } @@ -391,7 +397,11 @@ export class PiRuntime { } dispose() { - for (const entry of this.agents.values()) entry.closed = true; + for (const entry of this.agents.values()) { + entry.closed = true; + for (const receipt of entry.receipts.values()) + receipt.execution?.finish(); + } this.agents.clear(); void this.pi.dispose(); } @@ -851,6 +861,7 @@ export class PiRuntime { : context.channel, scope: context.topLevel ? context.channel : root, where, + step: () => receipt.execution?.pulse(), }; // A delivery of the same request takes it over, and says how it ended. const ours = () => entry.receipts.get(request) === receipt; @@ -860,32 +871,33 @@ export class PiRuntime { active.set(receipt.scope, receipt); entry.active.set(conversation, active); const sends = entry.sends.get(conversation) ?? 0; - waits.push(() => - this.settle(entry, pubkey, submission) - .then(async (waited) => { - if (!ours()) return; - if (waited.status === "done") - await this.answer( - entry, - handle, - conversation, - { where, sends }, - waited, - ); - else if ( - waited.status === "unanswered" && - waited.reason !== "aborted" - ) - await this.report(handle, where, unanswered(waited)); - }) - .catch(async (error: unknown) => { - if (ours() && !entry.closed) - await this.report(handle, where, errorText(error)); - }) - .finally(() => { - if (ours()) this.forget(entry, request); - }), - ); + waits.push(async () => { + try { + this.live(entry, pubkey); + receipt.execution = handle.execution?.(); + const waited = await this.settle(entry, pubkey, submission); + if (!ours()) return; + if (waited.status === "done") + await this.answer( + entry, + handle, + conversation, + { where, sends }, + waited, + ); + else if ( + waited.status === "unanswered" && + waited.reason !== "aborted" + ) + await this.report(handle, where, unanswered(waited)); + } catch (error) { + if (ours() && !entry.closed) + await this.report(handle, where, errorText(error)); + } finally { + receipt.execution?.finish(); + if (ours()) this.forget(entry, request); + } + }); } // Only once every run's sends are counted: a wait continues all of them, // and one could send while a later run's thread is still looked up. diff --git a/src/features/agents/keep-awake.test.ts b/src/features/agents/keep-awake.test.ts new file mode 100644 index 0000000000..6a6ffe5a0b --- /dev/null +++ b/src/features/agents/keep-awake.test.ts @@ -0,0 +1,190 @@ +import { expect, it, vi } from "vitest"; +import { + createKeepAwake, + type KeepAwakePlatform, + type PowerState, +} from "./keep-awake"; + +function fixture() { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: vi.fn((key: string, value: string) => { + values.set(key, value); + }), + }; + let changed = (_state: PowerState) => {}; + let fail = false; + let gate: Promise | undefined; + const platform: KeepAwakePlatform = { + open: vi.fn(async (listener) => { + changed = listener; + return 1; + }), + set: vi.fn(async (_lease, active) => { + await gate; + if (fail) throw new Error("Power service unavailable"); + return { supported: true, active, expired: false }; + }), + }; + const service = createKeepAwake(platform, storage); + return { + service, + platform, + storage, + values, + changed: (state: PowerState) => changed(state), + fail: (value: boolean) => { + fail = value; + }, + gate: (value?: Promise) => { + gate = value; + }, + }; +} + +it("defaults off, persists the choice, and keeps the assertion until all local execution ends", async () => { + const f = fixture(); + await vi.waitFor(() => expect(f.service.snapshot().pending).toBe(false)); + expect(f.service.snapshot().enabled).toBe(false); + const first = new AbortController(); + const a = f.service.begin(first.signal); + f.service.setEnabled(true); + await vi.waitFor(() => expect(f.service.snapshot().active).toBe(true)); + const b = f.service.begin(new AbortController().signal); + a.finish(); + await vi.waitFor(() => expect(f.service.snapshot().pending).toBe(false)); + expect(f.service.snapshot().active).toBe(true); + b.finish(); + await vi.waitFor(() => expect(f.service.snapshot().active).toBe(false)); + expect(f.values.get("buzz-agent-keep-awake.v1")).toBe("on"); + const restored = createKeepAwake(f.platform, f.storage); + expect(restored.snapshot().enabled).toBe(true); + await restored.dispose(); + await f.service.dispose(); +}); + +it("serializes pending acquisition with disable and retirement, ignores stale activity, and releases on disposal", async () => { + const f = fixture(); + await vi.waitFor(() => expect(f.service.snapshot().pending).toBe(false)); + f.service.setEnabled(true); + await vi.waitFor(() => expect(f.service.snapshot().pending).toBe(false)); + let release!: () => void; + f.gate( + new Promise((resolve) => { + release = resolve; + }), + ); + const controller = new AbortController(); + const work = f.service.begin(controller.signal); + f.service.setEnabled(true); + await vi.waitFor(() => + expect(f.platform.set).toHaveBeenLastCalledWith(1, true), + ); + f.service.setEnabled(false); + controller.abort(); + try { + expect(f.service.snapshot().working).toBe(false); + } finally { + f.gate(); + release(); + } + await vi.waitFor(() => expect(f.service.snapshot().pending).toBe(false)); + expect(f.service.snapshot().active).toBe(false); + const calls = vi.mocked(f.platform.set).mock.calls.length; + work.pulse(); + work.finish(); + expect(vi.mocked(f.platform.set).mock.calls).toHaveLength(calls); + f.service.setEnabled(true); + f.service.begin(new AbortController().signal); + await vi.waitFor(() => expect(f.service.snapshot().active).toBe(true)); + await f.service.dispose(); + expect(f.platform.set).toHaveBeenLastCalledWith(1, false); +}); + +it("native expiry stays paused until real new activity and failures require an observable retry", async () => { + const f = fixture(); + f.service.setEnabled(true); + const work = f.service.begin(new AbortController().signal); + await vi.waitFor(() => expect(f.service.snapshot().active).toBe(true)); + f.changed({ supported: true, active: false, expired: true }); + expect(f.service.snapshot().expired).toBe(true); + work.pulse(); + await vi.waitFor(() => expect(f.service.snapshot().active).toBe(true)); + f.fail(true); + work.pulse(); + await vi.waitFor(() => + expect(f.service.snapshot().error).toBe("Power service unavailable"), + ); + f.fail(false); + f.service.retry(); + await vi.waitFor(() => expect(f.service.snapshot().error).toBeNull()); + expect(f.service.snapshot().active).toBe(true); + f.storage.setItem.mockImplementationOnce(() => { + throw new Error("Disk full"); + }); + f.service.setEnabled(false); + expect(f.service.snapshot().saveError).toContain("could not be saved"); + expect(f.service.snapshot().enabled).toBe(false); + await vi.waitFor(() => expect(f.service.snapshot().active).toBe(false)); + f.service.retry(); + expect(f.values.get("buzz-agent-keep-awake.v1")).toBe("off"); + expect(f.service.snapshot().saveError).toBeNull(); + await f.service.dispose(); +}); + +it("does not acquire when unsupported or when an unreadable preference cannot establish consent", async () => { + const platform: KeepAwakePlatform = { + open: async () => 1, + set: vi.fn(async () => ({ + supported: false, + active: false, + expired: false, + })), + }; + const service = createKeepAwake(platform, { + getItem: () => { + throw new Error("Storage denied"); + }, + setItem: () => {}, + }); + service.begin(new AbortController().signal); + await vi.waitFor(() => expect(service.snapshot().pending).toBe(false)); + expect(service.snapshot()).toMatchObject({ + enabled: false, + supported: false, + active: false, + }); + service.setEnabled(true); + expect(service.snapshot().enabled).toBe(false); + expect(platform.set).not.toHaveBeenCalledWith(1, true); + await service.dispose(); +}); + +it("survives denied access to the localStorage property itself and offers save recovery", async () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get() { + throw new Error("Storage property denied"); + }, + }); + const platform: KeepAwakePlatform = { + open: async () => 1, + set: async (_lease, active) => ({ + supported: true, + active, + expired: false, + }), + }; + try { + const service = createKeepAwake(platform); + expect(service.snapshot().enabled).toBe(false); + service.setEnabled(true); + expect(service.snapshot().saveError).toContain("could not be saved"); + await service.dispose(); + } finally { + if (previous) Object.defineProperty(globalThis, "localStorage", previous); + else Reflect.deleteProperty(globalThis, "localStorage"); + } +}); diff --git a/src/features/agents/keep-awake.ts b/src/features/agents/keep-awake.ts new file mode 100644 index 0000000000..111f18108e --- /dev/null +++ b/src/features/agents/keep-awake.ts @@ -0,0 +1,168 @@ +import { Channel, invoke, isTauri } from "@tauri-apps/api/core"; + +export type PowerState = Readonly<{ + supported: boolean; + active: boolean; + expired: boolean; + error?: string | null; +}>; +export type KeepAwakePlatform = { + open(changed: (state: PowerState) => void): Promise; + set(lease: number, active: boolean): Promise; +}; +export type KeepAwakeSnapshot = PowerState & { + enabled: boolean; + working: boolean; + pending: boolean; + saveError: string | null; +}; +const key = "buzz-agent-keep-awake.v1"; +function nativePlatform(): KeepAwakePlatform | undefined { + if (!isTauri()) return; + return { + open(changed) { + const channel = new Channel(); + channel.onmessage = changed; + return invoke("agent_keep_awake_open", { channel }); + }, + set: (lease, active) => invoke("agent_keep_awake_set", { lease, active }), + }; +} + +/** App-owned execution, not display activity or process presence. Native owns + * the power assertion and its inactivity cap, even if the renderer stops. */ +export function createKeepAwake( + platform = nativePlatform(), + storage?: Pick, +) { + let enabled = false; + try { + storage ??= globalThis.localStorage; + enabled = storage.getItem(key) === "on"; + } catch { + // Missing or unreadable preference never keeps a computer awake. + } + let state: KeepAwakeSnapshot = Object.freeze({ + supported: !!platform, + active: false, + expired: false, + enabled, + working: false, + pending: !!platform, + saveError: null, + }); + const listeners = new Set<() => void>(); + const work = new Set<() => void>(); + let closed = false; + let lease: number | undefined; + let revision = 0; + let applied = -1; + let writing: Promise | undefined; + const publish = (patch: Partial) => { + state = Object.freeze({ ...state, ...patch }); + if (!closed) for (const listener of listeners) listener(); + }; + const failure = (error: unknown) => + error instanceof Error ? error.message : String(error); + const wanted = () => !closed && state.enabled && work.size > 0; + function project() { + if (!platform || lease === undefined || writing || applied === revision) + return; + writing = (async () => { + do { + const attempted = revision; + publish({ pending: !closed }); + try { + const power = await platform.set(lease as number, wanted()); + publish({ ...power, error: power.error ?? null, pending: false }); + } catch (error) { + publish({ error: failure(error), pending: false }); + } + // No automatic retry on failure. New work or explicit Retry may retry. + applied = attempted; + } while (applied !== revision); + })().finally(() => { + writing = undefined; + if (applied !== revision) project(); + }); + } + const changed = () => { + revision++; + publish({ working: work.size > 0 }); + project(); + }; + let opening: Promise | undefined; + function open() { + if (!platform || opening || lease !== undefined) return; + opening = platform + .open((power) => { + if (!closed) publish(power); + }) + .then((value) => { + lease = value; + project(); + }) + .catch((error) => publish({ error: failure(error), pending: false })) + .finally(() => { + opening = undefined; + }); + } + open(); + return { + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + setEnabled(value: boolean) { + if (closed || !state.supported) return; + let saveError: string | null = null; + try { + if (!storage) throw new Error("Storage unavailable"); + storage.setItem(key, value ? "on" : "off"); + if (storage.getItem(key) !== (value ? "on" : "off")) + throw new Error("Preference was not retained"); + } catch { + saveError = + "This choice is active, but could not be saved on this device. Try again."; + } + publish({ enabled: value, saveError }); + open(); + changed(); + }, + retry() { + this.setEnabled(state.enabled); + }, + /** Covers one real run or handed-off operation. Retirement always releases + * its demand; duplicate or late callbacks cannot recreate it. */ + begin(signal: AbortSignal) { + const finish = () => { + signal.removeEventListener("abort", finish); + if (work.delete(finish)) changed(); + }; + if (!closed && !signal.aborted) { + work.add(finish); + signal.addEventListener("abort", finish, { once: true }); + changed(); + } + return { + finish, + pulse() { + if (work.has(finish)) changed(); + }, + }; + }, + async dispose() { + closed = true; + for (const finish of [...work]) finish(); + changed(); + listeners.clear(); + await opening; + project(); + while (writing) await writing; + }, + }; +} +export type KeepAwake = ReturnType; diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 175abb22d2..cad22e6f17 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -1,3 +1,4 @@ +import type { KeepAwake } from "./keep-awake"; // The agent runner: one queue per agent, its rate limit, mention and watch // matching, due timers, the run deadline and the claim that lets exactly one // host run each agent. It knows nothing of where it runs: everything it needs @@ -212,6 +213,8 @@ type Runner = { /** The registration whose `run` it calls; a new one aborts the in-flight run. */ type: RegisteredAgentType | undefined; controller: AbortController; + /** Power demand ends when native custody changes, without cancelling work. */ + execution: AbortController; /** Events waiting to run in each lane, mentions first; due timers are found * when a run starts instead. */ readonly queues: Record; @@ -409,6 +412,7 @@ export type RunnerHost = { | undefined; /** This device's view of each agent's work. */ activity: LocalAgentActivity; + execution?: Pick; /** Whether a mention in `channel` continues the agent's owner's Solo chat, * so its turn answers, and shows typing, at channel top level. */ soloChat( @@ -612,7 +616,14 @@ export class AgentRunners { throw new Error( "Use session controls in the Buzz app that runs this agent.", ); - return change(); + const work = this.host.execution?.begin( + AbortSignal.any([runner.controller.signal, runner.execution.signal]), + ); + try { + return await change(); + } finally { + work?.finish(); + } } /** Runs `change` while a change of type holds the agent's idle runner: no * turn starts meanwhile, and its queue resumes after. Refused while a turn @@ -659,6 +670,7 @@ export class AgentRunners { pubkey: identity.pubkey, type, controller: new AbortController(), + execution: new AbortController(), queues: { mention: [], watch: [] }, running: false, rates: { @@ -1610,6 +1622,9 @@ export class AgentRunners { // Stopped or changed while its context was looked up: nothing is sent. if (!live()) return undefined; const generation = this.host.classifierKeyGeneration(); + const work = this.host.execution?.begin( + AbortSignal.any([runner.controller.signal, runner.execution.signal]), + ); const { pass, result, backend } = await classify(decisions, { classifier, event, @@ -1618,7 +1633,7 @@ export class AgentRunners { name: this.host.displayName(agent, event.pubkey), }, context, - }); + }).finally(() => work?.finish()); // On the `typesafe` backend: the key was removed outside the app since it // was read, the service refused it, or native found one the failed status // read could not. Other backends say nothing about the key. @@ -1983,6 +1998,7 @@ export class AgentRunners { ]); const handle = this.handleFor(runner, agent, signal); const working = this.working(runner, job, agent); + const execution = this.executing(runner); const trigger = job.trigger; const named = trigger.type === "mention" @@ -2004,14 +2020,21 @@ export class AgentRunners { ...pick, ...(team ? { team } : {}), signal, - workingUntil: working.hold, + workingUntil: (done: PromiseLike) => { + working.hold(done); + execution.hold(done); + }, + executingUntil: execution.hold, ignored: () => job.work?.remove(), acknowledged: () => job.work?.started(), superseded: () => job.work?.supersede(), interruptible: (stop: () => Promise) => job.work?.interruptible(stop), ...(named ? { named } : {}), - step: (tool: string, target?: string) => job.work?.step(tool, target), + step: (tool: string, target?: string) => { + job.work?.step(tool, target); + execution.pulse(); + }, current: () => !lifetime.aborted && this.stillCurrent(runner, job), }); const { trigger } = job; @@ -2031,6 +2054,7 @@ export class AgentRunners { ), ); }); + void completion.then(execution.release, execution.release); // Handover expiry releases the queue, never the actual work's outcome. void completion.then( () => working.release(), @@ -2081,6 +2105,16 @@ export class AgentRunners { pubkey: agent.pubkey, name: agent.name, owner: agent.owner, + execution: () => { + if ( + this.runners.get(runner.pubkey) !== runner || + !this.runs(runner.pubkey) + ) + throw new Error("This copy of Buzz no longer runs the agent."); + return this.host.execution?.begin( + AbortSignal.any([runner.controller.signal, runner.execution.signal]), + ); + }, publish: async (event: AgentEventTemplate) => this.publish(runner, event), query: async (filters: readonly object[]) => this.require().query(agent.pubkey, filters), @@ -2146,6 +2180,26 @@ export class AgentRunners { }); } + /** All executions, including attention turns with no message-display row. + * A full-turn hold outlives a reply without changing the reply lifecycle. */ + private executing(runner: Runner) { + const work = this.host.execution?.begin( + AbortSignal.any([runner.controller.signal, runner.execution.signal]), + ); + let holds = 1; + const release = () => { + if (--holds === 0) work?.finish(); + }; + return { + release, + pulse: () => work?.pulse(), + hold(done: PromiseLike) { + holds++; + void Promise.resolve(done).then(release, release); + }, + }; + } + /** Counts a run as working until it and everything it handed to * `workingUntil` have settled, however long that takes. A * mention run is shown typing in its thread meanwhile, from its first @@ -2254,6 +2308,9 @@ export class AgentRunners { private claimed(runner: Runner, held: boolean) { const before = this.runs(runner.pubkey); runner.mayHold = held; + if (!held) runner.execution.abort(); + else if (runner.execution.signal.aborted) + runner.execution = new AbortController(); runner.asked = true; this.heldChanged(runner, before); if (before !== this.runs(runner.pubkey)) this.host.ownershipChanged?.(); diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 5b4d445bf9..d36172b211 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -7110,3 +7110,93 @@ it("gives a run its owner's channel templates with the owner's agents each adds" { name: "Launch", canvas: "# Plan", agents: [bot, sibling] }, ]); }); + +it("accounts for local execution independently of reply display, across handoffs and attention turns", async () => { + const f = await setup(); + const power = f.service.keepAwake; + await f.service.create({ type: "example/echo", name: "Echo" }); + let reply!: () => void; + let turn!: () => void; + const replied = new Promise((resolve) => { + reply = resolve; + }); + const ended = new Promise((resolve) => { + turn = resolve; + }); + f.run.mockImplementationOnce((delivery) => { + delivery.workingUntil(replied); + delivery.executingUntil?.(ended); + }); + f.emit({ + events: [event("power-mention", { tags: [["p", bot]] })], + channelId: channel, + }); + try { + await vi.waitFor(() => expect(f.run).toHaveBeenCalledOnce()); + expect(power.snapshot().working).toBe(true); + reply(); + await vi.waitFor(() => + expect(f.activity.reader.work()[0]?.state).toBe("done"), + ); + expect(power.snapshot().working).toBe(true); + turn(); + await vi.waitFor(() => expect(power.snapshot().working).toBe(false)); + await f.service.save(bot, { attentionEnabled: true }); + let finish!: () => void; + f.run.mockImplementationOnce(({ workingUntil }) => { + workingUntil( + new Promise((resolve) => { + finish = resolve; + }), + ); + }); + f.emit({ events: [deploy("power-watch")] }); + await vi.waitFor(() => expect(f.run).toHaveBeenCalledTimes(2)); + expect(power.snapshot().working).toBe(true); + expect( + f.activity.reader.work().some((row) => row.state === "working"), + ).toBe(false); + // Retiring the actual runner releases demand even if a provider settles late. + await f.ctx.fiber.dispose(); + expect(power.snapshot().working).toBe(false); + finish(); + } finally { + reply(); + turn(); + } +}); + +it("fences recovered execution by custody and runner lifetime without a delivery", async () => { + const f = await setup(); + await f.service.create({ type: "example/echo", name: "Echo" }); + await vi.waitFor(() => expect(f.plugin.agents.handle(bot)).toBeDefined()); + const handle = f.plugin.agents.handle(bot); + const power = f.service.keepAwake; + const first = handle?.execution?.(); + try { + expect(power.snapshot().working).toBe(true); + first?.finish(); + expect(power.snapshot().working).toBe(false); + const recovered = handle?.execution?.(); + expect(power.snapshot().working).toBe(true); + f.connect(false); + await vi.waitFor(() => expect(f.native.release).toHaveBeenCalledWith(bot)); + expect(power.snapshot().working).toBe(false); + recovered?.pulse(); + expect(power.snapshot().working).toBe(false); + expect(() => handle?.execution?.()).toThrow("ask for its handle again"); + f.connect(true); + await vi.waitFor(() => expect(f.plugin.agents.handle(bot)).toBeDefined()); + const fresh = f.plugin.agents.handle(bot)?.execution?.(); + expect(power.snapshot().working).toBe(true); + recovered?.finish(); + expect(power.snapshot().working).toBe(true); + await f.ctx.fiber.dispose(); + expect(power.snapshot().working).toBe(false); + fresh?.pulse(); + expect(power.snapshot().working).toBe(false); + } finally { + first?.finish(); + await f.ctx.fiber.dispose(); + } +}); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 611fe8e762..900718bc94 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1,3 +1,4 @@ +import { createKeepAwake, type KeepAwake } from "./keep-awake"; import { createLocalAgentActivity } from "./local-activity"; import { AgentConfigStore, @@ -294,6 +295,9 @@ export type AgentHandle = Readonly<{ pubkey: string; name: string; owner: string; + /** Counts recovered local work outside a delivery. Finish on settlement; + * custody loss or runner retirement releases it even if the harness waits. */ + execution?(): ReturnType | undefined; publish(event: AgentEventTemplate): Promise; /** Reads the community as the agent, seeing only what it may see. */ query(filters: readonly object[]): Promise; @@ -467,6 +471,9 @@ export type Delivery = Readonly<{ /** Keeps the run counted as working after `run` returns, until `done` * settles, for a type that hands its work off and returns early. */ workingUntil(done: PromiseLike): void; + /** Execution may continue after its reply is displayed (Codex). This hold + * prevents idle sleep without extending typing, reply activity or lane locks. */ + executingUntil?(done: PromiseLike): void; /** Optional presentation evidence supplied by the harness, not inferred by the UI. */ step?(tool: string, target?: string): void; /** The plugin declined this request; no work or successful outcome is implied. */ @@ -780,6 +787,7 @@ export type NewAgent = Readonly<{ config?: unknown; }>; export type Agents = { + readonly keepAwake?: KeepAwake; register(type: AgentType): void; types(): readonly RegisteredAgentType[]; /** Adds a tool to every agent session in the app, beside the Buzz tools. @@ -1138,6 +1146,7 @@ export class AgentsService extends Service implements Agents { private state: AgentsSnapshot; /** By origin, so each agent listens in its own community, selected or not. */ private bound = new Map(); + readonly keepAwake = createKeepAwake(); private readonly runners: AgentRunners; private readonly coreMemory: CoreMemoryCache; /** Counts list requests, so only the latest one is applied. */ @@ -1353,6 +1362,7 @@ export class AgentsService extends Service implements Agents { displayName: (agent, pubkey) => this.displayName(agent, pubkey), custody: native, activity: localActivity, + execution: this.keepAwake, soloChat: (agent, channel, signal) => soloChat(() => this.relay(agent.pubkey), agent.owner, channel, signal), remembered: (pubkey, slug, body) => @@ -1409,6 +1419,11 @@ export class AgentsService extends Service implements Agents { for (const stop of stops) stop(); for (const origin of [...this.bound.keys()]) this.unbind(origin); this.runners.clear(); + void this.keepAwake + .dispose() + .catch((error) => + console.warn("Couldn’t release agent sleep prevention", error), + ); this.presence.clear(); // Clear what is shown without reconciling, which would make new runners. this.state = Object.freeze({ diff --git a/tests/browser/buzz-links.spec.mjs b/tests/browser/buzz-links.spec.mjs index 3c7424929e..ecef2b3dab 100644 --- a/tests/browser/buzz-links.spec.mjs +++ b/tests/browser/buzz-links.spec.mjs @@ -222,13 +222,13 @@ test("a message link's context menu opens its thread as a tab without leaving th ); const link = row.getByRole("link", { name: "Beta", exact: true }); await expect(link).toBeVisible(); - await virtuaIdle(page); const before = (await state(page)).entry; - // Target the link itself instead of a cached coordinate: under load WebKit can - // spend the first pointer move opening the preview and toolbar, shifting the - // actionable point before the contextmenu event. The test is about the link's - // composed context-menu owner, not stale coordinate hit-testing. - await link.click({ button: "right" }); + // Exercise the composed menu/tab action by keyboard. A pointer gesture can + // cross Virtua's scroll hold while the preview and message toolbar appear; + // an idle check before that gesture cannot establish its eventual hit target. + await link.focus(); + await expect(link).toBeFocused(); + await link.press("Shift+F10"); const menu = page.getByRole("menu"); await expect(menu.getByRole("menuitem")).toHaveText([ "Open link", diff --git a/tests/browser/channel-members-focus.spec.mjs b/tests/browser/channel-members-focus.spec.mjs index f11bc59072..6307f5fbfa 100644 --- a/tests/browser/channel-members-focus.spec.mjs +++ b/tests/browser/channel-members-focus.spec.mjs @@ -270,6 +270,19 @@ test("inline member identity geometry and separate addition focus", async ({ await search.hover(); await expect(memberKey).toBeHidden(); await expect(memberMetadata).toHaveCSS("height", "0px"); + // Establish the dialog's final position before recording exact geometry. + // Visibility does not mean its entrance transform has finished. + await dialog.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .filter( + (animation) => + animation.effect?.getComputedTiming().iterations !== Infinity, + ) + .map((animation) => animation.finished), + ); + }); const nameBoundsBeforeReveal = await memberName.boundingBox(); const rowBoundsBeforeReveal = await memberRow.boundingBox(); const avatarBoundsBeforeReveal = await memberAvatar.boundingBox();