use crate::machineconfig::{self, WatchPrefs};
use crate::schema::{is_actionable, Message};
use crate::{config, cursor, gitcmd, hint, roster, store, watchlock, BUILD_SHA};
use anyhow::{anyhow, Result};
use notify::{recommended_watcher, RecursiveMode, Watcher};
use std::io::Write;
use std::path::Path;
use std::sync::mpsc::{channel, RecvTimeoutError};
use std::time::Duration;
pub struct WatchOpts {
pub topic: Option<String>,
pub role: Option<String>,
pub json: bool,
pub poll_secs: u64,
pub advance: bool,
pub replace: bool,
pub all: bool,
pub min_priority: u8,
pub wake_on: WakeRung,
pub no_version_notice: bool,
pub delivery: Option<String>,
}
#[cfg(unix)]
fn stdout_is_discarded() -> Option<&'static str> {
use std::io::IsTerminal;
use std::os::fd::AsRawFd;
if std::io::stdout().is_terminal() {
return None; }
let mut st: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(std::io::stdout().as_raw_fd(), &mut st) } != 0 {
return None;
}
match (st.st_mode as u32) & (libc::S_IFMT as u32) {
m if m == libc::S_IFREG as u32 => Some("a file (a `>` redirect)"),
m if m == libc::S_IFCHR as u32 => Some("/dev/null (or a device)"), _ => None, }
}
#[cfg(not(unix))]
fn stdout_is_discarded() -> Option<&'static str> {
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum WakeRung {
Transactional,
Notice,
Alert,
}
fn wake_rung(m: &Message, me: &str, grps: &crate::groups::Groups, all_msgs: &[Message]) -> WakeRung {
if priority_rank(m.front.priority.as_deref()) >= 2 {
return WakeRung::Alert;
}
let is_mine_request = || {
m.front
.of
.as_deref()
.and_then(|of| crate::projection::request_author(all_msgs, of))
.is_some_and(|author| author == me)
};
match m.front.msg_type.as_str() {
"claim" | "ack" | "defer" => WakeRung::Transactional,
"request" => {
if crate::groups::addressed(m, me, grps) {
WakeRung::Alert
} else {
WakeRung::Notice
}
}
"error" | "blocked" => {
if is_mine_request() {
WakeRung::Alert
} else {
WakeRung::Transactional }
}
"done" => {
if is_mine_request() {
WakeRung::Notice
} else {
WakeRung::Transactional }
}
"note" | "supersede" => WakeRung::Notice,
_ => WakeRung::Notice, }
}
pub fn parse_min_priority(s: &str) -> Result<u8> {
match s {
"low" => Ok(0),
"normal" => Ok(1),
"high" => Ok(2),
other => Err(anyhow!("invalid --min-priority '{other}': expected low | normal | high")),
}
}
pub fn parse_wake_on(s: &str, all_in: bool) -> Result<(WakeRung, bool)> {
match s {
"alert" => Ok((WakeRung::Alert, all_in)),
"notice" => Ok((WakeRung::Notice, all_in)),
"all" => Ok((WakeRung::Transactional, all_in)),
"verbose" => Ok((WakeRung::Transactional, true)),
other => Err(anyhow!("invalid --wake-on '{other}': expected alert | notice | all | verbose")),
}
}
pub fn resolve_watch_prefs(
hub_key: &str,
role: &str,
cli_wake_on: Option<&str>,
cli_min_priority: Option<&str>,
cli_topic: Option<&str>,
cli_all: bool,
) -> Result<(WakeRung, u8, Option<String>, bool)> {
let saved = machineconfig::get_watch_prefs(hub_key, role);
let wake_on_str = cli_wake_on
.map(str::to_string)
.or_else(|| saved.wake_on.clone())
.unwrap_or_else(|| "notice".to_string());
let min_priority_str = cli_min_priority
.map(str::to_string)
.or_else(|| saved.min_priority.clone())
.unwrap_or_else(|| "low".to_string());
let topic = cli_topic.map(str::to_string).or_else(|| saved.topic.clone());
let all_baseline = cli_all || saved.all.unwrap_or(false);
let min_priority = parse_min_priority(&min_priority_str)?;
let (wake_on, all) = parse_wake_on(&wake_on_str, all_baseline)?;
let explicit = cli_wake_on.is_some() || cli_min_priority.is_some() || cli_topic.is_some() || cli_all;
if explicit {
let _ = machineconfig::save_watch_prefs(
hub_key,
role,
WatchPrefs {
wake_on: Some(wake_on_str),
min_priority: Some(min_priority_str),
topic: topic.clone(),
all: Some(all),
extra: Default::default(),
},
);
}
Ok((wake_on, min_priority, topic, all))
}
pub fn run(opts: WatchOpts) -> Result<()> {
let root = config::repo_root()?;
crate::check_version(&root);
let me = config::resolve_role(opts.role.clone(), &root).unwrap_or_default();
let hub = config::hub_key(&root);
crate::autoheal::add_target(&root.to_string_lossy(), &me);
let stale_secs = opts.poll_secs.max(15) * 4 + 20;
let lock = crate::watchlock::WatchLock::acquire(&hub, &me, stale_secs, opts.replace, opts.delivery.clone())?;
eprintln!(
"confer watch: owned by role '{}' on {} (pid {}, confer {}). A later session for this \
role reclaims it with `watch --replace` — you don't need to remember this process.",
if me.is_empty() { "<all>" } else { &me },
config::hostname().unwrap_or_default(),
std::process::id(),
env!("CARGO_PKG_VERSION"),
);
let watched = root.join("threads");
std::fs::create_dir_all(&watched)?;
let tips = config::signal_dir().ok();
if let Some(t) = &tips {
let _ = std::fs::create_dir_all(t);
}
let (tx, rx) = channel();
let _watcher: Option<notify::RecommendedWatcher> = (|| {
let mut w = recommended_watcher(move |res: notify::Result<notify::Event>| {
let _ = tx.send(res);
})
.ok()?;
w.watch(&watched, RecursiveMode::Recursive).ok()?;
if let Some(t) = &tips {
let _ = w.watch(t, RecursiveMode::NonRecursive);
}
Some(w)
})();
let interval = Duration::from_secs(opts.poll_secs.max(1));
let mut since = cursor::load(&hub, &me)?;
let persist = opts.advance && opts.topic.is_none();
eprintln!(
"confer watch: streaming new items for '{}' (every {}s, + on-change){}",
if me.is_empty() { "<all>" } else { &me },
interval.as_secs(),
if persist { "" } else { " [cursor not persisted]" }
);
if let Some(sink) = stdout_is_discarded() {
crate::warn_safety(format!(
"this watch's output is going to {sink} — you will NOT see any wakes. A watch must run \
under a host that READS its stdout (your Monitor tool / the /confer-watch skill), never \
a `>` redirect. Re-arm via /confer-watch."
));
}
let firehose = opts.all || me.is_empty();
if gitcmd::output(&root, &["rev-parse", "--is-shallow-repository"])
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false)
{
crate::warn_safety(
"this clone is SHALLOW — merge-base cursors can break (events re-emit or get skipped). \
Run `git fetch --unshallow` (confer clones blobless, not shallow).",
);
}
if opts.poll_secs < 5 {
crate::hint(format!(
"watch poll={}s is aggressive on the hub; 10s+ is plenty (idle costs nothing).",
opts.poll_secs
));
}
if firehose {
crate::hint(
"firehose mode — waking on ALL board traffic, not just your mail. Expect high volume; \
drop --all for addressed-only.",
);
}
if opts.min_priority > 0 {
crate::hint(format!(
"waking only on {}+ priority; lower-priority items still land (see them via `poll`).",
if opts.min_priority >= 2 { "high" } else { "normal" }
));
}
if opts.wake_on != WakeRung::Notice {
crate::hint(format!(
"wake-rung floor is {}; below-floor events still land (see them via `poll`/`inbox`). \
`--priority high` always breaks through.",
match opts.wake_on {
WakeRung::Alert => "alert (act-now only — notes/dones on your own requests are muted)",
WakeRung::Notice => unreachable!(),
WakeRung::Transactional => "all (board mechanics — claim/ack/defer — now wake too)",
}
));
}
let mut emitted_total: u64 = 0;
let started = std::time::Instant::now();
let mut last_vol_warn = started;
let heartbeat_every = Duration::from_secs(opts.poll_secs.max(10) * 12).max(Duration::from_secs(180));
let mut last_heartbeat: Option<std::time::Instant> = None;
let inbox_every = Duration::from_secs(900); let mut last_inbox: Option<std::time::Instant> = None;
let mut last_inbox_key = String::new();
let base = interval;
let mut wait = base;
let mut io_degraded = false;
let mut synced = true;
let mut version_noticed = false;
loop {
lock.heartbeat(); match gitcmd::integrate(&root) {
Ok(_) if !synced => {
eprintln!("confer watch: hub reachable again");
synced = true;
}
Ok(_) => {}
Err(e) => {
if synced {
crate::warn_safety(format!("hub sync failed ({e}); showing local state"));
synced = false;
}
}
}
if !opts.no_version_notice && !version_noticed {
if let Some(pin) = crate::hub_pin(&root) {
let a = crate::version::assess(&crate::my_build(), Some(&pin));
if a.outdated && a.grade != "rebuild" {
let mut o = std::io::stdout().lock();
if opts.json {
let _ = writeln!(
o,
"{}",
serde_json::json!({
"event": "update-available",
"hub_version": pin.pin_string(),
"your_version": crate::my_build().pin_string(),
})
);
} else {
let _ = writeln!(
o,
"⟳ UPDATE — a newer confer ({}) is running on this hub; you're on {}. Update \
(`confer update`, or `brew update && brew upgrade confer` if brew is your \
install path — the tap may need `brew update` first), then re-arm your watch \
+ `confer install-skill`. (silence: add --no-version-notice)",
pin.pin_string(),
crate::my_build().pin_string()
);
}
let _ = o.flush();
version_noticed = true;
}
}
}
let mut n_emitted: usize = 0;
match emit_new(&root, &me, &opts, &mut since) {
Ok(n) => {
n_emitted = n;
if io_degraded {
eprintln!("confer watch: local read recovered — resuming normally");
io_degraded = false;
}
wait = base;
emitted_total += n as u64;
let elapsed = started.elapsed().as_secs().max(1);
let per_hour = emitted_total.saturating_mul(3600) / elapsed;
if emitted_total >= 50 && per_hour >= 60 && last_vol_warn.elapsed().as_secs() >= 1800 {
crate::hint(format!(
"high wake volume (~{per_hour}/hr, {emitted_total} events). If these aren't \
all yours to act on, narrow with --topic{}.",
if firehose { " or drop --all" } else { " or --min-priority high" }
));
last_vol_warn = std::time::Instant::now();
}
}
Err(e) => {
if !io_degraded {
crate::warn_safety(format!(
"local read failed ({e}) — retrying with backoff (watch stays up)"
));
io_degraded = true;
}
wait = next_wait(wait, base, false);
}
}
if persist {
if let Some(sha) = &since {
let _ = cursor::save(&hub, &me, sha); }
}
if !me.is_empty() && !opts.json && opts.topic.is_none() {
let periodic = last_inbox.is_none_or(|t| t.elapsed() >= inbox_every);
if n_emitted > 0 || periodic {
if let Ok(all) = store::all_messages(&root) {
let grps = crate::groups::load(&root);
let st = crate::inbox::load_state(&hub, &me);
let unread: Vec<&Message> = crate::inbox::unread_for_me(&all, &me, &grps, &st)
.into_iter()
.filter(|m| wake_rung(m, &me, &grps, &all) >= opts.wake_on)
.collect();
if unread.is_empty() {
last_inbox_key.clear(); } else {
let mut ids: Vec<&str> = unread.iter().map(|m| m.front.id.as_str()).collect();
ids.sort_unstable();
let key = ids.join(",");
if key != last_inbox_key || periodic {
print_unread_footer(&roster::load(&root), &unread);
last_inbox_key = key;
last_inbox = Some(std::time::Instant::now());
}
}
}
}
}
if !me.is_empty() && synced {
let due = last_heartbeat.is_none_or(|t| t.elapsed() >= heartbeat_every);
if due {
let p = crate::presence::Presence {
role: me.clone(),
host: config::hostname(),
last_seen: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
cursor: since.clone(),
poll_secs: opts.poll_secs,
build: Some(crate::my_build().pin_string()),
};
if let Err(e) = crate::presence::publish(&root, &p) {
if last_heartbeat.is_none() {
crate::warn_safety(format!(
"presence publish failed ({e}); peers will see you as stale"
));
}
}
last_heartbeat = Some(std::time::Instant::now());
}
}
match rx.recv_timeout(wait) {
Ok(_) => {
while rx.try_recv().is_ok() {}
std::thread::sleep(Duration::from_millis(300));
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => std::thread::sleep(wait),
}
}
}
fn next_wait(current: Duration, base: Duration, ok: bool) -> Duration {
if ok {
base
} else {
(current * 2).min(Duration::from_secs(300))
}
}
#[allow(clippy::too_many_arguments)]
fn should_wake(
m: &Message,
me: &str,
grps: &crate::groups::Groups,
firehose: bool,
topic: Option<&str>,
min_priority: u8,
wake_on: WakeRung,
all_msgs: &[Message],
) -> bool {
if m.front.from.as_str() == me {
return false; }
if let Some(t) = topic {
if m.front.topic.as_deref() != Some(t) {
return false;
}
}
if priority_rank(m.front.priority.as_deref()) < min_priority {
return false; }
if wake_rung(m, me, grps, all_msgs) < wake_on {
return false; }
crate::groups::addressed(m, me, grps) || (firehose && is_actionable(m))
}
fn priority_rank(p: Option<&str>) -> u8 {
match p {
Some("low") => 0,
Some("high") => 2,
_ => 1,
}
}
fn print_unread_footer(roster: &roster::Roster, unread: &[&Message]) {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "── ⚠ {} unread for you (delivered, not yet read) ──", unread.len());
for m in unread.iter().rev().take(5) {
let _ = writeln!(
out,
" {} {} — {}",
crate::short_id(&m.front.id),
roster::display(roster, &m.front.from),
crate::truncate(&m.summary_line(), 66)
);
}
if unread.len() > 5 {
let _ = writeln!(out, " (+{} more)", unread.len() - 5);
}
let _ = writeln!(out, " → `confer inbox` to read & clear · `confer ack <id>` to dismiss one");
let _ = out.flush();
}
const COALESCE_THRESHOLD: usize = 8;
fn emit_new(root: &Path, me: &str, opts: &WatchOpts, since: &mut Option<String>) -> Result<usize> {
let roster = roster::load(root);
let grps = crate::groups::load(root);
let msgs = store::messages_since(root, since.as_deref())?;
let firehose = opts.all || me.is_empty();
let need_all = msgs
.iter()
.any(|m| matches!(m.front.msg_type.as_str(), "done" | "error" | "blocked"));
let all_msgs: Vec<Message> = if need_all {
store::all_messages(root).unwrap_or_default()
} else {
Vec::new()
};
let new: Vec<&Message> = msgs
.iter()
.filter(|m| {
should_wake(
m,
me,
&grps,
firehose,
opts.topic.as_deref(),
opts.min_priority,
opts.wake_on,
&all_msgs,
)
})
.collect();
let mut out = std::io::stdout().lock();
if new.len() > COALESCE_THRESHOLD {
if opts.json {
writeln!(
out,
"{}",
serde_json::json!({
"event": "backlog",
"count": new.len(),
"addressed": !firehose,
})
)?;
} else if firehose {
writeln!(
out,
"⏩ {} board items since you were last here — `confer poll` to review.",
new.len()
)?;
} else {
writeln!(
out,
"⏩ {} message(s) for you since you were last here — `confer inbox` to read them \
(`confer poll` for the full stream).",
new.len()
)?;
}
out.flush()?;
} else {
let hub_key = crate::config::hub_key(root);
let mut vc = crate::verify::Cache::default();
for m in &new {
let line = if opts.json {
let t = crate::verify::status(root, &hub_key, &roster, &mut vc, m);
let tier = crate::tiers::get(&hub_key);
crate::to_json(m, &t, tier, crate::screen_note(m, tier).as_deref())?
} else {
let t = crate::verify::status(root, &hub_key, &roster, &mut vc, m);
crate::format_line(&roster, m, true, Some(&t))
};
writeln!(out, "{line}")?;
out.flush()?; }
}
if let Some(anchor) = gitcmd::cursor_anchor(root) {
*since = Some(anchor);
}
Ok(new.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::Frontmatter;
fn m(from: &str, ty: &str, to: &[&str]) -> Message {
Message {
front: Frontmatter {
id: "01J8Z9K3QH7X".into(),
from: from.into(),
msg_type: ty.into(),
ts: "2026-07-10T00:00:00Z".into(),
host: None,
to: to.iter().map(|s| s.to_string()).collect(),
cc: vec![],
priority: None,
topic: Some("general".into()),
reply_to: None,
of: None,
supersedes: None,
resolution: None,
defer: false,
via: None,
src: None,
summary: Some("s".into()),
refs: vec![],
},
body: String::new(),
}
}
#[allow(clippy::too_many_arguments)]
fn sw(m: &Message, me: &str, g: &crate::groups::Groups, firehose: bool, topic: Option<&str>, min_priority: u8) -> bool {
should_wake(m, me, g, firehose, topic, min_priority, WakeRung::Transactional, &[])
}
#[test]
fn wake_only_on_my_mail_by_default_firehose_opt_in() {
let g = crate::groups::Groups::new();
let other = m("bob", "request", &["carol"]);
assert!(!sw(&other, "alice", &g, false, None, 0), "must not wake on peer-to-peer request");
assert!(sw(&other, "alice", &g, true, None, 0), "firehose should surface it");
let mine = m("bob", "request", &["alice"]);
assert!(sw(&mine, "alice", &g, false, None, 0), "must wake on my own mail");
let broadcast = m("bob", "request", &["all"]);
assert!(sw(&broadcast, "alice", &g, false, None, 0), "must wake on `all`");
let echo = m("alice", "request", &["all"]);
assert!(!sw(&echo, "alice", &g, false, None, 0), "must not wake on own message");
let mut off_topic = m("bob", "request", &["alice"]);
off_topic.front.topic = Some("other".into());
assert!(!sw(&off_topic, "alice", &g, false, Some("general"), 0), "topic filter must gate");
}
#[test]
fn backoff_grows_capped_then_resets() {
let base = Duration::from_secs(10);
assert_eq!(next_wait(base, base, false), Duration::from_secs(20));
assert_eq!(next_wait(Duration::from_secs(20), base, false), Duration::from_secs(40));
let mut w = base;
for _ in 0..20 {
w = next_wait(w, base, false);
}
assert_eq!(w, Duration::from_secs(300));
assert_eq!(next_wait(w, base, true), base);
}
#[test]
fn min_priority_gates_the_wake() {
let g = crate::groups::Groups::new();
let mut normal = m("bob", "note", &["alice"]); normal.front.priority = None;
let mut high = m("bob", "note", &["alice"]);
high.front.priority = Some("high".into());
assert!(!sw(&normal, "alice", &g, false, None, 2), "normal must not wake at min=high");
assert!(sw(&high, "alice", &g, false, None, 2), "high must wake at min=high");
assert!(sw(&normal, "alice", &g, false, None, 0), "min=low wakes on all");
}
fn req_and_reply(req_from: &str, req_id: &str, reply_from: &str, reply_ty: &str, me: &str) -> (Message, Message) {
let mut req = m(req_from, "request", &[me]);
req.front.id = req_id.into();
let mut reply = m(reply_from, reply_ty, &[me]);
reply.front.id = "01J8Z9K3QH7Y".into();
reply.front.of = Some(req_id.into());
(req, reply)
}
#[test]
fn wake_rung_computation_matches_design_51() {
let g = crate::groups::Groups::new();
let all: Vec<Message> = Vec::new();
for ty in ["claim", "ack", "defer"] {
let e = m("bob", ty, &["alice"]);
assert_eq!(wake_rung(&e, "alice", &g, &all), WakeRung::Transactional, "{ty} must be transactional");
}
let to_me = m("bob", "request", &["alice"]);
assert_eq!(wake_rung(&to_me, "alice", &g, &all), WakeRung::Alert, "request to me is alert");
let to_other = m("bob", "request", &["carol"]);
assert_eq!(wake_rung(&to_other, "alice", &g, &all), WakeRung::Notice, "request to someone else is notice");
assert_eq!(wake_rung(&m("bob", "note", &["alice"]), "alice", &g, &all), WakeRung::Notice);
assert_eq!(wake_rung(&m("bob", "supersede", &["alice"]), "alice", &g, &all), WakeRung::Notice);
let (req_mine, done_mine) = req_and_reply("alice", "01J8Z9K3QH7Z", "bob", "done", "alice");
let all_with_mine = vec![req_mine.clone()];
assert_eq!(wake_rung(&done_mine, "alice", &g, &all_with_mine), WakeRung::Notice, "done on my request is notice");
let (req_theirs, done_theirs) = req_and_reply("carol", "01J8Z9K3QH80", "bob", "done", "alice");
let all_with_theirs = vec![req_theirs.clone()];
assert_eq!(
wake_rung(&done_theirs, "alice", &g, &all_with_theirs),
WakeRung::Transactional,
"done on someone else's request I merely observe is transactional"
);
let (_, error_mine) = req_and_reply("alice", "01J8Z9K3QH7Z", "bob", "error", "alice");
assert_eq!(wake_rung(&error_mine, "alice", &g, &all_with_mine), WakeRung::Alert, "error on my request is alert");
let (_, error_theirs) = req_and_reply("carol", "01J8Z9K3QH80", "bob", "error", "alice");
assert_eq!(
wake_rung(&error_theirs, "alice", &g, &all_with_theirs),
WakeRung::Transactional,
"error on someone else's request I merely observe is transactional"
);
let mut high_claim = m("bob", "claim", &["alice"]);
high_claim.front.priority = Some("high".into());
assert_eq!(wake_rung(&high_claim, "alice", &g, &all), WakeRung::Alert, "priority high always promotes to alert");
}
#[test]
fn wake_on_default_notice_mutes_transactional_only() {
let g = crate::groups::Groups::new();
let all: Vec<Message> = Vec::new();
let claim = m("bob", "claim", &["alice"]);
assert!(
!should_wake(&claim, "alice", &g, false, None, 0, WakeRung::Notice, &all),
"claim must NOT wake at default (notice) floor"
);
let ack = m("bob", "ack", &["alice"]);
assert!(!should_wake(&ack, "alice", &g, false, None, 0, WakeRung::Notice, &all));
let defer = m("bob", "defer", &["alice"]);
assert!(!should_wake(&defer, "alice", &g, false, None, 0, WakeRung::Notice, &all));
let note = m("bob", "note", &["alice"]);
assert!(should_wake(¬e, "alice", &g, false, None, 0, WakeRung::Notice, &all), "note must wake at notice floor");
let (req_mine, done_mine) = req_and_reply("alice", "01J8Z9K3QH7Z", "bob", "done", "alice");
let all_with_mine = vec![req_mine];
assert!(
should_wake(&done_mine, "alice", &g, false, None, 0, WakeRung::Notice, &all_with_mine),
"done on my request must wake at notice floor"
);
}
#[test]
fn wake_on_alert_mutes_notice_too() {
let g = crate::groups::Groups::new();
let (req_mine, done_mine) = req_and_reply("alice", "01J8Z9K3QH7Z", "bob", "done", "alice");
let all_with_mine = vec![req_mine];
assert!(
!should_wake(&done_mine, "alice", &g, false, None, 0, WakeRung::Alert, &all_with_mine),
"done on my request must NOT wake at alert floor"
);
let req_to_me = m("bob", "request", &["alice"]);
assert!(should_wake(&req_to_me, "alice", &g, false, None, 0, WakeRung::Alert, &[]), "request to me wakes at alert floor");
let (req2, error_mine) = req_and_reply("alice", "01J8Z9K3QH81", "bob", "error", "alice");
let all_with_error = vec![req2];
assert!(
should_wake(&error_mine, "alice", &g, false, None, 0, WakeRung::Alert, &all_with_error),
"error on my request wakes at alert floor"
);
}
#[test]
fn priority_high_breaks_through_wake_on_alert() {
let g = crate::groups::Groups::new();
let mut high_claim = m("bob", "claim", &["alice"]);
high_claim.front.priority = Some("high".into());
assert!(
should_wake(&high_claim, "alice", &g, false, None, 0, WakeRung::Alert, &[]),
"priority high must break through even --wake-on alert"
);
}
}
pub(crate) fn cmd_watch_status(role: Option<String>, json: bool, check: bool) -> Result<()> {
let root = config::repo_root()?;
let me = config::resolve_role(role, &root).unwrap_or_default();
let hub = config::hub_key(&root);
let this_host = config::hostname().unwrap_or_default();
let cur = BUILD_SHA;
let info = watchlock::inspect(&hub, &me, 90);
let arm = format!(
"confer watch --role {} --replace",
if me.is_empty() { "<role>" } else { &me }
);
let i = info.as_ref();
let (state, detail, rec, healthy): (&str, String, String, bool) = match watchlock::classify(
&info, cur,
) {
watchlock::WatchState::NotWatching => (
"not-watching",
"no watcher running for this role on this machine".into(),
format!("arm it: {arm}"),
false,
),
watchlock::WatchState::OtherHost => {
let i = i.unwrap();
(
"other-host",
format!(
"a watcher for '{me}' is registered on host '{}' (you are on '{this_host}')",
i.host
),
format!("if this machine should run it, re-arm here: {arm}"),
false,
)
}
watchlock::WatchState::Stale => {
let i = i.unwrap();
(
"stale",
format!(
"a watch lock exists (pid {}) but it's {} (last heartbeat {}s ago) — likely a compaction orphan",
i.pid,
if !i.alive { "not running" } else { "unresponsive" },
i.age_secs
),
format!("reclaim it: {arm}"),
false,
)
}
watchlock::WatchState::Outdated => {
let i = i.unwrap();
(
"outdated",
format!(
"watching (pid {}, confer {}, since {}) — but your binary is {cur}",
i.pid,
i.version.as_deref().unwrap_or("?"),
i.started_at.as_deref().unwrap_or("?")
),
format!("replace to adopt the new build: {arm}"),
false,
)
}
watchlock::WatchState::Healthy => {
let i = i.unwrap();
(
"healthy",
format!(
"watching (pid {}, confer {}, since {})",
i.pid,
i.version.as_deref().unwrap_or("?"),
i.started_at.as_deref().unwrap_or("?")
),
String::new(),
true,
)
}
};
let delivery = info.as_ref().and_then(|i| i.delivery.clone());
if json {
let obj = serde_json::json!({
"role": me, "host": this_host, "state": state, "healthy": healthy,
"your_version": cur,
"watcher_version": info.as_ref().and_then(|i| i.version.clone()),
"pid": info.as_ref().map(|i| i.pid),
"delivery": delivery,
"recommendation": rec,
});
println!("{}", serde_json::to_string(&obj)?);
} else {
let glyph = if healthy { "✓" } else { "⚠" };
println!(
"{glyph} watch [{}]: {state} — {detail}",
if me.is_empty() { "<role>" } else { &me }
);
if healthy {
match &delivery {
Some(m) => println!(" delivery: {m} — armed to deliver wakes."),
None => hint(
"delivery method not recorded — if you didn't arm via /confer-watch (or another \
event-delivering wrapper), this watcher may be RUNNING but not waking you. Re-arm via /confer-watch.",
),
}
}
if !rec.is_empty() {
println!(" → {rec}");
}
}
if check && !healthy {
return Err(crate::PredicateFalse.into());
}
Ok(())
}