fix(emit): never emit a wave whose parallel jobs share one checkout - #14
Merged
Merged
Conversation
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']].
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes defect 2 of #12.
The defect, from the invariants
execution-manifest.md:359declares invariant 7 enforced: parallel jobs sharing one tree "would also see its siblings' writes, yielding a false BLOCK", sorun: parallelimpliesisolation: worktree. The validator enforces it on the manifest field andpartition-reviewerverifies it.The emitter then sets
agent_isolation: Nonefor any claude job withdepends_onwhen the project lacksworktree.baseRef: head(emit-workflow.py:2028-2032), andtopo_wavesnever 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: "", identicaldiff_digest;task-1's violations are preciselytask-2's sixwrite_allowedlanes,task-2's aretask-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.jscarriesagent_isolation: nullfor 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 shippedworktree.baseRef: headsincef9bb486, 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)
job_entry'sagent_isolationexpression, 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-directclaude job, which is equally a main-tree writer; invariant 7 makes two of those unreachable through a validated manifest, butbuild_plandoes not validate and the predicate should not lean on that.dispatch.workflow.jsis 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 asisolation_notes, empty on every run that was not re-shaped.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.wavesis 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 samebuild_plan. External backends are excluded by the predicate.Known limit, stated:
_worktree_base_is_headreads only the project's.claude/settings.json. Claude Code also honourssettings.local.jsonand 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. Fullscripts/*.py --selftestsweep,lint-frontmatter,rules-lintgreen locally.Note on CI
The red
Full test suiteistests/test-native-points.sh, which fails 2 PRECOMPACT rows on a cleanmaintoo - the hardcoded-date time bomb that #11 fixes. Not from this branch.