Skip to content

fix(loop): hydrate externalized node outputs before namespace construction - #286

Closed
magoamz wants to merge 1 commit into
compozy:mainfrom
magoamz:fix/hydrate-loop-output-blobs
Closed

magoamz wants to merge 1 commit into
compozy:mainfrom
magoamz:fix/hydrate-loop-output-blobs

Conversation

@magoamz

@magoamz magoamz commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #285.

Problem

Loop node results larger than LoopOutputInlineLimitBytes (16 KB) are externalized to
loop_output_blobs, and GenerationOutput.OutputRef then holds a sha256:… ref rather than the
payload. Nothing read that blob back. runtimeNamespace builds each node entry straight from the
ref:

entry["output"] = outputValue(output.OutputRef)

outputValue tries to parse the ref as JSON, fails, and returns the raw "sha256:…" string. Every
template reaching into that node's output then resolves against a hash.

Fan-out is where it fails loudly — resolveFanOutCollection cannot walk .output.tasks inside a
string, so the run dies with fan-out collection reference … is unavailable right after its producer
succeeded. Gate criteria, action params and prompt templates degrade silently through the same path.

This breaks the first-party dev-cycle software-delivery Loop for realistic inputs: load_tasks
inlines each task's full markdown body, so four ordinary task files produced a 41 KB output and the
fan-out driving the whole delivery could not resolve.

Change

Resolve content-addressed refs into their payloads immediately after the coordinator loads generation
outputs, so externalization stays a storage detail instead of something every consumer must know
about.

The read side already existed and was simply never wired — getLoopOutputByRefWithExecutor had no
production caller, and ErrOutputRefNotFound was already defined for exactly this. This exposes it
as LoopRepo.GetGenerationOutputPayload, adds it to GenerationOutputReader, and calls it from
hydrateGenerationOutputs.

Two properties worth calling out:

  • Fan-out materializations are covered too. A materialization carrying large items exceeds the
    limit itself and becomes a blob, which would break fanOutItem serving .item to the fan-out
    body. Hydrating at the load site fixes the collection and .item in one place; fixing only
    resolveFanOutCollection would have failed at the next step.
  • The HTTP surface is unchanged. Hydration is scoped to the two coordinator load sites rather
    than to LoopRepo.ListGenerationOutputs, so GET /loop-runs/:id keeps returning refs instead of
    inlining full payloads into API responses.

output_ref already holds either inline JSON or a ref depending on size, so replacing a ref with
its payload restores the invariant outputValue has always assumed rather than introducing new
semantics.

Scope

Read-path only. The write path (OutputPayloadRequiresRef, writeGenerationOutputBlob) and the
16 KB limit itself are untouched — the limit is a reasonable inline cap that was just missing its
matching read.

Tests

internal/loop/generation_output_hydration_test.go covers:

  • a content-addressed ref replaced by its payload
  • inline outputs passing through with no blob lookup
  • one lookup per distinct ref when several outputs share a payload
  • a missing blob surfacing ErrOutputRefNotFound instead of degrading to the ref
  • a regression asserting the fan-out collection reference is unavailable before hydration and
    resolves to its items after
go test ./internal/loop/...
ok  github.com/compozy/compozy/internal/loop
ok  github.com/compozy/compozy/internal/loop/dsl
ok  github.com/compozy/compozy/internal/loop/dsl/refs
ok  github.com/compozy/compozy/internal/loop/gate
ok  github.com/compozy/compozy/internal/loop/goal
ok  github.com/compozy/compozy/internal/loop/watch

go vet ./internal/loop/... ./internal/store/globaldb/... ./internal/daemon/... is clean, as is
gofmt.

Verification against the reported failure

The 16 KB boundary was measured by running software-delivery in a throwaway workspace against a
synthetic task file of increasing size and reading back loop_generation_outputs.output_ref: outputs
of 2 713 B / 9 789 B / 15 822 B stayed inline and ran, while 18 142 B / 19 302 B became blobs and
failed. Details in #285.

I have not been able to run the full repository suite locally (the sandbox only has the Go packages
checked out), so CI is the real signal for anything outside internal/loop.

…ction

Loop node results larger than LoopOutputInlineLimitBytes are written to the output
blob store and GenerationOutput.OutputRef holds a "sha256:..." ref instead of the
payload. Nothing read that blob back, so runtimeNamespace built node entries from the
raw ref and every template reaching into a large node's output resolved against the
hash string.

Fan-out is where it failed loudly: resolveFanOutCollection could not walk
.output.tasks inside a string and aborted the run with "fan-out collection reference
is unavailable". Gate criteria, action params and prompt templates degraded silently
through the same path.

Resolve content-addressed refs into their payloads right after the coordinator loads
generation outputs, so externalization stays a storage detail. The read side already
existed (getLoopOutputByRefWithExecutor, ErrOutputRefNotFound) and was simply never
wired; this exposes it as GetGenerationOutputPayload and calls it. Hydrating here also
covers fan-out materializations that exceed the limit themselves, keeping .item
available to the fan-out body.

The HTTP surface keeps returning refs: hydration is scoped to the two coordinator load
sites, not to the store repository.

Fixes compozy#285
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

@maaark11 is attempting to deploy a commit to the Compozy Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ed131f3-74ce-4f17-b582-f42aa531e9e7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

pedronauck added a commit that referenced this pull request Aug 4, 2026
Implement the complete Loop node lifecycle across runtime, persistence, APIs, CLI, native tools, web UI, SDKs, documentation, and QA coverage. Incorporate and extend the externalized Loop output hydration fix from PR #286.

Co-authored-by: Marco Aurélio <[email protected]>
@pedronauck

Copy link
Copy Markdown
Member

Closing this PR because its externalized Loop-output hydration fix was incorporated and extended in #305, which has now been merged as the complete Loop node lifecycle landing change. The merged implementation preserves inline outputs, hydrates external references before namespace evaluation, caches loaded payloads, covers fan-out and gate consumers, and reports missing blobs deterministically. @magoamz is credited as a co-author on the final merged commit. Thank you for the original fix and investigation.

@pedronauck pedronauck closed this Aug 4, 2026
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.

Loop fan-out breaks when a node's output exceeds the 16 KB inline limit

3 participants