Files
kyleandGrok 4.6 2a48b7f36d docs: M5d-2: after gate: ok, do not sum test results
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>
2026-10-03 12:56:14 -07:00

221 lines
8.5 KiB
Markdown

# 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 test
`crates/loopd/tests/jobs.rs`.
- Append, from `docs/plans/M5d2/files/runbook-jobs-invalid.md`, to `docs/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
- **`name` from the file stem.** `mail.toml` → `mail`. The stem must match `^[a-z0-9-]+$`
(bytes, nonempty) and `session_id(name, now)` must succeed. `Mail.toml` is `JobsError::Name`.
- **`session_id`.** `format!("job-{name}-{}t{}", ymd, hm)` with `ymd` = `rfc[0..4]+rfc[5..7]+rfc[8..10]`
and `hm` = `rfc[11..13]+rfc[14..16]`, all from `now.to_rfc3339()`, then
`SessionId::new`. `SessionId` is `[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 is
`JobsError::Invalid` naming the directory. Skip names that do not end in `.toml`. Sort by
`name` so the order is stable. Parse with `#[serde(deny_unknown_fields)]` on a private
`File { 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 last
`Run` whose `name` matches: `Started` → `Some(session)`, anything else → `None`.
- **`close_leftovers`.** Names whose last run is `Started` get one more `Run` with
`status: Failed` and the same `session`, `time: now`. Return how many names were closed.
A missing file is `Ok(0)`.
- **`Display` for `Invalid`.** `"{path}: {detail}; see docs/runbook.md#jobs-invalid"`. The
given unknown-keys test checks `ends_with("see docs/runbook.md#jobs-invalid")` and that
the path's file name is in the string.
- **`run_serve`.** After `check_summarise`, `jobs::load(&cfg.paths.home.join("jobs"))`. On
`Err`, print `loopd: {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_str` | already a `loopd` dependency |
| `SessionId::new` | the 64-byte alphabet check |
| `serde` `deny_unknown_fields` | unknown keys |
## Skeleton
`crates/loopd/src/jobs.rs` — fill every function; no `todo!()`. Keep the types in this order.
```rust
//! 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.**
```sh
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
target `jobs` does not compile (`E0432` unresolved `loopd::jobs`). Every other target
compiles.
- [ ] **3. The module.** Write `jobs.rs` from the skeleton, then fill the four bodies one at a
time (`session_id`, `load`, `append`, `live_session` / `close_leftovers`). Add `pub mod jobs;`
to `lib.rs`. Wire `run_serve` as in Facts. `cargo check -p loopd` prints no error and no
warning. `wc -l crates/loopd/src/jobs.rs` prints a number ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test jobs`: every test passes, 8 tests.
- [ ] **5. The runbook.** From the repository root:
```sh
{ echo; cat docs/plans/M5d2/files/runbook-jobs-invalid.md; } >> docs/runbook.md
```
Then `grep -c '^## jobs-invalid$' docs/runbook.md` prints `1`.
- [ ] **6. Check.** `git diff --stat` lists `crates/loopd/src/jobs.rs`,
`crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`, `docs/runbook.md`.
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1545** (1537 before, plus 8). After `gate: ok`, do not add up
`test result:` lines.
- [ ] **8. Log and commit.** Task cell `M5d2/05-loopd-jobs`. Stage with
`git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit as
`AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1545 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/jobs.rs`, `crates/loopd/src/lib.rs`,
`crates/loopd/src/main.rs`, `crates/loopd/tests/jobs.rs`, `docs/runbook.md` and the log.
## Stop and report if
- The branch check fails; step 2 differs; `cargo check` warns; a test fails; the gate's last
line is not `gate: ok`; you add a cron crate; a missing jobs directory is an error; `Display` for `Invalid`
does not end with the runbook pointer; `jobs.rs` exceeds 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`.