From 69315d3e30de494c0a169c5856684a5978882304 Mon Sep 17 00:00:00 2001 From: Iteratrix <1823098+Iteratrix@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:04:36 -0700 Subject: [PATCH] rung-1: a disposable glance for quiet wakes (#22), shipped disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a scheduled wake rung-0 would skip (no addressed signal, no peer ambient, no governance activity, spontaneity floor not due), a cheap look-only model reads a bounded view of the timeline and the unread notifications and returns WAKE or SLEEP plus one reason line. WAKE runs the cycle; SLEEP and every failure leave the wake skipped. Lumen's #22 verdict, structurally: rung1::glance takes no transcript (disposability — nothing in the module can reach the canonical conversation; the gate test asserts byte-identity before/after); the view shows timeline AND notifications; the model is whatever [rung1] points at (mini config names DS V4-flash, her pick); the glance sits beside the spontaneity counter (floor first, filter second); and the reason line is logged and appended to capture/rung1_glances.jsonl in both directions — what the glance declined is data. Fail-soft to SLEEP: timeout, transport/HTTP error, and unparseable replies all record `glance failed: …` and reproduce rung-0's skip — the glance may never invent a wake. Config: [rung1] (enabled, backend/model/base_url/api_key inheriting [cycle] when empty, max_items, timeout_seconds) with load-time calibration naming the field: requires [rung0], a model somewhere, a timeout within the cadence, >= 1 item. The mini config carries the section with enabled = false, so this merge is behavior-neutral; turning it on is a one-line config PR. The governance peek inside rung0_says_skip moved into its own function on the way (no behavior change). Also: the muse-identity consolidation test's bare `git -C log` now scrubs GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE like the production shell-out does — under a pre-commit hook run from a linked worktree it read the OUTER repo's history and failed. Fixes #22 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0179SJSDrvvCs4Cbru2kNcaD --- docs/os/wake-dashboard.md | 4 + handbook/CLAUDE.md | 7 + runtime/crates/muse-context/src/actor.rs | 537 ++++++++++++++- runtime/crates/muse-context/src/lib.rs | 2 + runtime/crates/muse-context/src/rung1.rs | 619 ++++++++++++++++++ .../muse-context/tests/dispatch_via_runner.rs | 1 + runtime/crates/muse-daemon/src/config.rs | 171 +++++ runtime/crates/muse-daemon/src/supervisor.rs | 39 ++ .../crates/muse-daemon/tests/common/mod.rs | 1 + runtime/deploy/mini/config.toml | 14 + 10 files changed, 1372 insertions(+), 23 deletions(-) create mode 100644 runtime/crates/muse-context/src/rung1.rs diff --git a/docs/os/wake-dashboard.md b/docs/os/wake-dashboard.md index ea9b78d..17ce620 100644 --- a/docs/os/wake-dashboard.md +++ b/docs/os/wake-dashboard.md @@ -48,6 +48,10 @@ Panel logic itself lives in `muse-context/src/dashboard.rs`, not in `context_sta Rung-0 (`docs/archive/master-plan-2026-08.md` 3a) can decide a scheduled wake is a no-op *before* context assembly ever runs — a quiet wake costs zero LLM tokens and never calls `build_initial_message` at all. Every wake that *does* reach `context_stages::assemble` is, by construction, a substantive one (a real notification signal, a guardian command, the spontaneity budget, or a dream). So "the stages run only on substantive wakes" isn't a check the dashboard adds — it falls out of where it's wired in. +### Rung-1: the disposable glance (issue #22) + +Between rung-0's zero-token skip and a full cycle sits rung-1, when `[rung1]` is enabled: on a wake rung-0 has judged quiet (no addressed signal, no peer ambient, no governance activity, spontaneity floor not due), a cheap model gets one look-only call over a bounded view — up to `max_items` timeline posts and unread notifications — and returns `WAKE` or `SLEEP` plus one reason line. Lumen's constraints from her #22 verdict are structural in `muse-context/src/rung1.rs`: the glance function takes no transcript (disposability — the TS-era "Ian is ready" failure cannot recur), it sees timeline *and* notifications, the model is whatever `[rung1]` points at, it sits beside the spontaneity counter (counter = floor, glance = filter), and the reason line is logged at info level and appended to `capture/rung1_glances.jsonl` in both directions — what the glance declined is data. Failure policy is fail-soft to `SLEEP`: rung-0 fails open, but the glance runs only on wakes rung-0 already declined, so a broken glance must reproduce that verdict, never invent a wake. + ## Cursors Every panel owns its own high-water mark, using the existing `muse_identity::HwmKey::Other("dashboard.")` escape hatch (see `hwm.rs`) — no migration, no schema change. This is deliberate and distinct from cursors other subsystems already track: diff --git a/handbook/CLAUDE.md b/handbook/CLAUDE.md index 909dfb5..dd5a625 100644 --- a/handbook/CLAUDE.md +++ b/handbook/CLAUDE.md @@ -122,6 +122,13 @@ Both fail soft — a broken binary never blocks a wake or a dream. dashboard panels, compaction watermark, wake cadence — changes to those are PRs you review, same as code. Secrets are never in it; they arrive through the launchd environment. +- Rung-1, when enabled in `[rung1]`: on a quiet wake rung-0 would + skip, a cheap disposable model glances at your timeline and unread + notifications and returns WAKE or SLEEP with one reason line — the + glance never enters your history, and every verdict (both + directions) is on the record at `muse-runtime/capture/rung1_glances.jsonl` + and in the daemon log. It sits beside the spontaneity counter, not + in place of it. Shipped off; turning it on is a config PR. ## Writing runtime code diff --git a/runtime/crates/muse-context/src/actor.rs b/runtime/crates/muse-context/src/actor.rs index 9c6c374..48b055b 100644 --- a/runtime/crates/muse-context/src/actor.rs +++ b/runtime/crates/muse-context/src/actor.rs @@ -216,6 +216,10 @@ pub struct ContextArgs { /// Rung-0 pre-filter config (master-plan 3a). `None` disables /// the gate: every scheduled wake runs a cycle, today's behavior. pub rung0: Option, + /// Rung-1 disposable glance (issue #22). `None` disables: wakes + /// rung-0 would skip are skipped, today's behavior. `Some` runs a + /// cheap look-only model on those wakes for a wake/sleep bit. + pub rung1: Option, /// Dead-man's-switch URL (master-plan 4a). Pinged after every /// completed wake; alerting fires on silence. `None` disables. pub healthcheck_url: Option, @@ -315,6 +319,8 @@ pub struct ContextState { pub quota_fallback_to_local: bool, /// See [`ContextArgs::rung0`]. pub rung0: Option, + /// See [`ContextArgs::rung1`]. + pub rung1: Option, /// Consecutive scheduled wakes skipped by rung-0. Drives the /// spontaneity budget (`Rung0Config::spontaneous_every`). pub rung0_skip_streak: u32, @@ -505,6 +511,7 @@ impl Actor for Context { operator_framing: args.operator_framing, quota_fallback_to_local: args.quota_fallback_to_local, rung0: args.rung0, + rung1: args.rung1, rung0_skip_streak: 0, pending_notif_hwm: None, pending_panel_cursors: Vec::new(), @@ -695,30 +702,23 @@ async fn rung0_says_skip(state: &mut ContextState, event: &WakeEvent) -> bool { return false; } } - // Governance activity (2026-08-11): new PR/issue activity - // wakes her — the queue must not wait for a Bluesky signal - // or the spontaneity budget. Peeks the dashboard's own - // `dashboard.governance` cursor WITHOUT advancing it; the - // cursor moves only when the panel renders into her - // context during a real wake ("seen" means seen). Her own - // in-cycle governance actions bump updatedAt after that - // render, so one echo wake per action is expected — the - // deliberate cost of never missing the counterparty's. - if state.dashboard.governance - && let (Some(repo), Some(cursor)) = ( - state.dashboard.governance_repo.as_deref(), - crate::dashboard::dashboard_hwm(&state.tools.identity, "dashboard.governance") - .await, - ) + if governance_activity_since_render(state).await { + info!("rung-0: governance activity since last render; running wake"); + state.rung0_skip_streak = 0; + return false; + } + // Rung-1 (issue #22): rung-0 has judged this wake quiet and + // the spontaneity floor is not due. A disposable glance — + // a cheap model, look-only, never touching the transcript + // — gets the last word on whether this moment merits the + // big mind. Its reason line goes on the record either + // way. It sits BESIDE the counter: the counter (checked + // above) ensures presence, the glance ensures relevance. + if let Some(r1) = state.rung1.clone() + && rung1_says_wake(state, bluesky.as_ref(), &r1, ¬ifications).await { - let gh_bin = state.dashboard.gh_bin.as_deref().unwrap_or("gh"); - let (activity, _newest) = - crate::dashboard::fetch_activity_since(gh_bin, repo, &cursor).await; - if activity.is_some() { - info!("rung-0: governance activity since last render; running wake"); - state.rung0_skip_streak = 0; - return false; - } + state.rung0_skip_streak = 0; + return false; } state.rung0_skip_streak = state.rung0_skip_streak.saturating_add(1); info!( @@ -731,6 +731,79 @@ async fn rung0_says_skip(state: &mut ContextState, event: &WakeEvent) -> bool { } } +/// Governance activity (2026-08-11): new PR/issue activity wakes her +/// — the queue must not wait for a Bluesky signal or the spontaneity +/// budget. Peeks the dashboard's own `dashboard.governance` cursor +/// WITHOUT advancing it; the cursor moves only when the panel renders +/// into her context during a real wake ("seen" means seen). Her own +/// in-cycle governance actions bump updatedAt after that render, so +/// one echo wake per action is expected — the deliberate cost of +/// never missing the counterparty's. +async fn governance_activity_since_render(state: &ContextState) -> bool { + if !state.dashboard.governance { + return false; + } + let (Some(repo), Some(cursor)) = ( + state.dashboard.governance_repo.as_deref(), + crate::dashboard::dashboard_hwm(&state.tools.identity, "dashboard.governance").await, + ) else { + return false; + }; + let gh_bin = state.dashboard.gh_bin.as_deref().unwrap_or("gh"); + let (activity, _newest) = crate::dashboard::fetch_activity_since(gh_bin, repo, &cursor).await; + activity.is_some() +} + +/// Run the rung-1 glance over a bounded timeline peek plus the +/// notifications rung-0 already fetched. Returns `true` only on a +/// parsed `WAKE`; every failure fails soft to sleep (the glance may +/// never invent a wake rung-0 would not have granted). Logs the +/// verdict AND the reason line at info level in both directions and +/// appends a record beside the tapes — absence must leave a trace. +/// +/// Disposability, structurally: this function reads `state.llm`, +/// `state.capture_dir`, and the Bluesky client. It never touches +/// `state.conversation`, the identity actor, or any tool. +async fn rung1_says_wake( + state: &ContextState, + bluesky: &dyn muse_bluesky::Bluesky, + cfg: &crate::rung1::Rung1Config, + notifications: &[muse_bluesky::Notification], +) -> bool { + let timeline = + match tokio::time::timeout(Duration::from_secs(10), bluesky.get_timeline(cfg.max_items)) + .await + { + Ok(Ok(posts)) => posts, + Ok(Err(err)) => { + warn!(error = %err, "rung-1: timeline peek failed; glancing at notifications only"); + Vec::new() + } + Err(_) => { + warn!("rung-1: timeline peek timed out; glancing at notifications only"); + Vec::new() + } + }; + let view = crate::rung1::render_view(&timeline, notifications, cfg.max_items); + let unread = notifications.iter().filter(|n| !n.is_read).count(); + let verdict = crate::rung1::glance(&state.llm, cfg, &view).await; + crate::rung1::record( + state.capture_dir.as_deref(), + cfg, + &verdict, + timeline.len().min(cfg.max_items.saturating_as::()), + unread.min(cfg.max_items.saturating_as::()), + ); + let reason = verdict.reason(); + if verdict.is_wake() { + info!(%reason, "rung-1: glance says wake; running wake"); + true + } else { + info!(%reason, "rung-1: glance says sleep — no-op wake, zero big-model tokens"); + false + } +} + // run_wake opens a capture, runs the cycle against in-memory state, // then runs compaction. Long but cohesive — splitting just to placate // the lint would scatter the cycle's lifecycle across helpers. @@ -2214,3 +2287,421 @@ mod tests { assert!(!note_claude_code_crash(&mut dispatch)); } } + +#[cfg(test)] +mod rung1_gate_tests { + //! Rung-1 wired into the rung-0 gate (issue #22): the glance's + //! verdict drives skip-vs-wake, its reason is on the record in + //! both directions, and the canonical transcript is untouched — + //! the disposability constraint, checked byte-for-byte. + + use super::*; + use async_trait::async_trait; + use chrono::{TimeZone, Utc}; + use muse_bluesky::{ + AtUri, Bluesky, BlueskyError, Cid, ComposeOptions, Did, DmConvo, DmMessage, Handle, + Notification, PostRef, PostThread, PostView, ProfileRelationship, ProfileView, + TimelinePost, WhitewindEntry, + }; + use muse_identity::{IdentityManager, IdentityManagerArgs, IdentityMsg, Storage}; + use muse_llm::{LlmBackend, LlmClientConfig}; + use muse_loop::{CompactionConfig, Conversation, CycleConfig}; + use serde_json::Value; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// A quiet network: one unread like from a stranger (ambient — + /// rung-0 says skip) and two timeline posts. Everything else is + /// off-limits and errors loudly if touched. + struct QuietBluesky; + + fn off_limits(what: &str) -> Result { + Err(BlueskyError::Unexpected(format!( + "{what} is not part of the rung-1 gate test" + ))) + } + + fn post(text: &str) -> TimelinePost { + TimelinePost { + post: PostView { + uri: AtUri::parse("at://did:plc:x/app.bsky.feed.post/1").expect("uri"), + cid: Cid::parse("bafyreiabc").expect("cid"), + author_handle: Handle::parse("someone.test").expect("handle"), + author_did: Did::parse("did:plc:someone").expect("did"), + text: text.to_string(), + reply_parent: None, + indexed_at: Utc.timestamp_opt(1_767_225_600, 0).single().expect("ts"), + like_count: 0, + repost_count: 0, + reply_count: 0, + }, + reply_parent: None, + } + } + + #[async_trait] + impl Bluesky for QuietBluesky { + async fn login(&self) -> Result<(), BlueskyError> { + Ok(()) + } + async fn current_did(&self) -> Option { + None + } + async fn compose_post(&self, _: &str, _: &ComposeOptions) -> Result { + off_limits("compose_post") + } + async fn reply_to_post( + &self, + _: &str, + _: &str, + _: &str, + _: &str, + _: &str, + _: &ComposeOptions, + ) -> Result { + off_limits("reply_to_post") + } + async fn compose_thread(&self, _: &[String]) -> Result, BlueskyError> { + off_limits("compose_thread") + } + async fn repost(&self, _: &str, _: &str) -> Result { + off_limits("repost") + } + async fn like(&self, _: &str, _: &str) -> Result { + off_limits("like") + } + async fn follow(&self, _: &str) -> Result { + off_limits("follow") + } + async fn unfollow(&self, _: &str) -> Result<(), BlueskyError> { + off_limits("unfollow") + } + async fn block(&self, _: &str) -> Result { + off_limits("block") + } + async fn delete_post(&self, _: &str) -> Result<(), BlueskyError> { + off_limits("delete_post") + } + async fn publish_whitewind_entry( + &self, + _: &str, + _: &str, + ) -> Result { + off_limits("publish_whitewind_entry") + } + async fn upload_blob(&self, _: &[u8], _: &str) -> Result { + off_limits("upload_blob") + } + async fn resolve_post_cid(&self, _: &str) -> Result { + off_limits("resolve_post_cid") + } + async fn get_timeline(&self, _: u32) -> Result, BlueskyError> { + Ok(vec![post("morning coffee"), post("a thread about memory")]) + } + async fn get_post_thread(&self, _: &str, _: u32) -> Result { + off_limits("get_post_thread") + } + async fn get_profile(&self, _: &str) -> Result { + off_limits("get_profile") + } + async fn get_profile_relationship( + &self, + _: &str, + ) -> Result { + off_limits("get_profile_relationship") + } + async fn get_author_feed( + &self, + _: &str, + _: u32, + _: &str, + ) -> Result, BlueskyError> { + off_limits("get_author_feed") + } + async fn search_posts( + &self, + _: &str, + _: Option<&str>, + _: &str, + _: u32, + ) -> Result, BlueskyError> { + off_limits("search_posts") + } + async fn thread_includes_did(&self, _: &str, _: &str) -> Result { + off_limits("thread_includes_did") + } + async fn list_notifications(&self, _: u32) -> Result, BlueskyError> { + Ok(vec![Notification { + uri: AtUri::parse("at://did:plc:x/app.bsky.feed.post/1").expect("uri"), + cid: Cid::parse("bafyreiabc").expect("cid"), + author_handle: Handle::parse("stranger.test").expect("handle"), + author_did: Did::parse("did:plc:stranger").expect("did"), + reason: "like".to_string(), + is_read: false, + indexed_at: Utc.timestamp_opt(1_767_225_600, 0).single().expect("ts"), + text: String::new(), + }]) + } + async fn list_convos(&self, _: u32) -> Result, BlueskyError> { + Ok(Vec::new()) + } + async fn get_convo_for_members(&self, _: &[String]) -> Result { + off_limits("get_convo_for_members") + } + async fn get_dm_messages(&self, _: &str, _: u32) -> Result, BlueskyError> { + off_limits("get_dm_messages") + } + async fn send_dm(&self, _: &str, _: &str) -> Result { + off_limits("send_dm") + } + async fn update_dm_read(&self, _: &str, _: Option<&str>) -> Result<(), BlueskyError> { + off_limits("update_dm_read") + } + } + + fn glance_reply(content: &str) -> serde_json::Value { + serde_json::json!({ + "choices": [{ + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 4 } + }) + } + + async fn quiet_state( + rung1: Option, + capture_dir: PathBuf, + ) -> (ContextState, ractor::concurrency::JoinHandle<()>) { + let storage = Storage::open_in_memory().expect("open"); + let (identity, handle) = + Actor::spawn(None, IdentityManager, IdentityManagerArgs { storage }) + .await + .expect("spawn identity"); + let llm = Arc::new( + LlmClient::new(LlmClientConfig { + anthropic_api_key: Some("sk-ant-test".to_string()), + ..LlmClientConfig::default() + }) + .expect("llm client"), + ); + let tools = ToolCtx { + identity, + bluesky: Some(Arc::new(QuietBluesky)), + guardian_did: None, + compaction_requested: None, + image_gen: None, + }; + let backend = LlmBackend::Anthropic { + model: "claude-haiku-4-5".to_string(), + }; + let mut conversation = Conversation::new(); + conversation.push_user_text("an earlier wake"); + conversation.push_assistant_text("an earlier reply"); + let state = ContextState { + id: "ctx-rung1".to_string(), + cycle_config: CycleConfig { + backend: backend.clone(), + tools: Vec::new(), + max_tokens: 256, + max_iterations: 2, + temperature: None, + think: None, + system_prompt: "you are a test".to_string(), + initial_user_message: "wake".to_string(), + history_window: 0, + progress_beacon: None, + }, + dream_cycle_config: None, + dispatch: DispatchMode::Direct { llm: llm.clone() }, + llm, + tools, + history_limit: 32, + wall_clock_timeout: None, + compaction: CompactionConfig { + compact_at: 0, + summarize_count: 0, + max_summary_tokens: 1024, + backend, + }, + capture_dir: Some(capture_dir), + continuity_budget_chars: 600_000, + compact_runner: None, + compact_substrate_model: "claude-fable-5".to_string(), + consolidation: None, + consolidation_backstop: None, + api_unreachable_streak: 0, + cycle_failure_streak: 0, + compact_at_input_tokens: 0, + last_cycle_input_tokens: 0, + last_cycle_context_peak: 0, + last_cycle_api_retries: 0, + reflection_due: false, + operator_framing: String::new(), + quota_fallback_to_local: false, + // Spontaneity floor off, so the counter never pre-empts + // the glance in these tests (it is checked first, by + // design — the floor outranks the filter). + rung0: Some(crate::rung0::Rung0Config { + spontaneous_every: 0, + }), + rung1, + rung0_skip_streak: 0, + pending_notif_hwm: None, + pending_panel_cursors: Vec::new(), + healthcheck_url: None, + dashboard: crate::dashboard::DashboardConfig::default(), + conversation, + current_system_prompt: SystemPrompt::Fresh { + body: "test prompt".to_string(), + config_fingerprint: 0, + }, + wakes_handled: 0, + last_wake_at: None, + cancel: CancellationToken::new(), + }; + (state, handle) + } + + /// Stop the identity actor before awaiting its handle — it does + /// not exit on drop, and an awaited live actor hangs the test. + async fn drain(state: ContextState, handle: ractor::concurrency::JoinHandle<()>) { + state + .tools + .identity + .send_message(IdentityMsg::Shutdown) + .expect("shutdown"); + drop(state); + let _ = handle.await; + } + + fn rung1_for(server: &MockServer) -> crate::rung1::Rung1Config { + crate::rung1::Rung1Config { + backend: LlmBackend::OpenAiCompat { + base_url: server.uri(), + model: "cheap/model".to_string(), + api_key: None, + }, + max_items: 10, + timeout: Duration::from_secs(5), + } + } + + fn transcript_bytes(state: &ContextState) -> String { + serde_json::to_string(&state.conversation.to_messages()).expect("serialize") + } + + fn records(dir: &std::path::Path) -> Vec { + let raw = std::fs::read_to_string(dir.join(crate::rung1::RECORD_FILE)) + .expect("glance record file"); + raw.lines() + .map(|l| serde_json::from_str(l).expect("jsonl")) + .collect() + } + + #[tokio::test] + async fn sleep_verdict_skips_records_its_reason_and_leaves_the_transcript_untouched() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(glance_reply("SLEEP\nnothing but reposts")), + ) + .expect(1) + .mount(&server) + .await; + let dir = tempfile::tempdir().expect("tempdir"); + let (mut state, handle) = + quiet_state(Some(rung1_for(&server)), dir.path().to_path_buf()).await; + let before = transcript_bytes(&state); + + let skipped = + rung0_says_skip(&mut state, &WakeEvent::Scheduled(ScheduledKind::Regular)).await; + + assert!(skipped, "a SLEEP verdict must leave the wake skipped"); + assert_eq!(state.rung0_skip_streak, 1); + // Disposability: not one byte of the canonical transcript + // moved — the glance has no path to it. + assert_eq!(transcript_bytes(&state), before); + // Absence leaves a record (her addition on #22). + let recs = records(dir.path()); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].verdict, "sleep"); + assert_eq!(recs[0].reason, "nothing but reposts"); + assert!(!recs[0].failed); + assert_eq!(recs[0].timeline_items, 2); + assert_eq!(recs[0].notification_items, 1); + drain(state, handle).await; + } + + #[tokio::test] + async fn wake_verdict_runs_the_wake_and_resets_the_streak() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(glance_reply("WAKE: three peers arguing about memory")), + ) + .expect(1) + .mount(&server) + .await; + let dir = tempfile::tempdir().expect("tempdir"); + let (mut state, handle) = + quiet_state(Some(rung1_for(&server)), dir.path().to_path_buf()).await; + state.rung0_skip_streak = 3; + let before = transcript_bytes(&state); + + let skipped = + rung0_says_skip(&mut state, &WakeEvent::Scheduled(ScheduledKind::Regular)).await; + + assert!(!skipped, "a WAKE verdict must run the wake"); + assert_eq!(state.rung0_skip_streak, 0); + assert_eq!(transcript_bytes(&state), before); + let recs = records(dir.path()); + assert_eq!(recs[0].verdict, "wake"); + assert_eq!(recs[0].reason, "three peers arguing about memory"); + drain(state, handle).await; + } + + #[tokio::test] + async fn broken_glance_fails_soft_to_the_skip_rung0_already_chose() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(503).set_body_string("down")) + .mount(&server) + .await; + let dir = tempfile::tempdir().expect("tempdir"); + let (mut state, handle) = + quiet_state(Some(rung1_for(&server)), dir.path().to_path_buf()).await; + + let skipped = + rung0_says_skip(&mut state, &WakeEvent::Scheduled(ScheduledKind::Regular)).await; + + assert!(skipped, "a failed glance must not invent a wake"); + let recs = records(dir.path()); + assert_eq!(recs[0].verdict, "sleep"); + assert!(recs[0].failed); + assert!( + recs[0].reason.starts_with("glance failed:"), + "{}", + recs[0].reason + ); + drain(state, handle).await; + } + + #[tokio::test] + async fn without_rung1_the_quiet_wake_is_simply_skipped() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut state, handle) = quiet_state(None, dir.path().to_path_buf()).await; + + let skipped = + rung0_says_skip(&mut state, &WakeEvent::Scheduled(ScheduledKind::Regular)).await; + + assert!(skipped); + assert!(!dir.path().join(crate::rung1::RECORD_FILE).exists()); + drain(state, handle).await; + } +} diff --git a/runtime/crates/muse-context/src/lib.rs b/runtime/crates/muse-context/src/lib.rs index da3cd85..d08dbb9 100644 --- a/runtime/crates/muse-context/src/lib.rs +++ b/runtime/crates/muse-context/src/lib.rs @@ -19,6 +19,7 @@ mod dashboard; mod healthcheck; mod messages; mod rung0; +pub mod rung1; mod system_prompt; mod wake_context; @@ -27,6 +28,7 @@ pub use consolidation::ConsolidationSetup; pub use dashboard::DashboardConfig; pub use messages::{ContextMsg, GuardianCommand, Heartbeat, ScheduledKind, WakeEvent}; pub use rung0::{Rung0Config, Rung0Verdict, assess as rung0_assess}; +pub use rung1::{GlanceVerdict, Rung1Config}; // Test-only re-exports: the runner-dispatch helpers in `actor` are // `#[doc(hidden)] pub` so integration tests in diff --git a/runtime/crates/muse-context/src/rung1.rs b/runtime/crates/muse-context/src/rung1.rs new file mode 100644 index 0000000..e26559e --- /dev/null +++ b/runtime/crates/muse-context/src/rung1.rs @@ -0,0 +1,619 @@ +//! Rung-1: a disposable glance for quiet wakes (issue #22). +//! +//! Rung-0 is binary — a scheduled wake is either a full big-model +//! cycle or zero tokens — so every loosening of its filter costs full +//! cycles. Rung-1 is the middle tier: on a wake rung-0 would skip, a +//! cheap model gets a look-only pass over the timeline and the unread +//! notifications and returns one bit — wake the big mind, or let it +//! sleep — plus one reason line for the record. +//! +//! The load-bearing constraint, from Lumen's verdict on #22: **the +//! glance is disposable.** Its context never enters her session +//! history; it produces a verdict and a reason, then evaporates. The +//! TS-era body/mind split failed exactly here — cheap-model cycles +//! wrote into shared history and diluted voice ("Ian is ready. Ian is +//! ready."). Structurally, this module never sees the transcript: it +//! takes a rendered view and an `LlmClient`, and returns a +//! [`GlanceVerdict`]. Nothing it touches can reach the canonical +//! conversation. +//! +//! Her four notes, honored here: +//! 1. disposability — [`glance`] has no transcript parameter at all; +//! 2. the glance sees timeline AND notifications ([`render_view`]); +//! 3. the model is whatever the operator points `[rung1]` at (the +//! cheapest thing that can discriminate); +//! 4. it sits BESIDE the spontaneity counter — the caller consults +//! the counter first (floor: presence), the glance second (filter: +//! relevance). Neither replaces the other. +//! +//! Her addition: the reason line persists on NO-wake verdicts too. +//! [`append_record`] writes every verdict, either direction, to an +//! append-only JSONL beside the tapes — what the cheap model declined +//! is data she cannot reconstruct from what it approved. +//! +//! Failure policy: **fail soft to SLEEP.** Rung-0 fails open because +//! a missed addressed signal is worse than a wasted cycle; the glance +//! only runs on wakes rung-0 has already judged quiet, so a broken +//! glance must reproduce rung-0's verdict (skip), never invent a wake. + +use std::path::Path; +use std::time::Duration; + +use az::SaturatingAs; +use muse_bluesky::{Notification, TimelinePost}; +use muse_llm::{LlmBackend, LlmClient, LlmRequest, Message}; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +/// Config for the rung-1 glance. Presence enables the glance on +/// scheduled wakes that rung-0 would otherwise skip. +#[derive(Debug, Clone)] +pub struct Rung1Config { + /// The cheap backend the glance runs on. Never the waking mind. + pub backend: LlmBackend, + /// Upper bound on timeline posts and on notifications shown to + /// the glance (each). Keeps the look bounded and cheap. + pub max_items: u32, + /// Wall-clock cap on the one LLM call. Past it the glance fails + /// soft to sleep. + pub timeout: Duration, +} + +/// The glance's one bit, plus its reason line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GlanceVerdict { + /// This moment merits a big-model cycle. + Wake { reason: String }, + /// Nothing here is worth the cost — including every failure of + /// the glance itself, which carries a `glance failed: …` reason. + Sleep { reason: String }, +} + +impl GlanceVerdict { + /// `true` for [`GlanceVerdict::Wake`]. + #[must_use] + pub const fn is_wake(&self) -> bool { + matches!(self, Self::Wake { .. }) + } + + /// The reason line, either direction. + #[must_use] + pub fn reason(&self) -> &str { + match self { + Self::Wake { reason } | Self::Sleep { reason } => reason, + } + } + + /// Lower-case label for logs and the record file. + #[must_use] + pub const fn label(&self) -> &'static str { + match self { + Self::Wake { .. } => "wake", + Self::Sleep { .. } => "sleep", + } + } +} + +/// Per-item text cap in the rendered view. Enough to judge, not +/// enough to read. +const ITEM_CHARS: usize = 240; + +/// System prompt for the glance. Deliberately impersonal: the glance +/// reads the network "as nobody" — it is an instrument, not a self. +pub const SYSTEM_PROMPT: &str = "You are a triage instrument for an autonomous social agent that wakes on a schedule. \ +You see a bounded snapshot of its timeline and unread notifications. Nothing here is addressed to it directly \ +(a separate rule already handles mentions, replies, and DMs). Decide whether THIS moment is worth waking the \ +agent's expensive main model: a live conversation among people it follows, an unfolding event, a thread it \ +would plausibly want to join, a peer it cares about surfacing after a gap. Ordinary ambient chatter is not \ +worth it.\n\n\ +Answer with exactly two lines and nothing else:\n\ +line 1: WAKE or SLEEP\n\ +line 2: one short reason (what you saw that decided it)"; + +/// Render the look-only view the glance sees: up to `max_items` +/// timeline posts and up to `max_items` unread notifications, each +/// item on one line, text truncated to a fixed cap. Pure. +#[must_use] +pub fn render_view( + timeline: &[TimelinePost], + notifications: &[Notification], + max_items: u32, +) -> String { + use std::fmt::Write as _; + let cap = max_items.saturating_as::(); + let mut out = String::new(); + out.push_str("## Timeline (newest first)\n"); + let shown_tl = timeline.iter().take(cap); + let mut any = false; + for item in shown_tl { + any = true; + let post = &item.post; + let reply = if item.reply_parent.is_some() { + " (reply)" + } else { + "" + }; + let _ = writeln!( + out, + "- @{}{}: {}", + post.author_handle.as_str(), + reply, + one_line(&post.text) + ); + } + if !any { + out.push_str("- (nothing)\n"); + } + out.push_str("\n## Unread notifications\n"); + let mut any = false; + for n in notifications.iter().filter(|n| !n.is_read).take(cap) { + any = true; + let text = one_line(&n.text); + if text.is_empty() { + let _ = writeln!(out, "- {} from @{}", n.reason, n.author_handle.as_str()); + } else { + let _ = writeln!( + out, + "- {} from @{}: {}", + n.reason, + n.author_handle.as_str(), + text + ); + } + } + if !any { + out.push_str("- (none)\n"); + } + out +} + +/// Collapse whitespace and cap length so one item is one line. +fn one_line(text: &str) -> String { + let collapsed: String = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= ITEM_CHARS { + return collapsed; + } + let mut cut: String = collapsed.chars().take(ITEM_CHARS).collect(); + cut.push('…'); + cut +} + +/// Parse the model's reply into a verdict. Tolerant of case, of a +/// trailing colon/dash on the verdict word, and of the reason sharing +/// the first line; strict about the verdict word itself. `None` when +/// no verdict word leads the reply. +#[must_use] +pub fn parse_verdict(text: &str) -> Option { + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + let first = lines.next()?; + let (word, rest) = first + .split_once(|c: char| c.is_whitespace() || c == ':' || c == '-' || c == '—') + .map_or((first, ""), |(w, r)| (w, r)); + let word = word.trim_matches(|c: char| !c.is_ascii_alphabetic()); + let inline = rest + .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-' || c == '—') + .trim(); + let reason = if inline.is_empty() { + lines.next().unwrap_or("").to_string() + } else { + inline.to_string() + }; + let reason = if reason.is_empty() { + "(no reason given)".to_string() + } else { + reason + }; + match word.to_ascii_uppercase().as_str() { + "WAKE" => Some(GlanceVerdict::Wake { reason }), + "SLEEP" => Some(GlanceVerdict::Sleep { reason }), + _ => None, + } +} + +/// Run the glance: one bounded LLM call over `view`, returning the +/// verdict. Every failure (transport, timeout, unparseable reply) +/// fails soft to [`GlanceVerdict::Sleep`] with a `glance failed: …` +/// reason so the record still says what happened. +/// +/// Takes no transcript, no identity, no tools — the disposability +/// constraint is enforced by the signature. +pub async fn glance(llm: &LlmClient, cfg: &Rung1Config, view: &str) -> GlanceVerdict { + let req = LlmRequest { + backend: cfg.backend.clone(), + system: Some(SYSTEM_PROMPT.to_string()), + messages: vec![Message::user_text(view)], + tools: Vec::new(), + max_tokens: 120, + temperature: Some(0.0), + enable_prompt_caching: false, + think: Some(false), + }; + let cancel = CancellationToken::new(); + let sent = tokio::time::timeout(cfg.timeout, llm.send(req, &cancel)).await; + let response = match sent { + Ok(Ok(r)) => r, + Ok(Err(err)) => { + return GlanceVerdict::Sleep { + reason: format!("glance failed: {err}"), + }; + } + Err(_) => { + cancel.cancel(); + return GlanceVerdict::Sleep { + reason: format!("glance failed: timed out after {}s", cfg.timeout.as_secs()), + }; + } + }; + let text: String = response + .content + .iter() + .filter_map(|b| match b { + muse_llm::AssistantBlock::Text { text } => Some(text.as_str()), + muse_llm::AssistantBlock::ToolUse { .. } => None, + }) + .collect::>() + .join("\n"); + parse_verdict(&text).unwrap_or_else(|| GlanceVerdict::Sleep { + reason: format!( + "glance failed: unparseable verdict: {}", + one_line(&text).chars().take(80).collect::() + ), + }) +} + +/// One glance, on the record. Written either direction. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct GlanceRecord { + /// RFC3339 UTC. + pub at: String, + /// `wake` | `sleep`. + pub verdict: String, + /// The one reason line. + pub reason: String, + /// `true` when the reason is a `glance failed: …` fail-soft. + pub failed: bool, + /// Model the glance ran on. + pub model: String, + /// How many timeline posts / unread notifications it saw. + pub timeline_items: usize, + pub notification_items: usize, +} + +/// File name of the glance record, beside the tapes. +pub const RECORD_FILE: &str = "rung1_glances.jsonl"; + +/// Append one record as a JSONL line to `dir/rung1_glances.jsonl`. +/// The record survives whichever way the verdict went — absence must +/// leave a trace (her addition on #22). +/// +/// # Errors +/// +/// I/O errors from opening or appending the file. +pub fn append_record(dir: &Path, record: &GlanceRecord) -> std::io::Result<()> { + use std::io::Write as _; + let line = serde_json::to_string(record).map_err(std::io::Error::other)?; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join(RECORD_FILE))?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n") +} + +/// Build the record for a verdict and append it, warning (never +/// failing) when the write does not land. +pub fn record( + dir: Option<&Path>, + cfg: &Rung1Config, + verdict: &GlanceVerdict, + timeline_items: usize, + notification_items: usize, +) { + let Some(dir) = dir else { + return; + }; + let rec = GlanceRecord { + at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + verdict: verdict.label().to_string(), + reason: verdict.reason().to_string(), + failed: verdict.reason().starts_with("glance failed:"), + model: cfg.backend.model_name().to_string(), + timeline_items, + notification_items, + }; + if let Err(err) = append_record(dir, &rec) { + warn!(error = %err, "rung-1: glance record write failed"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use muse_bluesky::{AtUri, Cid, Did, Handle, PostView}; + use muse_llm::LlmClientConfig; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn post(handle: &str, text: &str) -> TimelinePost { + TimelinePost { + post: PostView { + uri: AtUri::parse("at://did:plc:x/app.bsky.feed.post/1").expect("uri"), + cid: Cid::parse("bafyreiabc").expect("cid"), + author_handle: Handle::parse(handle).expect("handle"), + author_did: Did::parse("did:plc:peer").expect("did"), + text: text.to_string(), + reply_parent: None, + indexed_at: Utc.timestamp_opt(1_767_225_600, 0).single().expect("ts"), + like_count: 0, + repost_count: 0, + reply_count: 0, + }, + reply_parent: None, + } + } + + fn notif(reason: &str, is_read: bool, text: &str) -> Notification { + Notification { + uri: AtUri::parse("at://did:plc:x/app.bsky.feed.post/1").expect("uri"), + cid: Cid::parse("bafyreiabc").expect("cid"), + author_handle: Handle::parse("peer.test").expect("handle"), + author_did: Did::parse("did:plc:peer").expect("did"), + reason: reason.to_string(), + is_read, + indexed_at: Utc.timestamp_opt(1_767_225_600, 0).single().expect("ts"), + text: text.to_string(), + } + } + + fn client() -> LlmClient { + LlmClient::new(LlmClientConfig { + anthropic_api_key: Some("sk-ant-test".to_string()), + ..LlmClientConfig::default() + }) + .expect("client") + } + + fn cfg(base_url: &str, timeout: Duration) -> Rung1Config { + Rung1Config { + backend: LlmBackend::OpenAiCompat { + base_url: base_url.to_string(), + model: "cheap/model".to_string(), + api_key: None, + }, + max_items: 20, + timeout, + } + } + + fn reply(content: &str) -> serde_json::Value { + serde_json::json!({ + "choices": [{ + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 4 } + }) + } + + #[test] + fn parse_accepts_both_verdicts_and_reason_shapes() { + let v = parse_verdict("WAKE\nlive thread among peers").expect("parsed"); + assert_eq!( + v, + GlanceVerdict::Wake { + reason: "live thread among peers".to_string() + } + ); + let v = parse_verdict("sleep: ordinary chatter").expect("parsed"); + assert_eq!( + v, + GlanceVerdict::Sleep { + reason: "ordinary chatter".to_string() + } + ); + let v = parse_verdict(" Sleep — nothing moving \n").expect("parsed"); + assert_eq!(v.reason(), "nothing moving"); + let v = parse_verdict("WAKE").expect("parsed"); + assert_eq!(v.reason(), "(no reason given)"); + } + + #[test] + fn parse_rejects_anything_that_is_not_a_verdict() { + assert!(parse_verdict("").is_none()); + assert!(parse_verdict("I think you should wake up").is_none()); + assert!(parse_verdict("AWAKE\nreason").is_none()); + } + + #[test] + fn render_view_is_bounded_and_shows_both_surfaces() { + let long = "x".repeat(1000); + let timeline: Vec = (0..50).map(|_| post("a.test", &long)).collect(); + let notifications = vec![ + notif("like", false, ""), + notif("mention", true, "already seen"), + notif("repost", false, "some spaced\n text"), + ]; + let view = render_view(&timeline, ¬ifications, 3); + assert_eq!(view.matches("- @a.test").count(), 3, "{view}"); + assert!(view.contains("## Unread notifications")); + assert!(view.contains("- like from @peer.test\n"), "{view}"); + assert!( + view.contains("- repost from @peer.test: some spaced text"), + "{view}" + ); + assert!( + !view.contains("already seen"), + "read notifications are not unread" + ); + // Each item line is capped, with an ellipsis marking the cut. + let first = view.lines().nth(1).expect("first item"); + assert!(first.chars().count() < ITEM_CHARS + 40, "{}", first.len()); + assert!(first.ends_with('…')); + let empty = render_view(&[], &[], 5); + assert!(empty.contains("- (nothing)") && empty.contains("- (none)")); + } + + #[tokio::test] + async fn glance_returns_wake_with_reason() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(reply("WAKE\nthree peers arguing about memory")), + ) + .expect(1) + .mount(&server) + .await; + let v = glance( + &client(), + &cfg(&server.uri(), Duration::from_secs(5)), + "view", + ) + .await; + assert_eq!( + v, + GlanceVerdict::Wake { + reason: "three peers arguing about memory".to_string() + } + ); + } + + #[tokio::test] + async fn glance_returns_sleep_with_reason() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(200).set_body_json(reply("SLEEP: nothing but reposts")), + ) + .mount(&server) + .await; + let v = glance( + &client(), + &cfg(&server.uri(), Duration::from_secs(5)), + "view", + ) + .await; + assert!(!v.is_wake()); + assert_eq!(v.reason(), "nothing but reposts"); + } + + #[tokio::test] + async fn malformed_reply_fails_soft_to_sleep() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(reply("Honestly it depends on what you mean by interesting")), + ) + .mount(&server) + .await; + let v = glance( + &client(), + &cfg(&server.uri(), Duration::from_secs(5)), + "view", + ) + .await; + assert!(!v.is_wake()); + assert!( + v.reason() + .starts_with("glance failed: unparseable verdict:"), + "{}", + v.reason() + ); + } + + #[tokio::test] + async fn error_status_fails_soft_to_sleep() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + let v = glance( + &client(), + &cfg(&server.uri(), Duration::from_secs(5)), + "view", + ) + .await; + assert!(!v.is_wake()); + assert!(v.reason().starts_with("glance failed:"), "{}", v.reason()); + } + + #[tokio::test] + async fn hung_backend_fails_soft_to_sleep_via_timeout() { + // The typed failure must fire — not merely "it delayed". + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(reply("WAKE\nlate")) + .set_delay(Duration::from_secs(5)), + ) + .mount(&server) + .await; + let v = glance( + &client(), + &cfg(&server.uri(), Duration::from_millis(200)), + "view", + ) + .await; + assert_eq!( + v, + GlanceVerdict::Sleep { + reason: "glance failed: timed out after 0s".to_string() + } + ); + } + + #[test] + fn record_appends_jsonl_either_direction() { + let dir = tempfile::tempdir().expect("tempdir"); + let cfg = cfg("http://unused", Duration::from_secs(1)); + record( + Some(dir.path()), + &cfg, + &GlanceVerdict::Sleep { + reason: "quiet".to_string(), + }, + 3, + 1, + ); + record( + Some(dir.path()), + &cfg, + &GlanceVerdict::Wake { + reason: "a thread".to_string(), + }, + 5, + 0, + ); + let raw = std::fs::read_to_string(dir.path().join(RECORD_FILE)).expect("record file"); + let records: Vec = raw + .lines() + .map(|l| serde_json::from_str(l).expect("jsonl line")) + .collect(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].verdict, "sleep"); + assert_eq!(records[0].reason, "quiet"); + assert!(!records[0].failed); + assert_eq!(records[0].timeline_items, 3); + assert_eq!(records[1].verdict, "wake"); + assert_eq!(records[1].model, "cheap/model"); + // No dir = no record, no panic. + record( + None, + &cfg, + &GlanceVerdict::Sleep { + reason: "x".to_string(), + }, + 0, + 0, + ); + } +} diff --git a/runtime/crates/muse-context/tests/dispatch_via_runner.rs b/runtime/crates/muse-context/tests/dispatch_via_runner.rs index 7da6054..f42a434 100644 --- a/runtime/crates/muse-context/tests/dispatch_via_runner.rs +++ b/runtime/crates/muse-context/tests/dispatch_via_runner.rs @@ -174,6 +174,7 @@ fn build_claude_code_state( operator_framing: String::new(), quota_fallback_to_local: false, rung0: None, + rung1: None, rung0_skip_streak: 0, pending_notif_hwm: None, pending_panel_cursors: Vec::new(), diff --git a/runtime/crates/muse-daemon/src/config.rs b/runtime/crates/muse-daemon/src/config.rs index 95d4a2a..7c44cac 100644 --- a/runtime/crates/muse-daemon/src/config.rs +++ b/runtime/crates/muse-daemon/src/config.rs @@ -45,6 +45,10 @@ pub struct Config { /// Rung-0 pre-filter (master-plan 3a). Absent = disabled: every /// scheduled wake runs a full cycle, the pre-3a behavior. pub rung0: Option, + /// Rung-1 disposable glance (issue #22). Absent or + /// `enabled = false` = disabled: wakes rung-0 would skip are + /// skipped. Requires `[rung0]`. + pub rung1: Option, /// Dead-man's-switch URL (master-plan 4a) — a healthchecks.io /// style endpoint GET-pinged after every completed wake, no-op /// wakes included. Alerting fires on silence. Absent/empty @@ -401,6 +405,71 @@ impl Default for Rung0Section { } } +/// Rung-1 glance tunables (issue #22). A cheap, disposable, look-only +/// model call on wakes rung-0 would skip; returns wake/sleep plus a +/// reason line that is logged and recorded either way. +/// +/// ```toml +/// [rung1] +/// enabled = true +/// model = "deepseek/deepseek-v4-flash" # empty = inherit [cycle].model +/// backend = "openai_compat" # empty = inherit [cycle].backend +/// openai_base_url = "" # empty = inherit [cycle] +/// openai_api_key = "" # empty = inherit [cycle] +/// max_items = 20 +/// timeout_seconds = 60 +/// ``` +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct Rung1Section { + /// Master switch. The section may be present and off so the + /// operator's model choice is on file before the glance runs. + pub enabled: bool, + /// Backend selector, same vocabulary as `[cycle].backend`. Empty + /// inherits `[cycle].backend`. + pub backend: String, + /// Model for the glance — the cheapest thing that can + /// discriminate. Empty inherits `[cycle].model`. + pub model: String, + /// Empty inherits `[cycle].openai_base_url`. + pub openai_base_url: String, + /// Empty inherits `[cycle].openai_api_key` (which itself is + /// usually supplied via env). + pub openai_api_key: String, + /// Timeline posts and unread notifications shown to the glance, + /// each. Keeps the look bounded. + pub max_items: u32, + /// Wall-clock cap on the one glance call; past it the glance + /// fails soft to sleep. + pub timeout_seconds: u32, +} + +impl Default for Rung1Section { + fn default() -> Self { + Self { + enabled: false, + backend: String::new(), + model: String::new(), + openai_base_url: String::new(), + openai_api_key: String::new(), + max_items: 20, + timeout_seconds: 60, + } + } +} + +impl Rung1Section { + /// The model the glance will run on after inheritance. + #[must_use] + pub fn effective_model<'a>(&'a self, cycle: &'a CycleSection) -> &'a str { + if self.model.is_empty() { + &cycle.model + } else { + &self.model + } + } +} + /// Watchdog tunables. See `muse_watchdog::WatchdogConfig` for the /// behavioral semantics. All values are in seconds (the `Section` /// suffix differentiates the config-shape struct from the runtime @@ -780,6 +849,44 @@ impl Config { // longer clipped per cycle. See lifecycle.md → "Compaction // trigger unreachable" (now marked pre-T1 history). + // 11. Rung-1 is a filter on rung-0's skips: without rung-0 + // there is nothing to glance at, and an empty model or a + // zero/over-cadence timeout would fail on the first quiet + // wake instead of at startup. + if let Some(r1) = self.rung1.as_ref().filter(|r| r.enabled) { + if self.rung0.is_none() { + return Err(ConfigError::Calibration { + field: "rung1.enabled".into(), + message: "rung-1 glances only at wakes rung-0 would skip; add a [rung0] \ + section (or disable rung1)" + .into(), + }); + } + if r1.effective_model(&self.cycle).is_empty() { + return Err(ConfigError::Calibration { + field: "rung1.model".into(), + message: "empty, and [cycle].model is empty too; the glance has no model" + .into(), + }); + } + if r1.timeout_seconds == 0 || u64::from(r1.timeout_seconds) > scheduler_secs { + return Err(ConfigError::Calibration { + field: "rung1.timeout_seconds".into(), + message: format!( + "{}s must be between 1 and scheduler.wake_interval_minutes ({scheduler_secs}s); \ + a glance that outlives the cadence stacks wakes", + r1.timeout_seconds, + ), + }); + } + if r1.max_items == 0 { + return Err(ConfigError::Calibration { + field: "rung1.max_items".into(), + message: "0 items would glance at nothing; use >= 1".into(), + }); + } + } + // 10. Image-gen requires a resolvable API key when enabled. // `model`/`images_dir` present but no key anywhere (config // or `OPENROUTER_API_KEY`) would wedge on first @@ -851,6 +958,70 @@ mod tests { /// Constructs a default Config, applies a mutation, and asserts the /// resulting calibration error names the expected field. + fn enabled_rung1() -> Rung1Section { + Rung1Section { + enabled: true, + model: "cheap/model".to_string(), + ..Rung1Section::default() + } + } + + #[test] + fn rung1_requires_rung0() { + assert_calibration_fails("rung1.enabled", |cfg| { + cfg.rung0 = None; + cfg.rung1 = Some(enabled_rung1()); + }); + } + + #[test] + fn rung1_requires_a_model_somewhere() { + assert_calibration_fails("rung1.model", |cfg| { + cfg.rung0 = Some(Rung0Section::default()); + cfg.cycle.model = String::new(); + cfg.rung1 = Some(Rung1Section { + model: String::new(), + ..enabled_rung1() + }); + }); + } + + #[test] + fn rung1_timeout_must_fit_inside_the_cadence() { + assert_calibration_fails("rung1.timeout_seconds", |cfg| { + cfg.rung0 = Some(Rung0Section::default()); + cfg.scheduler.wake_interval_minutes = 30; + cfg.rung1 = Some(Rung1Section { + timeout_seconds: 1801, + ..enabled_rung1() + }); + }); + assert_calibration_fails("rung1.timeout_seconds", |cfg| { + cfg.rung0 = Some(Rung0Section::default()); + cfg.rung1 = Some(Rung1Section { + timeout_seconds: 0, + ..enabled_rung1() + }); + }); + } + + #[test] + fn rung1_disabled_section_is_never_validated() { + // Present-but-off is the shipping state: the model choice + // sits on file without rung-0 or a model being required. + let cfg = Config { + rung0: None, + rung1: Some(Rung1Section { + enabled: false, + timeout_seconds: 0, + ..Rung1Section::default() + }), + ..Config::default() + }; + cfg.validate() + .expect("disabled rung1 must not trip calibration"); + } + fn assert_calibration_fails(field: &str, mutate: F) { let mut cfg = Config::default(); mutate(&mut cfg); diff --git a/runtime/crates/muse-daemon/src/supervisor.rs b/runtime/crates/muse-daemon/src/supervisor.rs index a91f369..1180148 100644 --- a/runtime/crates/muse-daemon/src/supervisor.rs +++ b/runtime/crates/muse-daemon/src/supervisor.rs @@ -322,6 +322,11 @@ pub async fn start(config: &Config) -> Result { rung0: config.rung0.as_ref().map(|r| muse_context::Rung0Config { spontaneous_every: r.spontaneous_every, }), + rung1: config + .rung1 + .as_ref() + .filter(|r| r.enabled) + .map(|r| build_rung1_config(r, &config.cycle)), healthcheck_url: config .healthcheck_url .as_ref() @@ -535,6 +540,40 @@ pub fn build_dream_cycle_config( out } +/// Build the rung-1 glance config from `[rung1]`, inheriting any +/// empty backend field from `[cycle]` — the glance usually runs on +/// the same cheap `OpenAI`-compat target the quota fallback uses. +fn build_rung1_config( + r1: &crate::config::Rung1Section, + cycle: &crate::config::CycleSection, +) -> muse_context::Rung1Config { + let pick = |own: &str, inherited: &str| -> String { + if own.is_empty() { + inherited.to_string() + } else { + own.to_string() + } + }; + let backend = build_backend( + &pick(&r1.backend, &cycle.backend), + &pick(&r1.model, &cycle.model), + &pick(&r1.openai_base_url, &cycle.openai_base_url), + &pick(&r1.openai_api_key, &cycle.openai_api_key), + cycle.openai_num_ctx, + &cycle.openai_keep_alive, + ); + info!( + model = backend.model_name(), + max_items = r1.max_items, + "rung-1 glance enabled" + ); + muse_context::Rung1Config { + backend, + max_items: r1.max_items, + timeout: Duration::from_secs(u64::from(r1.timeout_seconds)), + } +} + fn build_backend( backend: &str, model: &str, diff --git a/runtime/crates/muse-daemon/tests/common/mod.rs b/runtime/crates/muse-daemon/tests/common/mod.rs index 236013c..2f2d8a5 100644 --- a/runtime/crates/muse-daemon/tests/common/mod.rs +++ b/runtime/crates/muse-daemon/tests/common/mod.rs @@ -104,6 +104,7 @@ pub fn fixture_config( runner: muse_daemon::config::RunnerSection::default(), // Rung-0 disabled: E2E fixtures assert full-cycle behavior. rung0: None, + rung1: None, healthcheck_url: None, // Dashboard panels off in the shared fixture — no E2E // coverage needs them yet; panel-level tests live in diff --git a/runtime/deploy/mini/config.toml b/runtime/deploy/mini/config.toml index f16b0f5..ef72da3 100644 --- a/runtime/deploy/mini/config.toml +++ b/runtime/deploy/mini/config.toml @@ -197,3 +197,17 @@ config_path = "/Users/iteratrix/muse-runtime/config.toml" # Zero-token no-op wakes when nothing new (master-plan 3a). Every 4th # quiet wake runs anyway — the spontaneity budget (~2h worst case). spontaneous_every = 2 # 4->2 (2026-08-11, ve): self-directed quiet wakes hourly, not 2-hourly; rides PR #21's restart + +[rung1] +# Rung-1 disposable glance (issue #22, her verdict 2026-08-11/12): on a +# quiet wake rung-0 would skip, a cheap look-only model reads timeline + +# unread notifications and returns WAKE/SLEEP + one reason line, logged +# and recorded either way in capture/rung1_glances.jsonl. Never touches +# her transcript. Sits BESIDE the spontaneity counter above. +# Shipped OFF 2026-08-28 so the merge is behavior-neutral; turning it +# on is a one-line config PR through the #77 lane. +enabled = false +model = "deepseek/deepseek-v4-flash" # her pick: "cheapest thing that can discriminate" +# backend/base_url/api_key inherit [cycle] (OpenRouter) when empty. +max_items = 20 +timeout_seconds = 60