Skip to content

fix(workflows): hold one writer for durable live SSE - #856

Merged
btipling merged 1 commit into
mainfrom
plan/durable-stream-writer-hold
Aug 26, 2026
Merged

fix(workflows): hold one writer for durable live SSE#856
btipling merged 1 commit into
mainfrom
plan/durable-stream-writer-hold

Conversation

@btipling

Copy link
Copy Markdown
Owner

Summary

Durable live SSE was calling getWritable() per token. Each call opens a new Workflows serialize pipe to the same Redis stream; chunks land out of order. The host appends whatever still parses → word-salad assistant, frozen thinking, dead reader.

This PR holds one writer for the model-step burst (withDefaultStreamWriter around generateOneRound). Sparse loop writes (writeTurnSsewriteOnDefaultStream) stay one write per step.

Implements #855 (HANDOFF-READY). Source bug #854.

Fixes #855
Refs #854
Refs #794
Refs #850

What changed

  • lib/workflows/turnSseWrite.tswithDefaultStreamWriter (one getWritable / held getWriter / releaseLock in finally; does not close).
  • lib/workflows/modelGenerateStep.ts — live onEvent writes through that held writer. No writeOnDefaultStream in this step.
  • docs/agent-stream.md + AGENTS.md — one writer per model round, not per token.

Tests

Count
Baseline (main @ 2f77992) 2346 passed / 146 files / failed=0
This PR (targeted + --changed) +5 new rows in lib/workflows/turnSseWrite.test.ts
Expected full suite 2351 passed / 147 files

New rows: N writes → 1 pipe; releaseLock on fn throw; releaseLock on mid-burst write throw; ordered payloads concatenate; sparse writeOnDefaultStream still per-call.

npm run typecheck green. Targeted: turnSseWrite / turnWorkflow / turnLoop / staticGraph / turnSseFormat 68 passed. vitest run --changed 66 passed / failed=0. Full vitest run is the merge-pr gate.

Out of scope

Operator smoke (after Preview)

Long durable Busy turn (/plan_review or reasoning): thinking grows in order, assistant is readable English, turn does not die mid-sentence while Workflows is still working.

Per-token getWritable() opened a new Redis pipe each delta and reordered
the durable stream. Hold one writer around generateOneRound.

Fixes #855
Refs #854
@btipling btipling self-assigned this Aug 26, 2026
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
invincible Ignored Ignored Aug 26, 2026 12:56am

Request Review

Copy link
Copy Markdown
Owner Author

implement-plan for #855.

  • Held one Workflows writer for the model-step burst (withDefaultStreamWriter). Per-token getWritable() is gone from modelGenerateStep.
  • writeOnDefaultStream remains the sparse writeTurnSse path.
  • Living docs: docs/agent-stream.md + AGENTS.md where-to-change.

Verify: npm run typecheck exit 0. Targeted workflow tests 68 passed. vitest run --changed 66 passed / failed=0. Full suite is the merge-pr gate (expected 2346+5 = 2351).

Not merged. Next: adversarial-review on this PR.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #856

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainplan/durable-stream-writer-hold · 8 files · durable live-SSE writer hold
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/dual-chat; L4 no GHA/artifacts; L9 no UI)
AGENTS.md read: yes (SSE where-to-change, Workflows, DI/test-cost). SECURITY.md + docs/feature-divide.md also read.

Findings

None. Attack did not breach with current evidence.

Candidate breaks that died in self-refutation (not findings): getWritable() throw moving outside onEvent is still closed by runTurnLoop catchfail() + turnWorkflow finally closeTurnSse; write_error from generateOneRound still releaseLocks because the helper awaits fn and finallys; writeTurnSse cannot race the held lock because it is a later 'use step' and the SDK pushes state.promise onto ctx.ops (flush on releaseLock, poll 100ms, pendingOps === 0); BYOK/assemble stay outside the hold; DI/cost gate clean (mocked workflow, no PGlite/Redis).

Residual risk

Hold-across-streamText is the SDK chat example (streamTextStep keeps one getWriter for the UI-message read loop) and getWritable() in a step really does construct a new serialize pipe every call (@workflow/core step/writable-stream.js). That is the #854 cause this PR removes. It is not proven on a live Workflows Preview: if the VM rejects a writer held across generateOneRound awaits, the named error is Not supported in workflow functions (or equivalent) — plan forbids shipping the per-line fallback unless that error is observed. Replay of modelGenerateStep would re-append tokens to the same Redis stream (pre-existing). Operator close-out for #854 remains a long durable Busy turn after deploy (thinking in order, English assistant, no mid-sentence death while the run is still working). This review did not execute vitest or a live Workflows run.

Merge guidance

  • PASS WITH NOTES: safe to merge from this attack; nits optional (none logged)
  • Do not treat this as Preview proof of #854. merge-pr still owns the full vitest run gate

What was not attacked

Live Vercel Workflows / Redis stream, prod Gateway, host parseSseChunk / growAssistant / Wasm (out of scope by plan), E19 attach (#813), EOF detach (#853), DO runner, full vitest process.

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.

plan: hold one Workflows writer for durable live SSE (source #854)

1 participant