Skip to content

Always return a Promise from getOutputStreamByRef - #808

Merged
sroussey merged 2 commits into
mainfrom
claude/fetchurltask-streaming-cache-ey52fj
Aug 15, 2026
Merged

Always return a Promise from getOutputStreamByRef#808
sroussey merged 2 commits into
mainfrom
claude/fetchurltask-streaming-cache-ey52fj

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

TaskOutputRepository.getOutputStreamByRef (and getOutputStreamByRefForRun) were declared as a tri-state union:

AsyncIterable<Uint8Array>|undefined|Promise<AsyncIterable<Uint8Array>|undefined>

StreamPortCodec.materialize takes AsyncIterable<Uint8Array>. The two do not compose, so every consumer had to decide for itself whether a given backing needed awaiting — and the tree had grown two narrowings of the one interface, in opposite directions: the concrete repositories (FsFolderTaskOutputRepository, StreamingMemoryRepo) overrode it with the synchronous half, while streamingTaskOutputRepositoryContract.ts declared its own Promise-only version.

Both are now Promise<AsyncIterable<Uint8Array> | undefined>.

Why it mattered even though nothing was broken

Both production materialize call sites — CacheCoordinator.ts and TaskRunner.ts — funnel through streamRefViaBacking(), which awaits internally. That await was doing double duty: collapsing the union and turning an asynchronous backing's dangling ref into undefined (a cache miss) rather than a truthy Promise a caller reads as a live stream. Nothing in the signature required it. Now the type does.

A backing that can answer synchronously still does; the cost is one microtask per ref, not per chunk.

The test churn is the interface's own evidence

Un-awaited call sites failed loudly once the union collapsed — TypeError: bytes is not async iterable from streamCodec, and expected Promise{…} to be undefined from nine assertions.

One did not fail, and is the point of the change:

expect(resumed.getOutputStreamByRef!(preCrashRef)).toBeDefined();

A Promise is always defined, so that assertion passed vacuously — it would have held for a dangling ref. It now awaits and tests what it claims to.

Changes

  • TaskOutputRepository: both declarations collapsed to Promise; JSDoc rewritten (the "synchronous backings return the iterable directly" paragraph is gone).
  • resolveRef.ts: CacheRefStreamResolver and RefStreamBacking.getOutputStreamByRef follow; streamRefViaBacking's explanatory comment about awaiting a maybe-sync value is no longer needed.
  • FsFolderTaskOutputRepository and StreamingMemoryRepo overrides marked async. TabularStreamingTaskOutputRepository already was.
  • Call sites awaited across CacheStreamOut, InputRefPortGating, TaskOutputRepositoryStream, RunPrivateClearRunCost, RunPrivateFsFolderStream, FsFolderStreamPort.

Breaking change

An external implementor must mark the method async; a caller relying on the synchronous return must await. Changelog entry added under ## Unreleased in @workglow/task-graph with the migration note.

Verification

turbo run build-types → 41 successful, 41 total
bun scripts/test.ts graph task task-graph vitest
→ 223 files passed, 2515 passed | 24 skipped
eslint + prettier --check on changed files → clean

https://claude.ai/code/session_01RFf49R1YHc5s8JAAEhRHyj


Generated by Claude Code

`getOutputStreamByRef` and `getOutputStreamByRefForRun` were declared as a
tri-state union — `AsyncIterable<Uint8Array> | undefined | Promise<...>` — which
does not compose with `StreamPortCodec.materialize`, whose parameter is the
iterable alone. Every consumer had to decide for itself whether a given backing
needed awaiting, and the tree had grown two narrowings of the one interface in
opposite directions: the concrete repositories overrode it with the synchronous
half, while the streaming contract helper declared its own Promise-only version.
The union also made the await in `streamRefViaBacking` load-bearing by
convention rather than by type. That await is what turns an asynchronous
backing's dangling ref into `undefined` — a cache miss — instead of a truthy
Promise a caller reads as a live stream, and nothing in the signature required
it. Both production `materialize` call sites funnel through that helper, which
is why nothing was broken; the type just did not say so.
A backing that can answer synchronously still does — the cost is one microtask
per ref, not per chunk.
The test churn is the interface's own evidence. Un-awaited call sites failed
loudly (`bytes is not async iterable`, `expected Promise{…} to be undefined`),
except one: `expect(repo.getOutputStreamByRef!(ref)).toBeDefined()` passed
vacuously, because a Promise is always defined. That assertion would have held
for a dangling ref.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFf49R1YHc5s8JAAEhRHyj
@github-actions

github-actionsBot commented Aug 15, 2026

Copy link
Copy Markdown

Coverage Report

StatusCategoryPercentageCovered / Total
🔵Lines60.37%38788 / 64244
🔵Statements59.86%40715 / 68011
🔵Functions60.96%7515 / 12327
🔵Branches48.67%19820 / 40721
File Coverage
FileStmtsBranchesFunctionsLinesUncovered Lines
Changed Files
packages/task-graph/src/cache/resolveRef.ts0%0%0%0%41-329
packages/task-graph/src/storage/TaskOutputRepository.ts6.66%0%0%6.66%56-291
packages/task-graph/src/testing/StreamingMemoryRepo.ts76.19%85.71%37.5%73.68%29-37, 87-103
Generated in workflow #3157 for commit c2e45c7 by the Vitest Coverage Report Action

`turbo run build-types` does not typecheck task-graph's own `__tests__` — a
separate `typecheck:tests` script does, one tsconfig.test.json per package — so
ten test doubles kept the old synchronous `getOutputStreamByRef` signature and
only CI caught them.
`AsyncStreamReader`'s last case is rewritten rather than widened. It asserted
that a synchronous backing hands back a non-thenable, and blanket-marking its
double `async` turned the test into a contradiction of its own premise. The
guarantee worth keeping is the runtime one: the interface now requires a
Promise, but `await` is a no-op on a non-thenable, so a JavaScript backing (or
one compiled against the older signature) returning a bare iterable still
works. The cast is what lets the test reach that shape, and it is the subject
rather than a convenience.
Also restores the parameter on `ConditionalTask.getCachePolicy`. That override
declared zero parameters, which satisfies `ITask` structurally but narrows the
arity seen through the CONCRETE type, so a caller holding a `ConditionalTask`
could not pass the inputs `TaskRunner` passes through the interface. Verified
pre-existing: `typecheck:tests` fails on those two lines with this branch's
task-graph source replaced by main's and dist rebuilt from it.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFf49R1YHc5s8JAAEhRHyj
@sroussey
sroussey merged commit e4beff6 into mainAug 15, 2026
25 of 26 checks passed
Sign up for freeto 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.

2 participants

@sroussey@claude