Task 01 told Ornith to add up every `test result:` N passed. A greedy sed kept the last digit (1512 became 1022) and it stopped with the correct T1 and given test uncommitted. T50/T59: the last line of make gate is the count; summing is not a stop. Co-Authored-By: Grok 4.6 <noreply@x.ai>
8.5 KiB
M5d-2 task 05: job files and the run ledger
Branch: m5d2. Check it: git branch --show-current prints m5d2 and git status --short
is empty; otherwise stop. Never switch branch.
Commit subject: loopd: job files, run ledger, jobs-invalid
Log row: the Task cell is M5d2/05-loopd-jobs.
Goal
Owner-written {home}/jobs/<name>.toml and a run ledger {home}/jobs/runs.jsonl (spec 4.2).
loopd reads job files and cannot write them. Unknown keys, a name that is not [a-z0-9-]+,
or a name so long that job-<name>-<yyyymmdd>t<hhmm> is not a SessionId, are
jobs-invalid. A missing jobs directory is an empty list.
Nothing ticks yet. loopd serve refuses to start when load errors, so a misspelt job cannot
sit silently.
Every place that changes
| Place | What happens there |
|---|---|
crates/loopd/src/jobs.rs |
new: Job, Run, RunStatus, load, session_id, append, close_leftovers, live_session |
crates/loopd/src/lib.rs |
pub mod jobs; |
crates/loopd/src/main.rs |
run_serve calls jobs::load after check_summarise |
docs/runbook.md |
append the given jobs-invalid entry |
crates/loopd/tests/jobs.rs |
new, given |
| everything else | no change |
Files
- Copy, from
docs/plans/M5d2/files/(the same path under it): the new testcrates/loopd/tests/jobs.rs. - Append, from
docs/plans/M5d2/files/runbook-jobs-invalid.md, todocs/runbook.md. - Create:
crates/loopd/src/jobs.rs(skeleton below). - Modify:
crates/loopd/src/lib.rs,crates/loopd/src/main.rs,docs/implementer-log.md. - Do not touch: any other file.
Facts you need
namefrom the file stem.mail.toml→mail. The stem must match^[a-z0-9-]+$(bytes, nonempty) andsession_id(name, now)must succeed.Mail.tomlisJobsError::Name.session_id.format!("job-{name}-{}t{}", ymd, hm)withymd=rfc[0..4]+rfc[5..7]+rfc[8..10]andhm=rfc[11..13]+rfc[14..16], all fromnow.to_rfc3339(), thenSessionId::new.SessionIdis[a-z0-9-], max 64."n".repeat(50)is too long (job-+ 50 +-+ 8 +t+ 4 = 68).load.read_dir.ErrorKind::NotFound→Ok(vec![]). Any other IO error isJobsError::Invalidnaming the directory. Skip names that do not end in.toml. Sort bynameso the order is stable. Parse with#[serde(deny_unknown_fields)]on a privateFile { schedule, prompt }.RunStatus.started/finished/failed(rename_all = "snake_case").append. Create the parent directory (0700 is fine; do not weaken a tighter mode). One JSON object, then a newline. Open append, write,sync_all.live_session. Walk every line of the ledger (missing file →Ok(None)). The lastRunwhosenamematches:Started→Some(session), anything else →None.close_leftovers. Names whose last run isStartedget one moreRunwithstatus: Failedand the samesession,time: now. Return how many names were closed. A missing file isOk(0).DisplayforInvalid."{path}: {detail}; see docs/runbook.md#jobs-invalid". The given unknown-keys test checksends_with("see docs/runbook.md#jobs-invalid")and that the path's file name is in the string.run_serve. Aftercheck_summarise,jobs::load(&cfg.paths.home.join("jobs")). OnErr, printloopd: {e}and return exit 1. Do not create the directory here.- A file that exists but cannot be read is an error. Only a missing directory is empty.
- The items the edits use. This table is complete.
Item What it does toml::from_stralready a loopddependencySessionId::newthe 64-byte alphabet check serdedeny_unknown_fieldsunknown keys
Skeleton
crates/loopd/src/jobs.rs — fill every function; no todo!(). Keep the types in this order.
//! Owner-written job files and the run ledger (M5d spec, section 4.2).
use std::path::{Path, PathBuf};
use bxcore::{SessionId, Timestamp};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Job {
pub name: String,
pub schedule: String,
pub prompt: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Started,
Finished,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Run {
pub time: Timestamp,
pub name: String,
pub session: SessionId,
pub status: RunStatus,
}
#[derive(Debug)]
pub enum JobsError {
Name(String),
Invalid { path: PathBuf, detail: String },
}
impl std::fmt::Display for JobsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JobsError::Name(name) => write!(
f,
"job name {name:?} is not a valid job; see docs/runbook.md#jobs-invalid"
),
JobsError::Invalid { path, detail } => write!(
f,
"{}: {detail}; see docs/runbook.md#jobs-invalid",
path.display()
),
}
}
}
impl std::error::Error for JobsError {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
schedule: String,
prompt: String,
}
pub fn session_id(name: &str, now: Timestamp) -> Result<SessionId, JobsError> {
// Build job-<name>-<yyyymmdd>t<hhmm> from now.to_rfc3339() as in Facts. SessionId::new
// maps a failure to JobsError::Name(name.to_string()).
let _ = (name, now);
Err(JobsError::Name(name.to_string()))
}
pub fn load(dir: &Path) -> Result<Vec<Job>, JobsError> {
let _ = dir;
Ok(Vec::new())
}
pub fn append(path: &Path, run: &Run) -> Result<(), JobsError> {
let _ = (path, run);
Ok(())
}
pub fn live_session(path: &Path, name: &str) -> Result<Option<SessionId>, JobsError> {
let _ = (path, name);
Ok(None)
}
pub fn close_leftovers(path: &Path, now: Timestamp) -> Result<usize, JobsError> {
let _ = (path, now);
Ok(0)
}
Replace the four stub bodies in the same task. Do not leave a stub that returns Ok(None) /
Ok(0) / Ok(vec![]) once you have written the real body. session_id must not stay on the
Err path.
Steps
-
1. Copy the given test.
cp docs/plans/M5d2/files/crates/loopd/tests/jobs.rs crates/loopd/tests/ -
2. See it fail.
cargo build --tests --keep-going -p loopd, timeout 600000 ms: test targetjobsdoes not compile (E0432unresolvedloopd::jobs). Every other target compiles. -
3. The module. Write
jobs.rsfrom the skeleton, then fill the four bodies one at a time (session_id,load,append,live_session/close_leftovers). Addpub mod jobs;tolib.rs. Wirerun_serveas in Facts.cargo check -p loopdprints no error and no warning.wc -l crates/loopd/src/jobs.rsprints a number ≤ 500. -
4. The tests.
cargo test -p loopd --test jobs: every test passes, 8 tests. -
5. The runbook. From the repository root:
{ echo; cat docs/plans/M5d2/files/runbook-jobs-invalid.md; } >> docs/runbook.mdThen
grep -c '^## jobs-invalid$' docs/runbook.mdprints1. -
6. Check.
git diff --statlistscrates/loopd/src/jobs.rs,crates/loopd/src/lib.rs,crates/loopd/src/main.rs,docs/runbook.md. -
7. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1545 (1537 before, plus 8). Aftergate: ok, do not add uptest result:lines. -
8. Log and commit. Task cell
M5d2/05-loopd-jobs. Stage withgit add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1545 tests.git diff HEAD~1 --statlists onlycrates/loopd/src/jobs.rs,crates/loopd/src/lib.rs,crates/loopd/src/main.rs,crates/loopd/tests/jobs.rs,docs/runbook.mdand the log.
Stop and report if
- The branch check fails; step 2 differs;
cargo checkwarns; a test fails; the gate's last line is notgate: ok; you add a cron crate; a missing jobs directory is an error;DisplayforInvaliddoes not end with the runbook pointer;jobs.rsexceeds 500 lines; you write a job file.
What the given tests pin
A valid mail.toml loads; unknown keys name the file and the runbook; Mail.toml is Name;
session_id is job-mail-20261003t0700; a 50-character name is Name; a missing directory is
empty; leftover started becomes failed and is no longer live; a started without a close
is live until finished.