From 37e0805572f367ee47179d2be8e9f7be2c9f36a3 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Wed, 21 Oct 2026 10:30:00 -0400 Subject: [PATCH 1/5] Keep awake while local agents work Add an off-by-default macOS idle-sleep preference tied to actual local agent execution, with a native inactivity cap and explicit recovery. Preserve display sleep and keep Codex execution distinct from reply display. Co-authored-by: Goose Signed-off-by: Clay Delk --- docs/agents.md | 29 +++ src-tauri/build.rs | 2 + src-tauri/capabilities/default.json | 2 + src-tauri/src/browser_permissions_tests.rs | 2 + src-tauri/src/keep_awake.rs | 248 +++++++++++++++++++++ src-tauri/src/lib.rs | 7 + src/app/AgentSettings.test.tsx | 70 ++++++ src/app/AgentSettings.tsx | 2 + src/app/KeepAwakePreference.tsx | 65 ++++++ src/bundled/codex/runtime.test.ts | 20 ++ src/bundled/codex/runtime.ts | 6 + src/features/agents/keep-awake.test.ts | 190 ++++++++++++++++ src/features/agents/keep-awake.ts | 168 ++++++++++++++ src/features/agents/runner.ts | 55 ++++- src/features/agents/service.test.ts | 55 +++++ src/features/agents/service.ts | 12 + 16 files changed, 929 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/keep_awake.rs create mode 100644 src/app/KeepAwakePreference.tsx create mode 100644 src/features/agents/keep-awake.test.ts create mode 100644 src/features/agents/keep-awake.ts diff --git a/docs/agents.md b/docs/agents.md index 0e14d4efb9..f82e4314fc 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -221,6 +221,35 @@ 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. 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 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 ac1610305c..fcdcc11fdb 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -114,6 +114,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 7220fb611a..c1048e5256 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,8 +51,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; @@ -545,6 +547,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, @@ -807,6 +811,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) @@ -846,6 +851,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::()); @@ -887,6 +893,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/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/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..a065250bd3 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(), @@ -2146,6 +2170,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 +2298,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..11819decdd 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -7110,3 +7110,58 @@ 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(); + } +}); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 611fe8e762..f71bad7c54 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, @@ -467,6 +468,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 +784,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 +1143,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 +1359,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 +1416,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({ From 83c74bb5d3dee68a7a39a390cf0703f92d553717 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Wed, 21 Oct 2026 15:02:15 -0400 Subject: [PATCH 2/5] Model power IPC in Builderlab login tests Keep the shared Agents power service outside the login fixture's native-call assertions without weakening its authentication checks. Co-authored-by: Goose Signed-off-by: Clay Delk --- src/bundled/builderlab/index.test.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index e3f2de78b1..db34954480 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"); From 4764774be3a0fba384906f632bd86e6c85756f1b Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 23 Oct 2026 09:37:49 -0400 Subject: [PATCH 3/5] Count recovered Pi turns as local execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep recovered waits under the runner’s existing lifetime and custody fences, independent of the delivery or session control that found them. Renew demand on harness steps and release it on settlement or retirement. Co-authored-by: Goose Signed-off-by: Clay Delk --- docs/agents.md | 10 ++-- src/bundled/pi/runtime.test.ts | 80 +++++++++++++++++++++++++++++ src/bundled/pi/runtime.ts | 68 ++++++++++++++---------- src/features/agents/runner.ts | 10 ++++ src/features/agents/service.test.ts | 35 +++++++++++++ src/features/agents/service.ts | 3 ++ 6 files changed, 175 insertions(+), 31 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index f82e4314fc..ec0ce1539c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -234,8 +234,12 @@ 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. Retiring a runner or losing -its custody releases its demand without changing existing turn-stop semantics. +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 @@ -246,7 +250,7 @@ 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 runtime tests, `app/AgentSettings.test.tsx`, native `keep_awake::tests`, +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. 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/runner.ts b/src/features/agents/runner.ts index a065250bd3..cad22e6f17 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -2105,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), diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 11819decdd..d36172b211 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -7165,3 +7165,38 @@ it("accounts for local execution independently of reply display, across handoffs 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 f71bad7c54..900718bc94 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -295,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; From 6296b2ba6821af8d0d9e863ac965330398144c35 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 23 Oct 2026 09:38:20 -0400 Subject: [PATCH 4/5] Stabilize menu and member geometry journeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record member bounds after the dialog’s entrance animations finish. Use the existing keyboard menu path for the thread-tab action journey, which does not assert pointer hit-testing during Virtua’s scroll hold. Keep exact geometry assertions and all browser cases unchanged. Co-authored-by: Goose Signed-off-by: Clay Delk --- tests/browser/buzz-links.spec.mjs | 12 ++++++------ tests/browser/channel-members-focus.spec.mjs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/browser/buzz-links.spec.mjs b/tests/browser/buzz-links.spec.mjs index aa9413ad65..ca59f60232 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(); From 8de82ea681286f8b221d3975bf9a48277fcfc9ba Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Fri, 23 Oct 2026 11:15:24 -0400 Subject: [PATCH 5/5] Bump tokio to 1.53.3 for RUSTSEC-2026-0386 (#1581) Signed-off-by: John Tennant Co-authored-by: Oliver Co-authored-by: Claude Opus 5.5 (cherry picked from commit 6fc1d18b1ee64d9000cc888571aa9cf2975c11e9) Signed-off-by: Clay Delk Co-authored-by: Goose --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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",