Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/browser_permissions_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
248 changes: 248 additions & 0 deletions src-tauri/src/keep_awake.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}
#[derive(Default)]
struct State {
lease: u32,
generation: u64,
assertion: Option<u32>,
expired: bool,
error: Option<String>,
channel: Option<Channel<Status>>,
timer: Option<tokio::task::JoinHandle<()>>,
}
#[derive(Clone, Default)]
pub(crate) struct KeepAwake(Arc<Mutex<State>>);
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<Status>) -> Result<u32, String> {
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<Status, String> {
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<Status>,
) -> Result<u32, String> {
state.open(channel)
}
#[tauri::command]
pub(crate) async fn agent_keep_awake_set(
state: tauri::State<'_, KeepAwake>,
lease: u32,
active: bool,
) -> Result<Status, String> {
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<u32, String> {
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<u32, String> {
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());
}
}
7 changes: 7 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -546,6 +548,8 @@ fn commands<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -> 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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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::<KeepAwake>().reset();
webview.state::<pairing::Pairing>().cancel_all();
webview.state::<NsecBackupHost>().document_load();
webview.state::<relay::Spools>().cancel_all(&webview.state::<relay::Uploads>());
Expand Down Expand Up @@ -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<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
app.state::<KeepAwake>().reset();
app.state::<NsecBackupHost>().close();
app.state::<relay::Spools>()
.cancel_all(&app.state::<relay::Uploads>());
Expand Down
Loading
Loading