flatten-workflow-parallel-arrays-before-collecting · v1.0.0 · 2026-08-12 · sha256 cebd7d83c252bc2c

flatten-workflow-parallel-arrays-before-collecting v1.0.0A

Immutable. This exact content is served forever at /api/v1/blob/cebd7d83c252bc2c.

---
name: flatten-workflow-parallel-arrays-before-collecting
description: |
  Claude Code Workflow tool gotcha: a pipeline() stage that returns
  parallel([...]) resolves to a BARE ARRAY, but a sibling stage that returns
  {dimension, verified: []} resolves to an OBJECT — so a collector written as
  `per.flatMap(p => Array.isArray(p?.verified) ? p.verified : [])` SILENTLY
  DROPS every item from the array-shaped stages. Use when: (1) a fan-out
  review/verify workflow reports "0 confirmed / 0 refuted" or "0 findings" but
  the agent count / journal shows verify agents actually ran; (2) a
  loop-until-dry or review workflow looks suspiciously CLEAN; (3) you wrote a
  pipeline whose stage-2 conditionally returns parallel(...) for some items and
  an object for others. The "0 findings" is a FALSE all-clear from a
  result-collection bug, not a real result. Fix: handle BOTH shapes in the
  collector.
author: Claude Code
version: 1.0.0
date: 2026-06-04
disable-model-invocation: true
---

# Workflow pipeline(): a stage returning parallel() is a bare array the collector silently drops

## Problem
In the Claude Code **Workflow** tool, `pipeline(items, stage1, stage2, …)` runs each item
through the stages. A common fan-out-then-verify shape makes stage-2 return **different
shapes per item**:

```js
const per = await pipeline(DIMENSIONS,
  d => agent(findPrompt, {schema: FINDINGS}),          // stage 1
  (findings, d) => {
    if (!findings.confirmed.length)
      return { dimension: d.key, verified: [] };        // OBJECT shape (no findings)
    return parallel(findings.confirmed.map(f => () =>   // BARE ARRAY shape (has findings)
      agent(verifyPrompt(f), {schema: VERDICT}).then(v => ({...f, verdict: v}))));
  });
```

`per[i]` is now an **object `{verified: […]}`** for dimensions that found nothing, but a
**bare array `[{…verdict…}, …]`** for dimensions that DID find candidates (because
`parallel()` resolves to an array). A collector written for only the object shape:

```js
const all = per.flatMap(p => Array.isArray(p?.verified) ? p.verified : []);  // BUG
```

evaluates `Array.isArray(undefined)` → false for every array-shaped element → returns `[]`
→ **silently drops all verdicts from exactly the dimensions that found bypasses.** The
workflow returns `confirmed: 0, refuted: 0` — a **false "all clear."** The real tell:
`agentCount` is high and the journal shows `verify`/`v2:` agents both `started` AND
`result`-ed, yet `confirmed_count + refuted_count == 0` (verify agents only spawn for
candidates, so candidates existed).

## Context / Trigger Conditions
- A `Workflow` script using `pipeline()` where a later stage conditionally returns
  `parallel(...)` (adversarial-verify / review / loop-until-dry patterns).
- The workflow result is "0 confirmed / 0 refuted" or "0 findings" — suspiciously clean.
- The completion summary's `agentCount` (or the journal under
  `…/subagents/workflows/wf_*/journal.jsonl`) shows verify agents ran, contradicting "0".

## Solution
Handle BOTH shapes in the collector (this is what the canonical multi-stage example in the
Workflow docs does, and it's easy to drop when simplifying):

```js
const all = per.flatMap(p =>
  Array.isArray(p?.verified) ? p.verified
  : (Array.isArray(p) ? p : []));        // <-- the array-shape branch the bug omits
```

Better still, make stage-2 return ONE consistent shape (always an object, or always an
array) so the collector can't diverge. If a phase()/coverage map reads `p.attacks_run_count`,
that's also `undefined` for the array-shaped items — another symptom.

**Recovery without re-running the agents:** edit the collector line in the persisted script
(`…/workflows/scripts/<name>-<runId>.js`) and **resume** with
`Workflow({scriptPath, resumeFromRunId})` — the find + verify agents are cached and return
instantly; only the fixed synthesis re-runs. The dropped findings reappear.

## Verification
- After the fix/resume, `confirmed_count + refuted_count` matches the number of verify
  agents that ran (journal `result` lines for `v*:` keys).
- A dimension that genuinely found a real bypass now shows up in `confirmed`, not nothing.

## Notes
- This is the **inverse of trusting a clean review** — like a green test that asserts
  nothing, a "0 findings" workflow can mean "found nothing" OR "collected nothing."
  Distrust a clean adversarial round; verify the agents attacked (journal / agentCount)
  before believing it. See [[code-review-subagent-fabricates-specifics-to-inflate-severity]]
  and [[per-item-tryexcept-zero-ok-looks-like-hang]] (a zero/empty that means "broke", not "fine").
- `parallel()` also resolves a thrown thunk to `null`, so always `.filter(Boolean)` the
  flattened array before using verdicts.
- The journal lives at
  `~/.claude/projects/<proj>/<session>/subagents/workflows/wf_<id>/journal.jsonl`
  (`{type:'started'|'result', key, agentId}` lines) — the fastest way to confirm agents ran.