loopd: a failed heartbeat still advances last_heartbeat
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -252,7 +252,7 @@ impl Scheduler {
|
|||||||
let text = format!("It is {}.\n\n{file}", now.to_rfc3339());
|
let text = format!("It is {}.\n\n{file}", now.to_rfc3339());
|
||||||
let mut emit = |_event: &TurnEvent| {};
|
let mut emit = |_event: &TurnEvent| {};
|
||||||
let outcome = run_turn(&mut session, &runtime, &text, None, &mut emit);
|
let outcome = run_turn(&mut session, &runtime, &text, None, &mut emit);
|
||||||
let outcome = compact::finish_turn(
|
let _ = compact::finish_turn(
|
||||||
&mut session,
|
&mut session,
|
||||||
&runtime,
|
&runtime,
|
||||||
&*ctx.memory,
|
&*ctx.memory,
|
||||||
@@ -261,12 +261,10 @@ impl Scheduler {
|
|||||||
&mut emit,
|
&mut emit,
|
||||||
outcome,
|
outcome,
|
||||||
);
|
);
|
||||||
if outcome.is_ok() {
|
drop(held);
|
||||||
self.last_heartbeat = Some(now);
|
self.last_heartbeat = Some(now);
|
||||||
work.push(TickWork::Heartbeat);
|
work.push(TickWork::Heartbeat);
|
||||||
}
|
}
|
||||||
drop(held);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close_idle_leftovers(ctx: &Context, now: Timestamp) {
|
fn close_idle_leftovers(ctx: &Context, now: Timestamp) {
|
||||||
|
|||||||
@@ -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<Context>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| 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/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/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 |
|
| 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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user