Skip to content

bug: durable turns 3–6× slower than tab-owned /api/agent (10–20 min → ~1 hour) #892

Description

@btipling

Summary

Operator-visible: a coding turn that used to finish in 10–20 minutes on tab-owned POST /api/agent now takes ~1 hour on the durable path (POST /api/turnsturnWorkflow) after the D17 cutover.

Same work (same model, same sandbox tools). The hour is wall-clock tax on the Workflows loop, not slower DeepSeek.

Parent / umbrella: #794
Status: source bug — create-plan is a later turn. This will almost certainly split (≥2 issues) if a plan tries to land reconnect + persist cadence + step-arg size in one PR.

This is not covered by:

  • #872 / #880 — per-round tool batch (stopped N writeTurnSse Fluid steps per tool). Live tokens already use one held writer. The remaining hop is still one Function per model round + one per tool batch + one persist per cadence point.
  • #884 / #885 — persist oversize killing the turn. Different failure mode.
  • #849 — reader EOF folded as Turn ended.

Expected

A durable coding turn’s wall clock is dominated by model + tool time, same as /api/agent.

  • Workflows step/queue/replay overhead is a small constant, not 3–6× the turn.
  • Sandbox / MCP / BYOK / persona / skills are not rebuilt from scratch on every model round and every tool batch.
  • Step args stay deltas (architecture lock). The growing transcript does not ride every modelGenerateStep / persistStep call into the event log.
  • A 15-minute /api/agent coding turn is still ~15–20 minutes after D17, not ~60.

Actual (grounded on main @ 98f1a0f)

D17 (#811 / PR #841) cut runPrompt over to POST /api/turns. /api/agent is tests/JSON only.

Before: one Fluid Function, one sandbox/MCP/BYOK setup, in-process model↔tool loop, whole-turn ceiling maxDuration = 1800.

After: turnLoop drives isolated 'use step' Functions. A 20-round coding turn is ~60 Function hops:

Per round Step
model modelGenerateStep
first tools round persistStep (running) — user-line
tools toolExecuteStep (batch, #880)
after the batch persistStep (running) again
end terminal persist + writeTurnSse (done) + closeTurnSse

#794’s cost example (8 rounds + 20 tools + 1 persist = 29 steps) is already stale. Live runTurnLoop persists after the first tools model and after every successful batch.

Grounded seams:

  1. Replay between every step (platform). Vercel Workflows suspends the orchestrator and enqueues a replay on every 'use step' (vercel/workflow#1930). plan: backend-agents RESTART — client-independent turns (umbrella, ≥20 small issues + handoff) #794 already flagged replay slowing after 2k events / 1 GB. Observability → Workflows → time between steps is the tell: if the gap ≫ step CPU, this is the bulk of the hour.

  2. Full world rebuild on every model and tool step. assembleDurableToolWorld runs inside both modelGenerateStep and toolExecuteStep: decrypt GH token, load HTTP instance, resolveAgentSandbox + new sandbox client, MCP connect, HTTP runner, persona + skills, and BYOK again in the model step. The turns route even says the POST probe client is closed after start() because the in-step assemble helper opens its OWN client per step VM. /api/agent did this once. A 20-round turn now does it ~40 times.

  3. Growing transcript stuffed into step args (O(n²) event log). Architecture lock: step I/O is deltas; messages stay orchestrator-local. Live code passes the full reconstructed messages[] into every modelGenerateStep, and the full deltas + fold.checkpoint into every persistStep. Later rounds serialize a bigger conversation into the Workflows event log, then replay it on the next resume. Short turns stay fine; 10–20 min coding turns get slower as they go.

  4. Mid-turn persist is its own step, every round. persistOnce(false) after the first tools model and after every batch — Blob PUT + Redis overlay + another replay. Detach/refresh needs some persist; it does not need a Function hop after every round.

  5. modelGenerateStep still has default 3 retries. toolExecuteStep.maxRetries = 0 (correct — don’t replay mutations). modelGenerateStep and persistStep do not set it. SDK default is 3 retries. A long streamText that blips or hits a step timeout can re-run the whole LLM round.

  6. No whole-turn wall clock. Old path died at 30 min. Workflows only cap per step at 1800s, loop at 512 steps. A turn that used to get killed at 30 min can now run to 60.

writeTurnSse / closeTurnSse are still 'use step'. Live tokens are correctly held inside the model/tool step (withDefaultStreamWriter); loop done/error still pay an extra hop. That is not the old N-writes-per-token bug.


How to confirm (one slow Production run)

Vercel → Observability → Workflows, one hour-long coding turn:

  1. Step count — expect ~3× model rounds, not ~1×.
  2. Time between steps vs step duration — large gaps = replay/queue.
  3. assembleDurableToolWorld / sandbox / MCP at the start of every model and tool step.
  4. Step arg size growing each persist/model (messages / deltas / checkpoint).
  5. invincible.turn.model logs — duplicate rounds with the same shape ⇒ retries.

If (2)+(3) dominate, it is the step tax + reconnect. If later steps get slower, it is (3) the O(n²) args. If a round’s LLM time appears twice, it is (5).


Likely seam (for a later plan — do not implement here)

Direction, not a locked design. Highest leverage first:

  • Cache sandbox/MCP/BYOK across steps in the same run, or pass a handle instead of reconnecting every model and tool step.
  • Persist on a timer / every N rounds / terminal only — not a 'use step' after every batch.
  • Pass deltas into steps (as locked); keep messages[] orchestrator-local. Stop sending full deltas into persist — persist can read Blob.
  • Set maxRetries = 0 (or 1) on modelGenerateStep.
  • Collapse loop done/close into the terminal persist step so they are not extra 'use step' calls.

Do not wrap runAgentStream / streamText+execute in one step (forbidden). Do not cut back to /api/agent. Do not cancel the run on F5. Do not change Wasm/protocol.

If this is too large for one PR (it is), split along the table above. Reconnect (2) and persist cadence (4) are the wall-clock fire; step-arg size (3) is the “gets worse as the turn goes” fire.


Not this


DoD

  • A representative durable coding turn (the class that used to be 10–20 min on /api/agent) is no longer ~1 hour. Wall clock is dominated by model + tool time, not step hops / reconnect / replay.
  • Observability on a long run: step count is not ~3× model rounds from persist+assemble hops; time-between-steps is not the majority of the hour.
  • assembleDurableToolWorld is not a cold sandbox+MCP handshake on every model and tool step of the same run.
  • Step args stay bounded (deltas), not the growing messages[] / full deltas / checkpoint on every persist.
  • modelGenerateStep does not silently 3×-retry a completed LLM round.
  • No /api/agent fallback. No wrapping runAgentStream in one step. No Wasm/protocol change. D18 unmount still detach. C15 409 still live-only.
  • Cloud ops: N/A for the source issue (no Production mutate here). Diagnosis is Observability on an existing Production run.
  • ⛔ No application code here — source bug. Split the later plan if it is >1 turn.

Refs

  • Operator report: since D17 cutover, 10–20 min coding turns take ~1 hour
  • Umbrella / architecture lock: #794 (deltas, no mega-step, replay budget)
  • D17 cutover: #811 / PR #841
  • Loop / cadence: lib/workflows/turnLoop.ts (persistOnce after first tools model + after every batch; MAX_WORKFLOW_STEPS = 512)
  • Assemble: lib/workflows/assembleDurableToolWorld.ts, lib/workflows/modelGenerateStep.ts, lib/workflows/toolExecuteStep.ts
  • Persist args: lib/workflows/persistStep.ts (deltas + fold.checkpoint as step args)
  • SSE hop: lib/workflows/turnSseStep.ts (writeTurnSse / closeTurnSse still 'use step')
  • Retries: toolExecuteStep.maxRetries = 0; modelGenerateStep unset (SDK default 3)
  • Start surface: app/api/turns/route.ts (probe client closed after start(); in-step assemble opens its own)
  • Platform: vercel/workflow#1930 replay between steps

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backend-agentsClient-independent agent turns — backend-run, survive tab close / refreshbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions