feat(tui): hold the host's idle-sleep assertion while a turn is in flight

Issue #2990 ("Active turn dies ... when the computer sleeps") was fixed in
v0.8.57 by detecting the suspend on wake and re-issuing the request
(`core::engine::streaming::sleep_gap_detected`). That survives a suspend; it
does not stop one. An unattended machine still idles into sleep mid-turn, and
a turn that outlives the idle timer is lost work with no error line.

This holds the platform's idle-sleep assertion for exactly as long as a turn:

- macOS: `caffeinate -i`
- Linux: `systemd-inhibit --what=idle --why="Codewhale turn in flight"
  --mode=block sleep infinity`
- other Unix: no inhibitor this module knows
- Windows: not implemented, deliberately. `SetThreadExecutionState` is
  thread-affine — the release has to happen on the thread that set it, which a
  guard travelling with a turn cannot promise. An untested holder that might
  never release would keep a laptop awake forever, which is worse than the
  problem this solves.

Release is `Drop`, and no guard is ever cached: a leaked inhibitor is worse
than the sleep it prevents. The guard rides the existing
`terminal_chrome_enabled` gate — the same one that already decides host-facing
chrome — so an interactive TUI turn holds it while headless hosts (`exec`,
app-server, CI) never do. No new config key; a dedicated `[tui]` opt-out is
stated as not-implemented in the module and in docs.

What it does not do, recorded next to the behaviour in
`docs/ENVIRONMENTS.md`: it does not defeat an explicit `sleep` / `pmset
sleepnow`, a closed lid, or a low battery, and it cannot run while the host is
suspended.

Verification (macOS aarch64, this worktree):

- The mechanism at the OS level, which is the part my code depends on:
  `pmset -g assertions` reports `PreventUserIdleSystemSleep` 0 -> 1 while a
  `caffeinate -i` is held. (The host also carries an unrelated
  `caffeinate -s -w 4908` from the user's own `deeprich` supervisor, which is
  why no sleep events appear in `pmset -g log`; that process is not ours.)
- `scripts/dev-test.sh crates/tui/src/sleep_guard.rs sleep_guard`
  -> `Summary [0.030s] 2 tests run: 2 passed`: the inhibitor is alive while the
  guard lives and gone after the drop — asserted through `kill(pid, 0)`, so the
  test cannot perturb the process it measures — and two guards own two
  independent processes, so the first drop releases only its own.
- `turn_loop` -> `67 tests run: 67 passed`; `engine::tests::turn`
  -> `20 tests run: 20 passed`.
- `cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` exit 0 · `cargo fmt --all -- --check` exit 0
- `check-blocking-calls-budget.py` PASS · `check-dead-code-budget.py` PASS

Not verified: Windows (no implementation) and the interactive TUI end to end —
the guard is exercised at its own boundary, and a real turn needs a provider.
This commit is contained in:
CodeWhale Bot committed 2026-09-18 20:48:52 -07:00
1 parent 80394f027c
commit 919d60232b
4 files changed
+200

No files matched your search

