Skip to content

CODER-007 — (Stretch) Real-time status push instead of polling (Durable Object / relay) #83

Closed

Description

@serge-ivo

Type: Backend

Description

Replace status polling with server-pushed updates so Working/Idle/Error and build-state transitions appear instantly and cheaply, using the existing WebSocket relay infrastructure.

Acceptance Criteria

  • The runner's existing status transitions are pushed to the console over the existing relay/WebSocket path (no new public inbound server).
  • Frontend subscribes and updates the header badge (CODER-005) and Builds panel (CODER-004) from pushed events, falling back to polling if the socket drops.
  • No regression to the relay-token security model (relay token only, never the account JWT).

Technical Notes

  • Builds on the existing RelayDO / relay-token handshake (see platform docs). This is a meaningful architecture change — only justified once polling proves insufficient.
  • Would supersede CODER-006's polling optimization for status (build history still reads from GitHub / CODER-002).

Dependencies

CODER-006 (shape of the status event), CODER-005 (consumer UI).

Activity

  1. serge-ivo commented on Aug 1, 2026

    @serge-ivo
    ContributorAuthor

    Architectural clarification (CODER-007)

    Elevated to PRIMARY real-time mechanism (no longer a stretch goal). Working/Idle/Error and build-state transitions push over the existing RelayDO WebSocket; polling (CODER-006) becomes the fallback only — used when the socket is unavailable or drops.

    KV vs DO (repo-wide decision). The Durable Object (RelayDO) is used only for the real-time relay — not for build history or status storage. Persistent build history stays in KV (CODER-002); durable app state stays in KV/D1. This keeps the DO hibernatable/near-zero-cost when idle.

    Auth. The WS handshake uses the short-lived, instance-scoped relay token (minted via POST /v1/relay/:instanceId/token with the account bearer) — never the 30-day account JWT on the socket, and no GitHub token crosses the wire. /connect accepts the relay token only, so a leaked relay URL can't become account/machine takeover.

  2. serge-ivo commented on Aug 1, 2026

    @serge-ivo
    ContributorAuthor

    Decision recorded: CODER-007 elevated to PRIMARY — build first

    Decision: Build WebSockets (CODER-007) as the primary mechanism; polling (CODER-006) is fallback-only.

    • CODER-007 (real-time WebSocket push via RelayDO) is the primary mechanism for all status updates.
    • This is the correct long-term architecture and should be built before any polling solution.
    • Server-side push cadence must respect the same three tiers from CODER-003:
      • Active (conversation running OR agent running) → push immediately on state change.
      • Passive (UI visible, no agent, no conversation) → push on change, but max once per 5 min.
      • Halted (UI hidden, no agent, no conversation) → suppress pushes entirely.
    • Durable Object (RelayDO) handles relay only; KV handles build-history persistence.
    • CODER-006 polling is demoted to fallback only — CODER-007 is the deliverable.
    • Priority: HIGH — build this before CODER-004 (Build Panel UI) so the frontend has a real data source to connect to.

    Suggested build order: CODER-001 → CODER-002 → CODER-007 → CODER-003 → CODER-004 → CODER-005, with CODER-006 as an optional fallback at the end.

  3. serge-ivo commented on Aug 2, 2026

    @serge-ivo
    ContributorAuthor

    Status: correctly blocked on its own dependencies — deferring (kept open)

    Investigated the existing relay infra (workers/api/src/relay-do.ts) before implementing. RelayDO today is a request/response relay: one "runner" WebSocket class, cloud POST /command → runner replies. There is no console-side subscriber socket and no producer of status events — the console learns status by polling /capture.

    Implementing CODER-007 as specified is genuinely premature right now, for three concrete reasons:

    1. No consumer. The pushed events are meant to drive the CODER-004 Builds panel and the CODER-005 header badge — neither exists yet (both open). The issue lists them as dependencies (Dependencies: CODER-006, CODER-005). Shipping the broadcast layer now = unwired dead code, which this repo explicitly avoids (see the PR feat(agents): formal declarative agent-definition schema (#51) #62 "removed as unwired dead code; rebuild it with the route when wired end-to-end").
    2. No event producer / shape. Real-time push needs the runner (published @proagentstore/browser-runner, bundled in the CLI) to emit transitions over the socket — a CLI release — and the event shape is CODER-006, unbuilt.
    3. The 'before 004' rationale doesn't hold. CODER-004's data source is the /deployments endpoint (shipped in CODER-001/002) + the aggregate (CODER-003) — not the WS push. So 004 does not need 007; it needs 003.

    Decision: defer CODER-007 until its consumer (004/005) and event shape (006) exist, then it's a clean, fully-wired change (RelayDO subscriber class + broadcast + runner emit + frontend subscribe-with-poll-fallback). Proceeding down the buildable order instead: CODER-003 (#79) → CODER-004 (#80) → CODER-005 (#81). Leaving this open.

  4. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Delivered as a cadence policy, not a transport — push channel declined (commit a50b5ce)

    The blockers from the last comment are gone: CODER-001…006 all closed, so the consumers (Builds panel #80, header badge #81) and the aggregate (#79) exist. Re-verified the polling before designing, and the conclusion changed.

    The valuable part of this ticket is its cadence policy, not its transport. The decision recorded above defines three tiers — Active (something running), Passive (visible, idle), Halted (hidden, idle). Those describe when an update is worth fetching, not how it arrives, so they apply to a poll exactly as well as to a push. Shipped on the poll they cost no console WebSocket client (there is none today — the console has never had one; /v1/agents/:id/ws exists server-side and is unused), no new token type on the socket, and no CLI release to emit runner events.

    What the polls actually cost. Nothing is near a rate limit: the default bucket is 240/min (lib/rate-limit.ts), /capture and takeover ride a separate 3000/min bucket, and a Coding tab with a session open spends ~36/min of the 240. So the case is cost and reach, not breakage.

    Poll Was Now (busy · visible-idle · hidden-idle)
    /runtime/status — console header 4s 4s · 20s · off
    /runtime/status — Coding tab 10s 10s · 60s · off
    /capture across sessions 3s 3s · 12s · off
    /capture open terminal 1.5s 1.5s · 6s · off
    /timeline Co-pilot 4.5s 4.5s · 20s · off
    /deployments + /builds 20s 20s · 120s · off

    /runtime/status is the standout and the reason the header dot was the first thing wired: each poll does two relay /command round-trips to the user's laptop (health + capabilities) plus a relayConnected probe — about three Durable Object hits — 21×/min across the two call sites, to re-read a boolean that changes about twice a day. Nothing in the console except one poll checked document.hidden, so a backgrounded tab kept doing that indefinitely. The Builds polls additionally spend the installation's GitHub Actions rate limit that runDeployWatch is already spending every minute.

    Why not the push channel. Three reasons, in order of weight:

    1. It would make the failure mode worse. Every bug this surface has actually shipped was a STUCK-STATE bug — [console] Six UI defects: Teamwork unwireable, runner dot stuck online, frozen status badge, wedged Loop start, slow first paint #190 (runner dot stuck online), [bug] /runtime/status reports the stale default row's node, so the console's connected dot can be flatly wrong #238 (stale node), [bug] Coding tab's offline state is unrecoverable — it hides the only control that could clear it #241 (unrecoverable offline). A poll self-heals from those; a dropped push does not. And the acceptance criteria already say "falling back to polling if the socket drops", so the poll never actually goes away — you end up maintaining both paths and the slow one still has to be correct.
    2. The console cannot subscribe to RelayDO as it stands. RelayDO is keyed per (instance, node) and accepts exactly one WebSocket tag, "runner"; the bare-instanceId DO never holds a socket because every modern runner connects with ?node=<hostname>. A console subscriber does not know which node to attach to, so pushing connectivity needs a second hop (per-node DO → per-instance fan-out) plus a new subscriber tag, a new token type or a security review of putting the account JWT on a socket, and reconnect/backoff on the client. That is real architecture for ≤4s of latency on a boolean — and only in the Active tier, where the poll is already fast. In the Passive tier latency is irrelevant by definition: nothing is running and nobody is waiting.
    3. The one remaining latency argument is already handled. A tier speed-up fetches once immediately, so a suppressed poll is never something a user can observe as staleness — you refocus the tab and it is fresh. And "busy" deliberately includes believing the runner is offline, since that is the state a change is imminent from and the one the user is fixing by running pags up, so [bug] Coding tab's offline state is unrecoverable — it hides the only control that could clear it #241 does not regress.

    Multi-machine correctness untouched. Zero server-side lines changed: RelayDO, the relay-token handshake, getBoundRunnerConn → getLiveRunnerConn live-socket routing, the config.runnerNode pin, and the 4409 same-node rejection are all as they were.

    A real bug fixed on the way. The tiering needs a correct "is the engine working" signal, and the existing one was wrong. The runner reports idle | thinking | responding (browser-runner/src/coding/runtime.ts), but five call sites had each independently written state === "thinking" || state === "working" — "working" is not a state the runner can emit, and "responding" (the engine streaming its answer) fell through as not busy. So a mid-answer session read "Idle" in the repo list, and — worse — watchForFinish and the coding Loop both treat not-busy as finished, so they could fire the completion summary or the next instruction into an engine that was still talking. Now one tested predicate (engine-busy.ts), one vocabulary, "working" kept as a legacy alias.

    Shipped: packages/sdk/src/poll-cadence.ts (pure resolvePollTier / resolvePollMs / shouldRefreshOnResume) + useTieredPolling in sdk/hooks.ts; agents/coder/web/src/engine-busy.ts; buildState / isBuildInFlight / anyBuildInFlight moved into builds-view.ts. 31 new unit tests.

    Follow-up, not done here (same primitive, outside this ticket's surface): the console still has ungated always-on polls elsewhere — Board 2.5s, Activity 4s, Tmux 4s (MCP tool calls), Terminals 5s, notifications 30s, Usage 30s, and takeover frames at 500ms. They are all useTieredPolling one-liners now that it exists. Worth a separate issue.

    Recommend closing this as delivered-by-different-means. If a push channel is ever revisited, the honest trigger is a product need for sub-second cross-tab state (multiple people watching one agent), not the cost of these polls.

  5. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Closing as delivered by different means — a50b5ce.

    The blocker noted earlier is genuinely gone (CODER-001…006 all closed, so #79/#80/#81 exist). But re-measuring the polling changed the conclusion about what to build, and the push channel was declined for three reasons:

    1. It makes the failure mode worse. Every bug this surface has actually shipped was a stuck-state bug — [console] Six UI defects: Teamwork unwireable, runner dot stuck online, frozen status badge, wedged Loop start, slow first paint #190 (dot stuck online), [bug] /runtime/status reports the stale default row's node, so the console's connected dot can be flatly wrong #238 (stale node), [bug] Coding tab's offline state is unrecoverable — it hides the only control that could clear it #241 (unrecoverable offline). A poll self-heals; a dropped push does not. The AC itself says "falling back to polling if the socket drops", so the poll never leaves — you just maintain both.
    2. The console cannot subscribe to RelayDO as built. It is keyed per (instance, node) with one "runner" tag, and the bare-instanceId DO never holds a socket because runners connect with ?node=. A console subscriber does not know the node, so this needs a second hop, a new tag, a new token type (or a security review of putting the account JWT on a socket) and client reconnect logic — real architecture for ≤4s of latency on a boolean.
    3. In the passive tier latency is irrelevant by definition: nothing is running and nobody is waiting.

    Shipped instead: this issue's own three-tier policy, applied to the poll. Active/Passive/Halted describe when an update is worth fetching, not how it arrives. /runtime/status was the worst offender — each poll is two relay round-trips to the user's laptop plus a relayConnected probe (~3 DO hits), 21×/min, re-reading a boolean that changes twice a day, and it ran forever in backgrounded tabs. Now 4s busy · 20s idle · off when hidden, with an immediate catch-up fetch on any tier speed-up so the suppression is unobservable.

    Two guards: busy beats hidden (the Coder Loop and watchForFinish advance off these polls — halting a busy background tab would stall real work), and believing the runner is offline counts as busy, so #241 cannot regress.

    A real bug surfaced on the way, worth more than the perf work: the runner emits idle | thinking | responding, but five call sites each wrote state === "thinking" || state === "working". "working" is never emitted, and "responding" — the engine streaming its answer — fell through as NOT busy. Since watchForFinish and the coding Loop both treat not-busy as finished, they could fire the completion summary or the next instruction into an engine that was still talking. Now one tested predicate (engine-busy.ts), with "working" kept as a legacy alias.

    Zero server-side lines changed: RelayDO, the relay-token handshake, live-socket routing, the config.runnerNode pin and the 4409 same-node rejection are untouched. 31 new unit tests; suite 237 files / 3408 tests green.

    The honest trigger for revisiting a push channel is a product need for sub-second cross-tab state (several people watching one agent), not the cost of these polls.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions