mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
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:
1 parent
80394f027c
commit
919d60232b
4 files changed
+200
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user