Skip to content

fix(execution): compact loop state before serializing a pause snapshot - #6256

Draft
waleedlatif1 wants to merge 3 commits into
stagingfrom
fix/pause-snapshot-loop-compaction
Draft

fix(execution): compact loop state before serializing a pause snapshot#6256
waleedlatif1 wants to merge 3 commits into
stagingfrom
fix/pause-snapshot-loop-compaction

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • A loop compacts its accumulated iteration outputs when it exits (orchestrators/loop.ts), but a pause is by definition mid-flight and never reaches that point. The running total arrived at serializePauseSnapshot uncompacted and tripped assertSnapshotValueIsCompact, which throws rather than degrades.
  • The throw is caught upstream and converts the pause into a failed run, so handlePostExecutionPauseState never writes the paused_executions row.
  • Run the same compaction the loop performs on exit, before serializing.

Why this is worse than a failed run

The HITL approval notification — email/Slack, containing the resume links — is sent during block execution (human-in-the-loop-handler.ts), well before the engine builds the paused result. So the approver receives a working-looking approval link pointing at a row that is never created, and the run surfaces a generic "Execution failed" with no indication that a byte budget caused it.

Reachability

Not theoretical. Nothing caps the accumulation:

  • allIterationOutputs is compacted per iteration (loop.ts) but never in aggregate until loop exit.
  • Accumulation survives resumesexecutor.ts rebuilds it from the snapshot — so an "approve each item" loop grows across every approval.
  • ~8 KB retained per iteration × 1000 iterations reaches the limit; a paginated API response per iteration gets there in a couple of hundred.

The canonical failing workflow is the most idiomatic HITL pattern there is: forEach over records → work → approval per record. It works for the first few hundred approvals and then abruptly stops pausing.

Why not degrade instead

pause-persistence.ts already handles a missing seed by failing the run, so catching the throw would produce the same failed run with a vaguer error. The snapshot is the point of a pause — an unstorable snapshot means an unresumable pause. The assertion stays as a valid post-condition; this satisfies it rather than tripping it.

Note the sibling call in getSerializableExecutionState is guarded, but it is not an equivalent case: it only runs on non-paused exits where the snapshot feeds a display payload, so degrading there costs a UI detail rather than the resume artifact.

Registering the minted keys

Compaction creates refs at pause time, and reads are gated on the context's key list (materialization.server.ts), so the resumed run could not materialize them unless their keys reach the snapshot's trustedLargeValueAccess. recordMaterializedAccessKeys is called for exactly that. This was caught by the test below, not by inspection.

Type of Change

  • Bug fix

Testing

Three cases in snapshot-serializer.test.ts, each mutation-verified:

  • serialization succeeds for an oversized loop scope — fails without the compaction pass
  • the offloaded values are authorized for the resumed run — fails without the key registration
  • a loop whose accumulated output already fits is left untouched

executor + lib/execution suites green (100 files, 1848 tests). One unrelated failure in executor/handlers/pi/cloud-review-tools.test.ts is pre-existing on clean staging — verified by stashing.

Not exercised against a live paused workflow.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

A loop compacts its accumulated iteration outputs when it exits, but a pause is
by definition mid-flight and never reaches that point. The running total
therefore arrived at the serializer uncompacted and tripped its size assertion,
which throws rather than degrades — turning the pause into a failed run, so no
paused_executions row was ever written.

The approval notification goes out during block execution, well before the
engine builds the paused result, so the approver was left holding a working
looking resume link pointing at a row that never existed, and the run reported
a generic failure with no hint that a byte budget caused it.

Run the same compaction the loop performs on exit, and register the keys it
mints: reads are gated on the context's key list, so a resumed run could not
materialize the offloaded values otherwise. The assertion stays — it is a valid
post-condition, and an unstorable snapshot means an unresumable pause.
@vercel

vercel Bot commented Aug 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 (UTC)
docs Skipped Skipped Aug 4, 2026 7:10pm

Request Review

@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes pause snapshot persistence and durable large-value offload on a critical HITL resume path; behavior is gated on oversized state and covered by serializer/engine tests, but compaction alters stored payload shape for affected runs.

