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>
5.1 KiB
M2a task 12: the startup self-test
Branch: m2a (run git switch m2a; git status --short must be empty, otherwise stop)
Commit subject: Add the startup self-test and the loopd selftest command
Goal
Before loopd serves anyone it checks that the server is the one its config describes, that tool
calls come back parsed, and that a second turn reuses the first turn's cache. If any check fails,
loopd refuses to start. This task writes the checks and a command that runs them.
Context
From the design brief: "On boot loopd checks: tool-call round trip parses, turn-2 prompt
processing count shows a cache hit, configured context matches what the server reports. It refuses
to start if any check fails."
Files
- Copy:
crates/loopd/tests/selftest.rs - Create:
crates/loopd/src/selftest.rs - Modify:
crates/loopd/src/lib.rs,crates/loopd/src/main.rs,docs/implementer-log.md
Interfaces
// crates/loopd/src/selftest.rs
#[derive(Debug)]
pub enum SelfTestError {
Mismatch { what: &'static str, expected: String, got: String },
ToolCall(String),
CacheMiss { expected: u64, got: u64 },
Infer(InferError),
Hash,
} // Display, std::error::Error, and From<InferError>
/// Runs the three checks in order. `on_step` is told the name of each check as it starts.
pub fn run(client: &Client, on_step: &mut dyn FnMut(&str)) -> Result<(), SelfTestError>;
The checks. All chat requests use client.config().slots.main, thinking: false, and
chat_with_retry with an event callback that does nothing.
1. on_step("server matches config"). Call props(). Compare, in this order, and return
Mismatch with the given what for the first that differs (expected from the config, got from
the server, both as text):
what |
Config | Server |
|---|---|---|
"chat template sha256" |
expect.template_sha256 (as hex) |
proto::sha256(chat_template.as_bytes()) (as hex); a hash error is SelfTestError::Hash |
"context per slot" |
expect.n_ctx |
props.n_ctx |
"slot count" |
expect.slots |
props.total_slots |
A server that is not the expected one is sent no prompt at all.
2. on_step("tool call round trip"). Messages: System "You are Boxmaker, a careful personal
agent." and User "Read /etc/hostname and tell me what it says." One tool:
ToolSchema {
name: "read_file".to_string(),
description: "Read a text file and return its contents.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "Absolute path" } },
"required": ["path"],
}),
}
Return ToolCall(reason) unless: finish_reason is ToolCalls, there is a first tool call, its
name is read_file, its arguments parse as JSON, and the JSON has a string path.
3. on_step("turn 2 cache hit"). No tools. Turn 1: the same System message and User "Name
one colour. One word." Turn 2: those two messages, then an Assistant message made from turn 1's
completion (its content, reasoning_content and tool_calls, unchanged), then User "Name
another. One word." If cache_outcome(&turn1.timings, &turn2.timings) is a Loss, return
CacheMiss with its two numbers.
main.rs. loopd selftest --config <path> loads the config (Config::load), makes a Client,
runs the self-test printing selftest: <step> to stderr for each step, then selftest: ok and
exit code 0, or selftest: FAILED: <error> and exit code 1. A config that cannot be loaded prints
loopd: <error> and exits 1. Any other arguments print a usage line and exit 2.
Steps
- 1. Copy.
git switch m2a, thencp docs/plans/M2a/files/crates/loopd/tests/selftest.rs crates/loopd/tests/ - 2. See the test fail.
cargo test -p loopd --test selftest. Expected: it does not compile. - 3. Write
selftest.rs, register it inlib.rs, and rewritemain.rs. Runcargo fmt --all. - 4. See the test pass.
cargo test -p loopd --test selftest. Expected:6 passed. - 5. Try it on the real server, if straylight can be reached. In one terminal:
cargo run -p inferproxy -- --listen /tmp/ip.sock --upstream straylight:11434. Write/tmp/loopd.tomlby copyingcrates/loopd/tests/fixtures/config/minimal.tomland changingsocketto/tmp/ip.sock. Thencargo run -p loopd -- selftest --config /tmp/loopd.toml. Expected: three step lines andselftest: ok, in a few seconds. Then setslots = 3in the file and run it again. Expected:selftest: FAILED: slot count: …and exit code 1. Put what you saw in your log row. Remove both files in/tmpafterwards. - 6. Run the gate.
make gate. Expected last line:gate: ok. - 7. Log and commit.
git add crates/loopd docs/implementer-log.md && git commit
Done when
cargo test -p loopd --test selftestreports 6 passed;make gateprintsgate: ok.
Stop and report if
- The real server fails check 1 with the unchanged
minimal.toml. That means the server's chat template or settings changed, which the owner needs to know; it is not something to fix in code.