+9
View File
@@ -688,6 +688,15 @@ impl Engine {
// Only interactive TUI hosts own terminal chrome. Headless exec,
// app-server, and stream-json stdout must remain byte-clean.
//
// The sleep guard rides the same gate: a turn that outlives the host's
// idle timer is lost work, and an interactive host is the only one
// that owns a human's machine. Bound to this function, so it releases
// on every return path. See `crate::sleep_guard` for its limits.
let _sleep_guard = self
.config
.terminal_chrome_enabled
.then(crate::sleep_guard::SleepGuard::hold);
if self.config.terminal_chrome_enabled {
crate::tui::notifications::set_taskbar_progress_busy();
crate::tui::notifications::start_title_animation("codewhale");
+1
View File
@@ -132,6 +132,7 @@ mod settings;
mod shell_dispatcher;
mod skill_state;
mod skills;
mod sleep_guard;
mod snapshot;
mod startup_trace;
mod task_manager;
+169
View File
@@ -0,0 +1,169 @@
//! Keep the host from idling into sleep while a turn is in flight.
//!
//! A suspended host cannot run the engine, so nothing here survives a real
//! suspend — `core::engine::streaming::sleep_gap_detected` already reports that
//! case and the engine re-issues the request (issue #2990). What this module
//! prevents is the avoidable one: an unattended machine idling into sleep in
//! the middle of a turn, which is how a long turn gets lost with no error at
//! all.
//!
//! Scope, stated so nobody expects more than it does:
//!
//! - It holds the platform's *idle-sleep* assertion only. An explicit `sleep`/
//! `pmset sleepnow`, a closed lid, or a low battery still wins — refusing
//! those is the machine owner's call, not a running task's.
//! - It is held for the duration of a turn and released on drop, so the host's
//! power behaviour outside a turn is untouched.
//! - It follows the same gate as the rest of the host-facing chrome
//! (`EngineConfig::terminal_chrome_enabled`): an interactive TUI turn holds
//! it, while headless hosts — `exec`, app-server, CI — never do. A dedicated
//! `[tui]` opt-out key is not implemented yet; the headless gate is the
//! escape hatch today.
//! - Windows is not implemented. `SetThreadExecutionState` is thread-affine —
//! the release has to happen on the thread that set it, which a guard that
//! travels with a turn cannot promise. Rather than ship an untested holder
//! that might silently never release, this is a no-op there for now.
//!
//! Release is `Drop` and never cached: a leaked inhibitor would keep a laptop
//! awake forever, which is worse than the problem this solves.
use std::process::{Child, Command, Stdio};
/// An idle-sleep assertion held for as long as this value lives.
pub struct SleepGuard {
/// The platform inhibitor process, when one was started. `None` means the
/// platform has no implementation, or the process could not be started —
/// keeping the host awake is best-effort and must never fail a turn.
#[cfg(unix)]
child: Option<Child>,
}
impl SleepGuard {
/// Hold the host awake until the returned guard drops.
#[must_use]
pub fn hold() -> Self {
#[cfg(unix)]
{
Self {
child: start_inhibitor(),
}
}
#[cfg(not(unix))]
{
Self {}
}
}
/// The inhibitor's process id, for diagnostics and tests. Absent when the
/// platform is a no-op or the process did not start.
#[cfg(all(test, unix))]
pub(crate) fn inhibitor_pid(&self) -> Option<u32> {
self.child.as_ref().map(Child::id)
}
}
#[cfg(unix)]
impl Drop for SleepGuard {
fn drop(&mut self) {
let Some(child) = self.child.as_mut() else {
return;
};
// Killing the inhibitor is what releases the assertion; reaping it
// keeps a zombie out of the process table.
let _ = child.kill();
let _ = child.wait();
}
}
/// `-i` prevents idle sleep. Without `-t` caffeinate runs until it is killed,
/// which is what `Drop` does; macOS releases the assertion with the process.
#[cfg(target_os = "macos")]
fn start_inhibitor() -> Option<Child> {
spawn("caffeinate", &["-i"])
}
/// `--what=idle` only: an explicit suspend or a closed lid is still honoured.
/// `sleep infinity` is the command whose lifetime holds the block open.
#[cfg(target_os = "linux")]
fn start_inhibitor() -> Option<Child> {
spawn(
"systemd-inhibit",
&[
"--what=idle",
"--why=Codewhale turn in flight",
"--mode=block",
"sleep",
"infinity",
],
)
}
/// Everything else Unix (BSD, illumos, …) has no inhibitor this module knows.
#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))]
fn start_inhibitor() -> Option<Child> {
None
}
#[cfg(unix)]
fn spawn(program: &str, args: &[&str]) -> Option<Child> {
Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// Whether the process is still there. `kill(pid, 0)` asks the kernel
/// without touching the process, so this cannot perturb the guard.
fn alive(pid: u32) -> bool {
// SAFETY: signal 0 performs the permission/existence check only.
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[test]
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn the_inhibitor_lives_exactly_as_long_as_the_guard() {
let guard = SleepGuard::hold();
let pid = guard
.inhibitor_pid()
.expect("this platform starts an inhibitor");
assert!(alive(pid), "the inhibitor must be running while held");
drop(guard);
// Reaping is synchronous in `Drop`, so the pid is gone immediately —
// and if it were reused by a new process in this window the test would
// be racing itself, which is why we assert on the guard's own child.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while alive(pid) {
assert!(
std::time::Instant::now() < deadline,
"a released guard must not leave an inhibitor keeping the host awake"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn holding_twice_holds_two_independent_inhibitors() {
// Turns are serialized, but nothing here should assume it: two guards
// must not share one process, or the first drop would release both.
let first = SleepGuard::hold();
let second = SleepGuard::hold();
let (a, b) = (
first.inhibitor_pid().expect("first inhibitor"),
second.inhibitor_pid().expect("second inhibitor"),
);
assert_ne!(a, b, "each guard owns its own inhibitor process");
drop(first);
assert!(!alive(a), "the first guard released only its own");
assert!(alive(b), "the second guard still holds the host awake");
}
}
+21
View File
@@ -37,6 +37,27 @@ CODEWHALE_PROVIDER=vllm VLLM_BASE_URL=http://127.0.0.1:8000/v1 VLLM_MODEL=<id> \
`codewhale exec` (add `--auto` for tool use) is the non-interactive path to
exercise the full agent loop.
## Keeping the host awake during a turn
While an interactive TUI turn is in flight, Codewhale holds the platform's
idle-sleep assertion, so an unattended machine does not idle into sleep
mid-turn and lose the work:
- macOS: `caffeinate -i`
- Linux: `systemd-inhibit --what=idle --why="Codewhale turn in flight" --mode=block sleep infinity`
The assertion is released the moment the turn ends, and it covers *idle* sleep
only: an explicit `sleep` / `pmset sleepnow`, a closed lid, or a low battery
still suspends the machine. Headless hosts — `exec`, app-server, CI — never
hold it, so a shared runner's power policy is untouched. Windows is not
implemented: `SetThreadExecutionState` is thread-affine and needs a holder that
pins the thread, so the gap is deliberate rather than silent.
If a turn is suspended anyway, the engine notices on wake — wall-clock elapsed
diverging from monotonic elapsed by more than the suspend threshold — reports
`System sleep detected; connection lost — retrying request`, and re-issues the
request instead of failing the turn (#2990).
## Consolidated runtime commands
The current `codewhale` binary runs the TUI in-process. Release installers copy