Skip to content

Add text-retract stream event for failed attempt recovery - #1039

Merged
sroussey merged 11 commits into
mainfrom
claude/wonderful-brahmagupta-4xsmg4
Oct 29, 2026
Merged

sroussey merged 11 commits into
mainfrom
claude/wonderful-brahmagupta-4xsmg4

Conversation

@sroussey

@sroussey sroussey commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Implements text-retract stream events to allow producers (like AgentTask) to take back text they streamed before abandoning an attempt. When a model call fails partway and is retried, the failed attempt's text is retracted so consumers see only the successful retry's output, as if the failed attempt never ran.

Key Changes

  • New StreamTextRetract event type (packages/task-graph/src/task/StreamTypes.ts):

    • Removes the last length characters (UTF-16 code units) from a port's accumulated text
    • Clamped at the start; later deltas append after what remains
    • retractionLength() is the one rule for how much a retraction removes (a fraction rounds down; anything not positive, NaN included, removes nothing), and retractText() is the fold built on it
  • StreamProcessor retraction handling (packages/task-graph/src/task/StreamProcessor.ts):

    • Truncates accumulated text on retraction
    • Fails cache sinks that already teed retracted bytes (cannot be taken back), but only for a retraction that actually removes text
    • Detaches failed ports so later deltas go to accumulator only
    • Refuses to settle sunk ports without accumulator unless finish payload states the value
  • AgentTask retry recovery (packages/ai/src/task/AgentRoundAttempt.ts, where main's refactor moved the round loop):

    • Forwards text and reasoning deltas live as they stream
    • On attempt failure, emits text-retract for exactly what that attempt sent
    • Counts what each port sent through retractionLength, so retraction lengths stay exact
    • Handles separator between reasoning rounds (retracted when all reasoning is taken back)
    • Abandons the in-flight model call if the stream stops being read before the attempt settles
  • Host round runners can retract (packages/ai/src/task/AgentRoundRunner.ts):

    • AgentRoundContext.onTextRetract(port, length) lets an AGENT_ROUND_RUNNER withdraw text its own call streamed; the turn caps it at what that attempt forwarded
  • StreamPump edge forwarding (packages/task-graph/src/task-graph/StreamPump.ts):

    • Retractions travel with their port's deltas on dataflow edges
    • Port-filtered edges carry retractions only for their own port
  • UI and CLI updates:

    • streamedText.ts: Added classifyTextRetract() (rounds the length like retractionLength) and retraction folding
    • AgentTaskTurnControls.test.ts: New tests for retraction on failed/timed-out attempts
    • AgentTaskReasoningAndImages.test.ts: Tests for multi-port retraction
    • AgentRoundAttempt.test.ts: the retry test now expects the partial to be forwarded, then retracted; a new test stops reading mid-stream and expects the call to be aborted; another passes a host runner's retraction through, capped
    • CLI chat, eval and web UI: Display retraction events and fold them into accumulated text
  • Comprehensive test coverage (StreamTextRetract.test.ts):

    • Processor truncation and cache sink failure, including a retraction that removes nothing
    • Port-filtered edge forwarding
    • Accumulator and event folding behavior, including fractional and NaN lengths

Implementation Details

  • Retractions are counted per port to handle multi-port streams (text + reasoning)
  • A port retracted to empty settles like one that never streamed: absent unless the producer states it in finish (AgentTask always does)
  • Separators between reasoning rounds are included in retraction when all reasoning is taken back
  • Every consumer folds with retractText(acc, length), which removes retractionLength(length) characters

🤖 Generated with Claude Code

https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

…d attempt's tail

AgentTask held every text and reasoning delta of a round until the attempt
settled, because the stream folded each delta into the port and nothing could
take one back: a retried attempt's partial would have been joined to the
retry's text. The cost was that a host showing an agent turn saw no streaming
at all.

A new stream event, `text-retract { port, length }`, removes the last
`length` characters streamed on an append port. AgentTask now forwards deltas
as they arrive and, when an attempt fails, retracts exactly what that attempt
forwarded on each port (its reasoning separator included), so earlier rounds
are untouched and the folded wire equals the settled output.

- StreamProcessor truncates the accumulator. Bytes already teed to a cache
  sink cannot be unwritten, so a retraction fails that port's router (the
  sink discards the partial write) and the port settles inline for the rest
  of the run; with no accumulator either, finish refuses unless the producer
  states the port's value.
- StreamPump treats the retraction as a per-port delta, so it travels down its
  own port's edges and no others.
- StreamEventAccumulator, chatTurn, StreamingAiTask, TextGenerationTask and
  ToolCallingTask honor it; `retractText` is the shared fold.
- CLI rows, run events and the web console fold it; the agent chat and eval
  stderr streams, which cannot erase, say the partial is discarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 28/28 files

Approve — reviewed files look good at 6141d32.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

main split AgentTask's turn loop into modules. The round code this branch
changed now lives in AgentRoundAttempt.ts, so AgentTask.ts takes main's
version, and live forwarding moves into attemptRound. A failed attempt's
text-retract per port, and passing on the child round's own retractions,
move with it. settleRound and attemptRound now yield text-retract
alongside text-delta.

main's new AgentRoundAttempt test asserted the old hold-until-settled
behaviour. It now checks that the retried attempt's partial is forwarded,
then retracted, and that the folded port holds only the retry's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Comment — found 1 issue(s) at a6601a3.

Actionable comment posted: 1

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/ai/src/task/AgentRoundAttempt.ts` (line 226, Important):
   `Math.max(0, NaN)` and the resulting `Math.min(...)` are `NaN`. Unlike the shared `retractText()` fold (which treats `NaN` as a no-op), this makes `textSent` become `NaN`; no retraction is yielded here, and the failure cleanup's `textSent > 0` check is false. If a child stream emits a `text-retract` with `length: NaN` after sending text and then fails, the failed attempt's text remains in the outer stream alongside the retry. Preserve the fold's no-op semantics or reject non-finite lengths before updating the counter.

Mergestorm: 30 free reviews a month

main's typecheck:tests step rejects the direct cast from StreamFinish to
the test's output shape (TS2352). Find the finish event with a type guard
instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Approve — reviewed files look good at 82daa71.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

A child round's text-retract with length NaN passed through Math.max and
Math.min unchanged. That turned the attempt's sent-count into NaN, so a
later failure had nothing to take back and the failed attempt's text
stayed in the stream. Skip such a retraction before it reaches the
counters, matching retractText.

CLAUDE.md said AgentRoundAttempt holds deltas; it now forwards them live
and takes a failed attempt's back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Comment — found 1 issue(s) at 5578b44.

Actionable comment posted: 1

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/ai/src/task/AgentRoundAttempt.ts` (line 256, Important):
   `yield event`
   If a consumer calls `return()`/breaks from `AgentTask.executeStream()` while this yield is suspended, `attemptRound` exits without aborting `roundAbort`/the owned task, clearing `timer`, or unsubscribing `off`. The in-flight model request can therefore keep running (and continue billing) after the stream has been abandoned; with no `timeoutMs`, the task and listener can remain indefinitely. Add cleanup for the generator-return path, including aborting the active attempt.

Mergestorm: 30 free reviews a month

Now that a round yields its deltas while the model call is still running,
a consumer that stops reading (return() on the generator, or a throw in the
loop over it) left attemptRound at a yield with the call in flight. Nothing
aborted the request, so it kept running and billing. With no timeoutMs, the
owned task and its listener were never released. A finally now abandons an
attempt that has not settled; the run's own finally then clears the timer
and the listener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Comment — found 1 issue(s) at 61d93e6.

Actionable comment posted: 1

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/ai/src/task/AgentRoundAttempt.ts` (line 232, Important):
   `Math.min(event.length, textSent)` subtracts the raw fractional length, but `retractText` folds it through `slice`, which removes an integer number of code units. For example, with earlier-round text `P` and this attempt's `abc`, a child retraction of `1.5` removes `bc` from the outer stream while leaving `textSent === 1.5`; if the attempt then fails, the cleanup retraction of `1.5` also removes the earlier `P`. Normalize the effective length to the same amount the fold removes (or reject non-integer lengths) before updating these counters; apply the same treatment to the reasoning branch.

Mergestorm: 30 free reviews a month

A retraction's length could be fractional or NaN, and each place that
folded or counted it handled that differently. retractText let slice
truncate a fractional length, while AgentRoundAttempt subtracted it raw.
That drifted the attempt's count away from the text: a child retraction
of 1.5 removed two characters but counted 1.5, so a later failure's own
retraction could reach into an earlier round's text.

retractionLength is now the rule: a fraction rounds down, and anything
not positive removes nothing. retractText folds through it,
AgentRoundAttempt counts through it, and the web example's TaskNode now
uses retractText. The CLI's classifyTextRetract rounds the same way, so
its own fold and the web console client (which gets lengths through it)
agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Comment — found 1 issue(s) at d0d3597.

Actionable comment posted: 1

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/task-graph/src/task/StreamTypes.ts` (line 37, Important):
   `acc.slice(0, Math.max(0, acc.length - length))` does not implement the newly documented round-down rule for fractional `length`. For example, folding `"Pabc"` with `length: 1.5` using this expression yields `"Pa"`, while `retractText`/`retractionLength` yields `"Pab"`. A consumer following this public example will diverge from `StreamProcessor` and the agent retry fold; document the canonical helper or normalize `length` in the expression.

Mergestorm: 30 free reviews a month

The StreamTextRetract doc comment and the dataflow doc both gave a raw
slice expression as the fold. That expression doesn't round a fractional
length down, so a consumer following it would disagree with
StreamProcessor and the agent retry fold. Both now name retractText and
retractionLength.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Request changes — found 1 issue(s) at 58ce47e.

Actionable comment posted: 1

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
   `return length > 0 ? Math.floor(length) : 0;`
   This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Mergestorm: 30 free reviews a month

…es text

StreamProcessor still checked the raw event.length > 0 before failing a
port's cache router. A retraction of 0.5, which retractText treats as
removing nothing, detached the sink anyway. With no accumulator, the
finish handler then refused the port it no longer had a copy of. The
decision now goes through retractionLength.

The CLI's agent chat and the eval stream print their "attempt withdrawn"
notices on the same rule, so a retraction that removes nothing prints
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Request changes — found 2 issue(s) at 0de83cd.

Actionable comments posted: 2

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
   `retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.

Additional findings outside changed lines:
1. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
   A sub-unit retraction can fail a cached no-accumulation run
   `return length > 0 ? Math.floor(length) : 0;`
   This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Still open from earlier reviews (1) — the lines they point at have not changed:

  • 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run · packages/task-graph/src/task/StreamTypes.ts line 57 · from 58ce47e
    return length > 0 ? Math.floor(length) : 0;
    This makes a length such as 0.5 a no-op, but StreamProcessor still tests the raw event.length > 0 before detaching a cache router. With noAccumulation enabled, an append stream that emits text, then a text-retract of 0.5, then finish without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Mergestorm: 30 free reviews a month

}
if (accumulated) {
const text = accumulated.get(event.port);
if (text !== undefined) accumulated.set(event.port, retractText(text, event.length));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocker · Blocker

Preserve an accumulated empty string after full retraction

retractText can leave this accumulator entry as "" when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream like text-delta("partial"), text-retract(99), finish({}) completes with no text property instead of text: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.

🤖 Prompt for AI Agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
`retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not changing this here. As far as I can tell it's the intended behaviour, not a regression.

  • The finish merge's if (text.length > 0) predates this PR (df50135). Empty accumulated text never overrides the finish payload.
  • So a port retracted to "" ends up exactly like a port that never streamed. The port is absent unless the producer states it in finish. That's the contract text-retract documents: the folded port reads as if the withdrawn text had never been sent.
  • AgentTask, the only producer that retracts, always states text and reasoning in its finish payload, so its output always carries them.
  • Writing "" for a fully retracted port would make it differ from a never-streamed one. Doing that for every empty accumulator would also change ports that only ever streamed empty deltas, which is outside this PR.

Leaving this thread open for the author to decide.


Generated by Claude Code

Ctrl-C mid-reply aborts the turn, and AgentTask retracts what the
aborted attempt streamed. The REPL read that retraction as a failed
attempt and printed "that attempt failed; the partial reply above is
discarded" before "interrupted", although nothing failed and nothing is
retried. The note is now skipped once the turn has been interrupted.

Also splits an inline `type` import in projectRunEvents.test.ts into a
top-level `import type`, per the repo's import convention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Request changes — found 2 issue(s) outside changed lines at b4716c0.

Actionable comments posted: 2

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
   Preserve an accumulated empty string after full retraction
   `retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.

2. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
   A sub-unit retraction can fail a cached no-accumulation run
   `return length > 0 ? Math.floor(length) : 0;`
   This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Still open from earlier reviews (2) — the lines they point at have not changed:

  • 🔴 Blocker · Preserve an accumulated empty string after full retraction · packages/task-graph/src/task/StreamProcessor.ts line 310 · from 0de83cd
    retractText can leave this accumulator entry as "" when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream like text-delta("partial"), text-retract(99), finish({}) completes with no text property instead of text: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
  • 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run · packages/task-graph/src/task/StreamTypes.ts line 57 · from 58ce47e
    return length > 0 ? Math.floor(length) : 0;
    This makes a length such as 0.5 a no-op, but StreamProcessor still tests the raw event.length > 0 before detaching a cache router. With noAccumulation enabled, an append stream that emits text, then a text-retract of 0.5, then finish without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Mergestorm: 30 free reviews a month

A host bound to AGENT_ROUND_RUNNER could stream a round's text and
reasoning to the turn but had no way to take any of it back. A host whose
own model call withdrew part of a reply (a ToolCallingTask whose provider
retracted) had to keep the withdrawn text in the turn, although the
in-process path removes it.

AgentRoundContext gains onTextRetract(port, length). The turn passes it on
as its own text-retract, counted through retractionLength and capped at
what the attempt forwarded on that port, so a host can never take back an
earlier round's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Request changes — found 2 issue(s) outside changed lines at 241edfa.

Actionable comments posted: 2

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
   Preserve an accumulated empty string after full retraction
   `retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.

2. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
   A sub-unit retraction can fail a cached no-accumulation run
   `return length > 0 ? Math.floor(length) : 0;`
   This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Still open from earlier reviews (2) — the lines they point at have not changed:

  • 🔴 Blocker · Preserve an accumulated empty string after full retraction · packages/task-graph/src/task/StreamProcessor.ts line 310 · from 0de83cd
    retractText can leave this accumulator entry as "" when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream like text-delta("partial"), text-retract(99), finish({}) completes with no text property instead of text: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
  • 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run · packages/task-graph/src/task/StreamTypes.ts line 57 · from 58ce47e
    return length > 0 ? Math.floor(length) : 0;
    This makes a length such as 0.5 a no-op, but StreamProcessor still tests the raw event.length > 0 before detaching a cache router. With noAccumulation enabled, an append stream that emits text, then a text-retract of 0.5, then finish without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Mergestorm: 30 free reviews a month

…magupta-4xsmg4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hti4mgm8Y8uukHyEnzhJLo

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 29/29 files

Request changes — found 2 issue(s) outside changed lines at 23d3f3d.

Actionable comments posted: 2

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1039: https://github.com/workglow-dev/libs/pull/1039 (head branch `claude/wonderful-brahmagupta-4xsmg4`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/task-graph/src/task/StreamProcessor.ts` (line 310, Blocker):
   Preserve an accumulated empty string after full retraction
   `retractText` can leave this accumulator entry as `""` when a retraction removes the entire port. The finish merge later skips every empty accumulated value (`if (text.length > 0)`), so a stream like `text-delta("partial"), text-retract(99), finish({})` completes with no `text` property instead of `text: ""`; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.

2. In `@packages/task-graph/src/task/StreamTypes.ts` (line 57, Blocker):
   A sub-unit retraction can fail a cached no-accumulation run
   `return length > 0 ? Math.floor(length) : 0;`
   This makes a `length` such as `0.5` a no-op, but `StreamProcessor` still tests the raw `event.length > 0` before detaching a cache router. With `noAccumulation` enabled, an append stream that emits text, then a `text-retract` of `0.5`, then `finish` without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Still open from earlier reviews (2) — the lines they point at have not changed:

  • 🔴 Blocker · Preserve an accumulated empty string after full retraction · packages/task-graph/src/task/StreamProcessor.ts line 310 · from 0de83cd
    retractText can leave this accumulator entry as "" when a retraction removes the entire port. The finish merge later skips every empty accumulated value (if (text.length > 0)), so a stream like text-delta("partial"), text-retract(99), finish({}) completes with no text property instead of text: ""; downstream consumers of this declared output port then receive an absent value despite accumulation being enabled.
  • 🔴 Blocker · A sub-unit retraction can fail a cached no-accumulation run · packages/task-graph/src/task/StreamTypes.ts line 57 · from 58ce47e
    return length > 0 ? Math.floor(length) : 0;
    This makes a length such as 0.5 a no-op, but StreamProcessor still tests the raw event.length > 0 before detaching a cache router. With noAccumulation enabled, an append stream that emits text, then a text-retract of 0.5, then finish without that port is therefore detached despite retracting nothing; the finish handler then throws because the detached port has no accumulator or stated value. Use the normalized retraction length for that sink decision too.

Mergestorm: 30 free reviews a month

@sroussey
sroussey merged commit 7e674c0 into main Oct 29, 2026
19 checks passed
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.

2 participants