Skip to content

feat(remote): client-side /acp liveness — heartbeat, resume, turn recovery - #55

Merged
brettchien merged 3 commits into
mainfrom
feat/remote-acp-liveness
Aug 14, 2026
Merged

feat(remote): client-side /acp liveness — heartbeat, resume, turn recovery#55
brettchien merged 3 commits into
mainfrom
feat/remote-acp-liveness

Conversation

@brettchien

Copy link
Copy Markdown
Contributor

Wires the four Studio-side fixes from the /acp zombie/RST investigation. All four surface as app-log lines so they're readable in the Activity panel.

Context

Symptoms seen in the field: repeated remote: ws read: WebSocket protocol error: Connection reset without closing handshake every few minutes, and an Orca session that only cleared after bouncing the ECS task. Root cause is a silent idle socket with no client-side liveness. This PR is the client half.

What changed

src-tauri/src/remote.rsrun_reconnecting now owns a persistent Session; run_once gains a liveness timer and threads its exit reason out through an outcome so every exit path runs cleanup.

  1. Idle heartbeat (item 1). A tokio::time::interval arm probes an idle socket with studio/ping — a method the gateway answers itself with -32601 (no agent turn, no tokens, answered even mid-prompt). Any inbound frame = liveness; a probe unanswered for a full interval ⇒ half-open ⇒ reconnect. Also keeps the socket warm, so the idle-RST churn stops at its source.
  2. session/resume on reconnect (item 2). The Session persists across attempts; a reconnect resumes the existing agent session instead of stacking a fresh session/new each time. If the gateway already reaped it, the resume error falls back to session/new.
  3. Abandoned-turn notice (item 3). When the socket ends with a turn in flight, it's recorded in Activity (the chat panel already closes the spinner off remote-status, so this is the observability half).
  4. Turn ceiling (item 4). A session/prompt with no result past PROMPT_TIMEOUT (600s) invalidates the socket and reconnects rather than trusting a wedged peer — ADR browser-tunnel-liveness R3.

crates/acp-tunnel/src/lib.rs — adds Session::heartbeat() (the probe builder) and Session::forget_session() (resume→new fallback), each with a unit test.

New Activity log lines

  • remote: reconnecting — will resume the existing session (info)
  • remote: session resumed — oab tools republished (info)
  • remote: session/resume rejected (…) — opening a fresh session (warn)
  • remote: liveness probe unanswered — socket half-open, reconnecting (warn)
  • remote: turn exceeded 600s with no result — dropping the socket and reconnecting (warn)
  • remote: connection dropped with a turn in flight — turn abandoned (warn)

Tests

cargo test -p acp-tunnel25 passed. studio-desktop compiles on the macOS desktop job (the Linux compile-gate is disabled and this box lacks the GTK/WebKit stack); remote.rs was rustc-parse-checked here (no syntax/type errors in reach).

Not in this PR (server side — needs your go)

This is the client half. The seat-burning zombie — an orphaned agent turn on a half-open socket the gateway can't detect — still needs, in openabdev/openab crates/openab-gateway: (a) server-initiated liveness (WS ping / read-idle timeout so the read loop actually breaks), and (b) firing each session's cancel Notify on teardown, not just abort()ing the relay task. I'll open that as a separate openab issue for review before touching it.

🤖 Generated with Claude Code

brettchien and others added 3 commits August 14, 2026 22:53
…overy

The Studio `/acp` client had no proactive liveness: an idle socket sat silent
until an intermediary RST it mid-think ("Connection reset without closing
handshake"), each reconnect opened a *fresh* `session/new` (stacking server-side
sessions), and an in-flight turn was dropped with no trace. This wires four
client-side guards, all surfaced as `app-log` lines in the Activity panel.

1. Idle heartbeat. A `tokio::time::interval` arm probes an idle socket with a
   request the gateway answers itself (`studio/ping` -> `-32601`): no agent turn,
   no tokens, answered even mid-prompt. Any inbound frame counts as liveness; a
   probe unanswered for a full interval => half-open => reconnect. Keeps the
   socket warm too, so the idle-RST churn stops at its source.

2. session/resume on reconnect. The `Session` now persists across attempts
   (owned by `run_reconnecting`); a reconnect resumes the existing agent session
   instead of stacking a new one. If the gateway has already reaped it, the
   resume error falls back to `session/new`.

3. Abandoned-turn notice. When the socket ends with a turn in flight, record it
   in Activity (the panel already closes the spinner off `remote-status`).

4. Turn ceiling. A `session/prompt` with no result past PROMPT_TIMEOUT (600s)
   invalidates the socket and reconnects rather than trusting a wedged peer
   (ADR browser-tunnel-liveness R3).

acp-tunnel gains `Session::heartbeat()` and `Session::forget_session()` (+ tests,
25 pass). Note: this is the *client* half — it stops Studio triggering/enduring
the churn, but the seat-burning server-side zombie (orphaned turn on a half-open
socket) still needs the gateway-side liveness + cancel-on-teardown in openab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erity

Folds the Activity-log audit into this PR. Two problems:

- The core writes ALL its logs to stderr (stdout is the JSON-RPC channel), but
  mcp.rs emitted every stderr line at `warn` — so benign INFO lines ("Scaling…",
  "✓ Desired count set…") rendered orange. Now `core_level()` levels off the
  line's own text and defaults benign lines to `info`.
- Three prefix styles coexisted (`subsystem:`, `[core]`, none) with mixed
  capitalization. Normalized every Activity line to `<subsystem>: <lowercase>`
  over eight subsystems (app / core / remote / roster / chat / identity / config
  / update); `…` (U+2026) for in-progress, no trailing punct for terminal.

Also adds three lines that were missing from the reconnect story:
  - `remote: dialing <url>…`  (the URL carries no secret — bearer is header-only)
  - `remote: reconnecting in 5s…`  (explains the gap before the next attempt)
  - `remote: disconnected by user`

Touches src-tauri/{mcp,lib,remote}.rs and console/src/{main,log}.ts. Console
build + 59 tests pass; Rust validated on the macOS `desktop` job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien merged commit 91ab4e2 into main Aug 14, 2026
2 checks passed
@brettchien
brettchien deleted the feat/remote-acp-liveness branch August 14, 2026 16:20
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.

1 participant