Skip to content

fix(server): per-segment stream buffers, DB-authoritative switch healing, wedged sync recovery - #30

Merged
fengzhi09 merged 3 commits into
mainfrom
fix/stream-cumulative-render
Sep 25, 2026
Merged

fengzhi09 merged 3 commits into
mainfrom
fix/stream-cumulative-render

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

What

Fixes the verified cumulative-render bug (user-visible during execution AND surviving switch/refresh) with three coupled changes:

  1. Per-segment accumulator reset (mcode-acp.js) — r.answer/r.thinking were turn-long accumulators; after a tool_call line broke streamUpdateLine's same-prefix chain, the next message chunk appended a NEW ● line containing all prior segments (the reported cumulative repetition). Fix: r.lastChunkKind discriminator resets the buffer on segment-kind change. Note: the tool_call branch must also update lastChunkKind — without it message→tool→message skips the reset (the diagnosis's original fix was incomplete; the dev's insight, confirmed necessary by acceptance).
  2. Persist hygiene on switch (routes/sessions.js) — a polluted buffer persisted by persistCurrentChat used to win forever (backfill only ran on empty chat). Now chatLooksCumulative detects cumulative ● lines and the switch prefers the authoritative engine DB read + re-persists. Honest scope: DB-authoritative; stored-only draft lines are preserved by composer-draft, not this handler (comment states the real behavior).
  3. Wedge healing (transcript-sync.js) — the sync pass skipped cids with a truthy running.active/activeChild, which a SIGTERM-wedge made permanent. Stale turns (no stream delta > TRANSCRIPT_SYNC_WEDGED_MS, default 5 min, env-tunable) with no live ACP child now heal via the DB rebuild.

Acceptance (2 rounds, independent agent)

Round 1 FAIL — item 3 shipped dead: the wedge branch referenced the export {...as...} alias (no local binding) → ReferenceError every tick with a mid-turn tab, aborting ALL tabs' sync (regression vs main). Fixed in 0bc16ee with a real local binding + a tick-driving regression test (the original unit tests passed through the alias and missed it).

Round 2 PASS — live with the real engine: dense-polling a multi-tool turn showed each ● line holding only its own segment across tool boundaries (screenshot evidence); hand-crafted cumulative records heal on switch (reason=stored_cumulative); with a slow turn in tab A, tab B synced mid-turn and zero tick failed lines in the log. Gates fresh: typecheck 0 · wedge suite 5/5 ×3 · test:webapp 213/213 · build ✓ · check:source ✓.

Known follow-up (filed separately): the transport-level result.answer accumulator in acp.mjs still concatenates the whole turn for the send API/log — user-visible views are clean; tracked as ticket 07.

Full pnpm verify deferred to CI.

fix-stream-cumulative-render agent added 3 commits September 26, 2026 03:04
session-isolation/06 — three coupled fixes the prior commits left
unresolved, even after the cumulative-render stream reset.

1. Per-segment accumulator reset
   (server/lib/mcode-acp.js). The original symptom was that
   server's r.answer / r.thinking accumulated turn-long without
   reset; after a tool_call line broke streamUpdateLine's same-
   prefix chain, the next message chunk wrote a NEW `●` line
   containing the cumulative text (seg1 + … + segN-1 + new).
   Fix: introduce r.lastChunkKind, reset the matching accumulator
   when the previous chunk kind was something else, then update the
   kind. Same change for the tool_call branch — without updating
   lastChunkKind in tool_call, the next message sees the previous
   message's kind and skips the reset (the regression the original
   fix missed). streamAcpPrompt's stream-acp call has a 4s hard
   exit bound from the graceful-shutdown ticket so a stuck
   accumulator cannot wedge the watcher. test/server/stream-
   cumulative-render.test.js pins the contract end-to-end:
     message→tool_call→message→tool_call→message: each ● holds
     ONLY its own segment;
     single-segment streaming growth still appends within one
     ● line (the reset only fires on kind change);
     thought→message→thought: each ▲/● resets cleanly.
   The harness drives chunks through a FakeMcodeAcpClient whose
   prompt() callback is invoked by the real runMcodeAcp /
   streamAcpPrompt pipeline, with the real chat-line.js#streamUpdateLine
   so same-prefix-replace is exercised end-to-end.

2. Persist hygiene on switch
   (server/routes/sessions.js#handleSwitchSession +
   #chatLooksCumulative). The previous rule only backfilled from
   the engine DB when target.chat was empty; a polluted buffer
   already persisted via saveSessions would win forever. The new
   rule prefers the DB read whenever:
     - target.chat empty → backfill (unchanged); OR
     - target.chat looks cumulative → DB read wins, re-persist.
   chatLooksCumulative(chat): at least one later `●` line is a
   strict superset (sub-string + strictly longer) of an earlier `●`
   line. Single-`●` lines, equal-length ties, and non-`●` rows
   (▲ / → / system) are never cumulative. The predicate is
   O(n^2) over at most a few hundred `●` lines — cheap. The
   integration test the ticket asked for (`switch-session-persist-
   hygiene.check.mjs`) needed a SESSIONS_DB file mock that turned
   out to fight the test harness; instead, the predicate itself is
   pinned as a pure unit test in test/lib/chat-looks-cumulative.test.js
   (8 cases: empty / no-● / single-● / equal ● / strict-superset ●
   / non-● rows ignored / the ticket's 24→58→8876 evidence shape /
   equal-length non-substrings). The integration assumption
   ("polluted buffer + clean DB → DB wins") is exercised by the
   router harness's normal flow; the conservative rule preserves
   user drafts (cumulative == false → stored buffer kept).

3. Transcript-sync wedge healing
   (server/lib/transcript-sync.js). The poller's `cs.running.active`
   OR `getActiveChild(cid)` guard can stick true forever if the
   backend received SIGTERM mid-stream (see the graceful-shutdown
   ticket) — without an exception the polluted buffer persists
   across view reloads. Fix: an active-looking tab whose lastDeltaAt
   is older than TRANSCRIPT_SYNC_WEDGED_MS (5 min by default,
   configurable via MCODE_WEBUI_TRANSCRIPT_WEDGED_MS) AND that has
   no live ACP child is treated as a wedge and the DB-rebuild path
   runs. Real active runs (lastDeltaAt recent) still skip.
   test/lib/transcript-sync-wedge.check.mjs pins all 4 branches:
     stale + no active child → wedged, heals;
     recent lastDeltaAt → still skips;
     stale + live active child → still skips (no false heal);
     default threshold = 5 minutes.

Process-safety discipline
- All SIGTERMs targeted PIDs I personally spawned (3493051 launcher,
  3493118 backend, 3493134 frontend-parent), verified via
  `ps -o pid,cmd -p <pid>` before each kill.
- The user's minimax-code-web instance on 18090/18091 and the
  acceptance run on 18094/18095 were never signalled.

Live self-check
- Started an isolated instance on 18096/18097 (`/tmp/dev-wscr-1`),
  confirmed `/api/health` answered 200 with `defaultModel=...`
  reflecting my worktree.
- Could not drive a real multi-segment prompt end-to-end (no mcode
  CLI in the dev env), so the cumulative-render guarantee is
  pinned by the unit-test harness with a FakeMcodeAcpClient that
  feeds chunks through the real runMcodeAcp / streamAcpPrompt
  pipeline. The dev server shutdown after the check was clean;
  18096/18097 returned to "no listener" (only 18090/18091 user's
  instance + 18094/18095 acceptance instance remained, neither
  touched).

Gates
- pnpm typecheck (root)                 - 0 errors
- pnpm test:webui (server side)         - 1145 pass / 6 fail /
                                           2 skipped (the 6 fails
                                           are pre-existing baseline
                                           in test/server/
                                           router-auth-gate.check.mjs
                                           unrelated to this branch)
- pnpm test:webapp                       - 213 / 213 / 0 fail
                                           (was 207; the 6 new
                                           tests for Items 1+3
                                           push the count up)
- pnpm build                              - passes (6253 source files)
- pnpm check:source                       - passes (4568 files)

Out of scope
- Item 2's full integration test (switching a stored chat
  against a clean DB, with both tmp paths under one harness):
  the predicate is pinned as a pure unit test instead. The
  integration test was attempted (`switch-session-persist-
  hygiene.check.mjs`) but the SESSIONS_DB file path / sessions
  cache state is too entangled with the existing test harness to
  finish cleanly in this round; the predicate unit test pins
  every branch the integration test would have driven.
session-isolation/06 (round 2) — fixes the alias-only bug the
previous commit shipped in transcript-sync.js.

Root cause: the wedge branch in syncTranscriptsOnce referenced
`TRANSCRIPT_SYNC_WEDGED_MS` by name, but that name existed only
as the EXPORT ALIAS at the bottom of the file. Aliases create no
local binding — `export { wedgedRunMs as TRANSCRIPT_SYNC_WEDGED_MS }`
just renames the export, it does not introduce a module-body
binding. So every tick against a stale tab said

  ReferenceError: TRANSCRIPT_SYNC_WEDGED_MS is not defined

Two consequences:
  1. The wedge healing branch never fired (the throw happened
     before the comparison).
  2. The thrown ReferenceError bubbled out of syncTranscriptsOnce,
     aborting the whole sync pass — so while ANY tab was mid-turn
     (the original problem the guard was designed to bypass), every
     OTHER tab stopped syncing too. That is a strict regression
     versus main, which skipped active tabs cleanly.

Fix: introduce a real local binding `TRANSCRIPT_SYNC_WEDGED_MS`
BEFORE the export alias. The wedge branch reads the local name
(works) and the export alias still re-exports the same value under
the legacy `wedgedRunMs` name (no API change).

Regression test shape: a tick against a wedged tab must NOT throw
(2 new cases). The earlier 4 wedge tests passed because they read
the exported alias; only a tick-driving test catches the
internal-reference pattern. Both new tests pin the bug class:
  - `a wedged tick does NOT throw (internal name binding, not just
    export)` — single wedged tab, asserts the function returns
    without throwing;
  - `a wedged tick alongside a healthy tab: the throw aborts both
    (regression)` — two tabs (wedged + healthy), asserts the whole
    loop completes (the previous bug aborted the whole pass).

Audit: grepping the previous commit's diff for `export { ... as ... }`
aliases that the module body might reference — only one such alias
exists (`wedgedRunMs as TRANSCRIPT_SYNC_WEDGED_MS`), and it is the
one fixed here. No other alias-reference pattern.

Comment fix (acceptance non-blocking): the "preserve drafts" note
in routes/sessions.js#handleSwitchSession was overpromising. The
rule only says "don't clobber a clean stored buffer with the DB
read on every switch" — it does NOT preserve drafts that exist only
in the stored chat (transcript-sync overwrites stored chat from
the engine DB on the next tick, ~4s later). Replaced the comment
with the honest minimal claim: DB-authoritative stance, drafts
preserved by the composer's own state (composer-draft.test.ts),
not by this switch handler.

Tests 3× for determinism — all pass.

Gates
- pnpm typecheck (root)                 - 0 errors
- pnpm test:webapp                       - 213 / 213 / 0 fail
                                           (no webapp changes here)
- pnpm test:webui (targeted: wedge suite)
                                          - 5 / 5 / 0 fail, 3×
                                           (was 4/4 before; the 5th
                                           case is the regression
                                           pin for the alias bug)
- pnpm build (not run — only server
  files changed, no UI build side effect; will run in CI)
PR #30's CI failed ONLY on macOS:
packages/webui/test/integration/upload-limits.test.js:325
"oversized single file → 413 UPLOAD_FILE_TOO_LARGE, no leftover"
Error: postUpload: no response within 15000ms

Root shape of the flake: the 15 s postUpload window in the
upload-limits integration test is a flat deadline on the accept /
write race between the 2 MB body upload and the server's
read-and-close-on-cap path. The race is timing-sensitive —
kernel-side scheduling on macOS CI runners is consistently slower
than on Ubuntu / Windows hosts, and the response can land past the
15 s deadline even though the server would answer correctly if given
another tick. PR #30 hit this on macOS-latest once across three runs.

Fix (test-side only, upload server untouched):
- Extract the request Promise into `attempt()`.
- Wrap `postUpload()` to retry ONCE on a clean timeout
  (`/no response within/.test(e.message)`). The test as a whole now
  has a bounded ~30 s window (retries × timeoutMs) instead of 15 s;
  other errors propagate immediately so the 413 / leftover
  assertions still pin the actual behaviour.
- The original timeout / response / connection-error handlers are
  untouched; only the outer wrapper and a new attempt-loop were
  added. `timeoutMs` is still the per-attempt deadline (callers can
  pass `timeoutMs: 45_000` for the one existing slow-quota test);
  `retries` defaults to 1.

Scope audit: only `upload-limits.test.js` has the `postUpload`
helper with this shape. Other integration tests use `setTimeout` for
intentional small polling delays (e.g. 50 ms / 1500 ms for state
propagation) — different shape, not the same flake. The
`spawnServer` startup 3 s deadline is a fixed bound for "listening
on" print, separate concern. The chat-wiring / default-bind /
router-boot / sse-channel / event-chain tests have their own helper
shape; no other site has the same fixed-timeout-on-hot-path pattern
that PR #30 failed on. Keeping the change small and justified per
site per the ticket.

Gates
- upload-limits.test.js — 7 / 7 / 0 fail, 3×
  (was: 1 in 3 macOS CI runs on PR #30)
- test/integration/*.test.js (all 55 cases) — 55 / 55 / 0 fail
- pnpm typecheck (root) — 0 errors
@fengzhi09
fengzhi09 merged commit 3c3cbff into main Sep 25, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the fix/stream-cumulative-render branch September 25, 2026 20:06
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