Files
boxmaker/docs/plans/M2a/11-llama-gate-retry.md
T
kyleandClaude Fable 5.1 76ccc251cd 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>
2026-09-17 13:34:11 -07:00

126 lines
6.0 KiB
Markdown

# M2a task 11: the slot gate, and retry
**Branch:** `m2a` (run `git switch m2a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add the per-slot gate and chat_with_retry`
## Goal
Two protections against a pile-up. The **gate** lets `loopd` have only one request in flight per
server slot; others wait their turn inside `loopd`, in order, up to a limit. **Retry** resends a
request whose server went away, a few times, with growing waits.
## Context
The inference server is shared and can be restarted under us. After a restart every conversation
that was waiting would retry at once and queue invisibly on the server. With the gate they queue
in `loopd`, where the queue is bounded and the caller is told (`Queued`, `Busy`). Retrying is safe
because nothing is recorded until a completion is final: a retry sends the same bytes again.
The gate is held for **one request**, from before it is sent until `chat` returns. It is the first
code in this milestone that several threads use at once.
## Files
- Copy: `crates/loopd/tests/retry.rs`
- Create: `crates/loopd/src/llama/gate.rs`, `crates/loopd/src/llama/retry.rs`
- Modify: `crates/loopd/src/llama/mod.rs`, `crates/loopd/src/llama/chat.rs`,
`docs/implementer-log.md`
## Interfaces
`crates/loopd/src/llama/gate.rs`:
```rust
#[derive(Default)]
pub struct SlotGate { /* private: a Mutex around per-slot state, and a Condvar */ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GateFull;
/// The right to have a request in flight on the slot. Dropping it passes the slot on.
pub struct Permit<'a> { /* private */ }
impl SlotGate {
pub fn new() -> Self;
pub fn acquire(&self, slot: u32, max_queue: usize, on_queued: &mut dyn FnMut(usize)) -> Result<Permit<'_>, GateFull>;
}
```
Gate rules:
1. A free slot with nobody waiting is taken at once; `on_queued` is not called.
2. Otherwise, if `max_queue` requests are already waiting for this slot, return `GateFull` at
once, without waiting. `max_queue` of 0 means nobody may wait.
3. Otherwise join the queue, call `on_queued(ahead)` **once**, where `ahead` is the holder (1 if
the slot is taken) plus the waiters already in the queue, and wait.
4. Waiters get the slot **in the order they arrived**. A `Condvar` wakes waiters in no particular
order, so give each waiter a ticket, keep the tickets in a `VecDeque`, and let a woken waiter
take the slot only if the slot is free **and** its ticket is at the front. Use `notify_all`.
5. Slots are independent of each other.
6. Dropping the `Permit` frees the slot and wakes the waiters. Implement `Drop`.
7. `Mutex::lock` and `Condvar::wait` return `Err` if another thread panicked while holding the
lock. Do not `unwrap`: recover the guard with `unwrap_or_else(|poisoned| poisoned.into_inner())`.
`crates/loopd/src/llama/retry.rs`:
```rust
pub fn is_retryable(e: &InferError) -> bool;
pub fn backoff_ms(schedule: &[u64], retry: u32, jitter: i32) -> u64;
impl Client {
pub fn chat_with_retry(&self, req: &ChatRequest, on_event: &mut dyn FnMut(&ChatEvent)) -> Result<Completion, InferError>;
}
```
Retry rules:
1. `is_retryable`, one line per variant, all nine listed so that a new variant is a compile error:
| Retried: the server went away and may be back | Not retried |
|---|---|
| `Connect`, `StreamClosedEarly`, `Stalled`, `LoadTimeout`, `Http` with status 503 | `Busy`, `WaitTimeout` (it has already waited), `ThinkingOverrun`, `Protocol`, `Http` with any other status |
2. `backoff_ms(schedule, retry, jitter)`: `retry` is 1 for the first retry (0 is treated as 1). The
base is `schedule[retry - 1]`, or the last entry past the end, or 0 for an empty schedule.
`jitter` is clamped to -1000..=1000 and moves the wait by up to a quarter of the base:
`base + (base / 4) * jitter / 1000`. Integer arithmetic; huge values must not overflow
(`saturating_*`, `try_from`). Examples: `([2000, 8000, 30000], 1, 0)` is 2000; `(…, 1, 1000)` is
2500; `(…, 1, -1000)` is 1500; `(…, 2, 500)` is 9000; `(…, 4, 0)` is 30000.
3. `chat_with_retry` calls `chat`. On an error that is retryable, and while fewer than
`retry_attempts` attempts have been made: compute the wait with a jitter that differs from call
to call (the sub-second nanoseconds of the system clock, reduced to -1000..=1000, are random
enough; do not add a crate). If the time since the first attempt plus the wait is more than
`retry_window_ms`, return the error. Otherwise emit
`ChatEvent::Retrying { attempt, after_ms, error }` (`attempt` is the number of the attempt about
to start, so 2 for the first retry; `error` is the error's `Display` text), sleep, and try again.
When attempts run out, return the last error.
Changes elsewhere:
- `Client` gets a second field, `pub(crate) gate: gate::SlotGate`, created in `Client::new`.
- `chat` starts by acquiring the gate for `req.slot` with `cfg.limits.queue_len`, passing
`|ahead| on_event(&ChatEvent::Queued { ahead })`. `GateFull` becomes `InferError::Busy`. Bind the
permit to a name such as `_permit` so that it lives until `chat` returns; `let _ = …` would drop
it at once.
## Steps
- [ ] **1. Copy.** `git switch m2a`, then
`cp docs/plans/M2a/files/crates/loopd/tests/retry.rs crates/loopd/tests/`
- [ ] **2. See the test fail.** `cargo test -p loopd --test retry`. Expected: it does not compile.
- [ ] **3. Write `gate.rs` and `retry.rs`, and make the two changes.** Run `cargo fmt --all`.
- [ ] **4. See the tests pass.** `cargo test -p loopd`. Expected: `retry` 13 passed, and every earlier
test file still passes. Run `cargo test -p loopd --test retry` ten times in a row.
- [ ] **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 retry` reports 13 passed, ten runs in a row; `make gate` prints
`gate: ok`.
- `gate.rs` has no `unwrap()` and no `expect(`.
## Stop and report if
- `waiters_are_served_in_order_one_at_a_time` fails now and then. That means rule 4 is not met;
if you cannot see why after two attempts, stop.