Overview
Fixes HITL pauses inside long-running loops or parallels that could fail during snapshot serialization when accumulated subflow state grew past the size limit—after approval notifications were already sent but before a paused_executions row was written.

compactPauseSnapshotScopes runs in buildPausedResult (now async) before serializePauseSnapshot. It only rebuilds when serialized loop/parallel state is already oversized; otherwise it does a bounded size check and skips work. When compaction runs, it offloads iteration outputs, forEach items, in-flight block outputs, and parallel branch/accumulated maps via the same payload serializer used on loop exit, using a 64KB per-value threshold so combined state can shrink below the snapshot limit. Offloaded refs are registered for resume via recordMaterializedAccessKeys.

Serialization now also asserts parallel execution state is compact, matching loops.

Reviewed by Cursor Bugbot for commit 1f833ee. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR compacts accumulated loop and parallel execution state before serializing pause snapshots, preserving authorization for newly materialized values.

  • Makes paused-result construction asynchronous so durable compaction completes before serialization.
  • Compacts loop iteration state, collections, in-flight outputs, and parallel branch state.
  • Adds pause-snapshot size enforcement for parallel execution state.
  • Adds serializer and engine coverage for oversized paused scopes.

Confidence Score: 5/5

The PR appears safe to merge because no eligible or outstanding blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/executor/execution/engine.ts Makes paused-result construction asynchronous and compacts execution scopes before snapshot serialization.
apps/sim/executor/execution/snapshot-serializer.ts Adds bounded loop and parallel state compaction, access-key registration, and parallel snapshot-size enforcement.
apps/sim/executor/execution/engine.test.ts Adds engine-level coverage proving an oversized loop can produce a paused result.
apps/sim/executor/execution/snapshot-serializer.test.ts Adds focused coverage for loop and parallel compaction, access authorization, and the small-state fast path.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Execution reaches HITL pause] --> B[Mark execution paused]
  B --> C[Measure loop and parallel state]
  C -->|Within limit| E[Serialize pause snapshot]
  C -->|Oversized| D[Compact payloads and record access keys]
  D --> E
  E --> F[Build paused execution result]
Loading

Reviews (2): Last reviewed commit: "fix(execution): cover every subflow fiel..." | Re-trigger Greptile

The first pass only compacted a loop's completed iteration outputs, which left
the same pause failure reachable by four other routes: a forEach collection, an
in-flight iteration output, two loops each individually under the limit but
oversized together, and a parallel's accumulated branch outputs. Parallel state
was not even asserted, so it shipped an oversized snapshot silently rather than
failing.

Compact every field that accumulates, assert parallel state alongside loop
state, and offload at a threshold far below the snapshot's own — the assertion
measures the combined record, so compacting at its ceiling is a no-op in
exactly the case that needs it.

Skip the pass entirely when the state already fits, so a pause per iteration
inside a modest loop pays one bounded measurement rather than a structural
rebuild each time, and count the compaction against the recorded duration
instead of stopping the clock before it runs.

Add an engine-level test: the serializer tests all passed with the call removed,
leaving the wiring itself undefended.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Reworked in 1f833ee after an independent audit. The bots were clean on the first round — Greptile 5/5, Bugbot pass, CI green, zero threads — and the fix was still materially incomplete.

It only covered one of five routes to the same failure. The audit proved the rest with tests:

Scenario Before
forEach items collection never compacted — still threw
in-flight currentIterationOutputs never compacted — still threw
two loops, each individually under the limit compaction was a no-op — still threw
parallelExecutions accumulated outputs not compacted and not asserted — shipped an oversized snapshot silently

The third is the one that shows the shape of the bug: the assertion measures the combined record while compaction ran per scope, so compacting at the snapshot's own ceiling did nothing in exactly the case that needed it. Offloading now uses a threshold well below that ceiling, and only once the state is already oversized.

The fourth is worse than the bug being fixed — a parallel traded a loud failure for a quiet one. It is now asserted alongside loop state.

The wiring was undefended. Deleting await compactPauseSnapshotScopes(this.context) from engine.ts left all 47 engine tests green — the entire point of the PR could be reverted without CI noticing. There is now an engine-level test, verified to go red under exactly that mutation.

