feat(runtime-api): terminal byte stream (#34), stream resume + idempotent submit (#76), pet agent-count pin (#12) - #6361
10 commits merged into
Conversation
App-side issue #12 reports the pet showing only single-agent state and asks whether the owner emits `activity["parallel"]` at all. It does — the count is derived JS-side from `agent:`-prefixed spans. The chain, traced end to end before writing anything: - the engine emits `Event::AgentSpawned/Progress/Complete`, and `tui/ui/event_loop.rs:1946` gates them on the owning session before calling `pet_watch::observe`; - `metadata()` (`pet_watch/mod.rs:207`) allowlists all three variants and forwards `event`, `id` and `worker_status`; - the JS worker dispatches `agent_spawned` into `start(`agent:${id('id')}`)` (`pet_watch/pet-native.js:1796`) and counts `agent:`-keyed spans into `parallel` (`pet-native.js:1675`); - `Scene.activity.parallel` is what the caption renders as "· ×N" (`pet_watch/mod.rs:469`). So there is no producer to write: adding one would be a second authority for a count that already flows. The JS half is already covered by `pet/tests/pet-engine.test.mjs` (`assert.equal(frame.activity.parallel,3)`, gated by `.github/workflows/pet.yml`). What no test covered was the Rust half of the agent path — `metadata()` is tested for tool, thinking and message events only — and that is the half that fails silently: a trimmed allowlist or a dropped id zeroes the count with no error and no log line. Verification: - `scripts/dev-test.sh crates/tui/src/tui/pet_watch/mod.rs agent_events_forward` -> `Summary [0.028s] 1 test run: 1 passed, 12891 skipped` - Proven to catch the failure it names, not just to pass: deleting `| Event::AgentSpawned { .. }` from the allowlist fails it with `panicked at crates/tui/src/tui/pet_watch/mod.rs:761:10: agent spawns are observed` -> `0 passed; 1 failed`. The allowlist was restored; `git diff` holds only this test. - `scripts/dev-test.sh crates/tui/src/tui/pet_watch/mod.rs pet_watch` -> `Summary [0.980s] 17 tests run: 17 passed, 12875 skipped` - `cargo fmt --all -- --check` exit 0 - `cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` exit 0 The app-side half of #12 — rendering, and whether the visualization itself changes beyond the caption — stays in codewhale-app; this pins the contract it consumes.
App-side issue #34 asks Core for "authenticated byte input/output, resize, exit and bounded replay", and is explicit that the inspected stateful terminal path is Unix-gated and that Core work must be linked before those paths are claimed. Recon of crates/tui/src/tools/terminal_session.rs found the owner had none of the four: no resize (the 24x120 PtySize was fixed at openpty and the master was dropped after the reader/writer clones were taken), no kill, and a 512 KiB ring whose only reader was the consuming tool-result cursor. This lands the primitives that contract needs, in the file's existing free-function style: - the pty master is retained on the session, so `resize_session` reaches the kernel's window size; - `OutputChunk` + `OutputBuffer::read_since` read from an *absolute* cursor without consuming, so two readers replay the same bytes and a repeated read is idempotent. A cursor the ring has moved past sets `gap` instead of silently answering from the middle of the stream — report, not repair; - `session_exit_status` polls the child (None = still running) and `kill_session` terminates it, so a dead shell stops looking alive; - `read_session_since` clamps one response to READ_LIMIT (64 KiB) over the bounded ring; - `take_output` now uses that same cursor arithmetic instead of its own copy of it, behaviour unchanged (its existing tests cover it). Known limitation, recorded on `read_session_since`: nothing re-reads dropped bytes from disk — the durable record is identity and lifecycle, never output — and no route consumes these yet. The Engine byte-stream route is the next slice; this is the owner work it needs. Verification (macOS aarch64, this worktree): - `scripts/dev-test.sh crates/tui/src/tools/terminal_session.rs terminal_session` -> `Summary [2.329s] 15 tests run: 15 passed, 12881 skipped`, including: * `resize_reaches_the_kernel_and_the_live_shell` — asserts the kernel's own `get_size`, then that the live shell reports `40 100` through `stty size`; * `session_read_since_is_non_consuming_and_clamped` — a repeat read at the same cursor returns identical bytes; a >64 KiB stream clamps to READ_LIMIT; * `bounded_replay_is_absolute_non_consuming_and_reports_its_gap` — past a wrapped ring the chunk reports `oldest_cursor` 32 and `gap` true; * `killed_shell_reports_an_exit_status`. - `cargo fmt --all -- --check` exit 0 - `python3 scripts/check-dead-code-budget.py` -> `PASS: 185 attributes, exactly at budget.` Not claimed: Windows. This path is `#[cfg(unix)]` end to end and every test here is `cfg(all(test, unix))`; ConPTY qualification is its own slice, as the ticket itself says.
The terminal owner gained byte replay, resize and exit in the previous commit;
this is the consumer that makes them reachable — and, per the compiler, the
consumer that makes them live code rather than scaffolding. `/v1` auth is the
route layer's bearer token; nothing here re-implements or bypasses it.
- `GET /v1/terminal/{name}/output` — the resumable byte stream, wire-shaped
like the jobs stream on purpose (`cursor` / `max_bytes` / `format` in,
`offset` / `next_cursor` / `total` / `dropped` out) so two byte streams in
one product do not speak two dialects. Reads never consume: several clients
can hold independent cursors, and polling never steals output from the
agent's own consuming read.
- `POST /v1/terminal/{name}/input` — bytes into the live session, `text` or
`base64`, bounded per frame. Input stays attributable by route: this is the
client's writer, `terminal_send` is the agent's.
- `POST /v1/terminal/{name}/resize` — the window the child draws for.
- `POST /v1/terminal/{name}/kill` — end the shell. The exit itself is read
from the stream (`running` / `exit_code`), not from the acknowledgement.
- Routes attach to shells the Engine already owns and never create one: a name
with no live session is `404`. An HTTP request must not be able to conjure a
shell the Engine does not know about.
- `RuntimeCapabilities` gains `terminal_stream`, `terminal_input`,
`terminal_resize` and `terminal_kill`, set from `cfg!(unix)` so the Windows
build advertises `false` for all four. A client gates its pane on the flag
instead of discovering the gap from a failed request; the Windows routes
answer `501` (the owner is Unix-only end to end) so "this build cannot do
terminals" is distinguishable from "that session is gone".
- `docs/RUNTIME_API.md` documents the family in the GPUI section, next to the
jobs stream, including the four stated limitations: no `wait_ms` long poll,
no scrollback recovery, live sessions only (a restarted Engine reports no
session rather than pretending to reattach), and no runtime-sdk wrapper yet.
Verification (macOS aarch64, this worktree):
- `scripts/dev-test.sh crates/tui/src/runtime_api/tests.rs terminal_routes_serve`
-> `Summary [0.208s] 1 test run: 1 passed` — the full seam over HTTP against
a real PTY: input through the route executes in the Engine's own session, the
shell's output comes back through the route with an absolute cursor, a repeat
read at the same cursor returns identical bytes, resize is confirmed by the
shell's own `stty size` reporting `40 100` (so a handler that only stored the
numbers would fail this), and kill is observed as `running: false`.
- `... tests.rs terminal_output_for_an_unknown` -> `1 test run: 1 passed`
(404 for an unknown session and for an over-long name).
- `... tests.rs terminal_capabilities` -> `1 test run: 1 passed`.
- `... crates/tui/src/tools/terminal_session.rs terminal` -> `360 tests run:
360 passed, 12543 skipped`.
- `... crates/protocol/src/runtime/mod.rs runtime::` -> `10 tests run: 10 passed`.
- `cargo clippy -p codewhale-tui -p codewhale-protocol --all-targets --locked -- -D warnings ...` exit 0. This is the gate that failed before the route
existed: the previous commit's primitives were dead code without a consumer.
- `cargo fmt --all -- --check` exit 0;
`check-blocking-calls-budget.py` -> `601 sites across 177 files, within budget`;
`check-dead-code-budget.py` -> `PASS: 185 attributes, exactly at budget`.
Not verified: Windows. The owner is `#[cfg(unix)]`, the routes answer 501 there,
and ConPTY qualification remains its own slice — the ticket says as much.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
Codewhale review
Complete review coverage: 1/1 passes, 7 file patches, sha256:c6e5419426afb13b4d85b0c7d3407136036a0868693778347ba2cd19a36b95f8.
Pass 1: The PR adds Engine-owned terminal byte-stream routes (output/input/resize/kill) over the existing PTY owner plus new primitives in terminal_session.rs (retained master, absolute non-consuming reads, resize, exit status, kill), advertises four new capabilities, documents them, and pins the pet agent-event projection. The unix paths look coherent: take_output is refactored onto the same cursor arithmetic and appears behaviour-preserving, reads are genuinely non-consuming, and input is bounded. The defects are (a) a non-unix build break, (b) a documented input default that the implementation contradicts, (c) a running field whose documented meaning is stronger than what the code computes, and (d) a capability test that is not unix-gated.
Findings
- [ERROR] Non-unix build of
runtime_api::terminalcannot resolve the unix-onlyterminal_session::READ_LIMIT(crates/tui/src/runtime_api/terminal.rs:152)
crates/tui/src/runtime_api/terminal.rsis compiled on every target —mod terminal;incrates/tui/src/runtime_api.rsis not cfg-gated — butREAD_LIMITis declared#[cfg(unix)] pub(crate) const READ_LIMIT: usize = 64 * 1024;(crates/tui/src/tools/terminal_session.rs:42).bounded_max_bytes(terminal.rs:150) is itself not unix-gated and references that path twice (terminal.rs:152 and :155), so on anot(unix)target the two path references cannot be resolved (name-not-found at those lines):cargo check --target x86_64-pc-windows-msvcfails before any of the 501 stubs inmod platformis reached. That directly contradicts the PR's Windows story ('the Windows routes answer501') and thecfg!(unix)capability flags, which only make sense if the crate builds on Windows. The five helpers touched by this compile unit (chunk_encoding,encode_bytes,decode_bytes,bounded_max_bytes,bounded_dimension) are used only by the unix handlers and by#[cfg(all(test, unix))]tests, so they are dead code on non-unix even after the path resolves. The reportedcargo clippy --all-targets -D warningsrun was executed on a unix host, so the entire#[cfg(not(unix))]module and this reference were never compiled and the failure could not have been observed. Fix by making the limit available on all targets (a#[cfg(not(unix))]READ_LIMIT, or a cfg-free shared constant) or by gating the unix-only helpers so the non-unix module is self-contained. - [WARNING]
runningis documented as 'exited and drained' but is computed fromtry_waitalone, so a client can drop the shell's last bytes (crates/tui/src/runtime_api/terminal.rs:77)
The response field doc says/// False once the shell has exited and no bytes remain pastnext_cursor., and docs/RUNTIME_API.md tells clients to learn the exit fromoutput(running/exit_code). The implementation islet running = exit.is_none();(terminal.rs:210), derived purely fromchild.try_wait(). Reaping the child is independent of the reader thread that appends into the ring, so there is a real window in which a poll returnsrunning: falsewhilenext_cursor < total(the shell's final line has not been appended yet). A client that stops polling onrunning: false— exactly what the field doc says is safe — silently truncates the stream; this is the scenario the feature exists to serve. Note that the sibling jobs route expresses the same state asdone= 'a terminal status and nothing left past the cursor' (docs/RUNTIME_API.md:1266), and the inline comment at terminal.rs:219-220 also contradicts the field doc ('runningalone must not imply there is nothing behind us'), so the two comments in this new file disagree. Minimal fix: either reword the field to say the shell has exited and instruct clients to drain untilnext_cursor == total, or derive the flag from both signals (finished only when the child has exited andchunk.next_cursor == chunk.total). - [WARNING] Terminal input's documented default encoding is text, but the route decodes base64 by default (
docs/RUNTIME_API.md:1233)
docs/RUNTIME_API.md:1233-1234 states forPOST /v1/terminal/{name}/input:{ "data", "encoding"? }, UTF-8 text by default orbase64for exact bytes — the same default the pre-existing jobs stdin route documents (datais UTF-8 text by default orbase64, docs/RUNTIME_API.md:1267-1269). The code does the opposite:terminal_inputcallsdecode_bytes(&request.data, request.encoding.as_deref().unwrap_or("base64"))(terminal.rs:240), and the in-code doc comment for the field (terminal.rs:86) says base64 is the default. A client that implements the documented contract and posts{"data":"ls\n"}with noencodinggets400 data is not valid base64instead of typing the command; only an explicitencoding: "text"works. Nothing in/v1/runtime/infolets a client detect the real default, so this is a wire-contract divergence, not a cosmetic doc nit. Decide the intended default and make code and docs agree (the jobs stdin precedent argues fortext); a regression test posting the documented body shape would pin it. - [WARNING]
runtime_info_advertises_terminal_capabilitiesasserts the four flags aretrueon every platform, but the server sets them fromcfg!(unix)(crates/tui/src/runtime_api/tests.rs:13760)
The new test (crates/tui/src/runtime_api/tests.rs:13738) hard-assertsassert_eq!(info["capabilities"][capability], true, ...)forterminal_stream/terminal_input/terminal_resize/terminal_kill, whiledefault_runtime_capabilities()sets all four fromcfg!(unix). On a Windows build the server truthfully answersfalse(and the routes answer 501), so the assertion fails there — i.e. once the non-unix build compiles (see the READ_LIMIT issue), the Windows test run is red for a reason that has nothing to do with the code under test. The sibling test added in the same PR,terminal_routes_serve_a_live_engine_session_over_http, is correctly marked#[cfg(unix)]. Fix by assertingcfg!(unix)instead of the literaltrue(which keeps the test meaningful on both platforms), or by gating the test with#[cfg(unix)].
Suggestions
crates/tui/src/runtime_api/terminal.rs:152—bounded_max_bytesmust not be the only unconditional consumer of the#[cfg(unix)]terminal_session::READ_LIMIT. Either declare the limit for all targets (e.g. a#[cfg(not(unix))] pub(crate) const READ_LIMIT: usize = 64 * 1024;next to the unix one in crates/tui/src/tools/terminal_session.rs:42, or hoist it to a cfg-free location), or mark the unix-only request-validation helpers (chunk_encoding,encode_bytes,decode_bytes,bounded_max_bytes,bounded_dimension)#[cfg(unix)]so thenot(unix)module needs none of them. Verify with a Windows-targetcargo check/clippy --all-targets -D warnings, which the reported Linux run never exercises.docs/RUNTIME_API.md:1233— Make the documented default match the implementation. If base64 really is the intended default forPOST /v1/terminal/{name}/input, say so here (and note that it differs from the jobs stdin route, which defaults to text); if text is intended, change theunwrap_or("base64")interminal_input(crates/tui/src/runtime_api/terminal.rs:240) tounwrap_or("text")and keep the docs and the in-code field comment consistent.crates/tui/src/runtime_api/tests.rs:13760— Replace the literaltrueininfo["capabilities"][capability], true,withcfg!(unix)so the test asserts the flag matches the build rather than hard-coding the unix answer; alternatively mark the whole test#[cfg(unix)]the way its siblingterminal_routes_serve_a_live_engine_session_over_httpalready is. Run rustfmt after the edit.
Assessment
Pass 1: Static review only: no build, test or runtime check was executed here, and the reported clippy/fmt runs were on a unix host. On the unix paths the design holds up — the take_output refactor onto OutputBuffer::read_since(cursor, usize::MAX) is behaviour-preserving (same clamped start, reads to the end, still sets read_cursor = total), the route-level reads are non-consuming and independent per cursor, input is bounded before it reaches the PTY, and resize/kill are genuinely delegated to the kernel/child. The serious gap is that the change was only ever compiled for unix: #[cfg(not(unix))] mod platform, and the unconditional terminal_session::READ_LIMIT reference outside it, mean the Windows build described in the PR text is unverified at best and does not compile as written. I could not inspect the projection (metadata) in crates/tui/src/tui/pet_watch/mod.rs, so the exact-JSON expectations of the new pet test, including its worker_status/id keys, remain unverified from the supplied excerpts. One further open question: the new route calls child.try_wait(), which reaps the child; whether the existing cancel/completion path in terminal_session.rs (command marker, CANCEL_CONFIRM_TIMEOUT, sentinel retries) ever calls child.wait() on the same session could not be checked from the excerpts — if it does, an HTTP output poll could make that later wait fail with ECHILD.
Advisory review by Codewhale (codewhale review --pr 6361 --post, head ba2d23f8e97fb76da74da3b495bfadf912b59230). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.
|
|
||
| fn bounded_max_bytes(requested: Option<usize>) -> Result<usize, ApiError> { | ||
| let max_bytes = requested.unwrap_or(TERMINAL_CHUNK_DEFAULT); | ||
| if !(1..=terminal_session::READ_LIMIT).contains(&max_bytes) { |
There was a problem hiding this comment.
[ERROR] Non-unix build of runtime_api::terminal cannot resolve the unix-only terminal_session::READ_LIMIT
crates/tui/src/runtime_api/terminal.rs is compiled on every target — mod terminal; in crates/tui/src/runtime_api.rs is not cfg-gated — but READ_LIMIT is declared #[cfg(unix)] pub(crate) const READ_LIMIT: usize = 64 * 1024; (crates/tui/src/tools/terminal_session.rs:42). bounded_max_bytes (terminal.rs:150) is itself not unix-gated and references that path twice (terminal.rs:152 and :155), so on a not(unix) target the two path references cannot be resolved (name-not-found at those lines): cargo check --target x86_64-pc-windows-msvc fails before any of the 501 stubs in mod platform is reached. That directly contradicts the PR's Windows story ('the Windows routes answer 501') and the cfg!(unix) capability flags, which only make sense if the crate builds on Windows. The five helpers touched by this compile unit (chunk_encoding, encode_bytes, decode_bytes, bounded_max_bytes, bounded_dimension) are used only by the unix handlers and by #[cfg(all(test, unix))] tests, so they are dead code on non-unix even after the path resolves. The reported cargo clippy --all-targets -D warnings run was executed on a unix host, so the entire #[cfg(not(unix))] module and this reference were never compiled and the failure could not have been observed. Fix by making the limit available on all targets (a #[cfg(not(unix))] READ_LIMIT, or a cfg-free shared constant) or by gating the unix-only helpers so the non-unix module is self-contained.
| dropped: u64, | ||
| encoding: &'static str, | ||
| data: String, | ||
| /// False once the shell has exited and no bytes remain past `next_cursor`. |
There was a problem hiding this comment.
[WARNING] running is documented as 'exited and drained' but is computed from try_wait alone, so a client can drop the shell's last bytes
The response field doc says /// False once the shell has exited and no bytes remain past next_cursor., and docs/RUNTIME_API.md tells clients to learn the exit from output (running / exit_code). The implementation is let running = exit.is_none(); (terminal.rs:210), derived purely from child.try_wait(). Reaping the child is independent of the reader thread that appends into the ring, so there is a real window in which a poll returns running: false while next_cursor < total (the shell's final line has not been appended yet). A client that stops polling on running: false — exactly what the field doc says is safe — silently truncates the stream; this is the scenario the feature exists to serve. Note that the sibling jobs route expresses the same state as done = 'a terminal status and nothing left past the cursor' (docs/RUNTIME_API.md:1266), and the inline comment at terminal.rs:219-220 also contradicts the field doc ('running alone must not imply there is nothing behind us'), so the two comments in this new file disagree. Minimal fix: either reword the field to say the shell has exited and instruct clients to drain until next_cursor == total, or derive the flag from both signals (finished only when the child has exited and chunk.next_cursor == chunk.total).
| total, dropped, encoding, data, running, exit_code}`: pass `next_cursor` | ||
| back to continue; reads never consume, so several clients may hold | ||
| independent cursors; `dropped` reports bytes the 512 KiB ring discarded | ||
| - `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, UTF-8 text by |
There was a problem hiding this comment.
[WARNING] Terminal input's documented default encoding is text, but the route decodes base64 by default
docs/RUNTIME_API.md:1233-1234 states for POST /v1/terminal/{name}/input: { "data", "encoding"? }, UTF-8 text by default or base64 for exact bytes — the same default the pre-existing jobs stdin route documents (data is UTF-8 text by default or base64, docs/RUNTIME_API.md:1267-1269). The code does the opposite: terminal_input calls decode_bytes(&request.data, request.encoding.as_deref().unwrap_or("base64")) (terminal.rs:240), and the in-code doc comment for the field (terminal.rs:86) says base64 is the default. A client that implements the documented contract and posts {"data":"ls\n"} with no encoding gets 400 data is not valid base64 instead of typing the command; only an explicit encoding: "text" works. Nothing in /v1/runtime/info lets a client detect the real default, so this is a wire-contract divergence, not a cosmetic doc nit. Decide the intended default and make code and docs agree (the jobs stdin precedent argues for text); a regression test posting the documented body shape would pin it.
| "terminal_kill", | ||
| ] { | ||
| assert_eq!( | ||
| info["capabilities"][capability], true, |
There was a problem hiding this comment.
[WARNING] runtime_info_advertises_terminal_capabilities asserts the four flags are true on every platform, but the server sets them from cfg!(unix)
The new test (crates/tui/src/runtime_api/tests.rs:13738) hard-asserts assert_eq!(info["capabilities"][capability], true, ...) for terminal_stream/terminal_input/terminal_resize/terminal_kill, while default_runtime_capabilities() sets all four from cfg!(unix). On a Windows build the server truthfully answers false (and the routes answer 501), so the assertion fails there — i.e. once the non-unix build compiles (see the READ_LIMIT issue), the Windows test run is red for a reason that has nothing to do with the code under test. The sibling test added in the same PR, terminal_routes_serve_a_live_engine_session_over_http, is correctly marked #[cfg(unix)]. Fix by asserting cfg!(unix) instead of the literal true (which keeps the test meaningful on both platforms), or by gating the test with #[cfg(unix)].
|
|
||
| fn bounded_max_bytes(requested: Option<usize>) -> Result<usize, ApiError> { | ||
| let max_bytes = requested.unwrap_or(TERMINAL_CHUNK_DEFAULT); | ||
| if !(1..=terminal_session::READ_LIMIT).contains(&max_bytes) { |
There was a problem hiding this comment.
bounded_max_bytes must not be the only unconditional consumer of the #[cfg(unix)] terminal_session::READ_LIMIT. Either declare the limit for all targets (e.g. a #[cfg(not(unix))] pub(crate) const READ_LIMIT: usize = 64 * 1024; next to the unix one in crates/tui/src/tools/terminal_session.rs:42, or hoist it to a cfg-free location), or mark the unix-only request-validation helpers (chunk_encoding, encode_bytes, decode_bytes, bounded_max_bytes, bounded_dimension) #[cfg(unix)] so the not(unix) module needs none of them. Verify with a Windows-target cargo check/clippy --all-targets -D warnings, which the reported Linux run never exercises.
| back to continue; reads never consume, so several clients may hold | ||
| independent cursors; `dropped` reports bytes the 512 KiB ring discarded | ||
| - `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, UTF-8 text by | ||
| default or `base64` for exact bytes → `{ "name", "written" }` |
There was a problem hiding this comment.
Make the documented default match the implementation. If base64 really is the intended default for POST /v1/terminal/{name}/input, say so here (and note that it differs from the jobs stdin route, which defaults to text); if text is intended, change the unwrap_or("base64") in terminal_input (crates/tui/src/runtime_api/terminal.rs:240) to unwrap_or("text") and keep the docs and the in-code field comment consistent.
| "terminal_kill", | ||
| ] { | ||
| assert_eq!( | ||
| info["capabilities"][capability], true, |
There was a problem hiding this comment.
Replace the literal true in info["capabilities"][capability], true, with cfg!(unix) so the test asserts the flag matches the build rather than hard-coding the unix answer; alternatively mark the whole test #[cfg(unix)] the way its sibling terminal_routes_serve_a_live_engine_session_over_http already is. Run rustfmt after the edit.
Reviewing my own tests before CI spent a Windows cycle on them: two of the three new ones would have failed the required `Test (windows-latest)` job, because on Windows these routes answer `501` and every terminal capability is `false` by design. - `runtime_info_advertises_terminal_capabilities` now asserts `cfg!(unix)` rather than `true`, which also makes it a real assertion on Windows: the flag must not claim a capability the build cannot serve. - `terminal_output_for_an_unknown_session_is_not_found_and_creates_nothing` is `#[cfg(unix)]`: the 404-not-501 distinction only exists where the routes serve bytes. - The five request helpers (`chunk_encoding`, `encode_bytes`, `decode_bytes`, `bounded_max_bytes`, `bounded_dimension`) are `#[cfg(unix)]` too — they are reached only by the Unix handlers, and leaving them ungated would have made them dead code on Windows under `-D warnings`. Verification: `cargo check -p codewhale-protocol --target x86_64-pc-windows-msvc --locked` exit 0, so the capability fields are portable. A full `codewhale-tui` check for Windows cannot run from macOS — `ring`'s build script needs a Windows C toolchain — so the Windows leg of this branch remains CI's to prove, and the Windows compile of `terminal.rs` is the one thing here I could not verify locally.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…76) The app-side ticket asks for resumable event streaming with sequence acknowledgements. The durable half already existed — per-thread `seq`, a JSONL event log, `since_seq` replay with a bounded tail — but nothing on the wire let a browser-style client use it: every SSE frame was written without an `id:`, so an `EventSource` had nothing to resume from, and the route only read the query cursor. - Journal frames now carry their durable `seq` as the SSE event id (the three yield sites in `replay_live_thread_events`). Ids ride journal frames only: the `stream.progress` frames are transport progress, not events, and giving them an id would invite a client to resume from a point it never received. - `stream_thread_events` reads `Last-Event-ID` and uses it as the cursor when no explicit `since_seq` was asked for. An explicit query cursor wins, so a deliberate replay-from-zero is never silently overridden by a stale header. - `last_event_id` accepts only a decimal sequence number. An opaque id from a proxy or an older client starts the stream from the durable head instead of failing to open it — a refused stream looks like an outage to a reconnecting client. - `RuntimeCapabilities` gains `event_stream_resume`, so the app can gate its reconnect controls on the capability rather than discovering it from a missing id. Not in this commit, deliberately: the idempotent-submission half of #76. The `operation_key` mechanism already exists with a lookup route; what is missing is surfacing a replay as a replay on `POST /v1/threads/{id}/turns` (it answers `201` either way today) and having app-server mint the key. That is its own slice, and this one is already verifiable on its own. Verification (macOS aarch64, this worktree): - `scripts/dev-test.sh crates/tui/src/runtime_api/tests.rs thread_event_frames_carry` -> `Summary [0.231s] 1 test run: 1 passed` — the first frame's `id:` equals its payload `seq`; a reconnect with only `Last-Event-ID` lands on the next durable event; `?since_seq=0` with a stale header still replays from zero. - `scripts/dev-test.sh crates/tui/src/runtime_api/tests.rs last_event_id_accepts` -> `1 test run: 1 passed` (absent, padded, and opaque ids). - The two tests this route already had still pass unchanged: `events_endpoint_respects_since_seq_cursor` and `event_handoff_replays_and_dedupes_interaction_prompts_without_a_gap` (`1 test run: 1 passed` each). - `scripts/dev-test.sh crates/tui/src/runtime_api/tests.rs event` -> `170 tests run: 170 passed, 12735 skipped`. - `... crates/protocol/src/runtime/mod.rs runtime::` -> `10 tests run: 10 passed`. - `cargo clippy -p codewhale-tui -p codewhale-protocol --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` exit 0 · `cargo fmt --all -- --check` exit 0 - `check-blocking-calls-budget.py` PASS · `check-dead-code-budget.py` PASS
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…ion (#76) The second half of #76: an ambiguous submit must be resolvable by operation lookup. The durable machinery already existed — `operation_key` is validated, fingerprinted and bound, and a replay returns the original turn — but the answer was indistinguishable from a fresh admission: `POST /v1/threads/{id}/turns` answered `201` either way, so a client that retried after a dropped response could not tell whether it had created a second turn or been handed the one it already had. - The admission path now reports the disposition. `start_turn_with_source` returns `(TurnRecord, bool)`; both replay returns (the pre-claim lookup and the recheck under the claim lock) report `true`, the tail reports `false`. `start_turn` and `start_turn_from_stored_images` keep their existing signatures, and `start_turn_reporting_replay` exposes the pair, so the 94 existing `start_turn` callers are untouched. - The route answers `200 { ..., idempotent_replay: true }` for a replay and keeps `201` for a new admission, following the Agent Mail precedent. The flag is omitted on a fresh admission, so every response an existing client already parses is byte-identical. Not in this commit: app-server minting an `operation_key` for its own submissions. That is the client half, it lands in the app lane, and the capability it needs (`turn_operation_idempotency`, `turn_operation_lookup`) is already advertised. Verification (macOS aarch64, this worktree): - `scripts/dev-test.sh crates/tui/src/runtime_api/tests.rs turn_endpoint_operation_key` -> `Summary [0.259s] 1 test run: 1 passed`. That test already existed and asserted the old `201` for a replay; it now pins the stronger contract — `200`, `idempotent_replay: true`, the original turn id, `409` on a changed request with the same key, exactly one `SendMessage`, and exactly one turn. It also asserts a fresh admission carries no flag. - The paths the signature change touches: `turn_operation` -> `5 tests run: 5 passed`; `start_turn` -> `5 passed`; `agent_mail` -> `6 passed`; `thread_goal` -> `5 passed`; `steer` -> `33 passed` (12900 skipped in each filter). - `cargo clippy -p codewhale-tui -p codewhale-protocol --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` exit 0 · `cargo fmt --all -- --check` exit 0 - `check-blocking-calls-budget.py` PASS · `check-dead-code-budget.py` PASS
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…ight Issue #2990 ("Active turn dies ... when the computer sleeps") was fixed in v0.8.57 by detecting the suspend on wake and re-issuing the request (`core::engine::streaming::sleep_gap_detected`). That survives a suspend; it does not stop one. An unattended machine still idles into sleep mid-turn, and a turn that outlives the idle timer is lost work with no error line. This holds the platform's idle-sleep assertion for exactly as long as a turn: - macOS: `caffeinate -i` - Linux: `systemd-inhibit --what=idle --why="Codewhale turn in flight" --mode=block sleep infinity` - other Unix: no inhibitor this module knows - Windows: not implemented, deliberately. `SetThreadExecutionState` is thread-affine — the release has to happen on the thread that set it, which a guard travelling with a turn cannot promise. An untested holder that might never release would keep a laptop awake forever, which is worse than the problem this solves. Release is `Drop`, and no guard is ever cached: a leaked inhibitor is worse than the sleep it prevents. The guard rides the existing `terminal_chrome_enabled` gate — the same one that already decides host-facing chrome — so an interactive TUI turn holds it while headless hosts (`exec`, app-server, CI) never do. No new config key; a dedicated `[tui]` opt-out is stated as not-implemented in the module and in docs. What it does not do, recorded next to the behaviour in `docs/ENVIRONMENTS.md`: it does not defeat an explicit `sleep` / `pmset sleepnow`, a closed lid, or a low battery, and it cannot run while the host is suspended. Verification (macOS aarch64, this worktree): - The mechanism at the OS level, which is the part my code depends on: `pmset -g assertions` reports `PreventUserIdleSystemSleep` 0 -> 1 while a `caffeinate -i` is held. (The host also carries an unrelated `caffeinate -s -w 4908` from the user's own `deeprich` supervisor, which is why no sleep events appear in `pmset -g log`; that process is not ours.) - `scripts/dev-test.sh crates/tui/src/sleep_guard.rs sleep_guard` -> `Summary [0.030s] 2 tests run: 2 passed`: the inhibitor is alive while the guard lives and gone after the drop — asserted through `kill(pid, 0)`, so the test cannot perturb the process it measures — and two guards own two independent processes, so the first drop releases only its own. - `turn_loop` -> `67 tests run: 67 passed`; `engine::tests::turn` -> `20 tests run: 20 passed`. - `cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` exit 0 · `cargo fmt --all -- --check` exit 0 - `check-blocking-calls-budget.py` PASS · `check-dead-code-budget.py` PASS Not verified: Windows (no implementation) and the interactive TUI end to end — the guard is exercised at its own boundary, and a real turn needs a provider.
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
Codewhale review
Complete review coverage: 1/1 passes, 12 file patches, sha256:fcc05990dfe456e6cc25f892d61a988f41ed2990350ee784d8423a72d6ee47bd.
Pass 1: Reviewed the 12-file diff: the new /v1/terminal byte-stream family (owner primitives + routes + capabilities), the SSE Last-Event-ID resume and idempotent-submit reporting in runtime_api.rs/runtime_threads.rs, and the new sleep guard. No error-level defect was found in the code paths I could inspect. I found one documentation contract that would break a client that follows it, one field-level contract mismatch in the new terminal response, and one non-unix lint issue. Three invariants sit outside the supplied diff (live-phase dedup base, the cfg(not(unix)) handler bodies, portable_pty error semantics) and I could not verify them.
Findings
- [WARNING] RUNTIME_API.md documents the wrong default encoding for POST /v1/terminal/{name}/input (
docs/RUNTIME_API.md:1233)
The new public doc line says the input route takes "UTF-8 text by default orbase64for exact bytes", but the handler decodes withrequest.encoding.as_deref().unwrap_or("base64")(crates/tui/src/runtime_api/terminal.rs,terminal_input), andTerminalInputRequest::encoding's own doc comment says "base64(default, exact bytes)". A client that follows the published contract and POSTs{"data":"ls -la\n"}with noencodingfield never reaches the shell:decode_bytesruns the base64 decoder and the request fails with 400 "data is not valid base64"; if the text happens to be a valid base64 string it is silently typed as the decoded bytes instead of the text, which is worse than an error. Either the doc line or the code default must change (the sibling/v1/threads/{id}/jobs/{job_id}/stdinroute documents and uses text-by-default, so a maintainer may prefer aligning the code instead of the doc). - [INFO]
runningis documented as "no bytes remain" but is derived from the child's exit alone (crates/tui/src/runtime_api/terminal.rs:77)
TerminalOutputResponse::runningis documented as "False once the shell has exited and no bytes remain pastnext_cursor", but the handler setsrunning = exit.is_none()fromsession_exit_status(atry_waiton the child), and the inline comment at the construction site explicitly says the opposite: "runningalone must not imply there is nothing behind us". The two statements in the same file contradict each other, and the field doc is the one the client reads. Because the reader thread appends asynchronously, the child can be observed as exited while its final output has not been appended yet — exactly the race the jobs stream closes withdone. A client that implements its "stream finished" condition asrunning == false(as the doc invites) can stop polling and lose the shell's trailing bytes; the field must instead be read as "the child has exited" and the client must keep polling untilnext_cursor == total(andoffset == cursor, i.e. no gap). Fixing the field doc (or adding a real drain flag computed againstchunk.next_cursor == chunk.total) is the smallest change. - [INFO] sleep_guard's std::process imports are unused on non-unix builds (
crates/tui/src/sleep_guard.rs:30)
use std::process::{Child, Command, Stdio};is unconditional, but all three names are used only under#[cfg(unix)](SleepGuard::child,spawn, and the macOS/Linuxstart_inhibitor), while the struct itself,hold()and the module are compiled on Windows too (the module has a#[cfg(not(unix))] { Self {} }branch). On acfg(not(unix))build every one of the three imports is unused, i.e. anunused_importswarning that becomes a build failure under the-D warningsinvocation this PR reports running. The same crate already gates exactly these sorts of imports (crates/tui/src/tools/terminal_session.rsputs#[cfg(unix)]onstd::sync/std::timeimports), so this looks like an omission rather than a policy. Fix: put#[cfg(unix)]on that use line; the Windows build then needs no imports at all.
Suggestions
-
docs/RUNTIME_API.md:1233— State the default the implementation actually uses (unwrap_or("base64")), so a client that omitsencodingis not told to send raw UTF-8 that the route then rejects with 400 "data is not valid base64". If text-by-default is what was intended (to match the jobs stdin route), change the default interminal_inputinstead and keep this line.- `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, `base64` by default for exact bytes, or `text` for UTF-8 → `{ "name", "written" }`
Assessment
Pass 1: No build, test or runtime check was executed for this review; everything below is source inspection of the supplied diff and the supplementary excerpts.
Strengths I could verify by reading: OutputBuffer::read_since is arithmetically equivalent to the cursor logic take_output used before (so the refactor is behaviour-preserving for the agent path) and cannot overflow next_cursor for any cursor/max_bytes combination, including a cursor ahead of the stream; both replay returns in start_turn_with_source set the replay flag and only the fresh-admission tail clears it, so the 200/201 split matches the flag; last_event_id only accepts a decimal and treats an opaque id as "no cursor", and query.since_seq.or_else(header) gives the query cursor precedence as documented.
Open questions I could not settle from the material given (none of these is asserted as a defect):
- The call site that hands the broadcast receiver to
replay_live_thread_eventsis not in the diff, so I cannot confirm that the live-phase dedup base now follows the header-derived cursor. If that function dedups againstquery.since_seqrather than against the last seq it actually emitted during replay, aLast-Event-IDreconnect could re-deliver events that were already emitted in the replay; the added test only inspects the first frame, so it would not catch that. The neighbouringevent_handoff_replays_and_dedupes_interaction_prompts_without_a_gaptest covers the handoff window for the query-cursor path. - The
#[cfg(not(unix))]platformmodule (and thepub(super) use platform::…re-export) is not compiled by any Linuxclippy/cargo checkrun, so nothing in this PR's verification touches those four bodies —ApiError::not_implemented's existence and the module's signatures are unverified here. The same is true of the three#[cfg(unix)]-only imports incrates/tui/src/runtime_api/terminal.rs(base64::Engine as _,crate::tools::terminal_session), which I deliberately did not report because ause super::*glob in the platform module makes the lint outcome ambiguous. - Whether
POST /v1/terminal/{name}/killis idempotent depends onportable_pty's unixChild::kill.std::process::Child::killreturnsInvalidInputonce the child has been reaped (try_waitcaches the status), and the output route callstry_waiton every poll — so a kill issued after a client has observedrunning: falsemay surface as a 500 rather than a no-op. Ifportable_ptypropagates that error, the route should treat an already-exited child as success (killed: falseor 200); the wrapper source is outside the diff, so I did not raise it as a finding. - The derive line for
StartTurnResponseis outside the supplied diff. If it includesDeserialize, the newidempotent_replayfield needs#[serde(default)]as well asskip_serializing_if, since admission responses omit it; the tests in this crate parse the body asserde_json::Value, so they would not reveal that.
Note also that the 64 KiB decoded input bound (TERMINAL_INPUT_MAX_BYTES) needs a router body limit well above ~88 KiB for base64 frames; the value behind DefaultBodyLimit in build_router is not in the diff, so the advertised bound may or may not be reachable.
Advisory review by Codewhale (codewhale review --pr 6361 --post, head 919d60232b5498abcb23fe7ab213ee984c102127). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.
| total, dropped, encoding, data, running, exit_code}`: pass `next_cursor` | ||
| back to continue; reads never consume, so several clients may hold | ||
| independent cursors; `dropped` reports bytes the 512 KiB ring discarded | ||
| - `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, UTF-8 text by |
There was a problem hiding this comment.
[WARNING] RUNTIME_API.md documents the wrong default encoding for POST /v1/terminal/{name}/input
The new public doc line says the input route takes "UTF-8 text by default or base64 for exact bytes", but the handler decodes with request.encoding.as_deref().unwrap_or("base64") (crates/tui/src/runtime_api/terminal.rs, terminal_input), and TerminalInputRequest::encoding's own doc comment says "base64 (default, exact bytes)". A client that follows the published contract and POSTs {"data":"ls -la\n"} with no encoding field never reaches the shell: decode_bytes runs the base64 decoder and the request fails with 400 "data is not valid base64"; if the text happens to be a valid base64 string it is silently typed as the decoded bytes instead of the text, which is worse than an error. Either the doc line or the code default must change (the sibling /v1/threads/{id}/jobs/{job_id}/stdin route documents and uses text-by-default, so a maintainer may prefer aligning the code instead of the doc).
| dropped: u64, | ||
| encoding: &'static str, | ||
| data: String, | ||
| /// False once the shell has exited and no bytes remain past `next_cursor`. |
There was a problem hiding this comment.
[INFO] running is documented as "no bytes remain" but is derived from the child's exit alone
TerminalOutputResponse::running is documented as "False once the shell has exited and no bytes remain past next_cursor", but the handler sets running = exit.is_none() from session_exit_status (a try_wait on the child), and the inline comment at the construction site explicitly says the opposite: "running alone must not imply there is nothing behind us". The two statements in the same file contradict each other, and the field doc is the one the client reads. Because the reader thread appends asynchronously, the child can be observed as exited while its final output has not been appended yet — exactly the race the jobs stream closes with done. A client that implements its "stream finished" condition as running == false (as the doc invites) can stop polling and lose the shell's trailing bytes; the field must instead be read as "the child has exited" and the client must keep polling until next_cursor == total (and offset == cursor, i.e. no gap). Fixing the field doc (or adding a real drain flag computed against chunk.next_cursor == chunk.total) is the smallest change.
| //! Release is `Drop` and never cached: a leaked inhibitor would keep a laptop | ||
| //! awake forever, which is worse than the problem this solves. | ||
|
|
||
| use std::process::{Child, Command, Stdio}; |
There was a problem hiding this comment.
[INFO] sleep_guard's std::process imports are unused on non-unix builds
use std::process::{Child, Command, Stdio}; is unconditional, but all three names are used only under #[cfg(unix)] (SleepGuard::child, spawn, and the macOS/Linux start_inhibitor), while the struct itself, hold() and the module are compiled on Windows too (the module has a #[cfg(not(unix))] { Self {} } branch). On a cfg(not(unix)) build every one of the three imports is unused, i.e. an unused_imports warning that becomes a build failure under the -D warnings invocation this PR reports running. The same crate already gates exactly these sorts of imports (crates/tui/src/tools/terminal_session.rs puts #[cfg(unix)] on std::sync/std::time imports), so this looks like an omission rather than a policy. Fix: put #[cfg(unix)] on that use line; the Windows build then needs no imports at all.
| - `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, UTF-8 text by | ||
| default or `base64` for exact bytes → `{ "name", "written" }` |
There was a problem hiding this comment.
State the default the implementation actually uses (unwrap_or("base64")), so a client that omits encoding is not told to send raw UTF-8 that the route then rejects with 400 "data is not valid base64". If text-by-default is what was intended (to match the jobs stdin route), change the default in terminal_input instead and keep this line.
| - `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, UTF-8 text by | |
| default or `base64` for exact bytes → `{ "name", "written" }` | |
| - `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, `base64` by | |
| default for exact bytes, or `text` for UTF-8 → `{ "name", "written" }` |
…g list
`main` is red on `Lint` and `Test (macos-latest)`. Neither is visible in a PR
rollup: `check-runtime-contract-budget` is advisory on pull requests and fatal
on push, and the macOS job that fails is not one of the three required checks.
So every PR since looked green while main carried both.
**Lint — an unrecorded identity change.** The checker refuses identity drift by
design, and `--update` cannot paper over it (`compare` raises before the update
path runs), so this is the explicit maintainer edit the file's own header asks
for. Two changes moved it:
- `execute_tools` enters every catalog outside Plan (`tool_catalog.rs:350`,
from code-mode Phase 1 `e23ce514c`), so the Act and Operate full tool names
and identity digests move with it.
- The `agent` tool advertises its `cwd` parameter (the subagents `cwd` move),
growing the shared active surface by 256 schema bytes / 64 estimated tokens
everywhere that tool appears — including Plan full, which is why that surface
grew without gaining a tool.
The `_comment` history records both, measured from the release train, and the
14 raised ceilings are the measured values (0 decreased; 55 metrics exactly at
budget afterwards).
**Test (macos-latest) — a real race, now proven fixed.** `threads_running_lists_active_turns_and_clears_on_settle`
forces a synthetic settle into the durable store, but the engine still owns
that record and can persist its own status afterwards — the listing is read
from the store, so a later engine write puts the turn back in flight and the
single read after the write fails. That is not a product defect: the engine is
entitled to finish its turn. The assertion now polls until the settle wins
(deadline `ci_scaled(5s)`, so a genuinely stuck turn still fails), which is how
the rest of this suite already handles async settling.
Verification (macOS aarch64, this worktree):
- The race is reproduced deterministically, not inferred: with the record
flipped back to `InProgress` after the test's write and settled 300ms later,
the previous single read fails with the CI shape verbatim —
`left: Array [Object {..., "active_turns": Array [Object {"turn_id": ...,
"status": String("in_progress")}]}]`, `right: Array []` — while the polling
version passes the identical injection (0.549s: it waited for the settle).
The injection was reverted; the commit holds only the fix.
- `scripts/dev-test.sh crates/tui/src/runtime_api/tests.rs threads_running_lists_active_turns`
-> `Summary [0.224s] 1 test run: 1 passed`
- `python3 scripts/check-runtime-contract-budget.py`
-> `[runtime-contract-budget] PASS: all 55 metrics are exactly at budget.`
- `cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` exit 0 · `cargo fmt --all -- --check` exit 0
Not verified: the macOS job itself, which only CI can run; the proof here is
that the injected CI failure shape no longer fails.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…ly (#6184) Issue #6184 is an engine that "silently freezes mid-run: user messages are persisted but never answered; no error, no log line, no crash entry". Recon for the instrumented hunt named the prime suspect: the tool-approval wait in `core/engine/approval.rs` had no engine-side deadline, the turn wall clock is *paused* across it so no budget ever fires, the approval card only expires when a view is top-of-stack, and the wait logged nothing — so a turn parked there is indistinguishable from a working one until the user gives up. Both waits now carry a heartbeat: - `await_tool_approval` and `await_user_input` tick every `WAIT_HEARTBEAT` (60s; 50ms under `cfg(test)` so the real path is observable without waiting a minute) and log a `tracing::warn!` naming the tool and the elapsed time. - The first heartbeat also sends `Event::Status`, so the user sees "Still waiting for tool approval on `<tool>` after Ns" once rather than a frozen screen. Later heartbeats keep the log trail without refilling the transcript. - The message comes from one `wait_announcement` helper, so the log line and the status event cannot drift apart. The user-input wait matters most in the case #6003 already allows: `user_input_timeout_seconds = 0` means wait indefinitely, and nothing bounded or reported that wait at all. Verification (macOS aarch64, this worktree): - `scripts/dev-test.sh crates/tui/src/core/engine/approval.rs a_parked_approval_announces` -> `Summary [0.189s] 1 test run: 1 passed` — a new test drives the real fixture to the approval gate, answers nothing, and asserts the announcement names both the wait and the tool. - Proven to catch the absence of the feature, not just to pass: disabling the `Event::Status` send makes that test fail after its 5s deadline (`FAIL [5.147s] ... panicked at approval.rs:554`). The mutation was reverted. - `... approval` (crate-wide) -> `283 tests run: 283 passed`; `turn_loop` -> `67 tests run: 67 passed` — the added event does not disturb the existing approval and turn-loop assertions. - `cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or` exit 0 · `cargo fmt --all -- --check` exit 0 - `check-blocking-calls-budget.py` PASS · `check-dead-code-budget.py` PASS · `check-runtime-contract-budget.py` PASS Still open from the same recon (next slices, not this commit): the event-channel send that can pend when the UI stops draining, the shell-permit wait, and the steer queue that nothing drains while the engine is parked.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…bility `cargo check (aarch64-unknown-linux-ohos)` failed on this branch with `error[E0432]: unresolved import crate::tools::terminal_session` (runtime_api/terminal.rs:37). The cause is a cfg mismatch I introduced: the owner module is gated `#[cfg(not(target_env = "ohos"))]` in `tools/mod.rs` while its functions are `#[cfg(unix)]` — and ohos *is* unix, so "the owner's functions exist" is `unix AND not-ohos`, not `unix`. - Every item that drives the owner is now gated `#[cfg(all(unix, not(target_env = "ohos")))]`. - The 501 stubs cover `any(not(unix), target_env = "ohos")`, so ohos gets the honest "this build cannot do terminals" answer instead of a resolution error, and the route registration in `runtime_api.rs` keeps resolving. Verification (macOS aarch64, this worktree): - `cargo check -p codewhale-tui --all-targets --locked` exit 0 - `./scripts/release/check-ohos-deps.sh` -> `OHOS dependency graph OK for codewhale-tui on aarch64-unknown-linux-ohos.` (plus the linker-wrapper and rquickjs feature edges), exit 0 - `scripts/dev-test.sh crates/tui/src/runtime_api/terminal.rs terminal` -> `360 tests run: 360 passed, 12548 skipped` - `cargo fmt --all -- --check` exit 0 · clippy `--all-targets -D warnings` exit 0 Not verified locally: the ohos *build* itself. `cargo check --target aarch64-unknown-linux-ohos` cannot run from macOS — `ring` and `libsqlite3-sys` need a cross C toolchain — and simulating the cfg with `RUSTFLAGS='--cfg target_env="ohos"'` fails inside `libc`, which keys off that cfg. CI's ohos job is the receipt for this fix.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…branch Brings the owner's Core unblocks in as themselves: the Engine terminal byte-stream routes (output/input/resize/kill, Unix-only, 501 elsewhere and on ohos), Last-Event-ID stream resume plus idempotent-replay reporting on turn submission, the pet agent-count contract pin, the turn-scoped host idle-sleep guard, and the #6184 approval/user-input wait heartbeat. Conflicts (3, all generated or measured files) resolved to main's newer versions, which the branch's own commits never intended to change: docs/public-surface-facts.json and web/lib/facts.generated.ts (0.10.0 capture facts), and scripts/runtime-contract-budget.json (main's 2026-09-19 Linux lock-in already carries the agent cwd / execute_tools ceilings the branch measured; every metric value is identical, only the _comment history differed). Gates on the merged tree: cargo fmt --check clean; blocking-call budget 603 sites within budget; dead-code budget 174 at budget; OHOS dependency graph OK. Rust tests for the merged code run in the next verification pass on this branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134iUMxmGuXiG1LzPgfZVnv
|
This branch is being carried into the 0.10.0 readiness PR #6370 as a merge of The three conflicts against current main were all generated or measured files and resolved to main's newer versions: On the merged tree (rustc 1.98.1, Linux): Generated by Claude Code |
Summary
Two Core unblocks from the tracker (
codewhale-app#61), on one branch.#34 — the Engine's terminal byte stream
The app-side ticket asks Core for "authenticated byte input/output, resize, exit and bounded replay" over the stateful terminal path, and says to link real Core work before claiming those paths. Two commits:
crates/tui/src/tools/terminal_session.rs). The pty master is now retained (it was dropped after the reader/writer clones were taken), soresize_sessioncan reach the kernel's window;OutputBuffer::read_sincereads from an absolute cursor without consuming and setsgapwhen the 512 KiB ring has moved past it (report, not repair);session_exit_statusandkill_sessionmake exit observable.take_outputnow reuses that same cursor arithmetic instead of its own copy.crates/tui/src/runtime_api/terminal.rs), which is also what makes them live code —cargo clippy --all-targetsfailed the lib build on the primitives alone, correctly, and this PR is the consumer.GET /v1/terminal/{name}/output— wire-shaped like the jobs stream on purpose (cursor/max_bytes/format→offset/next_cursor/total/dropped). Reads never consume, so several clients can hold independent cursors.POST /v1/terminal/{name}/input—textorbase64, bounded per frame. Input stays attributable by route: this is the client's writer,terminal_sendis the agent's.POST /v1/terminal/{name}/resizeandPOST /v1/terminal/{name}/kill.RuntimeCapabilitiesgainsterminal_stream,terminal_input,terminal_resize,terminal_kill, set fromcfg!(unix), so a client gates its pane on the flag rather than discovering the gap from a failed request. Windows answers501for all four (the owner is Unix-only), which keeps "this build cannot do terminals" distinguishable from "that session is gone".docs/RUNTIME_API.mddocuments the family in the GPUI section with its four stated limitations: nowait_mslong poll, no scrollback recovery, live sessions only (a restarted Engine reports no session rather than pretending to reattach), no runtime-sdk wrapper yet.#12 — the pet's agent count
The ticket's premise ("no producer populates
Activity.parallel") turned out to be stale: Core already emits it end to end (engine →event_loop.rs:1946→metadata()'s allowlist →pet-native.js:1796agent:${id}spans → the· ×Ncaption), and the JS half is already covered bypet/tests/pet-engine.test.mjs. So there is no producer to write — a second one would be a second authority for a count that already flows. What was missing is any test of the Rust half, which is the half that fails silently. This pins it, and the pin is proven to work: deletingEvent::AgentSpawnedfrom the allowlist fails it withagent spawns are observed. The app-side rendering half stays in codewhale-app.#76 — resumable streaming and idempotent submission
Two commits, both Core-side.
Resume from
Last-Event-ID. The durable half already existed (per-threadseq, a JSONL event log,since_seqreplay with a bounded tail), but nothing on the wire could use it: every SSE frame was written without anid:, so a browserEventSourcehad nothing to resume from. Journal frames now carry their durableseqas the SSE id — journal frames only, since thestream.progressframes are transport progress and an id there would invite a client to resume from a point it never received.stream_thread_eventsreadsLast-Event-IDas the cursor when no explicitsince_seqwas asked for; an explicit query cursor wins, so a deliberate replay-from-zero is never silently overridden by a stale header. An opaque id (proxy, older client) starts from the durable head instead of failing to open the stream.RuntimeCapabilitiesgainsevent_stream_resumeso a client can gate its reconnect controls on it.A replay is an acknowledgement, not an admission.
POST /v1/threads/{id}/turnsanswered201whether it admitted a new turn or handed back the original for a repeatedoperation_key— so a client retrying after a dropped response could not tell whether it had created a second turn. The admission path now reports the disposition (start_turn_with_reporting_replay; the two replay returns saytrue, the tail saysfalse), and the route answers200 { ..., idempotent_replay: true }for a replay, keeping201for a fresh admission. The flag is omitted on admission, so existing responses stay byte-identical, and the 94 existingstart_turncallers are untouched.The existing
turn_endpoint_operation_key_returns_original_and_conflicts_on_mismatchtest asserted the old201; it now pins the stronger contract, including409on a changed request with the same key, exactly oneSendMessage, and exactly one turn.Not in this PR: app-server minting an
operation_keyfor its own submissions (client half, app lane), and the Windows ConPTY port for the terminal family.Additional verification for these two commits:
... tests.rs thread_event_frames_carry→1 test run: 1 passed(first frame'sid:equals its payloadseq; reconnect with onlyLast-Event-IDlands on the next durable event;?since_seq=0with a stale header still replays from zero) ·last_event_id_accepts→1 passed·... tests.rs event→170 passed... tests.rs turn_endpoint_operation_key→1 test run: 1 passed, plus the paths the signature change touches:turn_operation5 passed ·start_turn5 passed ·agent_mail6 passed ·thread_goal5 passed ·steer33 passed--all-targets -D warningsexit 0 · fmt exit 0 · both ratchets PASSUnreding main (same branch, no extra PR)
mainwas red on two jobs, and neither is visible in a PR rollup —check-runtime-contract-budgetis advisory on pull requests and fatal on push, andTest (macos-latest)is not one of the three required checks.Lint— an unrecorded identity change.execute_toolsnow enters every catalog outside Plan (tool_catalog.rs:350, code-mode Phase 1e23ce514c), moving the Act and Operate full tool names and identity digests; and theagenttool advertises itscwdparameter, growing the shared active surface by 256 schema bytes / 64 estimated tokens everywhere that tool appears (including Plan full, which is why it grew without gaining a tool). The checker refuses identity drift and--updatecannot bypass it, so this is the explicit maintainer edit the file's header asks for: identities re-pinned, 14 ceilings raised to the measured values, and both facts recorded in the file's_commenthistory.check-runtime-contract-budget.py→PASS: all 55 metrics are exactly at budget.Test (macos-latest)— a real race, proven fixed.threads_running_lists_active_turns_and_clears_on_settleforced a synthetic settle into the durable store, but the engine still owns that record and can persist afterwards; the listing is read from the store, so a later engine write puts the turn back in flight and the single read fails. The assertion now polls until the settle wins (with aci_scaled(5s)deadline, so a genuinely stuck turn still fails).Proven rather than argued: with the record flipped back to
InProgressafter the test's write and settled 300ms later, the old single read fails with the CI shape verbatim (left: [... "status": "in_progress"],right: Array []) while the polling version passes the identical injection (0.549s — it waited for the settle). The injection was reverted; the commit holds only the fix.Testing
scripts/dev-test.sh crates/tui/src/runtime_api/tests.rs terminal_routes_serve→Summary [0.208s] 1 test run: 1 passed— the full seam over HTTP against a real PTY: input through the route executes in the Engine's own session, output comes back through the route at an absolute cursor, a repeat read at the same cursor returns identical bytes, resize is confirmed by the shell's ownstty sizereporting40 100(a handler that merely stored the numbers would fail this), and kill is observed asrunning: false.... terminal_output_for_an_unknown→1 test run: 1 passed;... terminal_capabilities→1 test run: 1 passed.... crates/tui/src/tools/terminal_session.rs terminal→360 tests run: 360 passed, 12543 skipped.... crates/protocol/src/runtime/mod.rs runtime::→10 tests run: 10 passed.cargo clippy -p codewhale-tui -p codewhale-protocol --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_orexit 0 ·cargo fmt --all -- --checkexit 0check-blocking-calls-budget.py→601 sites across 177 files, within budget·check-dead-code-budget.py→PASS: 185 attributes, exactly at budget.Not verified: Windows. The owner is
#[cfg(unix)]end to end and every test here iscfg(all(test, unix)); ConPTY qualification is its own slice, as the ticket says.Checklist
docs/RUNTIME_API.md, and the limitations are stated in the module)No-Issue: Core half of tracker codewhale-app#61; #34 and #12 live in the app repo, so there is no issue in this repo to close.