Review M2b: accept with one follow-up task; record lessons
All ten tasks pass the checklist, the gate, the audit and the device checks, including a four-turn conversation with a loopd restart and no cache loss. Reading and probing found four low defects: the busy guard is released before the final frame on the main path but not on the three error paths, its Drop skips a poisoned lock, an unreadable core.md is treated as missing, and bxctl's interactive loop exits on a failed turn. Task 11 carries the fixes with two new tests, checked against a fixed copy of the branch. The Model column is filled in (all Ornith) and one malformed row is repaired. Two rules are promoted to AGENTS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# M2b task 11: four small fixes (review follow-up)
|
||||
|
||||
**Branch:** `m2b` (run `git switch m2b`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `Fix four review findings: busy release, poison recovery, core.md errors, chat loop`
|
||||
|
||||
## Goal
|
||||
|
||||
The M2b review found four small defects. Two have a test to copy in; two are rules to apply. None
|
||||
changes an interface.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy (replacing the old ones): `crates/loopd/tests/baseline.rs`, `crates/bxctl/tests/chat.rs`
|
||||
- Modify: `crates/loopd/src/channel.rs`, `crates/loopd/src/baseline.rs`,
|
||||
`crates/bxctl/src/chat.rs`, `crates/bxctl/src/main.rs`, `docs/implementer-log.md`
|
||||
|
||||
## The four fixes
|
||||
|
||||
1. **`channel.rs`: release the session before *every* final frame.** Task 07 said to drop the
|
||||
busy guard before `turn_done` or the turn's error, and that is done. But the error frames for
|
||||
a session that cannot be opened or created (`no_such_session`, `session_exists`, `internal`)
|
||||
are still sent while the session is marked busy. `bxctl chat --session <new id>` reads
|
||||
`no_such_session` and at once sends the same turn again with `resume: false`, so it can be
|
||||
answered `session_busy` by a server thread that has not returned yet. Drop the guard before
|
||||
each of those three writes. The rule: the last frame of a connection is never sent while the
|
||||
session is busy.
|
||||
2. **`channel.rs`: a poisoned lock must not leave a session busy forever.** The guard's `Drop`
|
||||
does `if let Ok(mut busy) = self.ctx.busy.lock()`, which skips the removal when the lock is
|
||||
poisoned. Recover it with `unwrap_or_else(|p| p.into_inner())`, as `handle` already does.
|
||||
3. **`baseline.rs`: a `memory/core.md` that exists but cannot be read is an error.** Today
|
||||
`if let Ok(text) = read_to_string(..)` treats an unreadable file like a missing one, so a
|
||||
session starts without the memory the owner curated, and nothing says so. If the file exists,
|
||||
a read failure is `BaselineError::Read(path, e)`. A missing file is still fine.
|
||||
4. **`bxctl`: the interactive loop goes on after a failed turn, and `new_session_id` has no
|
||||
`expect`.** The spec says "A failed turn prints `bxctl: <error>` and the loop goes on"; today it
|
||||
exits 1. Report the error and continue; the session exists, so the next line resumes it. And
|
||||
`AGENTS.md` forbids `expect` in library code: replace the two in `new_session_id` with
|
||||
`unwrap_or_default()` for the clock and `unwrap_or_else` with a fixed valid id for the
|
||||
`SessionId` (it cannot fail, but the type must not be forced).
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.**
|
||||
|
||||
```sh
|
||||
git switch m2b
|
||||
cp docs/plans/M2b/files/crates/loopd/tests/baseline.rs crates/loopd/tests/
|
||||
cp docs/plans/M2b/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/
|
||||
```
|
||||
|
||||
- [ ] **2. See them fail.** `cargo test -p loopd --test baseline`: 1 of 7 fails,
|
||||
`an_unreadable_core_memory_file_is_an_error`. `cargo test -p bxctl --test chat`: 1 of 12 fails,
|
||||
`interactive_mode_survives_a_failed_turn`.
|
||||
- [ ] **3. Make the four fixes.** Run `cargo fmt --all`.
|
||||
- [ ] **4. See them pass.** `cargo test -p loopd --test baseline --test channel -p bxctl`.
|
||||
Expected: 7, 6 and 12 passed. Run the `channel` tests ten times in a row.
|
||||
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.**
|
||||
|
||||
```sh
|
||||
git add crates/loopd crates/bxctl docs/implementer-log.md
|
||||
git commit
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok` with 219 tests.
|
||||
- `grep -n "expect(" crates/bxctl/src/chat.rs` prints nothing.
|
||||
- `grep -c "drop(held)" crates/loopd/src/channel.rs` prints 4 (or the guard is scoped so that
|
||||
every final frame is written after it is gone).
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- `interactive_mode_survives_a_failed_turn` cannot pass without changing what `--say` does.
|
||||
@@ -1,7 +1,7 @@
|
||||
# M2b implementation plan: the agent loop
|
||||
|
||||
> **For the implementing model:** do not work from this file. The owner gives you one task file at
|
||||
> a time (`01-…` to `10-…`). This file is the index for the owner and the reviewer.
|
||||
> a time (`01-…` to `11-…`). This file is the index for the owner and the reviewer.
|
||||
|
||||
**Goal:** `loopd serve` holds conversations: each turn's request extends the one before, sessions
|
||||
live on disk and survive a restart, tool calls go through a port with limits on every kind of
|
||||
@@ -25,7 +25,7 @@ M1 frame protocol. `bxctl chat` is a client of that protocol.
|
||||
- Nothing is written to a session log until a completion is final. Nothing volatile is ever put
|
||||
in a message or the baseline.
|
||||
- Branch `m2b`. One task, one fresh OpenCode session, one commit. Run `cargo fmt --all` before the
|
||||
gate. Review happens once, after task 10.
|
||||
gate. Review happened once, after task 10; task 11 is its follow-up.
|
||||
|
||||
## Tasks
|
||||
|
||||
@@ -41,6 +41,7 @@ M1 frame protocol. `bxctl chat` is a client of that protocol.
|
||||
| 08 | `08-loopd-serve.md` | `loopd serve` | `loopd/tests/serve.rs` |
|
||||
| 09 | `09-bxctl-chat.md` | `bxctl chat` | `bxctl/tests/chat.rs` |
|
||||
| 10 | `10-verify-device.md` | `make verify-device` extended; the first `config/system.md` | `loopd/tests/device.rs` |
|
||||
| 11 | `11-review-fixes.md` | Review follow-up: busy release before every final frame, poison recovery, unreadable `core.md`, chat loop continues | updated `loopd/tests/baseline.rs`, `bxctl/tests/chat.rs` |
|
||||
|
||||
`files/` holds everything the tasks copy in. As in M2a, all of it was checked against a private
|
||||
reference implementation: the gate passes after every task in order, the new suites pass under CPU
|
||||
|
||||
@@ -57,6 +57,8 @@ fn fake_loopd(events: Vec<TurnEvent>, end: End) -> FakeLoopd {
|
||||
let id = request.id;
|
||||
let end = if turn.resume && turn.content == "trigger-no-such-session" {
|
||||
End::Error(ErrorCode::NoSuchSession, "session x does not exist")
|
||||
} else if turn.content == "trigger-turn-limit" {
|
||||
End::Error(ErrorCode::TurnLimit, "the turn hit a limit")
|
||||
} else {
|
||||
end.clone()
|
||||
};
|
||||
@@ -417,3 +419,45 @@ fn bad_arguments_print_usage() {
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// A failed turn is reported, and the conversation goes on: the session still exists and the
|
||||
/// next line is a new turn on it.
|
||||
#[test]
|
||||
fn interactive_mode_survives_a_failed_turn() {
|
||||
let fake = fake_loopd(
|
||||
vec![TurnEvent::Content {
|
||||
text: "ok".to_string(),
|
||||
}],
|
||||
End::Done(TurnDone {
|
||||
content: "ok".to_string(),
|
||||
usage: usage(),
|
||||
}),
|
||||
);
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
|
||||
.args(["chat", "--socket"])
|
||||
.arg(&fake.socket)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
std::io::Write::write_all(&mut stdin, b"first\ntrigger-turn-limit\nthird\n/quit\n")
|
||||
.unwrap();
|
||||
}
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"a failed turn does not end the chat"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("turn limit: the turn hit a limit"),
|
||||
"{stderr}"
|
||||
);
|
||||
let turns = fake.turns.lock().unwrap();
|
||||
assert_eq!(turns.len(), 3, "the turn after the failure was sent");
|
||||
assert!(turns[2].resume, "and it resumed the same session");
|
||||
assert_eq!(turns[2].session, turns[0].session);
|
||||
}
|
||||
|
||||
@@ -232,3 +232,28 @@ fn replay_of_a_prefix_is_a_prefix() {
|
||||
assert_eq!(whole[..part.len()], part[..], "prefix of {n} records");
|
||||
}
|
||||
}
|
||||
|
||||
/// A core memory file that exists but cannot be read is an error, not silently absent: the owner
|
||||
/// would otherwise get a session without the memory they curated, and no sign of it.
|
||||
#[test]
|
||||
fn an_unreadable_core_memory_file_is_an_error() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if running_as_root() {
|
||||
return; // root can read anything; the check is meaningless there
|
||||
}
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
home.write("memory/core.md", "secret memory\n");
|
||||
let core = home.dir.join("memory/core.md");
|
||||
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let result = Baseline::assemble(&cfg, &Registry::m2b());
|
||||
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let e = result.expect_err("an unreadable core.md must not be ignored");
|
||||
assert!(e.to_string().contains("core.md"), "{e}");
|
||||
}
|
||||
|
||||
fn running_as_root() -> bool {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user