loopd: cron tick, new session per run
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
use crate::baseline::Baseline;
|
||||
use crate::channel::{Busy, Context};
|
||||
use crate::compact;
|
||||
use crate::cron;
|
||||
use crate::jobs::{self, Run, RunStatus};
|
||||
use crate::session::{Session, SessionError};
|
||||
use crate::turn::{Runtime, run_turn};
|
||||
@@ -42,6 +43,133 @@ impl Scheduler {
|
||||
self.leftovers_closed = true;
|
||||
}
|
||||
self.heartbeat(ctx, now, &mut work);
|
||||
|
||||
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: {e}");
|
||||
return work;
|
||||
}
|
||||
};
|
||||
for job in &jobs {
|
||||
match cron::due(&job.schedule, now) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue,
|
||||
Err(e) => {
|
||||
eprintln!("loopd: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match jobs::live_session(&runs_path, &job.name) {
|
||||
Ok(Some(_)) => {
|
||||
work.push(TickWork::SkippedJob {
|
||||
name: job.name.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
eprintln!("loopd: cannot read the live session for {}: {e}", job.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let id = match jobs::session_id(&job.name, now) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!("loopd: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let held = match ctx.hold(id.clone()) {
|
||||
Ok(held) => held,
|
||||
Err(Busy) => {
|
||||
work.push(TickWork::SkippedJob {
|
||||
name: job.name.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let baseline = match Baseline::assemble(&ctx.cfg, &ctx.registry) {
|
||||
Ok(baseline) => baseline,
|
||||
Err(e) => {
|
||||
eprintln!("loopd: job {} baseline: {e}", job.name);
|
||||
drop(held);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut session = match Session::create(
|
||||
&ctx.cfg.paths.home,
|
||||
id.clone(),
|
||||
baseline,
|
||||
ctx.cfg.slots.background,
|
||||
) {
|
||||
Ok(session) => session,
|
||||
Err(SessionError::Exists(_)) => {
|
||||
match Session::open(&ctx.cfg.paths.home, id.clone()) {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
eprintln!("loopd: job {} session {}: {e}", job.name, id.as_str());
|
||||
drop(held);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("loopd: job {} session {}: {e}", job.name, id.as_str());
|
||||
drop(held);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = jobs::append(
|
||||
&runs_path,
|
||||
&Run {
|
||||
time: now,
|
||||
name: job.name.clone(),
|
||||
session: id.clone(),
|
||||
status: RunStatus::Started,
|
||||
},
|
||||
);
|
||||
let runtime = Runtime {
|
||||
cfg: &ctx.cfg,
|
||||
client: &ctx.client,
|
||||
port: &*ctx.port,
|
||||
registry: &ctx.registry,
|
||||
};
|
||||
let text = format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt);
|
||||
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,
|
||||
);
|
||||
let status = if outcome.is_ok() {
|
||||
RunStatus::Finished
|
||||
} else {
|
||||
RunStatus::Failed
|
||||
};
|
||||
let _ = jobs::append(
|
||||
&runs_path,
|
||||
&Run {
|
||||
time: now,
|
||||
name: job.name.clone(),
|
||||
session: id.clone(),
|
||||
status,
|
||||
},
|
||||
);
|
||||
drop(held);
|
||||
work.push(TickWork::Job {
|
||||
name: job.name.clone(),
|
||||
session: id,
|
||||
});
|
||||
}
|
||||
|
||||
work
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Tests for the cron tick. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use bxcore::Timestamp;
|
||||
use loopd::channel::Context;
|
||||
use loopd::jobs::{self, RunStatus};
|
||||
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";
|
||||
|
||||
fn sse() -> Reply {
|
||||
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"done"}}],"id":"j","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
|
||||
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"j","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(schedule: &str) -> (Home, FakeServer, Arc<Context>) {
|
||||
let home = Home::new();
|
||||
home.write("compact.md", "Summarise.\n");
|
||||
home.write(
|
||||
"jobs/mail.toml",
|
||||
&format!("schedule = \"{schedule}\"\nprompt = \"Check mail.\"\n"),
|
||||
);
|
||||
let server = FakeServer::start();
|
||||
let cfg = home.config(&server.socket);
|
||||
server.route(CHAT, vec![sse(), 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 a_due_job_starts_a_new_session_on_the_background_slot() {
|
||||
let (home, server, ctx) = world("0 7 * * *");
|
||||
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
|
||||
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
|
||||
let want = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
|
||||
assert_eq!(
|
||||
work,
|
||||
vec![TickWork::Job {
|
||||
name: "mail".to_string(),
|
||||
session: want.clone()
|
||||
}]
|
||||
);
|
||||
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();
|
||||
let content = last["content"].as_str().unwrap();
|
||||
assert!(
|
||||
content.starts_with("It is 2026-10-03T07:00:00.000Z."),
|
||||
"{content}"
|
||||
);
|
||||
assert!(content.contains("Check mail."), "{content}");
|
||||
let s = loopd::session::Session::open(&home.dir, want).unwrap();
|
||||
assert_eq!(s.slot(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_not_due_does_not_run() {
|
||||
let (_home, server, ctx) = world("0 7 * * *");
|
||||
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
|
||||
let work = sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
|
||||
assert!(work.is_empty());
|
||||
assert!(server.requests_to(CHAT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_previous_run_skips_the_tick() {
|
||||
let (home, server, ctx) = world("* * * * *");
|
||||
let ledger = home.dir.join("jobs/runs.jsonl");
|
||||
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
|
||||
jobs::append(
|
||||
&ledger,
|
||||
&jobs::Run {
|
||||
time: ts("2026-10-03T06:59:00.000Z"),
|
||||
name: "mail".to_string(),
|
||||
session: previous.clone(),
|
||||
status: RunStatus::Started,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let held = ctx.hold(previous).expect("hold");
|
||||
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
|
||||
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
|
||||
assert_eq!(
|
||||
work,
|
||||
vec![TickWork::SkippedJob {
|
||||
name: "mail".to_string()
|
||||
}]
|
||||
);
|
||||
assert!(server.requests_to(CHAT).is_empty());
|
||||
let text = home.read("jobs/runs.jsonl");
|
||||
assert!(text.contains("started"), "{text}");
|
||||
assert!(
|
||||
!text.contains("failed"),
|
||||
"a leftover still held is not closed as failed: {text}"
|
||||
);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leftover_started_is_failed_on_the_first_tick() {
|
||||
let (home, _server, ctx) = world("* * * * *");
|
||||
let ledger = home.dir.join("jobs/runs.jsonl");
|
||||
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
|
||||
jobs::append(
|
||||
&ledger,
|
||||
&jobs::Run {
|
||||
time: ts("2026-10-03T06:59:00.000Z"),
|
||||
name: "mail".to_string(),
|
||||
session: previous.clone(),
|
||||
status: RunStatus::Started,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
|
||||
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
|
||||
let text = home.read("jobs/runs.jsonl");
|
||||
assert!(
|
||||
text.contains("failed"),
|
||||
"leftover started was closed: {text}"
|
||||
);
|
||||
assert!(text.contains(previous.as_str()), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cron_turn_has_no_speaker() {
|
||||
let (home, _server, ctx) = world("0 7 * * *");
|
||||
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
|
||||
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
|
||||
let id = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
|
||||
let recs = {
|
||||
let text = home.read(&format!("sessions/{}/0.jsonl", id.as_str()));
|
||||
text.lines()
|
||||
.map(|l| serde_json::from_str::<bxcore::LogRecord>(l).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let user = recs
|
||||
.iter()
|
||||
.find_map(|r| match r {
|
||||
bxcore::LogRecord::User { speaker, .. } => Some(speaker),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(user, &None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_due_minutes_make_two_sessions() {
|
||||
let (home, server, ctx) = world("* * * * *");
|
||||
let mut sched = Scheduler::new(ts("2026-10-03T06:58:00.000Z"));
|
||||
sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
|
||||
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
|
||||
assert_eq!(server.requests_to(CHAT).len(), 2);
|
||||
let a = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
|
||||
let b = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert!(
|
||||
home.dir
|
||||
.join(format!("sessions/{}/0.jsonl", a.as_str()))
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
home.dir
|
||||
.join(format!("sessions/{}/0.jsonl", b.as_str()))
|
||||
.is_file()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user