Skip to content

Keep workflow runs alive across WebSocket disconnects - #4713

Merged
georgi merged 2 commits into
mainfrom
claude/workflow-runs-websocket-resilience-mb8vji
Aug 5, 2026
Merged

Keep workflow runs alive across WebSocket disconnects#4713
georgi merged 2 commits into
mainfrom
claude/workflow-runs-websocket-resilience-mb8vji

Conversation

@georgi

@georgi georgi commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What

Two related changes to the WebSocket run lifecycle:

  1. Workflow runs survive WebSocket disconnects the way chat turns already do (commit 1).
  2. The four ws-server bugs holding the Ring 1 deploy gate red are fixed (commit 2) — the gate (user-journeys.ymlfly-deploy.yml) has been red since it landed in fix(docker): unbreak the image build, and block PRs on it #4660, blocking every Fly deploy, including the rejected-upgrade fd-leak fix (fix(websocket): close the socket of a rejected WebSocket upgrade #4673) whose absence in prod exhausts the machine's file descriptors roughly daily (CLOSE_WAIT pile-up → connection resets → outage).

Run resilience (commit 1)

Previously, each connection's UnifiedWebSocketRunner cancelled every running job in disconnect(), and reconnect_job from a fresh connection misreported a completed run as failed with "Job event replay is unavailable".

Mirrors the ChatTurnRegistry design (resume_chat) for job runs:

Server (packages/websocket, packages/protocol)

  • New JobRunRegistry / JobRunSession (job-run-registry.ts): process-wide sessions keyed by user+job. Every job frame is stamped with a monotonic job_seq and appended to a bounded buffer (NODETOOL_JOB_REPLAY_BUFFER_EVENTS, default 2000). A running session nobody is attached to is cancelled after a detach grace (NODETOOL_JOB_DETACH_GRACE_MS, default 10 min); a finished session is kept for replay (NODETOOL_JOB_REPLAY_RETENTION_MS, default 5 min).
  • disconnect() detaches running jobs instead of cancelling them. Queued (never-started) runs are still drained and cancelled.
  • reconnect_job / resume_job accept last_seq, adopt the session onto the new connection, and reply with a job_resumed header followed by the missed tail. Cross-connection control (cancel_job, stop, stream_input, end_input_stream, update_node_properties) routes through the session's execution hooks.
  • The persisted-row fallback echoes only settled statuses (completed/failed/cancelled); any non-terminal row with no session reports failed instead of leaving the client spinning — and a genuinely completed job is no longer flipped to failed.
  • Concurrency caps (MAX_CONCURRENT_JOBS, per-workflow cap) count runs process-wide through the registry, so detached runs can't be bypassed by reconnecting.

Web (web/src/stores)

  • WorkflowRunner tracks a per-job job_seq cursor, resends reconnect_job with last_seq on the socket's open event for in-flight server runs (skipping browser-local runs), and handles job_resumed — including settling a store whose replay is already complete so it can't hang in "running".

Ring 1 deploy-gate fixes (commit 2)

  • cancelJob no longer sends an eager terminal job_update for an active run. That out-of-band frame told clients the job was over while nodes still read "running" (lifecycle.running-after-job-terminal, pinned by the mid-run-cancel-node/mid-run-cancel-streaming journeys) — the bug that left canvas nodes stuck spinning after Stop. The DB row is still marked cancelled immediately; the kernel's own terminal frame relays through the drain loop after node-level terminals.
  • The generation autosave dedupe read (Asset.paginate) in the drain loop is best-effort. On a DB-free run it threw "Database not initialized", killed the drain loop, and failed a run that had actually completed (provider-failure-mid-stream journey).
  • reliability-ring1.mjs gives ws-transport-faults its own per-fault block on its declared ws-server surface (like python-node-workflow): its ws faults all wrap one proxy, so the bare invocation applied all five simultaneously, and the three black-hole faults legitimately end client-side as "timeout" — covered by the harness's own ws-transport-faults.test.ts instead.

Testing

  • All 15 Ring 1 reliability runs pass locally (npm run reliability:ring1), including the four that fail on main today.
  • New job-run-registry.test.ts (14 tests) and job-run-resilience.test.ts (8 runner-level tests: detach + cross-connection replay incl. terminal frame, cross-connection cancel/control, slot accounting across connections, fallback statuses).
  • New workflowUpdates.jobResume.test.ts plus extended WorkflowRunner.test.ts on the web side (cursor tracking, open-event resubscribe, job_resumed paths, isBrowserRun reset).
  • The client-reconnect-mid-run reliability journey now asserts the fixed behavior: replayed node stream, job_resumed{status:"finished"}, real completed terminal state.
  • Full runs: packages/websocket 185 files / 2119 tests, reliability harness vitest 153 tests, web suites green; typecheck and lint clean (mobile typecheck fails pre-existing in this sandbox — its Expo tree isn't installed).

An adversarial review pass was run on the diff; the surviving accepted limitations are documented in code: one delivery target per session (a second tab takes the stream, same as chat), and the delivery chain relies on the bounded buffer for memory, not socket backpressure.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P6LtxKGYqd7tnK9MdopkHb

claude added 2 commits August 4, 2026 22:06
Chat turns already survive a dropped socket via the process-wide
ChatTurnRegistry (seq-stamped replay buffer, detach grace, resume_chat).
Workflow jobs did not: activeJobs is per-connection, disconnect()
cancelled every running job, and reconnect_job from a fresh connection
misreported a completed run as failed with "replay unavailable".

Mirror the chat design for job runs:

- New JobRunRegistry (packages/websocket/src/job-run-registry.ts):
  process-wide sessions keyed by user+job, frames stamped with job_seq
  into a bounded buffer, detach grace (NODETOOL_JOB_DETACH_GRACE_MS,
  10 min -> cancel) and post-finish retention
  (NODETOOL_JOB_REPLAY_RETENTION_MS, 5 min), plus execution hooks so a
  later connection can cancel, push stream input, or update node
  properties on an adopted run.
- disconnect() detaches running jobs instead of cancelling them; queued
  runs are still drained and cancelled.
- reconnect_job/resume_job accept last_seq, adopt the session, and reply
  with a job_resumed header followed by the missed tail. The
  persisted-row fallback now echoes only settled statuses; anything
  non-terminal with no session reports failed instead of hanging the
  client.
- Concurrency caps count runs process-wide through the registry, so
  detached runs cannot bypass MAX_CONCURRENT_JOBS across reconnects.
- Web WorkflowRunner tracks a job_seq cursor, resends reconnect_job with
  last_seq on the socket's open event for running jobs, and handles
  job_resumed (including settling a store whose replay is already
  complete).
- Reliability journey client-reconnect-mid-run now asserts the resumed
  stream and real terminal state instead of pinning the old gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6LtxKGYqd7tnK9MdopkHb
The reliability Ring 1 gate (user-journeys.yml, added with the journeys in
#4660) has been red since it landed, blocking every Fly deploy — including
the rejected-upgrade fd-leak fix (#4673) whose absence in prod exhausts the
machine's 10240 file descriptors roughly daily. Three of the four failures
are real ws-server bugs; the fourth is the gate mis-invoking a journey.

- cancelJob no longer sends an eager terminal job_update for an active run.
  That out-of-band frame told clients the job was over while nodes still
  read "running" (lifecycle.running-after-job-terminal — the
  mid-run-cancel-node and mid-run-cancel-streaming journeys), which is what
  left canvas nodes stuck spinning after Stop. The DB row is still marked
  cancelled immediately; the kernel's own terminal frame relays through the
  drain loop after the node-level terminals.

- The generation autosave dedupe read (Asset.paginate) in the drain loop is
  best-effort now. On a DB-free run it threw "Database not initialized",
  killed the drain loop, and failed a run that had actually completed
  (provider-failure-mid-stream journey).

- reliability-ring1.mjs gives ws-transport-faults its own per-fault block on
  its declared ws-server surface, like python-node-workflow: the ws faults
  all wrap one proxy, so the bare invocation applied all five at once, and
  the three black-hole faults legitimately end client-side as "timeout" — a
  verdict the CLI cannot express as success — and stay covered by the
  harness's own ws-transport-faults.test.ts.

All 15 Ring 1 runs pass locally; websocket suite 185 files / 2119 tests
green; harness vitest suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6LtxKGYqd7tnK9MdopkHb
@georgi
georgi merged commit 008931c into main Aug 5, 2026
26 checks passed
@georgi
georgi deleted the claude/workflow-runs-websocket-resilience-mb8vji branch August 5, 2026 05:20
georgi pushed a commit that referenced this pull request Aug 5, 2026
…oss machines

The job-run resilience shipped in #4713 is per-process: a run's replay
buffer and control hooks live in the one server executing it. Behind a
load balancer a reconnecting client lands on a random instance, so resume
silently degrades and cancel misses. Two mechanisms fix that, both inert
when no instance id is configured (NODETOOL_INSTANCE_ID, falling back to
Fly's FLY_MACHINE_ID):

- Owner-aware routing. The executing instance stamps its id on the Job
  row (new nullable runner_instance column, both dialects + migration).
  A reconnecting client appends resume_job=<id> to the handshake URL;
  an instance that does not own that still-running job answers the
  upgrade with `fly-replay: instance=<owner>` so Fly's proxy re-issues
  the handshake at the owner. A replayed request (fly-replay-src) is
  never replayed again, the stored owner id is validated before touching
  response bytes, the refused socket is destroyed on a timer if the peer
  never closes, and the client retires the hint after two consecutive
  failed connects so a dead owner cannot lock the shared socket out.

- Cross-instance cancel. cancel_job/stop for a run stamped by another
  instance marks the row cancelled with a conditional update (only while
  non-terminal — it cannot clobber the owner's completed outcome) and
  publishes on a control bus: Postgres LISTEN/NOTIFY on
  nodetool_job_control, in-process emitter under SQLite. LISTEN needs a
  session-pooled/direct connection, so the listener opens its own client
  from NODETOOL_JOB_CONTROL_DATABASE_URL / DIRECT_URL /
  DATABASE_DIRECT_URL; and because a pooler can eat notifications
  silently, each instance also polls its own running sessions' rows
  (NODETOOL_JOB_CANCEL_POLL_MS, default 15s) and cancels any that read
  cancelled — the bus makes cancels immediate, the poll makes them
  certain. Rows with no runner_instance (HTTP/trigger/MCP runs, which
  hold no session anywhere) keep the old not-found answer.

The web WebSocketManager gains a per-connect urlProvider (with a
generation guard so teardown during URL resolution cannot orphan a
socket); GlobalWebSocketManager builds the resume hint from the running
workflow stores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6LtxKGYqd7tnK9MdopkHb
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