Skip to content

feat: stream a worker's live harness screen to the orchestrator (medulla.screen.v1) - #83

Merged
senamakel merged 2 commits into
mainfrom
mosh-tinyplace-stream
Jul 28, 2026
Merged

feat: stream a worker's live harness screen to the orchestrator (medulla.screen.v1)#83
senamakel merged 2 commits into
mainfrom
mosh-tinyplace-stream

Conversation

@sanil-23

@sanil-23 sanil-23 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Lets the orchestrator watch a delegated task's actual terminal — the claude/codex session running it — instead of inferring progress from a status string every four seconds. Select a task in the Agents tab and its live screen replaces the transcript.

The motivating case is the one the status plane structurally cannot show: a harness stopped on Do you want to allow this command? writes no transcript records, so it emits no status frames and reads as "thinking" until the task times out. On screen it is unmistakable in one frame. That failure is why worker/pty/dialog.rs exists; this makes it visible rather than guessed at.

The model

Modelled on mosh's State Synchronization Protocol. What crosses the wire is the screen — a grid of styled cells plus a cursor — carried as a diff from the state the viewer is known to hold, not a pty byte stream. Three properties follow, and they're why this is affordable over an encrypted mailbox at all:

  • Cost tracks the screen, not the output. A harness dumping a build log costs what an idle one does. Sampling is timer-driven, never output-driven: a spinner repainting at 60 Hz would otherwise mean sixty sends and sixty ratchet advances a second on the lock that also serialises task dispatch.
  • An unchanged screen sends nothing. A watched-but-idle session is free.
  • Loss is self-healing. A frame that can't be applied is never retried; the viewer asks to resynchronise and the next full frame supersedes everything missed. Dropped, duplicated and reordered frames share one recovery path — which matters, because mailbox ordering isn't guaranteed.

Measured end to end on a real pty: a one-line paint on a 120×30 screen crosses as a 1-row delta.

Decisions worth a reviewer's attention

Streams are addressed by task id, not session id. A session serves one task at a time (claim_idle refuses a busy session and marks the one it returns, under one lock), so while a task runs the two identify the same thing — and the task id is the one both ends already hold. This needs no protocol change: the worker resolves task → pty internally and the session id never leaves the machine. It also makes authorization structural rather than a check: the running-task record is keyed by (authenticated sender, task id), so resolving a subscription at all proves the caller dispatched it. A peer cannot name another's task, because the key includes an identity it doesn't have.

The hub is a passive observer. No input and no resize message exist, so there is nothing to negotiate and no path from a subscriber into a session. Geometry belongs to the sender; a resize forces a full frame, since row indices don't survive one. Both are additive later behind the version tag.

Inverse video is a flag on the wire, resolved at paint time on both sides. Terminals compose REVERSED inconsistently with an explicit background, so both renderers swap fg/bg instead. If the two ends disagreed, a harness status bar would be invisible on one view and fine on the other.

Inbound envelopes now arrive over the relay's WebSocket rather than a GET /messages poll — latency from ~1500 ms to ~500 ms measured. Acks stay on HTTPS (the stream is a fan-out, not a consumption), delivery is deduplicated by message id (a reconnect replays the mailbox snapshot; a screen delta applied twice fails its base_seq check and forces a needless resync), and the fetch never goes away — it still runs when the socket is down, when the queue overflowed, when a frame won't parse, and on a 30 s reconciliation regardless. MEDULLA_INBOX_WS=0 restores pure polling.

Verification

Offline, deterministic:

  • Protocol core, sampler, router, store, renderer, and the selection trigger — unit tested throughout.
  • e2e_screen_stream runs the whole chain with a real child on a real pty: /bin/sh painting a pseudo-terminal → vt100 → convert → diff → JSON → the daemon's task record resolving the subscription → the hub's fold, back out as the text the child printed. Both frame shapes, plus the assertion that ~10 samples of an unchanged screen send nothing further.

Live, against staging-api.tiny.place (#[ignore]d, so cargo test still skips them and the offline-and-deterministic rule holds):

worker: 3Hob1FxUwsy1K2rweppbmCkuPef6unAr5Amj6kQ2fM3A
hub:    8m6ZTfUGMdnoWanb1V31SZncBfr9xA1oAXnkv4cAAHVB
✓ contact edge open
✓ push channels open on both ends
✓ subscribed
✓ full frame: the hub holds the worker's screen, seq 1 at 120x30
✓ delta applied: seq 2 now carries the later paint

Two identities, the real relay, a real harness. The run is shorter than the reconciliation window, so everything after the first drain provably crossed the socket.

Two things to know before reviewing

This was ported onto main, not replayed onto it. The work was written against a base 51 commits back, and main had since split transport, worker_loop, the hub runner/handle and the whole Agents tab into directory modules, replaced the Relay trait with the shared Bridge contract, and added workflow/system-info frame kinds. The original 15 commits could not apply through that — every intermediate state failed to build — so this is one coherent change against the current structure, conforming to the newer module and test-layout rules (#[path] test decls are now directory modules). The original history is preserved on a local backup/mosh-pre-rebase tag if anyone wants it.

The submodule pointer movedvendor/tinyplaced254505. That picks up two upstream fixes this depends on: connect() previously handed a bare http::Request to connect_async, which passes it through verbatim, so the upgrade went out with no Sec-WebSocket-Key and could never handshake against any server (tinyhumansai/tiny.place#269); and a dropped stream now re-dials under a policy, re-signing each attempt (#270). Both merged upstream. CI needs the submodule initialised or the workspace won't build.

Validation

cargo fmt --check                              # 0 diffs
cargo clippy --all-targets -- -D warnings      # clean
cargo test -p medulla --lib                    # 1460 passed
cargo test -p medulla-tui --lib                # 490 passed
# every integration suite green except e2e_tinyplace — see below
# live, opt-in:
cargo test -p medulla-tui --test live_screen_stream -- --ignored --nocapture
cargo test -p medulla --test live_inbox_push  -- --ignored --nocapture

e2e_tinyplace has 5 pre-existing failures in my sandbox (service_*, panicking at mock_tinyplace.rs:190). I verified rather than assumed: this branch never touches that code, and they reproduce identically on an untouched main in a scratch worktree. If they're green in CI, the sandbox is the problem.

Known gaps

  • Wide characters. ScreenCell.text carries no width, so a column-indexed diff will mis-place CJK and emoji. Cosmetic, not structural — mosh handles this explicitly and we don't.
  • Body-size limit is unverified. Nothing in the SDK or spec caps MessageEnvelope.body; a ~10 KB full frame becomes ~14 KB base64. Worth probing before relying on single-message full frames.
  • max_fps above ~1 is not yet worth much. The sampler clamps at 10 but nothing consults whether the push channel is up; tying effective rate to that is a natural follow-up.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds push-aware relay inbox handling, task-session tracking, a medulla.screen.v1 protocol, worker-side screen streaming, hub-side screen caching, and TUI controls for watching and rendering live worker screens.

Changes

Live screen streaming

Layer / File(s) Summary
Screen protocol and frame folding
src/sdk/src/tinyplace/screen/*, src/sdk/src/tinyplace/mod.rs
Defines screen wire types, JSON encoding, full/delta frame construction, sequence validation, resynchronization, and protocol tests.
Worker sampling and routing
src/tui/src/worker/stream/*
Converts PTY snapshots into styled screen grids, samples changes, manages stream lifecycles, and handles subscribe/unsubscribe messages.
Push inbox and session tracking
src/sdk/src/daemon/listener/*, src/sdk/src/daemon/transport/*, src/sdk/src/daemon/*, src/sdk/src/bridge/*
Adds WebSocket inbox delivery, bounded buffering, deduplication, reconciliation, push-aware waits, and executor session callbacks.
Hub screen cache
src/sdk/src/hub/*, src/sdk/src/runtime/*
Stores worker screen frames, requests resynchronization on gaps, exposes watch controls, and publishes screen snapshots through runtime APIs.
TUI watch and rendering
src/tui/src/ui/*, src/tui/src/worker_loop/*, src/tui/tests/*
Retargets subscriptions from task selection, renders cached screens, routes screen messages, and adds local/live end-to-end coverage.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TUI
  participant HubHandle
  participant WorkerScreenRouter
  participant SessionStream
  participant ScreenStore
  TUI->>HubHandle: watch(worker, task_id)
  HubHandle->>WorkerScreenRouter: Subscribe(task_id, resync)
  WorkerScreenRouter->>SessionStream: start subscription
  SessionStream->>ScreenStore: send full or delta frame
  ScreenStore-->>TUI: expose updated watched screen
Loading

Possibly related PRs

Suggested reviewers: senamakel

Poem

A rabbit watches frames hop through the wire,
Deltas bloom softly, then climb higher.
Push bells ring, screens glow bright,
Sessions guide the stream just right.
The TUI nibbles live pixels tonight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: streaming a worker screen to the orchestrator via medulla.screen.v1.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

sanil-23 added 2 commits July 28, 2026 04:44
Lets the orchestrator watch a delegated task's actual terminal — the
claude/codex session running it — instead of inferring progress from a
status string every four seconds. Select a task in the Agents tab and its
live screen replaces the transcript.

The motivating case is the one the status plane structurally cannot show:
a harness stopped on "Do you want to allow this command?" writes no
transcript records, so it emits no status frames and reads as thinking
until the task times out. On screen it is unmistakable in one frame.

Modelled on mosh's State Synchronization Protocol. What crosses the wire
is the screen — a grid of styled cells plus a cursor — carried as a diff
from the state the viewer is known to hold, not a pty byte stream. Cost
therefore tracks the screen rather than the output: sampling is
timer-driven, an unchanged screen sends nothing, and a one-line paint on
a 120x30 screen crosses as a one-row delta. Loss is self-healing — a
frame that cannot be applied is never retried; the viewer resynchronises
and the next full frame supersedes what was missed, which matters because
mailbox ordering is not guaranteed.

Streams are addressed by task id, not session id. A session serves one
task at a time, so while a task runs the two identify the same thing —
and the task id is the one both ends already hold, so nothing has to be
published. It also makes authorization structural: the running-task
record is keyed by (authenticated sender, task id), so resolving a
subscription at all proves the caller dispatched it.

The hub is a passive observer. There is no input and no resize message,
so there is nothing to negotiate and no path from a subscriber into a
session. Geometry belongs to the sender; a resize forces a full frame.

Inbound envelopes now arrive over the relay's WebSocket rather than a
GET poll — measured 1500ms to ~500ms. Acks stay on HTTPS (the stream is a
fan-out, not a consumption), delivery is deduplicated by message id (a
reconnect replays the mailbox snapshot, and a screen delta applied twice
fails its base_seq check), and the fetch still runs when the socket is
down, when the queue overflowed, when a frame will not parse, and on a
30s reconciliation. MEDULLA_INBOX_WS=0 restores pure polling.

Ported onto main rather than replayed: main had since split transport,
worker_loop, the hub runner/handle and the Agents tab into directory
modules, replaced the `Relay` trait with the shared `Bridge` contract,
and added workflow/system-info frame kinds. The 15 original commits could
not apply through that, so this is one coherent change against the
current structure, conforming to the newer module and test-layout rules.

Verified on the ported tree: medulla 1460, medulla-tui 486, every
integration suite green except the five pre-existing e2e_tinyplace
failures that reproduce on an untouched main. Live against staging, two
identities and a real pty: full frame at seq 1, delta applied at seq 2.
The four selection tests from the original branch, re-pointed at main's
structure: the Agents rail is `RailRow`s now rather than a flat
`AgentRow` list, and "Chat" is no longer a tab.

Each is strict rather than skipping — the fixture is asserted to have
both a task sublane and a lane row, so none can quietly pass by finding
nothing to select. They cover the three rules worth having: selecting a
task subscribes, reselecting the same one does not (every subscribe
carries `resync: true`, so re-issuing per keystroke would make the worker
resend a full frame), and leaving the tab releases the stream.
@sanil-23
sanil-23 force-pushed the mosh-tinyplace-stream branch from e6854f9 to 71a80be Compare July 27, 2026 23:15

@coderabbitai coderabbitai 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.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/sdk/src/daemon/mod.rs (1)

29-46: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the newly public listener types.

Making listener public exposes PushInbox, SeenIds, and ListenerGuard, but their declarations lack type-level rustdoc. Add /// docs at their definitions before exporting this API. As per coding guidelines, every public Rust item—including types—must have a /// doc comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/src/daemon/mod.rs` around lines 29 - 46, Add type-level /// rustdoc
comments to the definitions of PushInbox, SeenIds, and ListenerGuard in the
listener module before their public re-exports. Describe each type’s purpose,
while leaving the existing exports and behavior unchanged.

Source: Coding guidelines

🧹 Nitpick comments (5)
src/sdk/src/tinyplace/screen/tests.rs (1)

177-198: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the duplicate/reordered-frame paths the module doc promises.

apply.rs documents that "a dropped, duplicated, or reordered delta all fail the same way", but only the dropped case is asserted here. A replayed delta (base_seq older than the view) and a stale full frame arriving after a newer one both take distinct branches — notably a stale full frame is applied unconditionally and rewinds view.seq.

♻️ Suggested additional test
#[test]
fn a_replayed_delta_is_refused_without_touching_the_view() {
    let base = grid(10, vec![row("a")]);
    let FrameDecision::Send(full) = build_frame(None, &base, "w_1", 1, 0) else {
        panic!("expected a frame");
    };
    let mut view = None;
    apply_frame(&mut view, &full);

    let next = grid(10, vec![row("b")]);
    let FrameDecision::Send(delta) = build_frame(Some(&base), &next, "w_1", 2, 1) else {
        panic!("expected a frame");
    };
    assert_eq!(apply_frame(&mut view, &delta), ApplyOutcome::Applied);
    // The same delta arriving twice must not re-apply against seq 2.
    assert_eq!(apply_frame(&mut view, &delta), ApplyOutcome::NeedsResync);
    assert_eq!(view.expect("view survives").grid, next);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/src/tinyplace/screen/tests.rs` around lines 177 - 198, Add tests
alongside a_sequence_gap_asks_to_resync_without_touching_the_view covering both
replayed deltas and stale full frames. Verify a duplicate delta returns
NeedsResync without changing the already-applied view, and verify a full frame
with an older sequence is rejected without rewinding the current view or
sequence.
src/tui/tests/live_screen_stream.rs (2)

350-355: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Helper name promises more than it does.

worker_client_accept only re-requests the contact; the doc comment says "accept any pending contact request". Either rename to something like settle_contact_edge or inline the single request_contact call at Line 177.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/tests/live_screen_stream.rs` around lines 350 - 355, Rename
worker_client_accept to settle_contact_edge to accurately reflect that it
re-requests the contact rather than explicitly accepting pending requests, and
update all call sites accordingly; alternatively, remove the helper and inline
the single request_contact call at the indicated usage.

51-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Silent None on a real identity-load failure.

load_or_create_identity(...).ok()? makes a corrupt or unreadable config.json indistinguishable from "never onboarded" — the test just returns and passes. Since this is a manually-run diagnostic, print the error before skipping.

♻️ Proposed change
-    let (signer, config) = load_or_create_identity(&config_file, &env).ok()?;
+    let (signer, config) = match load_or_create_identity(&config_file, &env) {
+        Ok(loaded) => loaded,
+        Err(error) => {
+            eprintln!("skipping: {label} identity at {} is unusable: {error}", config_file.display());
+            return None;
+        }
+    };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/tests/live_screen_stream.rs` around lines 51 - 68, Update identity so
load_or_create_identity failures are handled explicitly instead of being
discarded by .ok()?; print the returned error with context before returning
None, while preserving the existing skip message for a missing config.json.
src/sdk/src/hub/screens/mod.rs (1)

66-91: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Avoid cloning the held grid on every applied frame.

apply runs per inbound frame (up to max_fps per watched session) and clones the full grid before folding, then reinserts. Taking the entry out and putting it back avoids one full-grid copy per frame.

♻️ Sketch
-        let mut view = screens.get(&key).map(|held| ScreenView {
-            grid: held.grid.clone(),
-            seq: held.seq,
-        });
+        let mut view = screens.remove(&key).map(|held| ScreenView {
+            grid: held.grid,
+            seq: held.seq,
+        });
         let outcome = apply_frame(&mut view, frame);
-        if outcome == ApplyOutcome::Applied {
-            if let Some(view) = view {
+        match (outcome, view) {
+            (ApplyOutcome::Applied, Some(view)) => {
                 screens.insert(
                     key,
                     WatchedScreen {
                         worker: worker.to_string(),
                         task_id: frame.task_id.clone(),
                         grid: view.grid,
                         seq: view.seq,
                         updated_at: now,
                     },
                 );
                 evict_stalest(&mut screens);
             }
+            // A rejected frame must leave the previous screen in place.
+            (_, Some(view)) => { /* reinsert `view` under `key` unchanged */ }
+            (_, None) => {}
         }

Note the rejected-frame branch must restore the prior entry (including its original updated_at), so keep the timestamp alongside the view if you take this route.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/src/hub/screens/mod.rs` around lines 66 - 91, Update
ScreenStore::apply to remove the existing entry from screens and fold the frame
directly into its grid instead of cloning the held grid. Preserve the prior
WatchedScreen, including updated_at, and restore it unchanged when apply_frame
rejects the frame; for applied frames, update the entry’s grid, sequence, and
updated_at before reinserting and evicting stale screens.
src/tui/src/ui/screen/mod.rs (1)

13-90: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move rendering implementation out of mod.rs.

Move these helpers into a sibling implementation module (for example, render.rs) and keep mod.rs to docs, wiring, re-exports, and test declaration.

As per coding guidelines, "mod.rs [must be] focused on docs, module wiring, re-exports, and minimal glue."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/src/ui/screen/mod.rs` around lines 13 - 90, Move the rendering
helpers wire_color, run_style, row_line, and grid_lines from mod.rs into a
sibling implementation module such as render.rs, preserving their behavior and
visibility. Keep screen_title in its current location unless it is part of the
rendering implementation, then update mod.rs to contain only module
documentation, module declarations, re-exports, and test wiring.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/sdk/src/daemon/listener/mod.rs`:
- Around line 212-229: Update the spawned listener future associated with
ListenerGuard so cancellation performs cleanup before the task exits: add an
RAII drop guard that clears the shared listening state and calls nudge(). Ensure
this guard runs for both normal completion and JoinHandle::abort(), preventing
the drain path from treating an aborted listener as healthy.

In `@src/sdk/src/daemon/transport/mod.rs`:
- Around line 392-394: Update the duplicate-delivery handling around SeenIds and
the acknowledgement path so an ID is not permanently skipped when its
acknowledgement fails. Ensure failed acknowledgements remove the ID from the
seen set or otherwise allow the next drain to retry acknowledgement, while
preserving deduplication after successful acknowledgement.
- Around line 371-386: Update drain_inbox to return immediately for non-positive
limit values, then replace PushInbox::take with a take_up_to(limit) operation so
pushed envelopes are capped at the requested limit. Fetch from the client only
with the remaining capacity after pushed envelopes, preserving the existing
error handling and total result bound.

In `@src/sdk/src/hub/runner/pump.rs`:
- Around line 212-227: The screen pump must authorize frames against the active
worker/task watch registry before applying them or requesting resynchronization.
In src/sdk/src/hub/runner/pump.rs lines 212-227, reject frames whose worker and
task are not currently registered. In src/sdk/src/hub/handle/mod.rs lines
87-111, maintain that registry for dispatched worker/task pairs: register before
sending a watch and roll back on send failure, then remove the entry before
unwatch so late frames are ignored.

In `@src/sdk/tests/live_inbox_push.rs`:
- Around line 107-113: Update drain_mailbox to acknowledge only messages whose
IDs start with the live-push- probe prefix, preserving unrelated mailbox
messages. Keep the existing listing and acknowledgement flow, and retain the
snapshot assertion behavior independent of mailbox emptiness.

In `@src/tui/src/event_loop/cmd_dispatch/mod.rs`:
- Around line 181-198: Serialize Cmd::WatchTask transitions so rapid selection
changes cannot interleave stale starts and stops; update the WatchTask dispatch
flow and its surrounding coordinator to track the latest request generation or
process transitions through a single ordered worker, ensuring obsolete starts
are skipped and the final selection is authoritative. Do not discard stop
failures: surface them or reconcile the watch state so failed stops cannot leave
unselected tasks streaming.

In `@src/tui/src/ui/app/input.rs`:
- Around line 354-364: Ensure every navigation path that changes the active
selection—Tab/BackTab, tab-bar clicks, rail-wheel movement, task clicks, and
arrow navigation—routes through retarget_watch so the previous worker is stopped
and the new target is started alongside any tab-entry command; update
src/tui/src/ui/app/input.rs lines 354-364 around retarget_watch and the relevant
transition handlers. In src/tui/src/ui/app/tests.rs lines 377-390, navigate away
via on_key or handle_click instead of assigning tab_index directly, and assert
that the unwatch command is emitted.

In `@src/tui/src/ui/app/render/agents/transcript.rs`:
- Around line 258-267: Update selected_screen to filter worker_screens by both
held.task_id and the selected watch target’s worker, matching the ScreenStore
(worker, task_id) key. Preserve the existing task-based lookup and return
behavior while ensuring screens from other workers are excluded.

In `@src/tui/src/ui/screen/mod.rs`:
- Around line 72-74: Update grid_lines to account for ScreenGrid.cursor and
hide_cursor when constructing each row, overlaying or styling the cursor cell so
transmitted cursors render in the appropriate position while remaining hidden
when hide_cursor is enabled. Preserve the existing row_line rendering for all
non-cursor cells.
- Around line 40-56: Update run_style so styles with ATTR_INVERSE and both wire
colors set to Default also receive Modifier::REVERSED, preserving inverse video
when color swapping has no visible effect. Add a regression test covering this
default-colour inverse case while keeping existing color inversion behavior
unchanged.

In `@src/tui/src/worker_loop/mod.rs`:
- Around line 246-265: Update the worker shutdown path to call screens.clear()
before dropping the ScreenRouter, ensuring its sampler tasks are aborted through
StreamRegistry::clear() rather than detached via dropped JoinHandles.

In `@src/tui/src/worker/stream/sampler.rs`:
- Around line 150-183: Scope stream registry entries by the authenticated sender
and task instead of task ID alone. Update StreamRegistry and its
subscribe/unsubscribe lookups in src/tui/src/worker/stream/sampler.rs#L150-183
to use a consistent sender-plus-task key; update the unsubscribe call in
src/tui/src/worker/stream/router.rs#L88-94 and the contains/subscribe calls in
src/tui/src/worker/stream/router.rs#L123-136 to construct and use that same
scoped key.
- Around line 173-182: Update the resync flow around SessionStream creation and
the stream folding logic to preserve a monotonic per-stream sequence or epoch
across restarts. Ensure newly spawned streams inherit the prior stream’s
ordering state rather than restarting at sequence 1, and reject delayed stale
full frames during folding while accepting the restarted stream’s current full
frame.

In `@src/tui/tests/e2e_screen_stream.rs`:
- Around line 289-302: Replace the fixed sleeps in the idle-stream assertion
with bounded polling that waits for the outbox to receive its initial frame and
then become stable across two consecutive intervals. Use the existing PATIENCE
bound, preserve the settled-count and unchanged-screen assertions, and avoid
counting a legitimate late first frame in the idle window.

---

Outside diff comments:
In `@src/sdk/src/daemon/mod.rs`:
- Around line 29-46: Add type-level /// rustdoc comments to the definitions of
PushInbox, SeenIds, and ListenerGuard in the listener module before their public
re-exports. Describe each type’s purpose, while leaving the existing exports and
behavior unchanged.

---

Nitpick comments:
In `@src/sdk/src/hub/screens/mod.rs`:
- Around line 66-91: Update ScreenStore::apply to remove the existing entry from
screens and fold the frame directly into its grid instead of cloning the held
grid. Preserve the prior WatchedScreen, including updated_at, and restore it
unchanged when apply_frame rejects the frame; for applied frames, update the
entry’s grid, sequence, and updated_at before reinserting and evicting stale
screens.

In `@src/sdk/src/tinyplace/screen/tests.rs`:
- Around line 177-198: Add tests alongside
a_sequence_gap_asks_to_resync_without_touching_the_view covering both replayed
deltas and stale full frames. Verify a duplicate delta returns NeedsResync
without changing the already-applied view, and verify a full frame with an older
sequence is rejected without rewinding the current view or sequence.

In `@src/tui/src/ui/screen/mod.rs`:
- Around line 13-90: Move the rendering helpers wire_color, run_style, row_line,
and grid_lines from mod.rs into a sibling implementation module such as
render.rs, preserving their behavior and visibility. Keep screen_title in its
current location unless it is part of the rendering implementation, then update
mod.rs to contain only module documentation, module declarations, re-exports,
and test wiring.

In `@src/tui/tests/live_screen_stream.rs`:
- Around line 350-355: Rename worker_client_accept to settle_contact_edge to
accurately reflect that it re-requests the contact rather than explicitly
accepting pending requests, and update all call sites accordingly;
alternatively, remove the helper and inline the single request_contact call at
the indicated usage.
- Around line 51-68: Update identity so load_or_create_identity failures are
handled explicitly instead of being discarded by .ok()?; print the returned
error with context before returning None, while preserving the existing skip
message for a missing config.json.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ae2f47c6-4124-4c84-ac48-1f7d72fd0829

📥 Commits

Reviewing files that changed from the base of the PR and between 2ae8a72 and 71a80be.

📒 Files selected for processing (60)
  • src/sdk/src/bridge/routing.rs
  • src/sdk/src/bridge/tinyplace.rs
  • src/sdk/src/bridge/types.rs
  • src/sdk/src/daemon/capabilities/mod.rs
  • src/sdk/src/daemon/entry.rs
  • src/sdk/src/daemon/listener/mod.rs
  • src/sdk/src/daemon/listener/tests.rs
  • src/sdk/src/daemon/mod.rs
  • src/sdk/src/daemon/providers/types.rs
  • src/sdk/src/daemon/runtime.rs
  • src/sdk/src/daemon/task_loop/run.rs
  • src/sdk/src/daemon/task_loop/workflow.rs
  • src/sdk/src/daemon/transport/mod.rs
  • src/sdk/src/daemon/transport/types.rs
  • src/sdk/src/daemon/types.rs
  • src/sdk/src/hub/handle/mod.rs
  • src/sdk/src/hub/mod.rs
  • src/sdk/src/hub/runner/mod.rs
  • src/sdk/src/hub/runner/pump.rs
  • src/sdk/src/hub/runner/types.rs
  • src/sdk/src/hub/screens/mod.rs
  • src/sdk/src/hub/screens/tests.rs
  • src/sdk/src/runtime/backend/runtime.rs
  • src/sdk/src/runtime/mod.rs
  • src/sdk/src/sessions/manager/turns.rs
  • src/sdk/src/tinyplace/mod.rs
  • src/sdk/src/tinyplace/screen/apply.rs
  • src/sdk/src/tinyplace/screen/codec.rs
  • src/sdk/src/tinyplace/screen/diff.rs
  • src/sdk/src/tinyplace/screen/mod.rs
  • src/sdk/src/tinyplace/screen/tests.rs
  • src/sdk/src/tinyplace/screen/types.rs
  • src/sdk/tests/e2e_daemon_providers.rs
  • src/sdk/tests/e2e_daemon_router.rs
  • src/sdk/tests/live_inbox_push.rs
  • src/tui/src/event_loop/cmd_dispatch/mod.rs
  • src/tui/src/ui/app/input.rs
  • src/tui/src/ui/app/keys/agents.rs
  • src/tui/src/ui/app/keys/mod.rs
  • src/tui/src/ui/app/render/agents/transcript.rs
  • src/tui/src/ui/app/state.rs
  • src/tui/src/ui/app/tests.rs
  • src/tui/src/ui/app/types.rs
  • src/tui/src/ui/mod.rs
  • src/tui/src/ui/screen/mod.rs
  • src/tui/src/ui/screen/tests.rs
  • src/tui/src/worker/executor/mod.rs
  • src/tui/src/worker/executor_tests/live.rs
  • src/tui/src/worker/executor_tests/mod.rs
  • src/tui/src/worker/mod.rs
  • src/tui/src/worker/stream/convert.rs
  • src/tui/src/worker/stream/mod.rs
  • src/tui/src/worker/stream/router.rs
  • src/tui/src/worker/stream/sampler.rs
  • src/tui/src/worker/stream/tests.rs
  • src/tui/src/worker_loop/commands.rs
  • src/tui/src/worker_loop/mod.rs
  • src/tui/tests/e2e_screen_stream.rs
  • src/tui/tests/live_screen_stream.rs
  • vendor/tinyplace

Comment on lines +212 to +229
/// A running listener, stopped when this guard drops.
///
/// A bare [`JoinHandle`] would be wrong here: dropping one *detaches* the task
/// rather than aborting it, so a listener tied to a session's lifetime would
/// outlive it and hold a socket open for a drain loop that no longer exists.
pub struct ListenerGuard(JoinHandle<()>);

impl ListenerGuard {
/// Stop the listener now rather than at drop.
pub fn abort(&self) {
self.0.abort();
}
}

impl Drop for ListenerGuard {
fn drop(&mut self) {
self.0.abort();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear push state when an aborted listener task is dropped.

JoinHandle::abort() can cancel the task while listening is still true, bypassing Lines 283-306. The drain can then treat a dead socket as healthy and defer fallback fetching. Add cancellation-safe cleanup inside the spawned future (for example, an RAII guard whose Drop clears listening and calls nudge()).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/src/daemon/listener/mod.rs` around lines 212 - 229, Update the
spawned listener future associated with ListenerGuard so cancellation performs
cleanup before the task exits: add an RAII drop guard that clears the shared
listening state and calls nudge(). Ensure this guard runs for both normal
completion and JoinHandle::abort(), preventing the drain path from treating an
aborted listener as healthy.

Comment on lines 371 to +386
pub async fn drain_inbox(&self, limit: i64) -> Vec<InboundMessage> {
let _guard = self.lock.lock().await;
let response = match self
.client
.messages
.list(&self.our_agent_id, Some(limit))
.await
{
Ok(response) => response,
Err(_) => return Vec::new(),
};

let mut envelopes = self.push.take();
if self.should_fetch() {
match self
.client
.messages
.list(&self.our_agent_id, Some(limit))
.await
{
Ok(response) => envelopes.extend(response.messages),
// A failed fetch is not fatal when the socket is up: it has
// delivered what it delivered, and the next drain retries.
Err(_) if !envelopes.is_empty() => {}
Err(_) => return Vec::new(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor limit for pushed envelopes.

Line 374 drains the entire push queue, then Line 379 can fetch another limit messages. A drain_inbox(50) call can therefore decrypt, dispatch, and acknowledge far more than 50 messages. Add a bounded PushInbox::take_up_to(limit) API, return early for non-positive limits, and fetch only the remaining capacity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/src/daemon/transport/mod.rs` around lines 371 - 386, Update
drain_inbox to return immediately for non-positive limit values, then replace
PushInbox::take with a take_up_to(limit) operation so pushed envelopes are
capped at the requested limit. Fetch from the client only with the remaining
capacity after pushed envelopes, preserving the existing error handling and
total result bound.

Comment on lines +392 to +394
if !self.seen.insert(&message.id) {
continue; // already handled; the ack for it is already in flight
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Retry acknowledgement for duplicate deliveries.

If the acknowledgement at Lines 429-433 fails, this ID remains in the relay mailbox but is permanently skipped here by SeenIds; later drains neither process nor acknowledge it. A duplicate should retry acknowledgement (or the ID must be removed from the seen set when acknowledgement fails).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/src/daemon/transport/mod.rs` around lines 392 - 394, Update the
duplicate-delivery handling around SeenIds and the acknowledgement path so an ID
is not permanently skipped when its acknowledgement fails. Ensure failed
acknowledgements remove the ID from the seen set or otherwise allow the next
drain to retry acknowledgement, while preserving deduplication after successful
acknowledgement.

Comment on lines +212 to +227
if screens.apply(from, &frame, crate::clock::now_millis())
== crate::tinyplace::ApplyOutcome::NeedsResync
{
if let Some(log) = log {
log(&format!(
"hub ← screen {task_id} from {from}: out of step at seq {} — resyncing",
frame.seq
));
}
let body =
crate::tinyplace::encode_screen_message(&crate::tinyplace::ScreenMessage::Subscribe {
task_id,
max_fps: DEFAULT_SCREEN_FPS,
resync: true,
});
let _ = relay.send(from, &body).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authorize frames against the active watch set.

The pump accepts every valid screen frame without checking that its (worker, task_id) is currently watched and dispatch-authorized. An authenticated peer can fill the bounded cache with unsolicited frames, evicting real screens; late frames can also recreate a screen after unwatch.

  • src/sdk/src/hub/runner/pump.rs#L212-L227: reject unregistered or worker/task-mismatched frames before applying or requesting resync.
  • src/sdk/src/hub/handle/mod.rs#L87-L111: maintain an active watch registry tied to the dispatched worker/task relationship; register before sending watch (roll back on failure) and remove before unwatch so late frames are ignored.
📍 Affects 2 files
  • src/sdk/src/hub/runner/pump.rs#L212-L227 (this comment)
  • src/sdk/src/hub/handle/mod.rs#L87-L111
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/src/hub/runner/pump.rs` around lines 212 - 227, The screen pump must
authorize frames against the active worker/task watch registry before applying
them or requesting resynchronization. In src/sdk/src/hub/runner/pump.rs lines
212-227, reject frames whose worker and task are not currently registered. In
src/sdk/src/hub/handle/mod.rs lines 87-111, maintain that registry for
dispatched worker/task pairs: register before sending a watch and roll back on
send failure, then remove the entry before unwatch so late frames are ignored.

Comment on lines +107 to +113
async fn drain_mailbox(client: &TinyPlaceClient, agent_id: &str) {
if let Ok(response) = client.messages.list(agent_id, Some(50)).await {
for message in response.messages {
let _ = client.messages.acknowledge(&message.id, agent_id).await;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

drain_mailbox deletes the developer's real mail, not just probe messages.

It acknowledges (deletes) every envelope in the configured identity's live mailbox, including anything unrelated that happened to be pending when the test ran. Probe envelopes are already identifiable by their live-push- id prefix — filter on that so an explicit --ignored run can't discard real mail.

🛡️ Proposed change
-/// Delete everything currently in the mailbox, so a probe leaves nothing behind.
+/// Delete only this probe's own messages, so a run leaves the mailbox as it
+/// found it — this is a live identity that may hold real mail.
 async fn drain_mailbox(client: &TinyPlaceClient, agent_id: &str) {
     if let Ok(response) = client.messages.list(agent_id, Some(50)).await {
-        for message in response.messages {
+        for message in response
+            .messages
+            .into_iter()
+            .filter(|message| message.id.starts_with("live-push-"))
+        {
             let _ = client.messages.acknowledge(&message.id, agent_id).await;
         }
     }
 }

Step 2's snapshot assertion only checks that a frame carries a string type, so it does not depend on an empty mailbox.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async fn drain_mailbox(client: &TinyPlaceClient, agent_id: &str) {
if let Ok(response) = client.messages.list(agent_id, Some(50)).await {
for message in response.messages {
let _ = client.messages.acknowledge(&message.id, agent_id).await;
}
}
}
/// Delete only this probe's own messages, so a run leaves the mailbox as it
/// found it — this is a live identity that may hold real mail.
async fn drain_mailbox(client: &TinyPlaceClient, agent_id: &str) {
if let Ok(response) = client.messages.list(agent_id, Some(50)).await {
for message in response
.messages
.into_iter()
.filter(|message| message.id.starts_with("live-push-"))
{
let _ = client.messages.acknowledge(&message.id, agent_id).await;
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sdk/tests/live_inbox_push.rs` around lines 107 - 113, Update
drain_mailbox to acknowledge only messages whose IDs start with the live-push-
probe prefix, preserving unrelated mailbox messages. Keep the existing listing
and acknowledgement flow, and retain the snapshot assertion behavior independent
of mailbox emptiness.

Comment on lines +72 to +74
pub fn grid_lines(grid: &ScreenGrid) -> Vec<Line<'static>> {
grid.lines.iter().map(|runs| row_line(runs)).collect()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Render the transmitted cursor state.

grid_lines ignores ScreenGrid.cursor and hide_cursor; the downstream pane renders only these lines, so remote cursors never appear. Overlay or style the cursor cell while honoring hide_cursor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/src/ui/screen/mod.rs` around lines 72 - 74, Update grid_lines to
account for ScreenGrid.cursor and hide_cursor when constructing each row,
overlaying or styling the cursor cell so transmitted cursors render in the
appropriate position while remaining hidden when hide_cursor is enabled.
Preserve the existing row_line rendering for all non-cursor cells.

Comment on lines 246 to 265
tokio::spawn(async move {
// Held here so the push channel dies with the loop it feeds. A detached
// listener would keep a socket open for a drain that no longer exists —
// and a `JoinHandle` dropped on its own detaches rather than aborting.
let _push = push;
loop {
for message in transport.drain_inbox(50).await {
if let Some(screen) = medulla::tinyplace::parse_screen_message(&message.text) {
screens.handle(&message.from, screen);
continue;
}
let frame = decode_task_frame(&message.text);
runtime.handle_message(message.from, message.text, frame);
}
tokio::time::sleep(INBOX_POLL).await;
// Returns early when the relay's push channel delivers, so a
// subscribe is acted on at about a round trip rather than up to a
// poll interval later. The interval stays the correctness floor.
transport.wait_for_inbox(INBOX_POLL).await;
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline src/tui/src/worker/stream --items all
rg -n -C3 'impl Drop for (StreamRegistry|ScreenRouter)|fn clear|\.clear\(\)' src/tui/src/worker src/tui/src/worker_loop
rg -n -C3 'inbox.*abort|JoinHandle|WorkerCmd::Quit' src/tui/src/worker_loop

Repository: tinyhumansai/medulla

Length of output: 8543


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,240p' src/tui/src/worker/stream/router.rs
printf '\n--- sampler ---\n'
sed -n '1,280p' src/tui/src/worker/stream/sampler.rs

Repository: tinyhumansai/medulla

Length of output: 14464


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,260p' src/tui/src/worker/stream/sampler.rs | nl -ba | sed -n '1,260p'
printf '\n--- router ---\n'
sed -n '1,220p' src/tui/src/worker/stream/router.rs | nl -ba | sed -n '1,220p'

Repository: tinyhumansai/medulla

Length of output: 198


Abort the samplers during worker shutdown
Call screens.clear() on the shutdown path. Dropping ScreenRouter only drops the JoinHandles, which detaches the sampler tasks; StreamRegistry::clear() is the path that actually aborts them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/src/worker_loop/mod.rs` around lines 246 - 265, Update the worker
shutdown path to call screens.clear() before dropping the ScreenRouter, ensuring
its sampler tasks are aborted through StreamRegistry::clear() rather than
detached via dropped JoinHandles.

Comment on lines +150 to +183
pub struct StreamRegistry {
streams: std::collections::HashMap<String, tokio::task::JoinHandle<()>>,
}

impl StreamRegistry {
/// An empty registry.
pub fn new() -> Self {
StreamRegistry::default()
}

/// Start (or restart) the stream for `session_id`.
///
/// Restarting is how a resync is served: the replacement stream begins
/// owing a full frame, which is exactly what the viewer asked for.
pub fn subscribe(
&mut self,
sessions: &PtyManager,
task_id: &str,
session_id: &str,
subscriber: &str,
max_fps: u8,
send: SendFn,
) {
self.unsubscribe(task_id);
let handle = spawn_session_stream(
sessions.clone(),
task_id.to_string(),
session_id.to_string(),
subscriber.to_string(),
max_fps,
send,
);
self.streams.insert(task_id.to_string(), handle);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope stream registry entries by authenticated sender and task.

Task resolution is keyed by (from, task_id), but the registry is keyed only by task_id. Two peers with the same task ID can suppress, replace, or unsubscribe each other’s streams.

  • src/tui/src/worker/stream/sampler.rs#L150-L183: key streams by a sender-plus-task identifier rather than task_id alone.
  • src/tui/src/worker/stream/router.rs#L88-L94: unsubscribe using the same sender-scoped key.
  • src/tui/src/worker/stream/router.rs#L123-L136: perform contains and subscribe with the sender-scoped key.
📍 Affects 2 files
  • src/tui/src/worker/stream/sampler.rs#L150-L183 (this comment)
  • src/tui/src/worker/stream/router.rs#L88-L94
  • src/tui/src/worker/stream/router.rs#L123-L136
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/src/worker/stream/sampler.rs` around lines 150 - 183, Scope stream
registry entries by the authenticated sender and task instead of task ID alone.
Update StreamRegistry and its subscribe/unsubscribe lookups in
src/tui/src/worker/stream/sampler.rs#L150-183 to use a consistent
sender-plus-task key; update the unsubscribe call in
src/tui/src/worker/stream/router.rs#L88-94 and the contains/subscribe calls in
src/tui/src/worker/stream/router.rs#L123-136 to construct and use that same
scoped key.

Comment on lines +173 to +182
self.unsubscribe(task_id);
let handle = spawn_session_stream(
sessions.clone(),
task_id.to_string(),
session_id.to_string(),
subscriber.to_string(),
max_fps,
send,
);
self.streams.insert(task_id.to_string(), handle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve frame ordering across resync restarts.

A resync recreates SessionStream, resetting its next frame to sequence 1. Since full frames are applied independently of base_seq, a delayed old full frame can overwrite a newer view, and the receiver cannot distinguish it from the restarted stream’s new full frame. Preserve a monotonic per-stream sequence/epoch across restarts and reject stale full frames when folding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/src/worker/stream/sampler.rs` around lines 173 - 182, Update the
resync flow around SessionStream creation and the stream folding logic to
preserve a monotonic per-stream sequence or epoch across restarts. Ensure newly
spawned streams inherit the prior stream’s ordering state rather than restarting
at sequence 1, and reject delayed stale full frames during folding while
accepting the restarted stream’s current full frame.

Comment on lines +289 to +302
// Let it settle: the child paints, the sampler sends, then nothing changes.
tokio::time::sleep(Duration::from_millis(1_500)).await;
let settled = outbox.lock().unwrap().len();
assert!(settled > 0, "the first frame should have gone out");

// A full second at ten samples a second. A still screen must add nothing.
tokio::time::sleep(Duration::from_millis(1_000)).await;
let after = outbox.lock().unwrap().len();
assert_eq!(
after,
settled,
"an unchanged screen sent {} further frame(s) across ~10 samples",
after - settled
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fixed 1.5s settle window makes the idle-stream assertion load-sensitive.

If the child's paint or the first sample lands after the 1.5s mark, either settled > 0 fails or a legitimate first/second frame is counted in the "after" window and the strict equality fails. Waiting for the outbox to go quiet (e.g. poll until the count is stable across two consecutive intervals, bounded by PATIENCE) keeps the property under test while removing the wall-clock dependence.

♻️ Sketch
-    // Let it settle: the child paints, the sampler paints, then nothing changes.
-    tokio::time::sleep(Duration::from_millis(1_500)).await;
-    let settled = outbox.lock().unwrap().len();
-    assert!(settled > 0, "the first frame should have gone out");
+    // Let it settle: poll until the count stops moving, so a busy box only
+    // makes this slower rather than red.
+    let deadline = Instant::now() + PATIENCE;
+    let mut settled = 0usize;
+    loop {
+        tokio::time::sleep(Duration::from_millis(250)).await;
+        let now = outbox.lock().unwrap().len();
+        if now > 0 && now == settled {
+            break;
+        }
+        settled = now;
+        assert!(Instant::now() < deadline, "the first frame never went out");
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Let it settle: the child paints, the sampler sends, then nothing changes.
tokio::time::sleep(Duration::from_millis(1_500)).await;
let settled = outbox.lock().unwrap().len();
assert!(settled > 0, "the first frame should have gone out");
// A full second at ten samples a second. A still screen must add nothing.
tokio::time::sleep(Duration::from_millis(1_000)).await;
let after = outbox.lock().unwrap().len();
assert_eq!(
after,
settled,
"an unchanged screen sent {} further frame(s) across ~10 samples",
after - settled
);
// Let it settle: poll until the count stops moving, so a busy box only
// makes this slower rather than red.
let deadline = Instant::now() + PATIENCE;
let mut settled = 0usize;
loop {
tokio::time::sleep(Duration::from_millis(250)).await;
let now = outbox.lock().unwrap().len();
if now > 0 && now == settled {
break;
}
settled = now;
assert!(Instant::now() < deadline, "the first frame never went out");
}
// A full second at ten samples a second. A still screen must add nothing.
tokio::time::sleep(Duration::from_millis(1_000)).await;
let after = outbox.lock().unwrap().len();
assert_eq!(
after,
settled,
"an unchanged screen sent {} further frame(s) across ~10 samples",
after - settled
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/tests/e2e_screen_stream.rs` around lines 289 - 302, Replace the fixed
sleeps in the idle-stream assertion with bounded polling that waits for the
outbox to receive its initial frame and then become stable across two
consecutive intervals. Use the existing PATIENCE bound, preserve the
settled-count and unchanged-screen assertions, and avoid counting a legitimate
late first frame in the idle window.

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds medulla.screen.v1, a mosh-style state-synchronisation protocol that streams a worker's live terminal screen to the orchestrator. The design is sound: sampling is timer-driven rather than output-driven, unchanged screens send nothing, and loss recovery is structural (a gap forces a full frame via NeedsResync rather than a retry). The PR also replaces the poll-only inbox with a WebSocket push channel, cutting observed latency from ~1 500 ms to ~550 ms while keeping the HTTPS fetch as a correctness floor.

  • Screen protocol (medulla.screen.v1) — diff/apply/codec live in the SDK (no vt100 dependency); conversion from emulator cells, the timer-driven sampler, and the inbound subscription router live in the app crate. Authorization is structural: a subscription resolves through (authenticated sender, task id), so a peer can only ever name its own running task.
  • WebSocket inbox listenerPushInbox queues relay-pushed envelopes and wakes the drain loop immediately; SeenIds deduplicates replayed snapshots; the HTTPS fetch is skipped only while the socket is healthy, owes nothing, and was reconciled recently; MEDULLA_INBOX_WS=0 restores pure polling.
  • TUI integration — selecting a task on the Agents rail subscribes to its screen (arrow keys and clicks both call retarget_watch); the screen supersedes the transcript pane while held; retarget_watch correctly stops the old subscription before starting the new one.

Confidence Score: 3/5

The protocol core, transport layer, and worker-side streaming are solid, but the TUI navigation wiring has a real gap: switching tabs with Tab/BackTab does not release the subscription, so a running worker streams screen frames indefinitely after the user has navigated away.

The protocol design, diff/apply/codec logic, WebSocket inbox, and e2e test coverage are all well-executed. The gap is in the TUI layer: tab_enter_cmd() — called on every Tab/BackTab keypress — never invokes retarget_watch(), so the watching state is never cleared on tab exit. The worker keeps sending one frame per second and advancing its Signal ratchet for as long as the session lives, for every task the user browsed before switching tabs.

Files Needing Attention: src/tui/src/ui/app/keys/mod.rs (Tab/BackTab arms need a retarget_watch() call) and src/tui/src/ui/app/render/agents/transcript.rs (selected_screen should filter by (worker, task_id) rather than task_id alone).

Important Files Changed

Filename Overview
src/tui/src/ui/app/keys/mod.rs Arrow-key navigation correctly calls retarget_watch(), but Tab/BackTab tab-switching goes through tab_enter_cmd() which never calls it — so switching tabs does not release the screen subscription, contradicting the documented intent in watch_target().
src/tui/src/ui/app/render/agents/transcript.rs Screen supersedes transcript when held — correct priority. selected_screen searches all worker screens by task_id alone rather than the full (worker, task_id) key the store holds.
src/sdk/src/hub/screens/mod.rs LRU-by-timestamp store is correct and bounded (CAPACITY=8). The evict_stalest None arm is dead code — while len > CAPACITY guarantees non-empty so min_by_key always returns Some.
src/sdk/src/daemon/listener/mod.rs WebSocket inbox listener is well-designed: bounded queue, failure cap, nudge() on any unrecognised frame, and reconnect delay. The fetch-on-unknown-frame-type (including keepalives) is documented and intentional.
src/sdk/src/daemon/transport/mod.rs drain_inbox correctly merges push-delivered and HTTPS-fetched envelopes, deduplicates by message ID via SeenIds, and skips HTTPS only when the socket is live, owes nothing, and was reconciled recently.
src/tui/src/worker/stream/sampler.rs Timer-driven sampler: correctly clamps FPS, diffs against last-sent grid, and terminates cleanly when the session disappears. The pure SessionStream::tick design makes it well-tested without a pty.
src/tui/src/worker/stream/router.rs Authorization via structural key lookup is sound; silent rejection of unknown tasks is intentional. Resync correctly replaces the running stream handle, which resets owes_full.
src/sdk/src/tinyplace/screen/diff.rs Row-level diff is correct: guards same-size check before indexing, handles the cursor-only-changed case, and the Unchanged return makes idle sessions free on the wire.
src/sdk/src/hub/runner/pump.rs route_screen correctly ignores non-Frame messages, sends a resync Subscribe on NeedsResync, and silently discards the relay send error (fallback is the viewer pane freezing, which is visible).
src/tui/tests/e2e_screen_stream.rs End-to-end test covers the real pty → emulator → wire → store chain, asserts both full-frame and delta paths, and verifies the unchanged-screen-costs-nothing property.

Sequence Diagram

sequenceDiagram
    participant TUI as TUI (Agents tab)
    participant Hub as Hub pump
    participant Relay as tiny.place relay
    participant Worker as Worker inbox drain
    participant Sampler as SessionStream sampler
    participant PTY as pty / vt100

    TUI->>Hub: retarget_watch() → Cmd::WatchTask
    Hub->>Relay: "PUT /messages (Subscribe{task_id, max_fps:1, resync:true})"
    Relay-->>Worker: WebSocket push (Subscribe envelope)
    Worker->>Worker: parse_screen_message → ScreenRouter::handle
    Worker->>Sampler: StreamRegistry::subscribe → spawn_session_stream

    loop Every sample_interval(max_fps)
        Sampler->>PTY: sessions.screen_rows(session_id)
        PTY-->>Sampler: ScreenSnapshot
        Sampler->>Sampler: SessionStream::tick → build_frame (diff or full)
        alt screen changed
            Sampler->>Relay: PUT /messages (Frame envelope)
            Relay-->>Hub: WebSocket push (Frame envelope)
            Hub->>Hub: parse_screen_message → route_screen → ScreenStore::apply
            alt NeedsResync
                Hub->>Relay: "PUT /messages (Subscribe{resync:true})"
            end
        else unchanged
            Sampler->>Sampler: FrameDecision::Unchanged — nothing sent
        end
    end

    TUI->>Hub: Tab key → tab_enter_cmd (no retarget_watch called)
    Note over Sampler: subscription keeps running — worker streams to nobody
Loading

Comments Outside Diff (1)

  1. src/tui/src/ui/app/keys/mod.rs, line 233-241 (link)

    P1 Tab-switch does not release the screen subscription

    Tab and BackTab advance tab_index and call tab_enter_cmd(), which never calls retarget_watch(). So when the user switches away from the Agents tab with the tab key, self.watching stays Some(…) and the worker keeps streaming at 1 fps indefinitely — burning a Signal ratchet advance and an HTTPS send every second — until the user returns to Agents and presses an arrow key or clicks.

    watch_target() explicitly documents the intent: "leaving it releases the subscription." But that only happens when retarget_watch() is actually called, which isn't wired to tab switching. Adding a retarget_watch() call inside the Tab/BackTab arms (before or after updating tab_index) would close the gap.

Reviews (1): Last reviewed commit: "test(agents): cover the watch trigger ag..." | Re-trigger Greptile

Comment on lines +263 to +274
self.runtime
.worker_screens()
.into_iter()
.find(|held| held.task_id == task.task_id)
}

/// Draw a watched worker's screen into `area`.
///
/// No cursor is drawn and no scrollback is offered, matching the worker's
/// own pane: this is a window, not a keyboard, and the protocol never
/// synchronised history in the first place.
fn draw_worker_screen(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 selected_screen searches by task_id only, not (worker, task_id)

worker_screens() returns screens from all workers, and the iterator searches with .find(|held| held.task_id == task.task_id). The ScreenStore itself is keyed by (worker, task_id), and both self.watching and the watch_target() result carry the worker address alongside the task ID.

If two workers were ever serving tasks with the same string ID, this would return the wrong screen. In practice, task IDs are orchestrator-generated and effectively unique — but using self.watching directly (which already holds the (worker_address, task_id) pair) and calling ScreenStore::get(worker, task_id) would make the lookup structurally correct and remove the dependency on that uniqueness assumption.

Comment on lines +155 to +160
match stalest {
Some(key) => {
screens.remove(&key);
}
None => return,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 None arm in evict_stalest is unreachable

The while screens.len() > CAPACITY guard guarantees the map is non-empty (since CAPACITY = 8 > 0), so min_by_key always returns Some. The None => return is dead code that adds noise without providing safety.

Suggested change
match stalest {
Some(key) => {
screens.remove(&key);
}
None => return,
}
if let Some(key) = stalest {
screens.remove(&key);
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@senamakel
senamakel merged commit d819317 into main Jul 28, 2026
5 of 6 checks passed
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