loopd: heartbeat tick on the background slot
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -47,16 +47,32 @@ impl Context {
|
|||||||
busy: Mutex::new(HashSet::new()),
|
busy: Mutex::new(HashSet::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark `id` busy. `Err(Busy)` if it already is. Tests and the scheduler use this so a skip
|
||||||
|
/// is not a sleep (T27).
|
||||||
|
pub fn hold(&self, id: SessionId) -> Result<Held<'_>, Busy> {
|
||||||
|
let mut busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if busy.contains(&id) {
|
||||||
|
return Err(Busy);
|
||||||
|
}
|
||||||
|
busy.insert(id.clone());
|
||||||
|
drop(busy);
|
||||||
|
Ok(Held { ctx: self, id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// While a turn runs, the session is in the busy set. The guard holds only an immutable borrow of
|
/// While a turn runs, the session is in the busy set. The guard holds only an immutable borrow of
|
||||||
/// the context and a clone of the id, never the lock, so another connection can still check
|
/// the context and a clone of the id, never the lock, so another connection can still check
|
||||||
/// membership while this turn is slow. The id is removed when the guard drops.
|
/// membership while this turn is slow. The id is removed when the guard drops.
|
||||||
struct Held<'a> {
|
pub struct Held<'a> {
|
||||||
ctx: &'a Context,
|
ctx: &'a Context,
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The session is already in the busy set.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Busy;
|
||||||
|
|
||||||
impl Drop for Held<'_> {
|
impl Drop for Held<'_> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let mut busy = self.ctx.busy.lock().unwrap_or_else(|p| p.into_inner());
|
let mut busy = self.ctx.busy.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod http;
|
|||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod llama;
|
pub mod llama;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
pub mod scheduler;
|
||||||
pub mod selftest;
|
pub mod selftest;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod socket;
|
pub mod socket;
|
||||||
|
|||||||
@@ -151,6 +151,17 @@ fn run_serve(path: &Path) -> ExitCode {
|
|||||||
|
|
||||||
let memory = Box::new(MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT));
|
let memory = Box::new(MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT));
|
||||||
let ctx = Arc::new(Context::new(cfg, client, port, Registry::m5c(), memory));
|
let ctx = Arc::new(Context::new(cfg, client, port, Registry::m5c(), memory));
|
||||||
|
|
||||||
|
// A heartbeat on the background slot, independent of the channel (M5b spec, section 4).
|
||||||
|
let ctx_sched = Arc::clone(&ctx);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut sched = loopd::scheduler::Scheduler::new(bxcore::Timestamp::now());
|
||||||
|
loop {
|
||||||
|
sched.tick(&ctx_sched, bxcore::Timestamp::now());
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if let Err(e) = channel::serve(listener, ctx) {
|
if let Err(e) = channel::serve(listener, ctx) {
|
||||||
eprintln!("loopd: the channel server stopped: {e}");
|
eprintln!("loopd: the channel server stopped: {e}");
|
||||||
return ExitCode::from(1);
|
return ExitCode::from(1);
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! Isolated heartbeat (and, from task 07, cron) turns (M5b spec, section 4).
|
||||||
|
|
||||||
|
use crate::baseline::Baseline;
|
||||||
|
use crate::channel::{Busy, Context};
|
||||||
|
use crate::compact;
|
||||||
|
use crate::jobs::{self, Run, RunStatus};
|
||||||
|
use crate::session::{Session, SessionError};
|
||||||
|
use crate::turn::{Runtime, run_turn};
|
||||||
|
use bxcore::{SessionId, Timestamp, TurnEvent};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TickWork {
|
||||||
|
Heartbeat,
|
||||||
|
SkippedBusy { session: SessionId },
|
||||||
|
HeartbeatUnreadable,
|
||||||
|
Job { name: String, session: SessionId },
|
||||||
|
SkippedJob { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Scheduler {
|
||||||
|
started: Timestamp,
|
||||||
|
last_heartbeat: Option<Timestamp>,
|
||||||
|
leftovers_closed: bool,
|
||||||
|
heartbeat_unreadable_logged: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scheduler {
|
||||||
|
pub fn new(started: Timestamp) -> Scheduler {
|
||||||
|
Scheduler {
|
||||||
|
started,
|
||||||
|
last_heartbeat: None,
|
||||||
|
leftovers_closed: false,
|
||||||
|
heartbeat_unreadable_logged: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick(&mut self, ctx: &Context, now: Timestamp) -> Vec<TickWork> {
|
||||||
|
let mut work = Vec::new();
|
||||||
|
if !self.leftovers_closed {
|
||||||
|
close_idle_leftovers(ctx, now);
|
||||||
|
self.leftovers_closed = true;
|
||||||
|
}
|
||||||
|
self.heartbeat(ctx, now, &mut work);
|
||||||
|
work
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heartbeat(&mut self, ctx: &Context, now: Timestamp, work: &mut Vec<TickWork>) {
|
||||||
|
if ctx.cfg.heartbeat.every_s == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let last = self.last_heartbeat.unwrap_or(self.started);
|
||||||
|
let due = now.unix_millis().saturating_sub(last.unix_millis())
|
||||||
|
>= ctx.cfg.heartbeat.every_s.saturating_mul(1000);
|
||||||
|
if !due {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = match SessionId::new("heartbeat") {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt = ctx.cfg.heartbeat_prompt();
|
||||||
|
let file = match fs::read_to_string(&prompt) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(e) => {
|
||||||
|
if !self.heartbeat_unreadable_logged {
|
||||||
|
eprintln!(
|
||||||
|
"loopd: {}: {e}; see docs/runbook.md#heartbeat-prompt-unreadable",
|
||||||
|
prompt.display()
|
||||||
|
);
|
||||||
|
self.heartbeat_unreadable_logged = true;
|
||||||
|
}
|
||||||
|
work.push(TickWork::HeartbeatUnreadable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let held = match ctx.hold(id.clone()) {
|
||||||
|
Ok(held) => held,
|
||||||
|
Err(Busy) => {
|
||||||
|
work.push(TickWork::SkippedBusy { session: id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut session = match Session::open(&ctx.cfg.paths.home, id.clone()) {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(SessionError::NotFound(_)) => {
|
||||||
|
let baseline = match Baseline::assemble(&ctx.cfg, &ctx.registry) {
|
||||||
|
Ok(baseline) => baseline,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("loopd: heartbeat baseline: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match Session::create(
|
||||||
|
&ctx.cfg.paths.home,
|
||||||
|
id.clone(),
|
||||||
|
baseline,
|
||||||
|
ctx.cfg.slots.background,
|
||||||
|
) {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("loopd: heartbeat session {}: {e}", id.as_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("loopd: heartbeat session {}: {e}", id.as_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let runtime = Runtime {
|
||||||
|
cfg: &ctx.cfg,
|
||||||
|
client: &ctx.client,
|
||||||
|
port: &*ctx.port,
|
||||||
|
registry: &ctx.registry,
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
&mut session,
|
||||||
|
&runtime,
|
||||||
|
&*ctx.memory,
|
||||||
|
&text,
|
||||||
|
None,
|
||||||
|
&mut emit,
|
||||||
|
outcome,
|
||||||
|
);
|
||||||
|
if outcome.is_ok() {
|
||||||
|
self.last_heartbeat = Some(now);
|
||||||
|
work.push(TickWork::Heartbeat);
|
||||||
|
}
|
||||||
|
drop(held);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_idle_leftovers(ctx: &Context, now: Timestamp) {
|
||||||
|
let jobs_dir = ctx.cfg.paths.home.join("jobs");
|
||||||
|
let runs_path = jobs_dir.join("runs.jsonl");
|
||||||
|
let jobs = match jobs::load(&jobs_dir) {
|
||||||
|
Ok(jobs) => jobs,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("loopd: cannot read the jobs: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for job in &jobs {
|
||||||
|
let session = match jobs::live_session(&runs_path, &job.name) {
|
||||||
|
Ok(Some(session)) => session,
|
||||||
|
Ok(None) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("loopd: cannot read the live session for {}: {e}", job.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Ok(held) = ctx.hold(session.clone()) {
|
||||||
|
drop(held);
|
||||||
|
let _ = jobs::append(
|
||||||
|
&runs_path,
|
||||||
|
&Run {
|
||||||
|
time: now,
|
||||||
|
name: job.name.clone(),
|
||||||
|
session,
|
||||||
|
status: RunStatus::Failed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! Tests for the heartbeat tick. Do not edit.
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use bxcore::{SessionId, 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 sse() -> Reply {
|
||||||
|
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"noted"}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
|
||||||
|
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
|
||||||
|
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
|
||||||
|
Reply::raw(format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ts(s: &str) -> Timestamp {
|
||||||
|
Timestamp::parse(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn world(every_s: u64) -> (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 = every_s;
|
||||||
|
cfg.heartbeat.prompt = home.dir.join("heartbeat.md");
|
||||||
|
server.route(CHAT, vec![sse()]);
|
||||||
|
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 every_s_zero_does_nothing() {
|
||||||
|
let (_home, server, ctx) = world(0);
|
||||||
|
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
|
||||||
|
let work = sched.tick(&ctx, ts("2026-10-03T08:00:00.000Z"));
|
||||||
|
assert!(work.is_empty());
|
||||||
|
assert!(server.requests_to(CHAT).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_due_heartbeat_runs_on_the_background_slot_with_a_time_line() {
|
||||||
|
let (home, server, ctx) = world(60);
|
||||||
|
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]);
|
||||||
|
let sent = server.requests_to(CHAT);
|
||||||
|
assert_eq!(sent.len(), 1);
|
||||||
|
assert_eq!(sent[0].json()["id_slot"], 1);
|
||||||
|
let body = sent[0].json();
|
||||||
|
let last = body["messages"].as_array().unwrap().last().unwrap();
|
||||||
|
assert_eq!(last["role"], "user");
|
||||||
|
let content = last["content"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
content.starts_with("It is 2026-10-03T07:01:00.000Z."),
|
||||||
|
"{content}"
|
||||||
|
);
|
||||||
|
assert!(content.contains("Look around."), "{content}");
|
||||||
|
let s = loopd::session::Session::open(&home.dir, SessionId::new("heartbeat").unwrap()).unwrap();
|
||||||
|
assert_eq!(s.slot(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_tick_before_every_s_does_not_run() {
|
||||||
|
let (_home, server, ctx) = world(60);
|
||||||
|
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
|
||||||
|
sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
|
||||||
|
let work = sched.tick(&ctx, ts("2026-10-03T07:01:30.000Z"));
|
||||||
|
assert!(work.is_empty());
|
||||||
|
assert_eq!(server.requests_to(CHAT).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_busy_heartbeat_session_is_skipped() {
|
||||||
|
let (_home, server, ctx) = world(60);
|
||||||
|
let held = ctx
|
||||||
|
.hold(SessionId::new("heartbeat").unwrap())
|
||||||
|
.expect("hold");
|
||||||
|
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::SkippedBusy {
|
||||||
|
session: SessionId::new("heartbeat").unwrap()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(server.requests_to(CHAT).is_empty());
|
||||||
|
drop(held);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_prompt_is_skipped_and_names_the_runbook() {
|
||||||
|
let home = Home::new();
|
||||||
|
let path = home.dir.join("heartbeat.md");
|
||||||
|
std::fs::create_dir(&path).unwrap();
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.heartbeat.every_s = 60;
|
||||||
|
cfg.heartbeat.prompt = path;
|
||||||
|
let ctx = Arc::new(Context::new(
|
||||||
|
cfg.clone(),
|
||||||
|
Client::new(cfg),
|
||||||
|
Box::new(ScriptedPort::new(vec![])),
|
||||||
|
Registry::m2b(),
|
||||||
|
Box::new(NoMemory),
|
||||||
|
));
|
||||||
|
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::HeartbeatUnreadable]);
|
||||||
|
assert!(server.requests_to(CHAT).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heartbeat_has_no_speaker() {
|
||||||
|
let (home, _server, ctx) = world(60);
|
||||||
|
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
|
||||||
|
sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
|
||||||
|
let recs = home.records("heartbeat");
|
||||||
|
let user = recs
|
||||||
|
.iter()
|
||||||
|
.find_map(|r| match r {
|
||||||
|
bxcore::LogRecord::User { speaker, .. } => Some(speaker),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(user, &None);
|
||||||
|
}
|
||||||
@@ -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/06-loopd-scheduler-heartbeat | 2026-10-03 | done | 2 | fail | none | Copied the given test `crates/loopd/tests/scheduler_heartbeat.rs` from `docs/plans/M5d2/files/`. Made `crates/loopd/src/scheduler.rs` from the skeleton: `tick` closes idle leftovers once on the first tick, then runs `heartbeat`; `heartbeat` returns early when `every_s == 0` or when not due (`now - last_heartbeat.unwrap_or(started) >= every_s * 1000`), reads the prompt first (pushing `HeartbeatUnreadable` and logging once via `heartbeat_unreadable_logged` on a failed read, without holding), then holds `heartbeat` (pushing `SkippedBusy` on `Busy`), opens it or creates it on `NotFound` with `Baseline::assemble` and the background slot, and runs the turn with `speaker None` and a no-op emit, updating `last_heartbeat` and pushing `Heartbeat` only on an ok outcome. `close_idle_leftovers` loads the jobs and, for each with a live `Started` session, holds it -- dropping the hold and appending a `Failed` run when the hold succeeds and skipping when it is `Busy`, rather than calling `jobs::close_leftovers` blindly, so a busy session is never closed. Added `pub mod scheduler;` after `pub mod llama;` in `lib.rs`. Wired `run_serve` to spawn a thread that `tick`s once a second before `channel::serve`. Made `Held` pub, added `pub struct Busy` and `Context::hold` in `channel.rs`. Appended the given `heartbeat-prompt-unreadable` entry to `docs/runbook.md` (heading count 1). First `make gate` failed on clippy `single_match` in `close_idle_leftovers`, fixed by converting the match to `if let Ok(held)` (the `Busy` case is the skip); the second run printed `gate: ok` with 1551 tests (1545 before, plus the 6 of `scheduler_heartbeat.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M5d2/05-loopd-jobs | 2026-10-03 | done | 2 | fail | none | Copied the given test `crates/loopd/tests/jobs.rs` from `docs/plans/M5d2/files/`. Made `crates/loopd/src/jobs.rs` from the skeleton (192 lines, under 500): `session_id` builds `job-<name>-<yyyymmdd>t<hhmm>` from `to_rfc3339()` and maps a `SessionId::new` failure to `JobsError::Name`; `load` reads the dir (NotFound -> empty, any other IO error or an unreadable file -> Invalid naming the path), skips names not ending in `.toml`, validates the stem against `[a-z0-9-]+` on the bytes (empty or bad -> Name), parses each with a private `File { schedule, prompt }` and `deny_unknown_fields`; `append` creates the parent dir, opens in append mode, writes one JSON object plus a newline and `sync_all`; `live_session` walks every line of the ledger (missing file -> None) and returns the last matching run's session only when that run is `Started`; `close_leftovers` closes each name whose last run is `Started` with a `Failed` run carrying the same session and `now`, and returns the count (missing file -> 0). Added `pub mod jobs;` after `pub mod llama;` in `lib.rs` (rustfmt order). Wired `run_serve` to call `jobs::load(&cfg.paths.home.join("jobs"))` after `check_summarise`, printing `loopd: {e}` and exiting 1 on error, without creating the directory. Appended the given `jobs-invalid` entry to `docs/runbook.md` (heading count 1). `cargo check -p loopd` clean, no error or warning; all 8 `jobs` tests pass. First `make gate` failed on clippy `collapsible-if` in `append` (the parent-dir guard), which I fixed by combining the two guards into one `if let Some(parent) = path.parent() && !parent.as_os_str().is_empty()`; the second run printed `gate: ok` with 1545 tests (1537 before, plus the 8 of `jobs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
| M5d2/05-loopd-jobs | 2026-10-03 | done | 2 | fail | none | Copied the given test `crates/loopd/tests/jobs.rs` from `docs/plans/M5d2/files/`. Made `crates/loopd/src/jobs.rs` from the skeleton (192 lines, under 500): `session_id` builds `job-<name>-<yyyymmdd>t<hhmm>` from `to_rfc3339()` and maps a `SessionId::new` failure to `JobsError::Name`; `load` reads the dir (NotFound -> empty, any other IO error or an unreadable file -> Invalid naming the path), skips names not ending in `.toml`, validates the stem against `[a-z0-9-]+` on the bytes (empty or bad -> Name), parses each with a private `File { schedule, prompt }` and `deny_unknown_fields`; `append` creates the parent dir, opens in append mode, writes one JSON object plus a newline and `sync_all`; `live_session` walks every line of the ledger (missing file -> None) and returns the last matching run's session only when that run is `Started`; `close_leftovers` closes each name whose last run is `Started` with a `Failed` run carrying the same session and `now`, and returns the count (missing file -> 0). Added `pub mod jobs;` after `pub mod llama;` in `lib.rs` (rustfmt order). Wired `run_serve` to call `jobs::load(&cfg.paths.home.join("jobs"))` after `check_summarise`, printing `loopd: {e}` and exiting 1 on error, without creating the directory. Appended the given `jobs-invalid` entry to `docs/runbook.md` (heading count 1). `cargo check -p loopd` clean, no error or warning; all 8 `jobs` tests pass. First `make gate` failed on clippy `collapsible-if` in `append` (the parent-dir guard), which I fixed by combining the two guards into one `if let Some(parent) = path.parent() && !parent.as_os_str().is_empty()`; the second run printed `gate: ok` with 1545 tests (1537 before, plus the 8 of `jobs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M5d2/04-loopd-cron | 2026-10-03 | done | 2 | pass | none | Copied the given test `crates/loopd/tests/cron.rs` from `docs/plans/M5d2/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `cron` with E0432 (`unresolved import loopd::cron`); every other target compiled. Made the module `cron.rs` from the skeleton: `due` splits on whitespace, requires five pieces, reads the civil fields from `to_rfc3339`, computes DOW as `(unix_millis/1000/86400 + 4) % 7`, ANDs the five `match_field` checks; `match_field` handles `*`, `*/n` (with `n == 0` rejected via `is_multiple_of`), `n-m` ranges, and bare numbers, each range-checked against the field bounds. Added `pub mod cron;` after `pub mod config;` in `lib.rs` (rustfmt order). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test cron` passes all 11. `wc -l crates/loopd/src/cron.rs` prints 150, well under 500. `git diff --stat` lists only `cron.rs` and `lib.rs`; `git status --short` shows `cron.rs` and the test as untracked. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1537 tests (1526 before, plus the 11 of `cron.rs`), matching the task's expected count. No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
| M5d2/04-loopd-cron | 2026-10-03 | done | 2 | pass | none | Copied the given test `crates/loopd/tests/cron.rs` from `docs/plans/M5d2/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `cron` with E0432 (`unresolved import loopd::cron`); every other target compiled. Made the module `cron.rs` from the skeleton: `due` splits on whitespace, requires five pieces, reads the civil fields from `to_rfc3339`, computes DOW as `(unix_millis/1000/86400 + 4) % 7`, ANDs the five `match_field` checks; `match_field` handles `*`, `*/n` (with `n == 0` rejected via `is_multiple_of`), `n-m` ranges, and bare numbers, each range-checked against the field bounds. Added `pub mod cron;` after `pub mod config;` in `lib.rs` (rustfmt order). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test cron` passes all 11. `wc -l crates/loopd/src/cron.rs` prints 150, well under 500. `git diff --stat` lists only `cron.rs` and `lib.rs`; `git status --short` shows `cron.rs` and the test as untracked. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1537 tests (1526 before, plus the 11 of `cron.rs`), matching the task's expected count. No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M5d2/03-loopd-heartbeat-config | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/config_heartbeat.rs` and the starter `config/heartbeat.md` from `docs/plans/M5d2/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `config_heartbeat` with E0609 (`no field heartbeat`) and E0599 (`no method heartbeat_prompt` / `check_summarise`); every other target compiled. Made H1 (`Config` gains `#[serde(default)] pub heartbeat: Heartbeat` after `compact`), H2 (new `Heartbeat { every_s: u64, prompt: PathBuf }` with `#[serde(deny_unknown_fields, default)]`, `Default` = `every_s` 0 and `prompt` `heartbeat.md`, placed after `Compact`'s Default impl), H3 (`load` joins `heartbeat.prompt` to the config file's parent beside `baseline.system`), H4 (`heartbeat_prompt` returns `self.heartbeat.prompt.clone()`, `check_summarise` walks `summarise` and returns Err naming both `summarise` and the unknown name, both after `compact_prompt`). Made M1 (`run_selftest`) and M2 (`run_serve`): after a successful `load` each calls `cfg.check_summarise(&loopd::tools::Registry::m5c())`, printing `loopd: {e}` and returning exit 1 on failure, before `Client::new` / the instance lock. Made A1 (`ask_memory_tool.rs` `loopd_serve_uses_the_m5c_registry` counts `Registry::m5c()` as 3). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test config_heartbeat --test config --test config_compact --test ask_memory_tool` passes (8 + 11 + 8 + 4). `git diff --stat` lists only `config.rs`, `main.rs` and `ask_memory_tool.rs`; `git status --short` shows the new test and `heartbeat.md` as untracked. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1526 tests (1518 before, plus the 8 of `config_heartbeat.rs`), matching the task's expected count. No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
| M5d2/03-loopd-heartbeat-config | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/config_heartbeat.rs` and the starter `config/heartbeat.md` from `docs/plans/M5d2/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `config_heartbeat` with E0609 (`no field heartbeat`) and E0599 (`no method heartbeat_prompt` / `check_summarise`); every other target compiled. Made H1 (`Config` gains `#[serde(default)] pub heartbeat: Heartbeat` after `compact`), H2 (new `Heartbeat { every_s: u64, prompt: PathBuf }` with `#[serde(deny_unknown_fields, default)]`, `Default` = `every_s` 0 and `prompt` `heartbeat.md`, placed after `Compact`'s Default impl), H3 (`load` joins `heartbeat.prompt` to the config file's parent beside `baseline.system`), H4 (`heartbeat_prompt` returns `self.heartbeat.prompt.clone()`, `check_summarise` walks `summarise` and returns Err naming both `summarise` and the unknown name, both after `compact_prompt`). Made M1 (`run_selftest`) and M2 (`run_serve`): after a successful `load` each calls `cfg.check_summarise(&loopd::tools::Registry::m5c())`, printing `loopd: {e}` and returning exit 1 on failure, before `Client::new` / the instance lock. Made A1 (`ask_memory_tool.rs` `loopd_serve_uses_the_m5c_registry` counts `Registry::m5c()` as 3). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test config_heartbeat --test config --test config_compact --test ask_memory_tool` passes (8 + 11 + 8 + 4). `git diff --stat` lists only `config.rs`, `main.rs` and `ask_memory_tool.rs`; `git status --short` shows the new test and `heartbeat.md` as untracked. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1526 tests (1518 before, plus the 8 of `config_heartbeat.rs`), matching the task's expected count. No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
|
|||||||
@@ -1217,3 +1217,29 @@ ls -l "$BOXMAKER_HOME/jobs/"
|
|||||||
Then start `loopd` again.
|
Then start `loopd` again.
|
||||||
|
|
||||||
**Check.** `loopd serve` prints `serving on` and `{home}/jobs/runs.jsonl` exists (empty is fine).
|
**Check.** `loopd serve` prints `serving on` and `{home}/jobs/runs.jsonl` exists (empty is fine).
|
||||||
|
|
||||||
|
## heartbeat-prompt-unreadable
|
||||||
|
|
||||||
|
**What you see.** `loopd` is serving, but heartbeat turns do not run. It logs this line once,
|
||||||
|
until the file reads:
|
||||||
|
|
||||||
|
```text
|
||||||
|
loopd: heartbeat.md: Permission denied (os error 13); see docs/runbook.md#heartbeat-prompt-unreadable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why.** `[heartbeat] prompt` is next to the config file (default `heartbeat.md`). The scheduler
|
||||||
|
skips the tick rather than send an empty or guessed user message. A missing file is the same:
|
||||||
|
restore the starter from `config/heartbeat.md` in the repository.
|
||||||
|
|
||||||
|
**Confirm.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -l "$(dirname "$LOOPD_CONFIG")/heartbeat.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix.** Restore mode `0644` (or whatever the owner uses for the other prompts) and content.
|
||||||
|
The next tick after a successful read logs nothing extra and runs the standing `heartbeat`
|
||||||
|
session.
|
||||||
|
|
||||||
|
**Check.** `sessions/heartbeat/0.jsonl` gains a new `User` whose text starts with `It is ` and an
|
||||||
|
RFC 3339 timestamp.
|
||||||
|
|||||||
Reference in New Issue
Block a user