Add M2a plan: thirteen tasks, tests, fake server and recordings

The tasks build the inference path: emsha-backed SHA-256, inferproxy,
config, a hand-written HTTP and SSE client, request building, delta
assembly, the chat state machine, the thinking cap, the slot gate with
retry, the startup self-test and on-device verification.

Everything the tasks copy in was checked against a private reference
implementation: the gate passes after each task in order, the timing
tests pass repeatedly under CPU load, and the reference passes the
self-test and all four device checks on straylight. Expected results
for the recorded streams were derived by a separate script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 13:34:11 -07:00
co-authored by Claude Fable 5.1
parent a49db39b54
commit 76ccc251cd
56 changed files with 6367 additions and 3 deletions
+86
View File
@@ -0,0 +1,86 @@
# M2a task 05: the SSE reader
**Branch:** `m2a` (run `git switch m2a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add a server-sent-events reader to loopd`
## Goal
Turn a streamed body into a sequence of `data:` payloads. `llama-server` streams a completion as
server-sent events: one JSON object per `data:` line, and `data: [DONE]` at the end.
## Context
```
data: {"choices":[…]}\n
\n
: a comment line\n
data: {"choices":[…]}\r\n
\r\n
data: [DONE]\n
\n
```
Lines end in `\n` or `\r\n`. Blank lines, comment lines (starting with `:`) and any field other
than `data` (`event:`, `id:`, …) carry nothing for us and are skipped. After `data:` one leading
space is dropped if present, and only one.
## Files
- Copy: `crates/loopd/tests/sse.rs`
- Create: `crates/loopd/src/sse.rs`
- Modify: `crates/loopd/src/lib.rs`, `docs/implementer-log.md`
## Interfaces
Consumes: `loopd::http::is_timeout`.
Produces, in `crates/loopd/src/sse.rs`:
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SseItem { Data(String), Done } // Data: the text after "data:", without the line ending
#[derive(Debug)]
pub enum SseError { Io(std::io::Error), Timeout, Truncated, LineTooLong, NotUtf8 } // Display, Error
pub struct Events<R> { /* private */ }
impl<R: std::io::Read> Events<R> {
pub fn new(reader: R, max_line: usize) -> Self;
/// The next item, or None when the stream has ended cleanly between lines.
pub fn next_item(&mut self) -> Result<Option<SseItem>, SseError>;
}
```
Rules the tests check:
1. The result must not depend on how the bytes arrive. A reader that gives one byte per call must
produce the same items, including for text with multi-byte characters: decode a line as UTF-8
only once the whole line is there.
2. A line longer than `max_line` bytes is `LineTooLong`. This applies **both** when the line is
still arriving with no newline in sight **and** when it arrives whole in a single read. Check
the length in both places.
3. When the reader ends (`read` returns 0): with nothing buffered, return `Ok(None)`, and keep
returning it if asked again. With a partial line buffered: `Truncated`.
4. Reader errors: `is_timeout` gives `Timeout`; `UnexpectedEof` gives `Truncated` (the HTTP body
reader reports a cut stream that way); `Interrupted` is retried; anything else is `Io`.
5. A line that is not valid UTF-8 is `NotUtf8`.
## Steps
- [ ] **1. Copy.** `git switch m2a`, then
`cp docs/plans/M2a/files/crates/loopd/tests/sse.rs crates/loopd/tests/`
- [ ] **2. See the test fail.** `cargo test -p loopd --test sse`. Expected: it does not compile.
- [ ] **3. Write `sse.rs`,** and add `pub mod sse;` to `lib.rs`. Run `cargo fmt --all`.
- [ ] **4. See the test pass.** `cargo test -p loopd --test sse`. Expected: `7 passed`. The last test
reads four recordings through the HTTP client from task 04.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit`
## Done when
- `cargo test -p loopd --test sse` reports 7 passed; `make gate` prints `gate: ok`.
## Stop and report if
- `recorded_streams_have_the_expected_shape` fails although `cargo test -p loopd --test http`
passes and the other six tests here pass.