Skip to content

feat: harden control-plane protocol for reliable board communication#3491

Merged
dgageot merged 5 commits into
mainfrom
board-protocol-hardening
Jul 7, 2026
Merged

feat: harden control-plane protocol for reliable board communication#3491
dgageot merged 5 commits into
mainfrom
board-protocol-hardening

Conversation

@dgageot

@dgageot dgageot commented Jul 6, 2026

Copy link
Copy Markdown
Member

External observers — docker agent board and other dashboards — communicate with the agent over the --listen control-plane via SSE and snapshot polling. On degraded transports such as future docker-sandbox tunnels, a silent TCP connection is indistinguishable from a live-but-quiet one, and a handful of event types were silently dropped under backpressure, leaving boards stuck showing a running session that had already finished. This set of changes makes the protocol robust enough to survive those conditions.

The server now emits a : ping SSE comment every 15 seconds on idle /events streams so clients can distinguish a quiet session from a hung transport; writes are mutex-serialized with real event frames so a heartbeat never interleaves with a delta. GET /snapshot now includes the session's aggregate cost via sess.TotalCost(), fixing undercount that happened when clients tried to sum per-message fields themselves. A 404 response for an unknown session now carries {"code":"unknown_session"} so a client can tell "session not yet created, keep polling" from "route missing or old binary, relaunch" — previously both were opaque 404s. On the fan-out side, subscriber overflow no longer drops stream_started, stream_stopped, user_message, error, paused, or session_title events; instead the oldest pending delta is evicted. Those turn-boundary events never reached the SSE replay buffer, so dropping them caused boards to show perpetually-running cards. The existing non-blocking StreamStopped path (deadlock fix #3070) is unchanged. Finally, the built-in board's SSE client arms an idle watchdog after receiving its first heartbeat ping: three missed heartbeats trigger a reconnect and full resync. Agents that predate heartbeat support never arm the watchdog, so older binaries keep working without change.

All five changes are backward compatible for existing clients. New tests cover the heartbeat ticker (pkg/server/server_heartbeat_test.go), the fan-out eviction logic (pkg/app/fanout_test.go), and the board client watchdog (pkg/board/client_test.go); all pass under -race.

dgageot added 5 commits July 6, 2026 21:59
An idle GET /events stream now emits an SSE comment (": ping") every
15s, so clients can tell a quiet session from a hung transport (e.g.
across a paused VM) and reconnect instead of blocking forever on a read
that will never fail. SSE comments carry no id and are invisible to
EventSource clients, so the sequenced stream and its replay are
untouched.

Event writes and heartbeats come from different goroutines, so frame
writes are serialized with a mutex.
The snapshot now carries the session's total cost (TotalCost, which
includes sub-session and item-level costs such as compaction). External
consumers like the board no longer need to fetch every message and sum
per-message costs — which undercounted — to display what a session has
spent.
…ble code

GET /snapshot of a session the server does not know now 404s with
{"code":"unknown_session"} in the body. Clients that own their session
ids (the board) can finally tell the two 404s apart: with the code, the
server is current but the session is not created/attached yet — keep
waiting; without it, the route itself is missing (a binary that predates
/snapshot) — relaunch to pick up the current binary. Previously both
looked identical and the board could only guess.

The new server tests cover heartbeats, the aggregate snapshot cost, and
this error code.
The event fan-out drops events for a slow subscriber rather than block
the bus — fine for content deltas, but dropping a stream_started,
stream_stopped, user_message, error, paused or session_title skews an
SSE consumer's turn accounting for good: the event never reaches the
control plane's replay buffer, so reconnecting cannot recover it (the
board's stuck-running cards).

On overflow of a turn-boundary event, evict the oldest pending message
— almost always a delta the next one supersedes — and retry once. Other
events keep the previous drop behavior, and a wedged subscriber still
never blocks the fan-out.
The built-in board's control-plane client arms an idle watchdog once
the server proves it sends heartbeats (": ping" SSE comments): three
missed heartbeats means the transport is hung — not that the session is
quiet — so the stream is aborted and the card's watcher reconnects and
resyncs, instead of showing a stale status forever. Servers that
predate heartbeats never arm the watchdog, so idle streams to older
agents keep working.
@dgageot dgageot requested a review from a team as a code owner July 6, 2026 20:04
@aheritier aheritier added area/api For features/issues/fixes related to the usage of the cagent API area/core Core agent runtime, session management area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jul 6, 2026
@dgageot dgageot merged commit 9bc81e7 into main Jul 7, 2026
14 checks passed
@dgageot dgageot deleted the board-protocol-hardening branch July 7, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api For features/issues/fixes related to the usage of the cagent API area/core Core agent runtime, session management area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants