Skip to content

fix(storyboard): thread contributions through step runs#2243

Merged
bokelley merged 1 commit into
mainfrom
fix-storyboard-contributes-main
Jun 16, 2026
Merged

fix(storyboard): thread contributions through step runs#2243
bokelley merged 1 commit into
mainfrom
fix-storyboard-contributes-main

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

  • Thread branch-set contribution state through runStoryboardStep() results/options.
  • Add --contributions JSON|CSV to adcp storyboard step so step-by-step orchestrators can pass the accumulator forward.
  • Add regressions for legacy contributes: true normalization and Addie-style step-by-step assert_contribution execution.

Root Cause

Full runStoryboard() already keeps a shared contribution accumulator, so branch-set storyboards pass there. runStoryboardStep() is intentionally stateless and previously initialized a fresh empty contributions set for every row. Addie-style row-by-row execution could therefore pass a contributing branch step, then fail the later synthetic assert_contribution because the accumulator was empty.

Validation

  • npm run build:lib
  • node --test test/lib/storyboard-branch-set-grading.test.js
  • npx commitlint --from origin/main --to HEAD --verbose
  • pre-push hook: typecheck + build

@bokelley bokelley marked this pull request as ready for review June 16, 2026 19:38
@bokelley bokelley force-pushed the fix-storyboard-contributes-main branch from 3256b16 to 16a0e83 Compare June 16, 2026 19:40

@aao-ipr-bot aao-ipr-bot 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.

Clean, contained fix for a real state-threading gap. Right architecture: the stateless runStoryboardStep() becomes a witness that accumulates branch-set state through its result, and the orchestrator owns carrying it forward — same accumulator semantics a full runStoryboard() gets internally, without smuggling hidden state into a path that's intentionally stateless.

Things I checked

  • Both entry points call validateStoryboardShape (runner.ts:1040, :3404), which resolves contributes: truecontributes_to in place. So found.step.contributes_to is populated before the new accumulation block reads it — the stepwise test's contributes: true shorthand normalizes correctly. Verified by the new assertion at the test's contributes_to === 'past_start_handled' / contributes === undefined.
  • The explicit re-add at runner.ts:3499 is necessary, not redundant. executeStep only reads the contributions Set (passes it into ValidationContext at runner.ts:4306/:4880 so the assert_contribution any_of check can see it) — it never mutates it. The mutation at runner.ts:2737 lives in executeStoryboardPass, not executeStep. Without the re-add, a contributing step run stepwise would never surface its own flag. code-reviewer confirmed.
  • No collision on the return: the full-run path never sets contributions on StoryboardStepResult, so return { ...result, contributions: Array.from(contributions) } doesn't clobber anything. Set dedup means no double-count when both the threaded-in flag and the step's own flag coincide.
  • parseStringListFlag (bin/adcp.js:876) — JSON-array path guards Array.isArray + per-element string check, @file delegates to parseJsonFlag, CSV path trims/filters. Malformed bracket input routes to JSON.parse → throws → exit 2. Shape-validated input only; no injection surface.
  • Changeset present (.changeset/thread-step-contributions.md), descriptively named. Test plan in the PR body is fully checked — build:lib, the node test, commitlint, pre-push hook.

Follow-ups (non-blocking — file as issues)

  • contributes_if is silently dropped in stateless step mode. runner.ts:3499 calls evalContributesIf(found.step.contributes_if, new Map()) — empty prior-results map means any step carrying contributes_if: prior_step.X.passed evaluates false and never records its contribution (runner.ts:5936-5943). A storyboard that passes in full-run mode can therefore fail a downstream assert_contribution gate when Addie runs it row-by-row. Pre-existing (the empty map predated this PR), and the new test uses no contributes_if so the gap is unverified — but this PR is about cross-step threading, and this is the one piece that doesn't thread. Add a one-line comment at L3499 and consider threading prior_step_results through StoryboardRunOptions if Addie needs conditional-contribution storyboards.
  • Human-mode step output doesn't print contributions. --json round-trips cleanly (output key contributions ↔ flag --contributions — good symmetry for an LLM orchestrator), but the non---json step printer shows Context: and not the contribution flags. Mirror it with a Contributions: line so the flag's purpose is discoverable without forcing --json. (dx-expert)
  • Help text doesn't name the output→input linkage. The OPTIONS entry mirrors --context but nothing tells a reader the step result emits a contributions array meant to be fed back. One EXAMPLES line showing step N → step N+1 threading would close it.

Minor nits (non-blocking)

  1. contributionsValue is a dead return field. bin/adcp.js:1148 adds it to parseAgentOptions, but handleStoryboardStepCmd re-parses --contributions straight from args (bin/adcp.js:4520) and nothing reads opts.contributionsValue. Consistent with the also-dead contextValue/requestValue siblings — pre-existing pattern, not a regression. Drop all three or wire them, whenever someone's in there.
  2. Changeset type. patch for a change that adds two optional interface fields and a CLI flag is defensible as an additive bugfix, but it's additive public surface — minor is the textbook call. Non-blocking; categorization isn't wrong, just conservative.

Approving on the strength of the necessary-re-add verification plus the clean stepwise round-trip test. Follow-ups noted.

@aao-ipr-bot aao-ipr-bot 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.

Clean, contained fix. Step mode was initializing a fresh empty contributions set per row, so an Addie-style row-by-row run could pass a contributing branch step and then fail the synthetic assert_contribution gate against an empty accumulator — this threads the state the stateless runner intentionally dropped.

Things I checked

  • Seed placement is correct. runner.ts:3484 seeds new Set(options.contributions ?? []) before executeStep, and that same Set flows into ValidationContext.contributions (runner.ts:4306), which is what the any_of validation backing the synthetic assert_contribution gate (runner.ts:4697-4699) reads. Seeding before the call is exactly what makes a stepwise gate see prior branch-set state. Right shape.
  • Single writer, no double-record. contributions.add(...) lives only in executeStoryboardPass (runner.ts:2743) and the new step-mode block (runner.ts:3499-3503). executeStep only reads the set, never mutates it — so step mode has exactly one writer.
  • No mutation-aliasing. new Set(...) copies the caller's array and Array.from(contributions) returns a fresh one; options.contributions is never mutated, so the test's thread (first.contributions → next call) is safe.
  • evalContributesIf(found.step.contributes_if, new Map()) is consistent, not a divergence. Step mode already hands priorStepResults: new Map() to executeStep (runner.ts:3486); a contributes_if predicate that depends on a prior step can't have executed that step in the same stateless invocation anyway. The empty Map matches what the rest of step mode already sees. The common branch-set case (no contributes_if) records unconditionally in both modes.
  • Normalization holds in step mode. runStoryboardStepBody runs the shape pass that resolves contributes: truecontributes_to, so found.step.contributes_to is populated before the new block reads it — the contributes: true regression covers the legacy shorthand path.
  • Types are additive-optional. contributions?: string[] added to both StoryboardRunOptions and StoryboardStepResult; non-breaking on the public surface. Changeset present and scoped to @adcp/sdk.
  • code-reviewer: ready to push, two Low nits, no blocker. dx-expert: ship it, three non-blocking doc touch-ups.

Follow-ups (non-blocking — file as issues)

  • Changeset type leans patch on additive public surface. Two new optional fields on exported interfaces plus a new CLI flag is conventionally minor. It's non-breaking either way so patch ships nothing untracked — not worth churning the release, but worth knowing the next additive option will want minor.
  • --contributions help text names the input but not the output it pairs with. bin/adcp.js OPTIONS says "Pass contribution flags from previous step" but doesn't say the value is the contributions array off the prior step's --json result. An agent generating a step loop reads --help, not types.ts. dx-expert has concrete copy. Also: the non---json step printer echoes Context: but never Contributions:, so a human driving steps by hand can't see what to thread forward without --json.

Minor nits (non-blocking)

  1. contributionsValue out of parseAgentOptions is dead. bin/adcp.js plumbs it out but handleStoryboardStepCmd re-parses --contributions independently. Harmless — it mirrors the pre-existing contextValue/requestValue, which are also computed-and-unread. Symmetry or deletion, your call.
  2. parseStringListFlag mis-buckets a JSON object. A value like {"a":1} starts with neither [ nor @, so it falls to the CSV branch and yields junk tokens instead of erroring. Edge input only; the three documented forms ([...], @file, CSV) all behave.

Approving on the strength of the verified single-writer/no-aliasing data-flow plus both expert verdicts.

@bokelley bokelley merged commit 076e5ea into main Jun 16, 2026
31 checks passed
@bokelley bokelley deleted the fix-storyboard-contributes-main branch June 16, 2026 19:56
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