Also addressed: the pass is skipped when the state already fits (it was an unconditional structural rebuild on every pause, which for approval-inside-a-loop is O(N²) across a run), the duration no longer stops the clock before compaction runs, and the fixtures use the real any[][] shape so oversized entries exercise the chunked-manifest path rather than a single ref.

101 files / 1853 tests green; the one failure in executor/handlers/pi/cloud-review-tools.test.ts is pre-existing on clean staging, verified by stashing.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1f833ee. Configure here.

}
if (scope.items?.length) {
scope.items = await compactList(scope.items)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale loop item after compaction

High Severity

The compactPauseSnapshotScopes function compacts a loop's scope.items array but misses refreshing scope.item, which holds the current element. If scope.item contains a large payload, serializeLoopExecutions will include this uncompacted value in the snapshot. This causes pause serialization to fail due to exceeding size limits, preventing the execution from being recorded as paused.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f833ee. Configure here.

Compacting a loop's `items` was a regression: the orchestrator indexes that
collection to derive the current `item`, the resume path rebuilds the scope
verbatim without materializing anything, and the loop resolver asserts no refs
reach it — so an oversized forEach would have traded a failed pause for a
broken resume. `currentIterationOutputs` is excluded for the same reason: the
block executor has already compacted its entries, and they resolve through the
reference path rather than being read raw.

Offloading is now limited to exactly the accumulators the orchestrators
themselves compact when a subflow exits. An oversized `items` collection
therefore still fails the pause; that is the honest outcome until it can be
handled without breaking iteration.
@waleedlatif1
waleedlatif1 marked this pull request as draft August 4, 2026 19:16
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Converting to draft. A full-lifecycle audit found this is not a strict improvement, and I don't think it should merge in this shape.

The failure was moved, not removed. persistPauseResultcollectLargeValueReferenceKeysgetBoundedUniqueKeys throws above MAX_LARGE_VALUE_REFERENCES_PER_SCOPE = 5_000. Because compaction creates one ref per entry, any run with more than ~5,000 accumulated subflow entries still fails the pause — same user-visible outcome as the bug — but now after thousands of storage uploads, thousands of metadata transactions, 12+ seconds of added latency, and thousands of orphaned objects whose reference rows were never written. Verified end-to-end: Large value reference set contains 20000 large value references, exceeding the limit of 5000.

The snapshot can end up larger than the one that was rejected. trustedLargeValueAccess.largeValueKeys is unasserted and unbounded, and this change feeds it directly. Measured on the same case: loopExecutions drops to 6.5 MB and passes the guard, while the total snapshot reaches 9.66 MB — bigger than the 9.0 MB that previously threw. That is the same measurement-versus-reality mismatch this PR set out to fix, reintroduced one level up.

The key registration I added is a no-op for two of the three fields. collectLargeValueKeys ends at Object.values(), which is empty for a Map — and branchOutputs/accumulatedOutputs are Maps when it runs. Zero keys registered. It only appears to work because assertLargeValueRefAccess also passes on executionId, so the PR relies on a fallback while its own test asserts the exact-key grant. The test only covered the array field, which is why this was invisible.

64 KiB is the mechanism, not a policy. It exists to force the aggregate branch of compactSubflowResults, which force-stores every entry via an unbounded Promise.all — while the sibling compactBlockLogs right below caps concurrency at 4. For string-typed values a ref envelope (576 B) can exceed the value it replaces (449 B), so the compacted form was measured 27% larger.

Resume gets slower, not lighter. Refs are never hydrated on rebuild — only warmed, serially, one storage GET at a time — and the result is discarded into an LRU. This is a DB-row-size change, not a memory change.

Also: largeValueKeys grows monotonically across pause/resume generations with nothing pruning it, and offloaded data now lives under time-based retention independent of whether the execution is still paused, so a long pause can resume against dangling refs.

The right approach is a chunked manifestcreateLargeArrayManifest gives one ref for many entries, which addresses the fanout, the 5,000 cap, the key bloat, and the serial warm together. That is a different design from what is here, so I would rather rebuild it than patch this.

The one piece worth keeping regardless is the parallelExecutions assertion: without it an oversized parallel snapshot ships silently instead of failing. I will split that out.

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