Skip to content

feat(server): SSE heartbeat on streaming path (#47) - #49

Merged
dtzp555-max merged 8 commits into
mainfrom
feat/47-sse-heartbeat
Apr 24, 2026
Merged

feat(server): SSE heartbeat on streaming path (#47)#49
dtzp555-max merged 8 commits into
mainfrom
feat/47-sse-heartbeat

Conversation

@dtzp555-max

Copy link
Copy Markdown
Owner

Summary

Opt-in SSE heartbeat on the streaming path (CLAUDE_HEARTBEAT_INTERVAL env, ms; 0=disabled, default). Emits : keepalive\n\n comment frames on idle windows (reset on every real chunk), covering both the pre-first-byte pause and mid-stream tool-use pauses. Also adds X-Accel-Buffering: no on SSE responses so heartbeats survive nginx/Cloudflare default buffering. Addresses #47 without re-introducing any v3.3-era client-killing timer.

Claude Code Alignment Evidence (REQUIRED)

  • Corresponding cli.js reference. N/A — see next box.

  • If cli.js does not perform this operation, I have stated this explicitly below and justified the scope under ALIGNMENT.md Rule 2.

    cli.js does not perform SSE response shaping. claude -p speaks newline-delimited JSON to stdout, not SSE. SSE is an OCP-owned translation layer that converts that stdout stream into OpenAI-compatible data: chunks. This PR adds an application-layer liveness signal (comment frames) on that existing translation layer. Per AGENTS.md ("OCP forwards, observes, and multiplexes traffic that cli.js already emits"), response shaping is in-scope for OCP; per ALIGNMENT.md Rule 2, no new endpoint is introduced. See design doc at docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md §"ALIGNMENT.md disposition".

  • Commit message citations. Every commit that touches server.mjs includes the line cli.js citation: N/A — SSE response shaping is OCP translation layer. The two doc-only commits (30753e3, 6e7ec19) and the release-kit commit (eedf176) do not touch server.mjs.

Type of change

  • Bug fix
  • Feature (new opt-in behavior on an existing translation layer; no cli.js surface change)
  • Refactor
  • Deletion
  • Documentation / governance

Reviewer checklist

  • The implementer confirmed cli.js does not perform SSE response shaping; the Rule-2 justification is recorded in the spec and in every server.mjs commit message. Independent reviewer opens cli.js separately if they want to re-verify the absence.
  • CI alignment.yml run — pending CI.
  • Author is not self-approving. This PR is merged only after fresh-context reviewer approval (two already completed — see "Verification" below).
  • Scope justification per ALIGNMENT.md Rule 2 has been reviewed.

Related

  • ALIGNMENT.md Rule(s) invoked: Rule 2 (no invention — justified: SSE translation layer, not a new endpoint)
  • Related issue: feat: add streaming heartbeat/keep-alive during long Claude CLI operations #47 (original request)
  • Out of scope, filed separately: bug(streaming): CLAUDE_TIMEOUT path does not res.end() or emit SSE error frame #48 (server.mjs:480-489 dangling-client timeout behavior)
  • Design doc: docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md
  • Implementation plan: docs/superpowers/plans/2026-04-25-47-sse-heartbeat-plan.md
  • Historical lesson reference: v3.3 single-timeout refactor (commit 3843ec8) — the heartbeat in this PR is an observability hint only; its onFire callback only writes and re-arms, never calls res.end() / proc.kill / throws. This discipline is what the v3.3 lesson requires.

User-visible change self-check

  • This PR has user-visible changes → README § "Streaming heartbeat" and env var table row added for CLAUDE_HEARTBEAT_INTERVAL. CHANGELOG v3.12.0 entry documents the feature and the X-Accel-Buffering: no header.

Privacy self-check

  • No real names / handles introduced.
  • No literal personal paths.
  • No personal machine hostnames.
  • No personal email addresses beyond noreply@anthropic.com (LLM co-author trailer).

Grep evidence (run locally before push, plus blacklist re-run):

$ git diff main...feat/47-sse-heartbeat -- server.mjs | grep -iE 'api/oauth/usage|api/usage'
(empty — blacklist clean)

$ git diff main...feat/47-sse-heartbeat | grep -iE 'taodeng|Tao Deng|@gmail\.com|@yahoo|Taos-Mac|MacBook-Pro|C:\\Users\\|/Users/[a-z]|/home/[a-z]'
(three hits, all are the literal regex pattern embedded inside docs/superpowers/plans/…plan.md — meta-pattern, not real identifiers)

$ git log main..feat/47-sse-heartbeat --format='%an <%ae>' | sort -u
dtzp555-max <taodeng1977@gmail.com>     # project's public committer identity

Independent review (Iron Rule 10)

Two fresh-context opus reviewer runs were completed on this branch prior to PR open:

  1. Initial review (pre-Phase-3). Verdict APPROVE against the 12-item checklist covering D1–D6, scope lock, ALIGNMENT, privacy, LOC budget, and heartbeat-cannot-abort invariant. Recommended KEEP_BUMP on the ocp-plugin/* version files (aligning them to the main package.json SPOT per CLAUDE.md release-kit overlay; sibling plugin files had drifted to 3.3.1 while main was 3.11.x).

  2. Delta review of db11105 (post-Phase-3 fix-up). Verdict APPROVE. Phase 3 runtime verification surfaced that the D4 eager ensureHeaders() combined with the pre-existing if (!ensureHeaders()) return; guard inside stdout.on('data') silently dropped every content chunk (the headersSent and dead-socket branches were conflated into a single return false). Fix splits the two conditions. Reviewer also noted this fix closes a latent multi-chunk bug that existed on main but was masked because claude CLI typically emits its JSON body in a single stdout burst.

Verification (Phase 3 — cloud-backed manual smoke + negative test)

Run against the Mac rig with v3.12.0 from this branch. All identifiers sanitized; $OCP_KEY substituted for the real bearer; session shown as captured.

Positive test (CLAUDE_HEARTBEAT_INTERVAL=2000, 2000ms interval for fast observation):

$ curl -N -sS --max-time 90 -D /tmp/headers.txt \
    -H "Authorization: Bearer $OCP_KEY" -H "Content-Type: application/json" \
    http://127.0.0.1:3456/v1/chat/completions \
    -d '{"model":"claude-opus-4-7","stream":true,
         "messages":[{"role":"user","content":"Write a 250-word essay on how SSE comment frames work in the EventSource spec."}]}' \
    > /tmp/stream.txt

elapsed: 21s   stream size: 2392 bytes

$ grep -i 'x-accel-buffering' /tmp/headers.txt
X-Accel-Buffering: no

$ grep -c '^: keepalive$' /tmp/stream.txt
10

$ grep -m1 '^: keepalive$' /tmp/stream.txt | od -c | head -1
0000000    :       k   e   e   p   a   l   i   v   e  \n  \n        # literal ": keepalive\n\n"

$ grep -c '"content"' /tmp/stream.txt
(>0 — content chunks streamed through)

$ grep -c '^data: \[DONE\]$' /tmp/stream.txt
1

$ grep heartbeat_active /path/to/proxy.log | tail -1
{"ts":"…Z","level":"info","event":"heartbeat_active","session":null,"intervalMs":2000}

Negative test (CLAUDE_HEARTBEAT_INTERVAL unset, i.e. default 0):

elapsed: 30s   stream size: 2399 bytes   (content streams normally)

$ grep -i 'x-accel-buffering' /tmp/headers-off.txt
X-Accel-Buffering: no          # still present — D5 is unconditional

$ grep -c '^: keepalive$' /tmp/stream-off.txt
0                              # zero heartbeats, as expected

Release kit

  • package.json 3.11.1 → 3.12.0
  • ocp-plugin/package.json 3.3.1 → 3.12.0 (re-aligned to SPOT; reviewer-approved)
  • ocp-plugin/openclaw.plugin.json 3.3.1 → 3.12.0
  • CHANGELOG.md: new ## v3.12.0 section with Features / Behavior changes / Config additions
  • README.md: env var table row + new "Streaming heartbeat" section

Tag push to v3.12.0 triggers .github/workflows/release.yml automatically (per CLAUDE.md release-kit overlay, auto_create_on_tag_push: true).

🤖 Generated with Claude Code

dtzp555-max and others added 8 commits April 25, 2026 08:45
Draft spec for an opt-in idle-watchdog SSE heartbeat covering both
pre-first-byte and mid-stream silent windows. Default disabled,
controlled by CLAUDE_HEARTBEAT_INTERVAL. Targets ~40 LOC.

Decisions captured: D1 whole-stream reset-on-byte; D2 SSE comment
frame; D3 default disabled; D4 relocate ensureHeaders() to post-spawn;
D5 X-Accel-Buffering: no on both SSE header sites; D6 single log line
per affected request.

Scope-locked: does not touch CLAUDE_TIMEOUT semantics, the separate
server.mjs:480-489 dangling-client bug, issues #41/#42, or the
non-streaming path.

Includes privacy preflight and cloud-testing plan per maintainer
feedback.

Refs: #47

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 phases: pre-work (file 480-bug), implementation (5 tasks on
feat/47-sse-heartbeat), opus fresh-context review, cloud verification
on Mac rig, privacy preflight, PR+release.

Each implementation task carries concrete code, syntax check, and a
scoped commit message. LOC budget enforced in Task 1.6 gate (~45
server.mjs lines max). Reviewer checklist scopes scope-lock, ALIGNMENT,
privacy, and heartbeat-cannot-abort discipline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per design doc (refs #47). Helper is a per-request idle watchdog that
emits `: keepalive\n\n` SSE comment frames; returns a {reset, stop} handle.
No wiring yet — helper is unused, safe to commit in isolation.

cli.js citation: N/A — SSE response shaping is an OCP-owned translation
layer, not a cli.js operation. See AGENTS.md and ALIGNMENT.md Rule 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the ensureHeaders() call from "on first stdout byte" to
"immediately after successful spawn." This is a prerequisite for the
heartbeat covering the pre-first-byte silent window (the 'processing
large contexts' failure mode in #47).

Behavioral consequence: the narrow "spawn succeeded but subprocess died
before any byte" branch at server.mjs:610-611 becomes effectively dead
in the common case. The post-headers SSE-stop path (612-619) handles
it instead. The branch remains defensively for the client-closed-before-
ensureHeaders race.

Isolated commit per design doc §D4 so reviewer can focus on this one
behavior change.

cli.js citation: N/A — SSE header emission is OCP response-shaping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sendSSE() accepts optional hb handle and calls hb.reset() before write
- callClaudeStreaming starts heartbeat after ensureHeaders() and passes
  hb to the three streaming sendSSE call sites
- All three exit paths (proc close, proc error, res close) call
  hb.stop() to guarantee timer cleanup; no-op handle when disabled
  means zero runtime cost when CLAUDE_HEARTBEAT_INTERVAL=0

Heartbeat never aborts — only writes comment frames and re-arms. Aligns
with v3.3 timeout discipline (single CLAUDE_TIMEOUT, no secondary
client-killing timers).

cli.js citation: N/A — SSE response shaping is OCP translation layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…47)

nginx (and many LBs / Cloudflare) default to proxy_buffering=on, which
would buffer heartbeat comment frames indefinitely and defeat the
feature silently. This header hints no-buffering; other stacks ignore
it. Applied at both SSE header sites (real streaming + cache-hit).

cli.js citation: N/A — response header shaping is OCP translation layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the release-kit companion files per Iron Rule 5.2 / 11 example:
version bump across package.json + ocp-plugin + openclaw.plugin.json,
CHANGELOG v3.12.0 section, README env var row + "Streaming heartbeat"
explainer.

Tag push to v3.12.0 triggers .github/workflows/release.yml to create
the GitHub Release automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 smoke test revealed every content chunk was being dropped
after the D4 eager ensureHeaders() call: the stdout.on('data') guard
`if (!ensureHeaders()) return;` early-returned on every chunk because
ensureHeaders returned false for the already-sent case (conflated with
the dead-connection case).

Split the two conditions explicitly: return false only when res is
ended/destroyed; return true when headers are (already or now) sent.
This also fixes a latent multi-chunk bug on main that was masked
because claude CLI typically outputs in one stdout chunk.

Verified: node -c server.mjs; subsequent re-run of Phase 3 smoke test
now sees streaming content chunks + heartbeat frames.

cli.js citation: N/A — SSE response shaping is OCP translation layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dtzp555-max
dtzp555-max merged commit b871b72 into main Apr 24, 2026
2 checks passed
@dtzp555-max
dtzp555-max deleted the feat/47-sse-heartbeat branch April 24, 2026 23:09
dtzp555-max added a commit that referenced this pull request May 4, 2026
…rtbeat-field-check.sh (#55)

Two related changes — both repo-hygiene, no behavior impact.

### `.gitignore` (new)

Ignores runtime artifacts that should never be tracked:

- `logs/` and `*.log` — proxy.log, last_send.log, heartbeat-field-check log
- `node_modules/` — dependency cache
- `.env`, `.env.*` — local secrets/config
- `.DS_Store`, `*.swp`, `*~` — editor/OS scratch

`logs/` was previously untracked-but-present in working trees; the new
ignore makes that intent explicit and prevents accidental commits.

### `scripts/heartbeat-field-check.sh` (commit existing untracked file)

This script was authored as a one-shot field-evidence gatherer for the
v3.12.0 SSE heartbeat work (PR #49 / issue #47). It fired successfully on
2026-05-02 09:00 Australia/Brisbane via launchd
(`~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist`) and posted a
summary comment to issue #47.

Useful tooling pattern (one-shot field check + launchd schedule + dry-run
flag), worth keeping under version control rather than letting it die in
an untracked working tree.

Verification: `git status` clean after both adds.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
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