Skip to content

fix(emit): never emit a wave whose parallel jobs share one checkout - #14

Merged
copeus merged 3 commits into
procoders:mainfrom
khymerao:fix/parallel-worktree-downgrade
Sep 11, 2026
Merged

copeus merged 3 commits into
procoders:mainfrom
khymerao:fix/parallel-worktree-downgrade

Conversation

@khymerao

@khymerao khymerao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes defect 2 of #12.

The defect, from the invariants

execution-manifest.md:359 declares invariant 7 enforced: parallel jobs sharing one tree "would also see its siblings' writes, yielding a false BLOCK", so run: parallel implies isolation: worktree. The validator enforces it on the manifest field and partition-reviewer verifies it.

The emitter then sets agent_isolation: None for any claude job with depends_on when the project lacks worktree.baseRef: head (emit-workflow.py:2028-2032), and topo_waves never re-checks (:745-806). Two such jobs in one wave are exactly the parallel+direct shape invariant 7 forbids: each direct-mode gate measures the whole tree minus a per-job before-image taken at register (:3458), so each attributes the other's writes and both BLOCK - deterministically, whenever both write before either gates.

The manifest passes a check the run does not honour, and the failure is reported as a scope violation by a worker that stayed in its lane.

Verified on the reporting project's records, not asserted: two dependents, both mode: direct, worktree: "", identical diff_digest; task-1's violations are precisely task-2's six write_allowed lanes, task-2's are task-1's two files plus seven run-dir artifacts; snapshot mtimes 09:01:35/36 against receipts at 09:10 and 09:12, so the overlap is proven rather than inferred. dispatch.workflow.js carries agent_isolation: null for every job.

This is not a designed signal. The author's own words call it a hazard: emit-workflow.py:3761 "the gate cannot tell two writers apart"; TROUBLESHOOTING.md:180 "several dependent jobs in the same wave share one checkout and can collide"; CHANGELOG.md:946 "Never seen before because every earlier dependent job was manifest-direct". This repository has shipped worktree.baseRef: head since f9bb486, so its own dogfooding never reaches the branch.

The fix

After topo_waves, a wave carrying 2+ main-checkout claude jobs is split so each runs alone. A wave is already a barrier, so more barriers is strictly safer, and one writer at a time is what attribution can handle. Jobs that get real worktrees - and every external backend, which owns its own tree and is gated in worktree mode - keep their parallelism.

Why serialize rather than refuse. House style leans fail-closed, and refusal at partition-review time is the honest alternative. I chose availability: the run still completes, one job at a time, and the operator is told what was re-shaped and which setting restores full parallelism. Refusing would turn a working-if-slower run into no run at all for a condition the project can fix in one line. Say the word and I will flip it to a refusal.

Corrections since the first revision (from review)

  • The predicate is now the exact negation of job_entry's agent_isolation expression, not a second copy of the positive condition - two expressions to keep in sync is the drift class the emitter itself warns about. It therefore also counts a manifest-direct claude job, which is equally a main-tree writer; invariant 7 makes two of those unreachable through a validated manifest, but build_plan does not validate and the predicate should not lean on that.
  • The decision is no longer ephemeral. It was one stderr line, gone the moment emit finished, while dispatch.workflow.js is what gets committed - an auditor comparing the reviewed partition (N waves) with the run (N+k) had no explanation. It is now carried on the plan as isolation_notes, empty on every run that was not re-shaped.
  • README corrected. It listed the setting under Requirements as something that "has to be" head, which this change makes untrue - the run completes without it. README and TROUBLESHOOTING now both say the setting buys parallelism, not correctness.

Regressions considered

Resume relaunches the committed script, so the split is frozen with it. state.json.waves is written by the emitter and read by no other script. The residual dispatcher takes waves from the emit report. Split waves are subsets, so still <= max_parallel. The serial-job rule runs before this. Epic uses the same build_plan. External backends are excluded by the predicate.

Known limit, stated: _worktree_base_is_head reads only the project's .claude/settings.json. Claude Code also honours settings.local.json and the user-level file; someone who set the key there gets serialized and told to set it. That is pre-existing conservatism, and the note text names the file it actually reads.

Tests

534/534 (was 526/526). Mutation-checked - disabling the split fails the wave assertion with [['a'], ['b', 'c']], which is the defect itself. Full scripts/*.py --selftest sweep, lint-frontmatter, rules-lint green locally.

Note on CI

The red Full test suite is tests/test-native-points.sh, which fails 2 PRECOMPACT rows on a clean main too - the hardcoded-date time bomb that #11 fixes. Not from this branch.

A job with `depends_on` has its agent downgraded from `isolation: worktree` to the
MAIN checkout unless the project pins `worktree.baseRef: "head"` in
`.claude/settings.json`. For ONE such job per wave that is fine and the existing
comment says why: direct mode attributes writes with a single before-image.

For TWO in one wave it cannot work. One before-image cannot separate two concurrent
writers, so each job's diff contains the other's files and BOTH are BLOCKED for
out-of-lane writes - a run guaranteed to fail before any code is judged. Observed on
a seven-job run: wave 2 ran two dependents in parallel and each blocked carrying the
other's entire lane.

It is also an invariant violation. compound-v-validate-manifest.py REQUIRES
`isolation: worktree` for parallel jobs and partition-reviewer verifies it; the
emitter then downgrades it at runtime, so the manifest passes a check the run does
not honour and the failure surfaces two waves later as an out-of-lane BLOCK.

This is invisible to this repository specifically: superpowers-v's own
.claude/settings.json contains {"worktree": {"baseRef": "head"}}, so
_worktree_base_is_head is always True when dogfooding here. Every project without
that file is exposed by default.

Fix: after topo_waves, split a wave carrying 2+ downgraded jobs so each gets its own
wave - a wave is already a barrier, so more barriers is strictly safer, and one
writer at a time is the case attribution can handle. Jobs that were NOT downgraded
keep their real worktrees and stay together, so parallelism is only given up where
it could not have worked. The downgrade was previously silent; emit now says on
stderr which jobs were serialized and which setting restores full parallelism.

Selftest: 5 new rows. With the guard disabled the wave check fails
(`[['a'], ['b', 'c']]`), which is the defect itself.
Review findings on the previous commit.

- FALSE POSITIVE on external backends. `_agent_isolation_downgraded` read the
  manifest's `isolation`, but `job_entry` pins a non-claude job to `direct` and the
  emitted script still gates it in worktree mode over the worker-owned tree
  (the `externalBackend` branch). A codex/cursor dependent job is fully
  attributable, so treating it as downgraded cost parallelism for nothing. The
  predicate now returns False for any backend other than claude, and the docstring
  no longer claims to mirror an expression it does not.
- Partition by job id rather than dict equality: `j not in downgraded` compared
  dicts by VALUE, which is safe only while topo_waves rejects duplicate ids.
- TROUBLESHOOTING.md said "several dependent jobs in the same wave share one
  checkout and can collide with each other's edits". This change makes that stale:
  the shape is no longer emitted. Rewritten to say what `baseRef: head` now buys —
  parallelism, not survival — and that a dependent wave without it still completes,
  one job at a time.

Selftest: 533/533 (was 531). Two new rows — the predicate is False for an external
backend, and two codex dependents keep their shared wave.
…ave is re-shaped

execution-manifest.md declares invariant 7 ENFORCED: parallel jobs sharing one tree
"would also see its siblings' writes, yielding a false BLOCK", so
`run: parallel` implies `isolation: worktree`. The validator enforces it on the
manifest field. The emitter then sets `agent_isolation: None` for any claude job
with `depends_on` when the project lacks `worktree.baseRef: head`, and `topo_waves`
never re-checks. Two such jobs in one wave are exactly the parallel+direct shape
invariant 7 forbids: each direct-mode gate measures the whole tree minus a per-job
before-image taken at register, so each attributes the other's writes and BOTH
block - deterministically, whenever both write before either gates.

Verified on the reporting project's records: two dependents, `mode: direct`,
`worktree: ""`, identical diff_digest; each one's violations are precisely the
other's write_allowed lanes.

Fix: after `topo_waves`, a wave carrying 2+ main-checkout claude jobs is split so
each runs alone. A wave is already a barrier, so more barriers is strictly safer,
and one writer at a time is what attribution can handle. Jobs that get real
worktrees, and every external backend (which owns its own tree and is gated in
worktree mode), keep their parallelism.

Review corrections since the first revision:

- The predicate is now the exact NEGATION of `job_entry`'s `agent_isolation`
  expression rather than a second copy of the positive condition, so it cannot
  drift. That also makes it count a manifest-`direct` claude job, which is equally
  a main-tree writer; invariant 7 makes two of those unreachable through a
  validated manifest, but `build_plan` does not validate and the predicate should
  not depend on that.
- The decision is no longer ephemeral. A stderr line is gone the moment emit
  finishes while `dispatch.workflow.js` is what gets committed, so an auditor
  comparing the reviewed partition (N waves) with the run (N+k) had no explanation.
  It is now carried on the plan as `isolation_notes`, empty on every run that was
  not re-shaped.
- README said the setting "has to be" `head`. This change makes that untrue: the
  run completes without it, one job at a time. README and TROUBLESHOOTING now say
  the setting buys parallelism, not correctness.

Selftest: 534/534 (was 526). Mutation-checked - disabling the split fails the wave
assertion with [['a'], ['b', 'c']].
@copeus
copeus merged commit 973afdc into procoders:main Sep 11, 2026
1 of 2 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