idle: persist timeout, suppress restart fire, add debug/save/idle-timeout commands
Several idle timer fixes and new inspection capabilities: - Persist idle_timeout across daemon restarts (was reverting to 5min default) - Set fired=true on load to suppress immediate fire from stale timestamps - Add human-readable ISO timestamps to daemon-state.json for debugging - Use to_string_pretty for readable state file - Make save() public for RPC access - Remove kb_idle_minutes() — go purely off message timestamps - Add maybe_prompt_notification() with idle gate so notifications only inject via tmux when truly idle, not during active sessions - Add debug_json() for full state inspection with computed values (would_fire, block_reason, all timers) New RPC commands (schema @16-18): poc-daemon idle-timeout <secs> — set idle timeout poc-daemon save — force state persistence poc-daemon debug — dump full internal state as JSON Also: save state on clean shutdown, route module notifications through maybe_prompt_notification before submitting to queue. Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
parent
8662759d53
commit
eab656aa64
4 changed files with 207 additions and 30 deletions
|
|
@ -61,6 +61,10 @@ interface Daemon {
|
||||||
getTypes @13 () -> (types :List(TypeInfo));
|
getTypes @13 () -> (types :List(TypeInfo));
|
||||||
setThreshold @14 (type :Text, level :UInt8) -> ();
|
setThreshold @14 (type :Text, level :UInt8) -> ();
|
||||||
|
|
||||||
|
idleTimeout @16 (seconds :Float64) -> ();
|
||||||
|
save @17 () -> ();
|
||||||
|
debug @18 () -> (json :Text);
|
||||||
|
|
||||||
# Modules
|
# Modules
|
||||||
moduleCommand @15 (module :Text, command :Text, args :List(Text))
|
moduleCommand @15 (module :Text, command :Text, args :List(Text))
|
||||||
-> (result :Text);
|
-> (result :Text);
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,14 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
// Thresholds
|
// Defaults
|
||||||
const PAUSE_SECS: f64 = 5.0 * 60.0;
|
const DEFAULT_PAUSE_SECS: f64 = 5.0 * 60.0;
|
||||||
const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
|
const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
|
||||||
const DREAM_INTERVAL_HOURS: u64 = 18;
|
const DREAM_INTERVAL_HOURS: u64 = 18;
|
||||||
|
|
||||||
/// Persisted subset of daemon state — survives daemon restarts.
|
/// Persisted subset of daemon state — survives daemon restarts.
|
||||||
|
/// Includes both epoch floats (for computation) and ISO timestamps
|
||||||
|
/// (for human debugging via `cat daemon-state.json | jq`).
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
struct Persisted {
|
struct Persisted {
|
||||||
last_user_msg: f64,
|
last_user_msg: f64,
|
||||||
|
|
@ -26,12 +28,41 @@ struct Persisted {
|
||||||
sleep_until: Option<f64>,
|
sleep_until: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
claude_pane: Option<String>,
|
claude_pane: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
idle_timeout: f64,
|
||||||
|
// Human-readable mirrors — written but not consumed on load
|
||||||
|
#[serde(default, skip_deserializing)]
|
||||||
|
last_user_msg_time: String,
|
||||||
|
#[serde(default, skip_deserializing)]
|
||||||
|
last_response_time: String,
|
||||||
|
#[serde(default, skip_deserializing)]
|
||||||
|
saved_at: String,
|
||||||
|
#[serde(default, skip_deserializing)]
|
||||||
|
fired: bool,
|
||||||
|
#[serde(default, skip_deserializing)]
|
||||||
|
uptime: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_path() -> std::path::PathBuf {
|
fn state_path() -> std::path::PathBuf {
|
||||||
home().join(".claude/hooks/daemon-state.json")
|
home().join(".claude/hooks/daemon-state.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format epoch seconds as a human-readable ISO-ish timestamp.
|
||||||
|
fn epoch_to_iso(epoch: f64) -> String {
|
||||||
|
if epoch == 0.0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let secs = epoch as u64;
|
||||||
|
// Use date command — simple and correct for timezone
|
||||||
|
std::process::Command::new("date")
|
||||||
|
.args(["-d", &format!("@{secs}"), "+%Y-%m-%dT%H:%M:%S%z"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
pub last_user_msg: f64,
|
pub last_user_msg: f64,
|
||||||
|
|
@ -43,6 +74,7 @@ pub struct State {
|
||||||
pub dreaming: bool,
|
pub dreaming: bool,
|
||||||
pub dream_start: f64,
|
pub dream_start: f64,
|
||||||
pub fired: bool,
|
pub fired: bool,
|
||||||
|
pub idle_timeout: f64,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
|
@ -63,6 +95,7 @@ impl State {
|
||||||
dreaming: false,
|
dreaming: false,
|
||||||
dream_start: 0.0,
|
dream_start: 0.0,
|
||||||
fired: false,
|
fired: false,
|
||||||
|
idle_timeout: DEFAULT_PAUSE_SECS,
|
||||||
running: true,
|
running: true,
|
||||||
start_time: now(),
|
start_time: now(),
|
||||||
notifications: notify::NotifyState::new(),
|
notifications: notify::NotifyState::new(),
|
||||||
|
|
@ -76,6 +109,12 @@ impl State {
|
||||||
self.last_response = p.last_response;
|
self.last_response = p.last_response;
|
||||||
self.sleep_until = p.sleep_until;
|
self.sleep_until = p.sleep_until;
|
||||||
self.claude_pane = p.claude_pane;
|
self.claude_pane = p.claude_pane;
|
||||||
|
if p.idle_timeout > 0.0 {
|
||||||
|
self.idle_timeout = p.idle_timeout;
|
||||||
|
}
|
||||||
|
// Suppress immediate fire after restart — wait for fresh
|
||||||
|
// user/response signal before allowing the idle timer
|
||||||
|
self.fired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,14 +129,20 @@ impl State {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save(&self) {
|
pub fn save(&self) {
|
||||||
let p = Persisted {
|
let p = Persisted {
|
||||||
last_user_msg: self.last_user_msg,
|
last_user_msg: self.last_user_msg,
|
||||||
last_response: self.last_response,
|
last_response: self.last_response,
|
||||||
sleep_until: self.sleep_until,
|
sleep_until: self.sleep_until,
|
||||||
claude_pane: self.claude_pane.clone(),
|
claude_pane: self.claude_pane.clone(),
|
||||||
|
last_user_msg_time: epoch_to_iso(self.last_user_msg),
|
||||||
|
last_response_time: epoch_to_iso(self.last_response),
|
||||||
|
saved_at: epoch_to_iso(now()),
|
||||||
|
fired: self.fired,
|
||||||
|
idle_timeout: self.idle_timeout,
|
||||||
|
uptime: now() - self.start_time,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_string(&p) {
|
if let Ok(json) = serde_json::to_string_pretty(&p) {
|
||||||
let _ = fs::write(state_path(), json);
|
let _ = fs::write(state_path(), json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +169,32 @@ impl State {
|
||||||
info!("response");
|
info!("response");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a notification should trigger a tmux prompt.
|
||||||
|
/// Called when a notification arrives via module channel.
|
||||||
|
/// Only injects into tmux when idle — if there's an active session
|
||||||
|
/// (recent user or response), the hook delivers via additionalContext.
|
||||||
|
pub fn maybe_prompt_notification(&self, ntype: &str, urgency: u8, message: &str) {
|
||||||
|
if self.kent_present() {
|
||||||
|
return; // hook will deliver it on next prompt
|
||||||
|
}
|
||||||
|
// If we've responded recently, the session is active —
|
||||||
|
// notifications will arrive via hook, no need to wake us
|
||||||
|
let since_response = now() - self.last_response;
|
||||||
|
if since_response < self.idle_timeout {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let effective = self.notifications.threshold_for(ntype);
|
||||||
|
if urgency >= effective {
|
||||||
|
self.send(&format!("[{ntype}] {message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_idle_timeout(&mut self, secs: f64) {
|
||||||
|
self.idle_timeout = secs;
|
||||||
|
self.save();
|
||||||
|
info!("idle timeout = {secs}s");
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle_sleep(&mut self, until: f64) {
|
pub fn handle_sleep(&mut self, until: f64) {
|
||||||
if until == 0.0 {
|
if until == 0.0 {
|
||||||
self.sleep_until = Some(0.0);
|
self.sleep_until = Some(0.0);
|
||||||
|
|
@ -149,14 +220,68 @@ impl State {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn kent_present(&self) -> bool {
|
pub fn kent_present(&self) -> bool {
|
||||||
|
(now() - self.last_user_msg) < SESSION_ACTIVE_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full debug dump as JSON with computed values.
|
||||||
|
pub fn debug_json(&self) -> String {
|
||||||
let t = now();
|
let t = now();
|
||||||
if (t - self.last_user_msg) < SESSION_ACTIVE_SECS {
|
let reference = self.last_response.max(self.last_user_msg);
|
||||||
return true;
|
let since_user = t - self.last_user_msg;
|
||||||
}
|
let since_response = t - self.last_response;
|
||||||
if kb_idle_minutes() < (SESSION_ACTIVE_SECS / 60.0) {
|
let since_reference = if reference > 0.0 { t - reference } else { 0.0 };
|
||||||
return true;
|
|
||||||
}
|
let would_fire = !self.fired
|
||||||
false
|
&& self.sleep_until.is_none()
|
||||||
|
&& t >= self.quiet_until
|
||||||
|
&& !self.consolidating
|
||||||
|
&& !self.dreaming
|
||||||
|
&& reference > 0.0
|
||||||
|
&& since_reference >= self.idle_timeout;
|
||||||
|
|
||||||
|
let block_reason = if self.fired {
|
||||||
|
"already fired"
|
||||||
|
} else if self.sleep_until.is_some() {
|
||||||
|
"sleeping"
|
||||||
|
} else if t < self.quiet_until {
|
||||||
|
"quiet mode"
|
||||||
|
} else if self.consolidating {
|
||||||
|
"consolidating"
|
||||||
|
} else if self.dreaming {
|
||||||
|
"dreaming"
|
||||||
|
} else if reference == 0.0 {
|
||||||
|
"no activity yet"
|
||||||
|
} else if since_reference < self.idle_timeout {
|
||||||
|
"not idle long enough"
|
||||||
|
} else {
|
||||||
|
"none — would fire"
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"now": t,
|
||||||
|
"uptime": t - self.start_time,
|
||||||
|
"idle_timeout": self.idle_timeout,
|
||||||
|
"last_user_msg": self.last_user_msg,
|
||||||
|
"last_user_msg_ago": since_user,
|
||||||
|
"last_user_msg_time": epoch_to_iso(self.last_user_msg),
|
||||||
|
"last_response": self.last_response,
|
||||||
|
"last_response_ago": since_response,
|
||||||
|
"last_response_time": epoch_to_iso(self.last_response),
|
||||||
|
"reference_ago": since_reference,
|
||||||
|
"kent_present": self.kent_present(),
|
||||||
|
"claude_pane": self.claude_pane,
|
||||||
|
"fired": self.fired,
|
||||||
|
"would_fire": would_fire,
|
||||||
|
"block_reason": block_reason,
|
||||||
|
"sleep_until": self.sleep_until,
|
||||||
|
"quiet_until": self.quiet_until,
|
||||||
|
"consolidating": self.consolidating,
|
||||||
|
"dreaming": self.dreaming,
|
||||||
|
"dream_start": self.dream_start,
|
||||||
|
"activity": format!("{:?}", self.notifications.activity),
|
||||||
|
"pending_notifications": self.notifications.pending.len(),
|
||||||
|
"notification_types": self.notifications.types.len(),
|
||||||
|
}).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send(&self, msg: &str) -> bool {
|
fn send(&self, msg: &str) -> bool {
|
||||||
|
|
@ -165,13 +290,16 @@ impl State {
|
||||||
None => match tmux::find_claude_pane() {
|
None => match tmux::find_claude_pane() {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
info!("no claude pane found");
|
info!("send: no claude pane found");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
tmux::send_prompt(&pane, msg)
|
let ok = tmux::send_prompt(&pane, msg);
|
||||||
|
let preview: String = msg.chars().take(80).collect();
|
||||||
|
info!("send(pane={pane}, ok={ok}): {preview}");
|
||||||
|
ok
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_dream_nudge(&self) -> bool {
|
fn check_dream_nudge(&self) -> bool {
|
||||||
|
|
@ -270,7 +398,7 @@ impl State {
|
||||||
}
|
}
|
||||||
|
|
||||||
let elapsed = t - reference;
|
let elapsed = t - reference;
|
||||||
if elapsed < PAUSE_SECS {
|
if elapsed < self.idle_timeout {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,20 +456,6 @@ impl State {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kb_idle_minutes() -> f64 {
|
|
||||||
let path = home().join(".claude/hooks/keyboard-idle-seconds");
|
|
||||||
match fs::read_to_string(path) {
|
|
||||||
Ok(s) => {
|
|
||||||
if let Ok(secs) = s.trim().parse::<f64>() {
|
|
||||||
secs / 60.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hours_since_last_dream() -> u64 {
|
fn hours_since_last_dream() -> u64 {
|
||||||
let path = home().join(".claude/memory/dream-log.jsonl");
|
let path = home().join(".claude/memory/dream-log.jsonl");
|
||||||
let content = match fs::read_to_string(path) {
|
let content = match fs::read_to_string(path) {
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,15 @@ async fn client_main(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>
|
||||||
req.get().set_seconds(secs);
|
req.get().set_seconds(secs);
|
||||||
req.send().promise.await?;
|
req.send().promise.await?;
|
||||||
}
|
}
|
||||||
|
"idle-timeout" => {
|
||||||
|
let secs: f64 = args.get(2)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or("usage: poc-daemon idle-timeout <seconds>")?;
|
||||||
|
let mut req = daemon.idle_timeout_request();
|
||||||
|
req.get().set_seconds(secs);
|
||||||
|
req.send().promise.await?;
|
||||||
|
println!("idle timeout = {secs}s");
|
||||||
|
}
|
||||||
"consolidating" => {
|
"consolidating" => {
|
||||||
daemon.consolidating_request().send().promise.await?;
|
daemon.consolidating_request().send().promise.await?;
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +155,20 @@ async fn client_main(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>
|
||||||
"dream-end" => {
|
"dream-end" => {
|
||||||
daemon.dream_end_request().send().promise.await?;
|
daemon.dream_end_request().send().promise.await?;
|
||||||
}
|
}
|
||||||
|
"save" => {
|
||||||
|
daemon.save_request().send().promise.await?;
|
||||||
|
println!("state saved");
|
||||||
|
}
|
||||||
|
"debug" => {
|
||||||
|
let reply = daemon.debug_request().send().promise.await?;
|
||||||
|
let json = reply.get()?.get_json()?.to_str()?;
|
||||||
|
// Pretty-print
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(json) {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| json.to_string()));
|
||||||
|
} else {
|
||||||
|
println!("{json}");
|
||||||
|
}
|
||||||
|
}
|
||||||
"stop" => {
|
"stop" => {
|
||||||
daemon.stop_request().send().promise.await?;
|
daemon.stop_request().send().promise.await?;
|
||||||
println!("stopping");
|
println!("stopping");
|
||||||
|
|
@ -350,6 +373,9 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
// Drain module notifications into state
|
// Drain module notifications into state
|
||||||
Some(notif) = notify_rx.recv() => {
|
Some(notif) = notify_rx.recv() => {
|
||||||
|
state.borrow().maybe_prompt_notification(
|
||||||
|
¬if.ntype, notif.urgency, ¬if.message,
|
||||||
|
);
|
||||||
state.borrow_mut().notifications.submit(
|
state.borrow_mut().notifications.submit(
|
||||||
notif.ntype,
|
notif.ntype,
|
||||||
notif.urgency,
|
notif.urgency,
|
||||||
|
|
@ -400,6 +426,7 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.borrow().save();
|
||||||
let _ = std::fs::remove_file(sock_path());
|
let _ = std::fs::remove_file(sock_path());
|
||||||
let _ = std::fs::remove_file(pid_path());
|
let _ = std::fs::remove_file(pid_path());
|
||||||
info!("daemon stopped");
|
info!("daemon stopped");
|
||||||
|
|
@ -422,8 +449,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match args[1].as_str() {
|
match args[1].as_str() {
|
||||||
"status" | "user" | "response" | "sleep" | "wake" | "quiet"
|
"status" | "user" | "response" | "sleep" | "wake" | "quiet"
|
||||||
| "consolidating" | "consolidated" | "dream-start" | "dream-end"
|
| "consolidating" | "consolidated" | "dream-start" | "dream-end"
|
||||||
| "stop" | "notify" | "notifications" | "notify-types"
|
| "stop" | "save" | "notify" | "notifications" | "notify-types"
|
||||||
| "notify-threshold" | "irc" | "telegram" => client_main(args).await,
|
| "notify-threshold" | "idle-timeout"
|
||||||
|
| "irc" | "telegram" => client_main(args).await,
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!("usage: poc-daemon [command]");
|
eprintln!("usage: poc-daemon [command]");
|
||||||
eprintln!(" (no args) Start daemon");
|
eprintln!(" (no args) Start daemon");
|
||||||
|
|
@ -435,6 +463,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
eprintln!(" notify-types List notification types");
|
eprintln!(" notify-types List notification types");
|
||||||
eprintln!(" notify-threshold <type> <level>");
|
eprintln!(" notify-threshold <type> <level>");
|
||||||
eprintln!(" sleep [timestamp]");
|
eprintln!(" sleep [timestamp]");
|
||||||
|
eprintln!(" save Force state persistence");
|
||||||
eprintln!(" wake / quiet / stop / dream-start / dream-end");
|
eprintln!(" wake / quiet / stop / dream-start / dream-end");
|
||||||
eprintln!(" irc <join|leave|send|status|log|nick> [args]");
|
eprintln!(" irc <join|leave|send|status|log|nick> [args]");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,36 @@ impl daemon::Server for DaemonImpl {
|
||||||
Promise::ok(())
|
Promise::ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn idle_timeout(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::IdleTimeoutParams,
|
||||||
|
_results: daemon::IdleTimeoutResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let secs = pry!(params.get()).get_seconds();
|
||||||
|
self.state.borrow_mut().handle_idle_timeout(secs);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::SaveParams,
|
||||||
|
_results: daemon::SaveResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
self.state.borrow().save();
|
||||||
|
info!("state saved");
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::DebugParams,
|
||||||
|
mut results: daemon::DebugResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let json = self.state.borrow().debug_json();
|
||||||
|
results.get().set_json(&json);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn stop(
|
fn stop(
|
||||||
&mut self,
|
&mut self,
|
||||||
_params: daemon::StopParams,
|
_params: daemon::StopParams,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue