From 30608995cacc44680dc1ec0222c251914eebd615 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 3 Oct 2026 18:24:18 -0700 Subject: [PATCH] loopd: a failed heartbeat still advances last_heartbeat Co-Authored-By: ornith-1.5-35b-a3b --- crates/loopd/src/scheduler.rs | 8 ++- .../loopd/tests/scheduler_heartbeat_failed.rs | 50 +++++++++++++++++++ docs/implementer-log.md | 1 + 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 crates/loopd/tests/scheduler_heartbeat_failed.rs diff --git a/crates/loopd/src/scheduler.rs b/crates/loopd/src/scheduler.rs index ab7c8e7..bc0cf54 100644 --- a/crates/loopd/src/scheduler.rs +++ b/crates/loopd/src/scheduler.rs @@ -252,7 +252,7 @@ impl Scheduler { let text = format!("It is {}.\n\n{file}", now.to_rfc3339()); let mut emit = |_event: &TurnEvent| {}; let outcome = run_turn(&mut session, &runtime, &text, None, &mut emit); - let outcome = compact::finish_turn( + let _ = compact::finish_turn( &mut session, &runtime, &*ctx.memory, @@ -261,11 +261,9 @@ impl Scheduler { &mut emit, outcome, ); - if outcome.is_ok() { - self.last_heartbeat = Some(now); - work.push(TickWork::Heartbeat); - } drop(held); + self.last_heartbeat = Some(now); + work.push(TickWork::Heartbeat); } } diff --git a/crates/loopd/tests/scheduler_heartbeat_failed.rs b/crates/loopd/tests/scheduler_heartbeat_failed.rs new file mode 100644 index 0000000..615b1f9 --- /dev/null +++ b/crates/loopd/tests/scheduler_heartbeat_failed.rs @@ -0,0 +1,50 @@ +//! A failed heartbeat turn still advances `last_heartbeat`. Do not edit. + +mod support; + +use bxcore::Timestamp; +use loopd::channel::Context; +use loopd::llama::Client; +use loopd::memory::NoMemory; +use loopd::scheduler::{Scheduler, TickWork}; +use loopd::tools::Registry; +use std::sync::Arc; +use support::{FakeServer, Home, Reply, ScriptedPort}; + +const CHAT: &str = "/v1/chat/completions"; +const PROMPT: &str = "Look around.\n"; + +fn ts(s: &str) -> Timestamp { + Timestamp::parse(s).unwrap() +} + +fn world() -> (Home, FakeServer, Arc) { + let home = Home::new(); + home.write("heartbeat.md", PROMPT); + home.write("compact.md", "Summarise.\n"); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.heartbeat.every_s = 60; + cfg.heartbeat.prompt = home.dir.join("heartbeat.md"); + server.route(CHAT, vec![Reply::fixture("bad_request")]); + let ctx = Arc::new(Context::new( + cfg.clone(), + Client::new(cfg), + Box::new(ScriptedPort::new(vec![])), + Registry::m2b(), + Box::new(NoMemory), + )); + (home, server, ctx) +} + +#[test] +fn a_failed_heartbeat_turn_still_advances_last_heartbeat() { + let (_home, server, ctx) = world(); + let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z")); + let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z")); + assert_eq!(work, vec![TickWork::Heartbeat]); + assert_eq!(server.requests_to(CHAT).len(), 1); + let work = sched.tick(&ctx, ts("2026-10-03T07:01:30.000Z")); + assert!(work.is_empty(), "{work:?}"); + assert_eq!(server.requests_to(CHAT).len(), 1); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index ec7b8a2..565d481 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5d2/11-loopd-heartbeat-failed-last | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_heartbeat_failed.rs` from `docs/plans/M5d2/files/`. Applied H1 in `crates/loopd/src/scheduler.rs` inside `heartbeat`: after the quoted block (found exactly once) the bookkeeping is now unconditional — `let _ = compact::finish_turn(...)` instead of binding `outcome`, then `drop(held)`, then `self.last_heartbeat = Some(now)` and `work.push(TickWork::Heartbeat)` with no `if outcome.is_ok()`. The unreadable-prompt and busy `return` paths stay untouched and never reach H1; cron `Finished`/`Failed` and the job arm are unchanged. `cargo test -p loopd --test scheduler_heartbeat_failed` failed first (`left: []`, `right: [Heartbeat]`), then passed with `scheduler_heartbeat` (6). `cargo check -p loopd` clean, no warning. `make gate` printed `gate: ok` with 1575 tests (1574 before, plus 1). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/10-memoryd-tiers | 2026-10-03 | done | 2 | fail | none | Copied the given tests `crates/memoryd/tests/tiers.rs` and `crates/bxctl/tests/cli_memory_tiers.rs` and the starter `config/jobs/tier.toml.example` from `docs/plans/M5d2/files/`. Added `MemoryCommand::Tiers` after `Sync` in `bxcore/src/memory.rs` with the doc comment from M1. Created `crates/memoryd/src/tiers.rs` with `list`: identity entries first (memory.md then user.md via `store.entries`, each `hits 0 last -`), then notes from `notes::parse`, each joined to `recalls/` on `(noted, session, record)` where `hits` counts the lines holding a matching hit and `last` is the max `time` among them or `-`; moved/dropped notes are still listed as `note` with class `private` and `trusted`, and untrusted notes are marked. `recalls/` is read via `store.path("recalls")`: a missing directory yields an empty tally (all zeros), a file that exists but cannot be read is an error, and a line that does not parse is skipped. `Logged` and `Hit` deserialize without `deny_unknown_fields` so `epoch` and `query` on a line are ignored. Used a small `Tally { n: u64, last: String }` so the map is `HashMap<(String, i64), Tally>` and clippy's `type_complexity` stays off. Added `pub mod tiers;` after `pub mod socket;` in lib.rs, the `Tiers` arm in `serve/admin.rs`, `["tiers"]` in bxctl/src/memory.rs, `| tiers` in the USAGE line, and the edited USAGE pin in cli_memory_sync.rs (left cli_memory.rs untouched). First `make gate` failed on a third leading space I had put on the USAGE memory line, which the `usage_explains_the_speaker_flag` indentation check caught; restored it to two spaces. `cargo check` clean, no warning; `wc -l` prints 139 for tiers.rs. All suites pass: `tiers` 4, `cli_memory_tiers` 2 (plus `cli_memory` 4 and `cli_memory_sync` 4). `make gate` printed `gate: ok` with 1574 tests (1568 before, plus 6). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/09-memoryd-summary-index | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/tool_summary_index.rs` from `docs/plans/M5d2/files/`. Edited `index_record` in `crates/memoryd/src/index.rs`, adding the `ToolSummary` arm before `_ => {}`: `Source { kind: "summary", role: "summary", class: *class, untrusted: *untrusted, ..base.clone() }` with `time` from the record, guarded by `if !content.trim().is_empty() && *class <= self.max_class`; `DataClass` implements `Ord` so `<=` compiles. The existing `if base.class > self.max_class` guard at the top of `index_record` stays, and the rolling-label update in `index_log` still looks at `ToolResult` only (no `ToolSummary` added there). Updated the module doc "never a tool's result" to say summaries of opted-in results are indexed. `cargo check -p memoryd` clean, no error or warning. All suites pass: `tool_summary_index` 5, `recall` 6. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1568 tests (1563 before, plus 5). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/08-loopd-tool-summary | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/tool_summary_call.rs` and the starter `config/summarise.md` from `docs/plans/M5d2/files/`. Created `crates/loopd/src/summarise.rs` with the `missing` function: walks `session.records()` once, remembering `tool_calls` by `id -> name` and each `ToolSummary`'s `call` in `done`, queueing opted-in `ToolResult`s during the walk (an in-walk `done.contains` still queues), then dropping any whose call is already done after the walk; for each pending it builds `messages_with_carry` plus a `User` message of the trimmed `summarise.md` prompt and the result content capped by `tool_result_cap`, sends on `slots.background` with `tools: Vec::new()` and thinking on, neutralises and caps the completion by `summary_chars`, and appends a `ToolSummary` copying call/tool_call_id/class/untrusted; context-full or other infer errors log `loopd: tool summary: {e}` and skip. Added `pub mod summarise;` after `pub mod sse;` in `lib.rs` (rustfmt order). Edited `finish_turn` in `compact.rs` (F1) to call `summarise::missing` as the first statement of both the `Ok(done)` arm and the `Err(SessionFull)` arm, before `compact_session`. `cargo check -p loopd` clean, no error or warning; `wc -l` prints 96 for summarise.rs and 194 for compact.rs, both under 500. All suites pass: `tool_summary_call` 6, `tool_summary_messages` 3, `compact_turn` 6, `turn` 6. `CallId(pub u64)` `.0` field compiled. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1563 tests (1557 before, plus 6). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |