Skip to content

fix(agent): TextEncoder byte-length in modelMessages (Workflows has no Buffer) - #939

Merged
btipling merged 2 commits into
mainfrom
fix/model-messages-no-buffer
Sep 4, 2026
Merged

fix(agent): TextEncoder byte-length in modelMessages (Workflows has no Buffer)#939
btipling merged 2 commits into
mainfrom
fix/model-messages-no-buffer

Conversation

@btipling

@btipling btipling commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Summary

Production durable turns fail immediately after the first model round:

{ tag: "invincible.turn.loop", status: "failed", elapsedMs: 12369 }
Turn ended · error · Buffer is not defined

POST /.well-known/workflow/v1/flow on main (Workflows canvas) — not a Node Function.

#937 added buildModelMessages to derivePersistFold, which the 'use workflow' loop calls on every persist. That helper used Buffer.byteLength. Vercel Workflows has no Node Buffer global. The throw is caught by runTurnLoop's outer catch, persist is skipped, and the operator sees Turn ended · error · Buffer is not defined.

Fix

TextEncoder for UTF-8 byte length — same pattern as usageSummary.ts / sessionRepository.utf8ByteLength. Do not import sessionRepository into the workflow graph.

Tests (+3)

  • modelMessages.test.ts source-lock: no Buffer identifier in executable code
  • modelMessages.test.ts runtime: hide globalThis.Buffer, projection + byte-cap still work
  • turnLoop.test.ts: derivePersistFold does not throw when Buffer is absent (the production call site)

Gates

  • npm run typecheck
  • vitest run lib/agent/modelMessages.test.ts lib/workflows/turnLoop.test.ts — 123 passed (was 120: 8 + 112)

Ops

Cloud ops N/A — code-only. Needs a Production deploy of this PR to unbrick durable turns.

Refs #936 #549

…o Buffer)

#937's derivePersistFold runs on the Workflows canvas and called
Buffer.byteLength. Vercel Workflows has no Node Buffer, so every persist
threw ReferenceError: Buffer is not defined and the loop failed the turn
(~12s in, after the first model round).

Use TextEncoder like usageSummary / sessionRepository.utf8ByteLength.
Lock with a no-Buffer runtime test + source-lock.

Refs #936 #549
@btipling btipling self-assigned this Sep 4, 2026
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
invincible Ignored Ignored Sep 4, 2026 4:17am UTC

Request Review

@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 #939

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainfix/model-messages-no-buffer · 3 files · canvas Buffer.byteLengthTextEncoder
Lenses run: L1, L5, L6, L8 (skip: L2 no secrets/runner/API boundary; L3 no UI/Wasm; L4 no CI/artifact/workflow change; L7 no config/host hardcoding; L9 no UI)
AGENTS.md read: yes (feature-divide + SECURITY skimmed; this diff is workflow-canvas agent-loop only)

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Minor L6 Canvas Buffer lock is file-local (lib/agent/modelMessages.test.ts source-lock greps only modelMessages.ts). Production closure is reachableImports('lib/workflows/turnWorkflow.ts') (already used by the B11 static-graph test). Add Buffer.byteLength(JSON.stringify(fold)) in lib/workflows/turnLog.ts or turnLoop.ts (both canvas-reachable, not 'use step' leaves). This PR's new tests stay green. Next durable persist throws Buffer is not defined — the same ~12s production brick this PR is fixing. Defender: the runtime test hides globalThis.Buffer then calls derivePersistFold, so a Buffer on that path is caught. That does not cover other canvas helpers (logTurnLoop, formatTurnSse, modelFinish, workPath) that run on the same VM. Graph walk of this SHA: zero Buffer hits in non-step closure — so this is a lock gap, not a live leak. high
Nit L6 lib/workflows/turnLoop.test.ts · Buffer-absent derivePersistFold test does not assert the hide worked. If Reflect.deleteProperty(globalThis, 'Buffer') is a no-op, the test passes on Node while proving nothing about the Workflows VM. Sibling modelMessages.test.ts does expect(typeof g.Buffer).toBe('undefined'). Defender: on current Node 22 Buffer is a configurable getter; delete works (probed). The sibling assertion would fail the suite if delete ever stopped working. Still a copy-paste hole on the production call-site test. high

Residual risk

Workflows VM (vercel/workflow packages/core/src/vm/index.ts) injects TextEncoder / TextDecoder and does not inject Buffer — so the swap matches the actual sandbox, not just usageSummary / sessionRepository (those are not canvas-reachable). Current turnWorkflow closure is Buffer-free. encoder.encode(s).length allocates a Uint8Array per byte-cap JSON.stringify in buildModelMessages's drop-oldest loop (cap 8 MiB × up to 4096 rows); Buffer.byteLength did not. Unlikely to OOM vs the stringify itself. Durable-turn int CI is Node, not the canvas VM — it cannot catch this class. Production stays bricked until this SHA is deployed to Production (Preview was ignored).

Merge guidance

PASS WITH NOTES: safe to merge from this attack; nits optional. Do not treat green durable-turn int as canvas proof. Ship a Production deploy after merge — that is the unbrick.

What was not attacked

Live Production Workflows canvas (no deploy of this SHA). Self-hosted runner. Gateway. Wasm/bridge. 'use step' bodies (persistStepmessageCheckpoint still uses Buffer.byteLength — legal: step VM is Node).

Adversarial-review #939: the modelMessages source-lock was file-local.
Walk turnWorkflow's reachableImports (skip 'use step' leaves) and fail
if executable code still names Buffer. Also assert the Buffer-absent
derivePersistFold test actually hid the global.

Refs #936 #549
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