loopd: background ToolSummary for opted-in results
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
Summarise this tool result for later recall. Keep facts, names, numbers and URLs. Drop boilerplate
|
||||||
|
and navigation. Do not use tools. Do not address the owner. Do not invent facts.
|
||||||
@@ -149,14 +149,22 @@ pub fn finish_turn(
|
|||||||
outcome: Result<TurnOutcome, TurnError>,
|
outcome: Result<TurnOutcome, TurnError>,
|
||||||
) -> Result<TurnOutcome, TurnError> {
|
) -> Result<TurnOutcome, TurnError> {
|
||||||
match outcome {
|
match outcome {
|
||||||
Err(TurnError::SessionFull) => match compact_session(session, rt, memory) {
|
Err(TurnError::SessionFull) => {
|
||||||
|
if let Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) {
|
||||||
|
eprintln!("loopd: tool summary: {e}");
|
||||||
|
}
|
||||||
|
match compact_session(session, rt, memory) {
|
||||||
Ok(()) => run_turn(session, rt, text, speaker, on_event),
|
Ok(()) => run_turn(session, rt, text, speaker, on_event),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_compact(session, &e);
|
log_compact(session, &e);
|
||||||
Err(TurnError::SessionFull)
|
Err(TurnError::SessionFull)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Ok(done) => {
|
Ok(done) => {
|
||||||
|
if let Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) {
|
||||||
|
eprintln!("loopd: tool summary: {e}");
|
||||||
|
}
|
||||||
if let Some(usage) = session.last_usage()
|
if let Some(usage) = session.last_usage()
|
||||||
&& due(&usage, rt.cfg.compact.threshold, rt.cfg.expect.n_ctx)
|
&& due(&usage, rt.cfg.compact.threshold, rt.cfg.expect.n_ctx)
|
||||||
&& let Err(e) = compact_session(session, rt, memory)
|
&& let Err(e) = compact_session(session, rt, memory)
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ pub mod selftest;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod socket;
|
pub mod socket;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod summarise;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod turn;
|
pub mod turn;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Opted-in tool-output summaries (M5d spec, section 4.3).
|
||||||
|
|
||||||
|
use crate::baseline::messages_with_carry;
|
||||||
|
use crate::compact::cap_chars;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::llama::{ChatMessage, ChatRequest, Client};
|
||||||
|
use crate::session::Session;
|
||||||
|
use bxcore::sanitize::neutralise;
|
||||||
|
use bxcore::{CallId, DataClass, LogRecord, Timestamp, ToolCall};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
/// Append a `ToolSummary` for each opted-in `ToolResult` in this epoch that does not yet have
|
||||||
|
/// one. Inference uses the background slot and no tools.
|
||||||
|
pub fn missing(cfg: &Config, client: &Client, session: &mut Session) -> Result<(), String> {
|
||||||
|
let path = cfg.baseline.system.with_file_name("summarise.md");
|
||||||
|
let prompt = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
let mut names: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut done: HashSet<u64> = HashSet::new();
|
||||||
|
let mut pending: Vec<(CallId, String, String, DataClass, bool)> = Vec::new();
|
||||||
|
for record in session.records() {
|
||||||
|
match record {
|
||||||
|
LogRecord::Assistant { tool_calls, .. } => {
|
||||||
|
for ToolCall { id, name, .. } in tool_calls {
|
||||||
|
names.insert(id.clone(), name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogRecord::ToolSummary { call, .. } => {
|
||||||
|
done.insert(call.0);
|
||||||
|
}
|
||||||
|
LogRecord::ToolResult {
|
||||||
|
call,
|
||||||
|
tool_call_id,
|
||||||
|
content,
|
||||||
|
class,
|
||||||
|
untrusted,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let Some(name) = names.get(tool_call_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !cfg.compact.summarise.iter().any(|n| n == name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.push((
|
||||||
|
*call,
|
||||||
|
tool_call_id.clone(),
|
||||||
|
content.clone(),
|
||||||
|
*class,
|
||||||
|
*untrusted,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.retain(|(call, ..)| !done.contains(&call.0));
|
||||||
|
for (call, tool_call_id, content, class, untrusted) in pending {
|
||||||
|
let mut messages =
|
||||||
|
messages_with_carry(session.baseline(), session.records(), session.carry());
|
||||||
|
messages.push(ChatMessage::User {
|
||||||
|
content: format!(
|
||||||
|
"{}\n\n{}",
|
||||||
|
prompt.trim_end(),
|
||||||
|
cap_chars(&content, cfg.r#loop.tool_result_cap)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
let request = ChatRequest {
|
||||||
|
slot: cfg.slots.background,
|
||||||
|
messages,
|
||||||
|
tools: Vec::new(),
|
||||||
|
thinking: true,
|
||||||
|
};
|
||||||
|
let text = match client.chat_with_retry(&request, &mut |_| {}) {
|
||||||
|
Ok(completion) => completion.content.unwrap_or_default(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("loopd: tool summary: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let text = cap_chars(neutralise(&text).as_ref(), cfg.compact.summary_chars);
|
||||||
|
session
|
||||||
|
.append(LogRecord::ToolSummary {
|
||||||
|
time: Timestamp::now(),
|
||||||
|
call,
|
||||||
|
tool_call_id,
|
||||||
|
content: text,
|
||||||
|
class,
|
||||||
|
untrusted,
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Tests for the opted-in tool-output summary. Do not edit.
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use bxcore::{CallId, DataClass, LogRecord, SessionId, ToolCall};
|
||||||
|
use loopd::baseline::Baseline;
|
||||||
|
use loopd::compact;
|
||||||
|
use loopd::llama::Client;
|
||||||
|
use loopd::memory::NoMemory;
|
||||||
|
use loopd::session::Session;
|
||||||
|
use loopd::summarise;
|
||||||
|
use loopd::tools::Registry;
|
||||||
|
use loopd::turn::{Runtime, run_turn};
|
||||||
|
use support::{FakeServer, Home, Reply, ScriptedPort};
|
||||||
|
|
||||||
|
const CHAT: &str = "/v1/chat/completions";
|
||||||
|
const SUMMARISE: &str = "Summarise this tool result.\n";
|
||||||
|
|
||||||
|
fn sse(content: &str) -> Reply {
|
||||||
|
let content_json = serde_json::to_string(content).unwrap();
|
||||||
|
let data1 = format!(
|
||||||
|
r#"data: {{"choices":[{{"finish_reason":null,"index":0,"delta":{{"content":{content_json}}}}}],"id":"s","timings":{{"cache_n":0,"prompt_n":10,"predicted_n":1}}}}"#
|
||||||
|
);
|
||||||
|
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"s","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() -> bxcore::Timestamp {
|
||||||
|
bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(name: &str) -> LogRecord {
|
||||||
|
LogRecord::Assistant {
|
||||||
|
time: ts(),
|
||||||
|
content: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
tool_calls: vec![ToolCall {
|
||||||
|
id: "call_f".to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result(untrusted: bool) -> LogRecord {
|
||||||
|
LogRecord::ToolResult {
|
||||||
|
time: ts(),
|
||||||
|
call: CallId(1),
|
||||||
|
tool_call_id: "call_f".to_string(),
|
||||||
|
content: "the fetched page is about tea".to_string(),
|
||||||
|
class: DataClass::Public,
|
||||||
|
untrusted,
|
||||||
|
truncated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(home: &Home, cfg: &loopd::config::Config) -> Session {
|
||||||
|
let registry = Registry::m2b();
|
||||||
|
Session::create(
|
||||||
|
&home.dir,
|
||||||
|
SessionId::new("a").unwrap(),
|
||||||
|
Baseline::assemble(cfg, ®istry).unwrap(),
|
||||||
|
cfg.slots.main,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_opted_in_result_gets_a_summary_on_the_background_slot() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("summarise.md", SUMMARISE);
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.compact.summarise = vec!["http_fetch".to_string()];
|
||||||
|
server.route(CHAT, vec![sse("tea page")]);
|
||||||
|
let client = Client::new(cfg.clone());
|
||||||
|
let mut s = session(&home, &cfg);
|
||||||
|
s.append(assistant("http_fetch")).unwrap();
|
||||||
|
s.append(result(false)).unwrap();
|
||||||
|
summarise::missing(&cfg, &client, &mut s).unwrap();
|
||||||
|
let sent = server.requests_to(CHAT);
|
||||||
|
assert_eq!(sent.len(), 1);
|
||||||
|
assert_eq!(sent[0].json()["id_slot"], 1);
|
||||||
|
assert!(sent[0].json().get("tools").is_none());
|
||||||
|
assert!(
|
||||||
|
s.records().iter().any(|r| matches!(r, LogRecord::ToolSummary { content, call, untrusted, .. } if content == "tea page" && *call == CallId(1) && !*untrusted)),
|
||||||
|
"{:?}",
|
||||||
|
s.records()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_summary_copies_the_result_labels() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("summarise.md", SUMMARISE);
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.compact.summarise = vec!["http_fetch".to_string()];
|
||||||
|
server.route(CHAT, vec![sse("x")]);
|
||||||
|
let client = Client::new(cfg.clone());
|
||||||
|
let mut s = session(&home, &cfg);
|
||||||
|
s.append(assistant("http_fetch")).unwrap();
|
||||||
|
s.append(result(true)).unwrap();
|
||||||
|
summarise::missing(&cfg, &client, &mut s).unwrap();
|
||||||
|
match s.records().last() {
|
||||||
|
Some(LogRecord::ToolSummary {
|
||||||
|
class,
|
||||||
|
untrusted,
|
||||||
|
tool_call_id,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
assert_eq!(*class, DataClass::Public);
|
||||||
|
assert!(*untrusted);
|
||||||
|
assert_eq!(tool_call_id, "call_f");
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_not_in_summarise_is_left_alone() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("summarise.md", SUMMARISE);
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.compact.summarise = vec!["http_fetch".to_string()];
|
||||||
|
let client = Client::new(cfg.clone());
|
||||||
|
let mut s = session(&home, &cfg);
|
||||||
|
s.append(assistant("clock")).unwrap();
|
||||||
|
s.append(result(false)).unwrap();
|
||||||
|
summarise::missing(&cfg, &client, &mut s).unwrap();
|
||||||
|
assert!(server.requests_to(CHAT).is_empty());
|
||||||
|
assert!(
|
||||||
|
!s.records()
|
||||||
|
.iter()
|
||||||
|
.any(|r| matches!(r, LogRecord::ToolSummary { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_that_already_has_a_summary_is_not_summarised_again() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("summarise.md", SUMMARISE);
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.compact.summarise = vec!["http_fetch".to_string()];
|
||||||
|
server.route(CHAT, vec![sse("once")]);
|
||||||
|
let client = Client::new(cfg.clone());
|
||||||
|
let mut s = session(&home, &cfg);
|
||||||
|
s.append(assistant("http_fetch")).unwrap();
|
||||||
|
s.append(result(false)).unwrap();
|
||||||
|
summarise::missing(&cfg, &client, &mut s).unwrap();
|
||||||
|
summarise::missing(&cfg, &client, &mut s).unwrap();
|
||||||
|
assert_eq!(server.requests_to(CHAT).len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
s.records()
|
||||||
|
.iter()
|
||||||
|
.filter(|r| matches!(r, LogRecord::ToolSummary { .. }))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_stay_the_same_after_a_summary() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("summarise.md", SUMMARISE);
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.compact.summarise = vec!["http_fetch".to_string()];
|
||||||
|
server.route(CHAT, vec![sse("tea page")]);
|
||||||
|
let client = Client::new(cfg.clone());
|
||||||
|
let mut s = session(&home, &cfg);
|
||||||
|
s.append(assistant("http_fetch")).unwrap();
|
||||||
|
s.append(result(false)).unwrap();
|
||||||
|
let before = loopd::baseline::messages(s.baseline(), s.records());
|
||||||
|
summarise::missing(&cfg, &client, &mut s).unwrap();
|
||||||
|
let after = loopd::baseline::messages(s.baseline(), s.records());
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn after_a_turn_an_opted_in_call_is_summarised() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("summarise.md", SUMMARISE);
|
||||||
|
home.write("compact.md", "Summarise.\n");
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.compact.summarise = vec!["read_file".to_string()];
|
||||||
|
server.route(
|
||||||
|
CHAT,
|
||||||
|
vec![
|
||||||
|
Reply::fixture("tool_call"),
|
||||||
|
Reply::fixture("plain"),
|
||||||
|
sse("hosts file"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let client = Client::new(cfg.clone());
|
||||||
|
let port = ScriptedPort::new(vec![support::ok_result("127.0.0.1 localhost\n")]);
|
||||||
|
let registry = Registry::m2b();
|
||||||
|
let mut s = Session::create(
|
||||||
|
&home.dir,
|
||||||
|
SessionId::new("a").unwrap(),
|
||||||
|
Baseline::assemble(&cfg, ®istry).unwrap(),
|
||||||
|
cfg.slots.main,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rt = Runtime {
|
||||||
|
cfg: &cfg,
|
||||||
|
client: &client,
|
||||||
|
port: &port,
|
||||||
|
registry: ®istry,
|
||||||
|
};
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone()));
|
||||||
|
compact::finish_turn(
|
||||||
|
&mut s,
|
||||||
|
&rt,
|
||||||
|
&NoMemory,
|
||||||
|
"hosts?",
|
||||||
|
None,
|
||||||
|
&mut |e| events.push(e.clone()),
|
||||||
|
outcome,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
s.records()
|
||||||
|
.iter()
|
||||||
|
.any(|r| matches!(r, LogRecord::ToolSummary { .. })),
|
||||||
|
"{:?}",
|
||||||
|
s.records()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.requests_to(CHAT).last().unwrap().json()["id_slot"],
|
||||||
|
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/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/07-loopd-scheduler-cron | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_cron.rs` from `docs/plans/M5d2/files/`. Added the cron loop into `tick` in `crates/loopd/src/scheduler.rs`, right after `self.heartbeat(...)` and before `work` is returned (heartbeat untouched): load `jobs` via `jobs::load(&ctx.cfg.paths.home.join("jobs"))`, on `Err` log `loopd: {e}` and return; for each job in order check `cron::due` (`Err` log+skip, `Ok(false)` skip), then `jobs::live_session` on `{home}/jobs/runs.jsonl` (`Some` push `SkippedJob` and continue), then `session_id` (`Err` log+skip), then `ctx.hold` (`Busy` push `SkippedJob`). On a hold: `Baseline::assemble`, `Session::create` on `slots.background` (on `Exists`, `open` instead), append `Started`, run `run_turn` then `compact::finish_turn` with user text `format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt)` and speaker `None`, append `Finished` on an ok turn else `Failed`, drop the hold, push `TickWork::Job { name, session }`. Added `use crate::cron;` to the imports. `cargo check -p loopd` clean, no error or warning; `wc -l` prints 304, under 500. All four suites pass: `cron` 11, `jobs` 8, `scheduler_cron` 6, `scheduler_heartbeat` 6. `cargo fmt --all` wrapped the `Session::create` call and its `Exists` arm as a nested match; `make gate` printed `gate: ok` with 1557 tests (1551 before, plus the 6 of `scheduler_cron.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
| M5d2/07-loopd-scheduler-cron | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_cron.rs` from `docs/plans/M5d2/files/`. Added the cron loop into `tick` in `crates/loopd/src/scheduler.rs`, right after `self.heartbeat(...)` and before `work` is returned (heartbeat untouched): load `jobs` via `jobs::load(&ctx.cfg.paths.home.join("jobs"))`, on `Err` log `loopd: {e}` and return; for each job in order check `cron::due` (`Err` log+skip, `Ok(false)` skip), then `jobs::live_session` on `{home}/jobs/runs.jsonl` (`Some` push `SkippedJob` and continue), then `session_id` (`Err` log+skip), then `ctx.hold` (`Busy` push `SkippedJob`). On a hold: `Baseline::assemble`, `Session::create` on `slots.background` (on `Exists`, `open` instead), append `Started`, run `run_turn` then `compact::finish_turn` with user text `format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt)` and speaker `None`, append `Finished` on an ok turn else `Failed`, drop the hold, push `TickWork::Job { name, session }`. Added `use crate::cron;` to the imports. `cargo check -p loopd` clean, no error or warning; `wc -l` prints 304, under 500. All four suites pass: `cron` 11, `jobs` 8, `scheduler_cron` 6, `scheduler_heartbeat` 6. `cargo fmt --all` wrapped the `Session::create` call and its `Exists` arm as a nested match; `make gate` printed `gate: ok` with 1557 tests (1551 before, plus the 6 of `scheduler_cron.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
| 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/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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user