Skip to content

feat(runtime-api): terminal byte stream (#34), stream resume + idempotent submit (#76), pet agent-count pin (#12) - #6361

Merged
10 commits merged into
mainfrom
work/0.9.14-core-unblocks
Sep 21, 2026
Merged

10 commits merged into
mainfrom
work/0.9.14-core-unblocks

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 19, 2026

Copy link
Copy Markdown
Owner

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:

  1. The owner gained the primitives (crates/tui/src/tools/terminal_session.rs). The pty master is now retained (it was dropped after the reader/writer clones were taken), so resize_session can reach the kernel's window; OutputBuffer::read_since reads from an absolute cursor without consuming and sets gap when the 512 KiB ring has moved past it (report, not repair); session_exit_status and kill_session make exit observable. take_output now reuses that same cursor arithmetic instead of its own copy.
  2. The routes that consume them (crates/tui/src/runtime_api/terminal.rs), which is also what makes them live code — cargo clippy --all-targets failed 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/formatoffset/next_cursor/total/dropped). Reads never consume, so several clients can hold independent cursors.
  • POST /v1/terminal/{name}/inputtext 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 and POST /v1/terminal/{name}/kill.
  • Routes attach to shells the Engine already owns and never create one (404 otherwise) — an HTTP request must not conjure a shell the Engine does not know about.
  • RuntimeCapabilities gains terminal_stream, terminal_input, terminal_resize, terminal_kill, set from cfg!(unix), so a client gates its pane on the flag rather than discovering the gap from a failed request. Windows answers 501 for all four (the owner is Unix-only), which keeps "this build cannot do terminals" distinguishable from "that session is gone".
  • docs/RUNTIME_API.md documents the family in the GPUI section with its 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), 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:1946metadata()'s allowlist → pet-native.js:1796 agent:${id} spans → the · ×N caption), and the JS half is already covered by pet/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: deleting Event::AgentSpawned from the allowlist fails it with agent 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-thread seq, a JSONL event log, since_seq replay with a bounded tail), but nothing on the wire could use it: every SSE frame was written without an id:, so a browser EventSource had nothing to resume from. Journal frames now carry their durable seq as the SSE id — journal frames only, since the stream.progress frames are transport progress and an id there would invite a client to resume from a point it never received. stream_thread_events reads Last-Event-ID 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. An opaque id (proxy, older client) starts from the durable head instead of failing to open the stream. RuntimeCapabilities gains event_stream_resume so a client can gate its reconnect controls on it.

A replay is an acknowledgement, not an admission. POST /v1/threads/{id}/turns answered 201 whether it admitted a new turn or handed back the original for a repeated operation_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 say true, the tail says false), and the route answers 200 { ..., idempotent_replay: true } for a replay, keeping 201 for a fresh admission. The flag is omitted on admission, so existing responses stay byte-identical, and the 94 existing start_turn callers are untouched.

The existing turn_endpoint_operation_key_returns_original_and_conflicts_on_mismatch test asserted the old 201; it now pins the stronger contract, including 409 on a changed request with the same key, exactly one SendMessage, and exactly one turn.

Not in this PR: app-server minting an operation_key for 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_carry1 test run: 1 passed (first frame's id: equals its payload seq; reconnect with only Last-Event-ID lands on the next durable event; ?since_seq=0 with a stale header still replays from zero) · last_event_id_accepts1 passed · ... tests.rs event170 passed
  • ... tests.rs turn_endpoint_operation_key1 test run: 1 passed, plus the paths the signature change touches: turn_operation 5 passed · start_turn 5 passed · agent_mail 6 passed · thread_goal 5 passed · steer 33 passed
  • clippy --all-targets -D warnings exit 0 · fmt exit 0 · both ratchets PASS

Unreding main (same branch, no extra PR)

main was red on two jobs, and neither is visible in a PR rollup — check-runtime-contract-budget is advisory on pull requests and fatal on push, and Test (macos-latest) is not one of the three required checks.

Lint — an unrecorded identity change. execute_tools now enters every catalog outside Plan (tool_catalog.rs:350, code-mode Phase 1 e23ce514c), moving the Act and Operate full tool names and identity digests; and the agent tool advertises its cwd parameter, 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 --update cannot 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 _comment history. check-runtime-contract-budget.pyPASS: all 55 metrics are exactly at budget.

Test (macos-latest) — a real race, proven fixed. threads_running_lists_active_turns_and_clears_on_settle forced 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 a ci_scaled(5s) deadline, so a genuinely stuck turn still fails).

Proven rather than argued: with the record flipped back to InProgress after 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_serveSummary [0.208s] 1 test run: 1 passedthe 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 own stty size reporting 40 100 (a handler that merely stored the numbers would fail this), and kill is observed as running: false.
  • ... terminal_output_for_an_unknown1 test run: 1 passed; ... terminal_capabilities1 test run: 1 passed.
  • ... crates/tui/src/tools/terminal_session.rs terminal360 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_or exit 0 · cargo fmt --all -- --check exit 0
  • check-blocking-calls-budget.py601 sites across 177 files, within budget · check-dead-code-budget.pyPASS: 185 attributes, exactly at budget.

