fix(storyboard): thread contributions through step runs#2243
Conversation
3256b16 to
16a0e83
Compare
There was a problem hiding this comment.
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 resolvescontributes: true→contributes_toin place. Sofound.step.contributes_tois populated before the new accumulation block reads it — the stepwise test'scontributes: trueshorthand normalizes correctly. Verified by the new assertion at the test'scontributes_to === 'past_start_handled'/contributes === undefined. - The explicit re-add at
runner.ts:3499is necessary, not redundant.executeSteponly reads the contributions Set (passes it intoValidationContextatrunner.ts:4306/:4880so theassert_contributionany_ofcheck can see it) — it never mutates it. The mutation atrunner.ts:2737lives inexecuteStoryboardPass, notexecuteStep. Without the re-add, a contributing step run stepwise would never surface its own flag.code-reviewerconfirmed. - No collision on the return: the full-run path never sets
contributionsonStoryboardStepResult, soreturn { ...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 guardsArray.isArray+ per-element string check,@filedelegates toparseJsonFlag, CSV path trims/filters. Malformed bracket input routes toJSON.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_ifis silently dropped in stateless step mode.runner.ts:3499callsevalContributesIf(found.step.contributes_if, new Map())— empty prior-results map means any step carryingcontributes_if: prior_step.X.passedevaluates false and never records its contribution (runner.ts:5936-5943). A storyboard that passes in full-run mode can therefore fail a downstreamassert_contributiongate when Addie runs it row-by-row. Pre-existing (the empty map predated this PR), and the new test uses nocontributes_ifso 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 threadingprior_step_resultsthroughStoryboardRunOptionsif Addie needs conditional-contribution storyboards.- Human-mode step output doesn't print
contributions.--jsonround-trips cleanly (output keycontributions↔ flag--contributions— good symmetry for an LLM orchestrator), but the non---jsonstep printer showsContext:and not the contribution flags. Mirror it with aContributions: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
--contextbut nothing tells a reader the step result emits acontributionsarray meant to be fed back. One EXAMPLES line showing step N → step N+1 threading would close it.
Minor nits (non-blocking)
contributionsValueis a dead return field.bin/adcp.js:1148adds it toparseAgentOptions, buthandleStoryboardStepCmdre-parses--contributionsstraight fromargs(bin/adcp.js:4520) and nothing readsopts.contributionsValue. Consistent with the also-deadcontextValue/requestValuesiblings — pre-existing pattern, not a regression. Drop all three or wire them, whenever someone's in there.- Changeset type.
patchfor a change that adds two optional interface fields and a CLI flag is defensible as an additive bugfix, but it's additive public surface —minoris 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.
There was a problem hiding this comment.
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:3484seedsnew Set(options.contributions ?? [])beforeexecuteStep, and that same Set flows intoValidationContext.contributions(runner.ts:4306), which is what theany_ofvalidation backing the syntheticassert_contributiongate (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 inexecuteStoryboardPass(runner.ts:2743) and the new step-mode block (runner.ts:3499-3503).executeSteponly reads the set, never mutates it — so step mode has exactly one writer. - No mutation-aliasing.
new Set(...)copies the caller's array andArray.from(contributions)returns a fresh one;options.contributionsis 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 handspriorStepResults: new Map()toexecuteStep(runner.ts:3486); acontributes_ifpredicate 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 (nocontributes_if) records unconditionally in both modes.- Normalization holds in step mode.
runStoryboardStepBodyruns the shape pass that resolvescontributes: true→contributes_to, sofound.step.contributes_tois populated before the new block reads it — thecontributes: trueregression covers the legacy shorthand path. - Types are additive-optional.
contributions?: string[]added to bothStoryboardRunOptionsandStoryboardStepResult; 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
patchon additive public surface. Two new optional fields on exported interfaces plus a new CLI flag is conventionallyminor. It's non-breaking either way sopatchships nothing untracked — not worth churning the release, but worth knowing the next additive option will wantminor. --contributionshelp text names the input but not the output it pairs with.bin/adcp.jsOPTIONS says "Pass contribution flags from previous step" but doesn't say the value is thecontributionsarray off the prior step's--jsonresult. An agent generating a step loop reads--help, nottypes.ts.dx-experthas concrete copy. Also: the non---jsonstep printer echoesContext:but neverContributions:, so a human driving steps by hand can't see what to thread forward without--json.
Minor nits (non-blocking)
contributionsValueout ofparseAgentOptionsis dead.bin/adcp.jsplumbs it out buthandleStoryboardStepCmdre-parses--contributionsindependently. Harmless — it mirrors the pre-existingcontextValue/requestValue, which are also computed-and-unread. Symmetry or deletion, your call.parseStringListFlagmis-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.
Summary
runStoryboardStep()results/options.--contributions JSON|CSVtoadcp storyboard stepso step-by-step orchestrators can pass the accumulator forward.contributes: truenormalization and Addie-style step-by-stepassert_contributionexecution.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 emptycontributionsset for every row. Addie-style row-by-row execution could therefore pass a contributing branch step, then fail the later syntheticassert_contributionbecause the accumulator was empty.Validation
npm run build:libnode --test test/lib/storyboard-branch-set-grading.test.jsnpx commitlint --from origin/main --to HEAD --verbose