Skip to content

feat(app): wait_for_status + read_terminal on the live bridge (F-mcp-terminal-sync, #195)#227

Merged
bastien-gallay merged 3 commits into
mainfrom
worktree-195-mcp-terminal-sync
Jul 25, 2026
Merged

feat(app): wait_for_status + read_terminal on the live bridge (F-mcp-terminal-sync, #195)#227
bastien-gallay merged 3 commits into
mainfrom
worktree-195-mcp-terminal-sync

Conversation

@bastien-gallay

Copy link
Copy Markdown
Collaborator

What

The wait rung, closing act → wait → observe. Orchestration (#194) could
act and snapshot (#212) could perceive, but nothing could synchronise:
run_in_session writes and returns, so an agent had no way to know when a
command finished short of polling snapshot — and racing the very transition
it was watching for.

MCP tool What it does
wait_for_status(session[, statuses][, timeout_ms]) Blocks until the session's OSC-derived activity reaches one of statuses (default idle-or-attention). Returns { status, timed_out }.
read_terminal(session[, lines]) One pane's visible text — the deep read the light snapshot leaves out. Returns { text, rendered }.

How

The wait is the first bridge request whose reply lands in a later update.
That turned out to need no new machinery: ReplyPort is already a take-once
slot behind an Arc, built precisely so an answer need not be immediate. The
shell parks it in a waiter list and settles it from the status change itself —
nothing polled, no transition raced (the feature-torture report cut
wait_for_text for exactly that failure mode).

serve(WaitForStatus) ─┬─ handle unknown ──────────► answer now (invalid_params)
                      ├─ already on a target ─────► answer now
                      └─ otherwise ──────────────► park a StatusWaiter
                                                        │
update(PtyStatus) ─┐                                    │
update(PtyExited) ─┴──► settle_waiters(session, status) ┘  + sweep dead ports

Three rules earn their own tests, each for a concrete failure:

  • A session already on the target answers at once. Otherwise a caller
    asking "tell me when it's idle" about an idle session waits out its whole
    bound, having missed the transition it asked about.
  • An exit settles every waiter, whatever it asked for. Verified in core:
    pty_exited records Exited and returns no effects — a crash emits no
    status change. And a dead session will never reach the target anyway.
  • A caller that gave up is swept off the list, rather than held for a
    session that may never move again (ReplyPort::abandoned).

Timing out is not a failure. On timeout the tool reads the session's
current status back and answers { status, timed_out: true }, so an agent can
choose between waiting again and giving up instead of getting a bare error. The
bound is the caller's timeout_ms (default 30 s), capped at 5 minutes — no
parameterisation can park a caller indefinitely (Q7).

read_terminal keeps three outcomes distinct that a scoped snapshot
collapses into a single absent key: unknown handle (invalid_params), live but
undrawn (rendered: false — retry, don't give up on the handle), and text.
Truncation reuses core::snapshot::tail_lines rather than growing a second
rule that could drift from a snapshot's.

Two commits

  1. refactor(app) — tidy-first, no behaviour change: the Message::Bridge
    arm moves out of update (which already carries a too-many-lines allow)
    into shell::serve, so there is one auditable place where an external
    caller meets core::App.
  2. feat(app) — the rung above.

Tests

Written red first. 20 new, all green:

  • shell (11) — answers-at-once; parks then settles; stays parked through a
    non-target status; ignores another session's change; rejects an unknown
    handle without parking; settled by an unclean exit; abandoned waiter swept;
    and four read_terminal cases (text, line budget, undrawn, unknown handle).
  • tools (9) — default statuses, named statuses, unknown status name
    rejected (not silently dropped), settled status reported, rejection relayed
    as invalid_params, the timeout path (a two-reply test shell: the wait is
    received and never answered, then the status read-back), the cap, and
    read_terminal's handle/line-budget/undrawn/rejection shaping.

Full suite green (cargo test --workspace); fmt · clippy · machete ·
deny · check-deps · check-arch · markdownlint all pass. ROADMAP entry
ticked.

Closes #195.

Answering an external request is not one thing: a read answers off owned
state, a mutation needs `&mut` and the effect executor. That fork sat inline
in `update`, which already carries a too-many-lines allow, and the wait rung
adds a third shape (a reply that lands later, not in this update).

Move it to `shell::serve`, unchanged, so there is one auditable place where an
external caller meets `core::App`.
Orchestration could act and perceive, but not synchronise: `run_in_session`
writes and returns, so an agent had no way to know when the command finished
short of polling `snapshot` and racing the transition it was watching for.

`wait_for_status` blocks until a session's OSC-derived activity reaches one of
the asked-for statuses, and `read_terminal` returns one pane's visible text —
the deep read the light `snapshot` deliberately leaves out.

The wait is the first bridge request whose reply lands in a *later* `update`.
`ReplyPort` was already built for that (a take-once slot behind an `Arc`), so
the shell parks it in a waiter list and settles it from the status change
itself — nothing polled, no transition raced.

Three rules earn their own tests:

- a session already sitting on a target answers at once, or a caller asking
  "tell me when it is idle" about an idle session would wait out its bound
  having missed the transition it asked about;
- an exit settles every waiter whatever it asked for — a crash records
  `Exited` without emitting a status change, and a dead session will never
  reach the target anyway;
- a caller that gave up is swept off the list rather than held for a session
  that may never move again.

Timing out is not a failure: the tool answers `{ status, timed_out: true }`
with the session's current status, so an agent can choose between waiting again
and giving up. The bound is the caller's `timeout_ms` (default 30 s), capped at
5 minutes so no parameterisation can park a caller indefinitely (Q7).

`read_terminal` keeps three outcomes distinct that a scoped `snapshot`
collapses into one absent key: unknown handle (invalid_params), live but
undrawn (`rendered: false`, retry), and text.
@bastien-gallay
bastien-gallay force-pushed the worktree-195-mcp-terminal-sync branch from f5b11a0 to 3ecd0c7 Compare July 25, 2026 19:32
Four defects the review found, all on the same seam — three of them ways a
caller ends up parked on a session core has already given up on.

`Exited` is terminal in core: `StatusChanged` refuses to overwrite it. So a
wait placed *after* an unclean exit parked forever, because the exit that would
have settled it had already fired. `serve_wait` now short-circuits on it, the
same rule `settle_waiters` applies — extracted to one `settles` predicate
rather than two copies that already disagreed.

The settling status was taken from the message. A status still in flight when
the exit landed is dropped by core, but was reported to the client — a crashed
session answering "idle". Read it back from core instead, which also gives the
clean-exit case (session gone from the registry) its `Exited` answer for free.

Closing a tab drops its sessions with no PTY exit reaching `update`, so nothing
settled those waiters, and nothing swept the ones whose caller had timed out on
a quiet workspace. `serve` now sweeps first: it is the one path that runs on
every external call, however still the sessions are.

Finally, the status read-back after a timeout propagated its own failure. The
likeliest cause of the first timeout is a wedged shell, which fails the
read-back too — turning the answer the caller had earned into an error. It is
best-effort now (`status: null`), on a 250ms bound so it barely extends the
wait it follows.
@bastien-gallay
bastien-gallay merged commit b5e3b00 into main Jul 25, 2026
17 checks passed
@bastien-gallay
bastien-gallay deleted the worktree-195-mcp-terminal-sync branch July 25, 2026 20:14
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.

F-mcp-terminal-sync: wait_for_status + read_terminal (act -> wait -> observe)

1 participant