Not verified: Windows. The owner is #[cfg(unix)] end to end and every test here is cfg(all(test, unix)); ConPTY qualification is its own slice, as the ticket says.

Checklist

  • No new layer replacing an existing one: this is the existing terminal owner plus routes over it
  • Docs updated (docs/RUNTIME_API.md, and the limitations are stated in the module)
  • Tests added where they protect behaviour that fails silently
  • TUI manual check: n/a (no TUI surface changed)

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.

CodeWhale Bot added 3 commits September 18, 2026 19:25
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.
Copilot AI lite review requested due to automatic review settings September 19, 2026 02:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::terminal cannot resolve the unix-only terminal_session::READ_LIMIT (crates/tui/src/runtime_api/terminal.rs:152)
    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.
  • [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 (crates/tui/src/runtime_api/terminal.rs:77)
    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).
  • [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 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.
  • [WARNING] runtime_info_advertises_terminal_capabilities asserts the four flags are true on every platform, but the server sets them from cfg!(unix) (crates/tui/src/runtime_api/tests.rs:13760)
    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)].

Suggestions

  • crates/tui/src/runtime_api/terminal.rs:152bounded_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.
  • docs/RUNTIME_API.md:1233 — 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.
  • crates/tui/src/runtime_api/tests.rs:13760 — 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.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

Comment thread docs/RUNTIME_API.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread crates/tui/src/runtime_api/tests.rs Outdated
"terminal_kill",
] {
assert_eq!(
info["capabilities"][capability], true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/RUNTIME_API.md
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" }`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/tui/src/runtime_api/tests.rs Outdated
"terminal_kill",
] {
assert_eq!(
info["capabilities"][capability], true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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
@Hmbown Hmbown changed the title feat(runtime-api): the Engine terminal byte stream + pet agent-count pin (#34, #12) feat(runtime-api): terminal byte stream (#34), stream resume + idempotent submit (#76), pet agent-count pin (#12) Sep 19, 2026
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 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).
  • [INFO] running is documented as "no bytes remain" but is derived from the child's exit alone (crates/tui/src/runtime_api/terminal.rs:77)
    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.
  • [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/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.

Suggestions

  • docs/RUNTIME_API.md:1233 — 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"? }`, `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):

  1. The call site that hands the broadcast receiver to replay_live_thread_events is not in the diff, so I cannot confirm that the live-phase dedup base now follows the header-derived cursor. If that function dedups against query.since_seq rather than against the last seq it actually emitted during replay, a Last-Event-ID reconnect 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 neighbouring event_handoff_replays_and_dedupes_interaction_prompts_without_a_gap test covers the handoff window for the query-cursor path.
  2. The #[cfg(not(unix))] platform module (and the pub(super) use platform::… re-export) is not compiled by any Linux clippy/cargo check run, 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 in crates/tui/src/runtime_api/terminal.rs (base64::Engine as _, crate::tools::terminal_session), which I deliberately did not report because a use super::* glob in the platform module makes the lint outcome ambiguous.
  3. Whether POST /v1/terminal/{name}/kill is idempotent depends on portable_pty's unix Child::kill. std::process::Child::kill returns InvalidInput once the child has been reaped (try_wait caches the status), and the output route calls try_wait on every poll — so a kill issued after a client has observed running: false may surface as a 500 rather than a no-op. If portable_pty propagates that error, the route should treat an already-exited child as success (killed: false or 200); the wrapper source is outside the diff, so I did not raise it as a finding.
  4. The derive line for StartTurnResponse is outside the supplied diff. If it includes Deserialize, the new idempotent_replay field needs #[serde(default)] as well as skip_serializing_if, since admission responses omit it; the tests in this crate parse the body as serde_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.

Comment thread docs/RUNTIME_API.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread docs/RUNTIME_API.md
Comment on lines +1233 to +1234
- `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, UTF-8 text by
default or `base64` for exact bytes → `{ "name", "written" }`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- `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

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

Hmbown pushed a commit that referenced this pull request Sep 20, 2026
…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

Hmbown commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

This branch is being carried into the 0.10.0 readiness PR #6370 as a merge of work/0.9.14-core-unblocks at 041e628 (all ten commits, authorship intact), rather than re-implemented.

The three conflicts against current main were all generated or measured files and resolved to main's newer versions: docs/public-surface-facts.json and web/lib/facts.generated.ts (the 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 this branch measured; every metric value is identical, only the _comment history differed).

On the merged tree (rustc 1.98.1, Linux): terminal_routes_serve_a_live_engine_session_over_http, terminal_output_for_an_unknown_session…, runtime_info_advertises_terminal_capabilities, thread_event_frames_carry_the_id…, last_event_id_accepts_only_decimal_cursors, turn_endpoint_operation_key_returns_original…, threads_running_lists_active_turns_and_clears_on_settle, a_parked_approval_announces…, agent_events_forward… each 1 passed; sleep_guard 2 passed; terminal_session 15 passed; runtime_api::tests:: 249 passed; turn_loop 67 passed. If #6370 lands by squash instead of merge, this PR will need closing by hand with that credit.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants