* chore(#3677): checkpoint design artifacts (gitignored, dev-only) * test(#3677): add failing regression test for the crash-window duplicate-dispatch gap (RED) Independently re-traces resume-mode.md/planner-wave.md/worktree-dispatch.md/ merge-wave.md and src/quick-batch.cts's resumeBatch (lines 894-899) and confirms the prior research pass's Open Question 1: a coordinator crash between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge) leaves BATCH.json at "pending" with no STATE.md row yet (only written in Step 9), so --resume's eligibility re-derivation would dispatch a second executor into a new worktree for the same item, orphaning the first. This test asserts worktree-dispatch.md's Step 6 excludes an item whose SUMMARY.md already exists from the spawn set, mirroring planner-wave.md's existing PLAN.md-existence check one layer earlier. Fails against the current worktree-dispatch.md, which has no such guard. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1 for the full trace and fix-location rationale. * fix(#3677): guard worktree-dispatch.md against re-dispatching an already-executed item (GREEN) worktree-dispatch.md's Step 6 re-derives eligibility every dispatch round via the same quick-batch resume call resume-mode.md uses, but had no check for "did this item already finish executing" the way planner-wave.md already checks "did this item already get planned" (PLAN.md existence) before re-planning. A coordinator crash between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge) left the item eligible for a second dispatch on --resume, orphaning the first worktree's real, already- committed work and silently losing it once the second executor's SUMMARY.md write clobbered the first at the same item_dir path. Adds a SUMMARY.md-existence exclusion before spawn-plan is computed, symmetric to planner-wave.md's PLAN.md check. The excluded item is not lost: merge-wave.md's own mergeable-wave criterion (status=pending, SUMMARY.md on disk, not yet merged) already picks it up independently of this eligible/spawn list. Workflow-prose-only fix — touches no already-merged/reviewed .cts module. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1 for the fix-location rationale (why not resumeBatch itself). * test(#3677): add real-git coverage for worktree-ownership tampering, scope drift, and submodules Closes the three coverage gaps identified in 40-design.md §2/§3 (#3677, epic #3344 Phase 5's own AC bullets: "arbitrary-worktree ownership attempts", "scope drift", "submodules"): - Arbitrary-worktree ownership tampering: a manifest entry naming a non-agent branch is silently dropped at normalization before any git subprocess runs; a manifest entry naming a plausible agent-branch that was never actually created by this repo's own worktree.create (a genuinely foreign repo/branch) is blocked via base_mismatch. Both leave the foreign location and repoRoot's HEAD provably untouched. - Advisory scope drift: a committed path outside declared files_modified still merges successfully (advisory, never blocking) while surfacing a scope_out_of_declared warning naming the drifted path; an exact declared-scope match produces zero warnings (boundary case). - Real .gitmodules submodule integration: a repo containing a real local git submodule merges cleanly through executeWorktreeWaveCleanupPlan for an unrelated plan; a real gitlink pointer bump (declared) merges cleanly with the superproject tree reflecting the new pinned commit; an undeclared bump is advisory-only and surfaces a scope warning naming vendor/sub, same as any other undeclared modification. No src/*.cts changes — all three gaps were coverage-only; the underlying primitives already behaved correctly (independently verified against real git subprocess output before writing each assertion). * docs(#3677): document how to diagnose a preserved quick-batch worktree Extends the one-sentence "worktree is preserved (never deleted)" mention into a concrete diagnosis procedure: where the preserved directory is, how to read the executor's real commits/diff against the plan's declared files_modified, how to read the item's own SUMMARY.md independent of merge outcome, how to manually merge-and-clean-up or discard, and how to re-run --resume afterward. Also documents that a SUMMARY.md-written-but-still- pending item (the crash-window case fixed in this same PR) needs no manual intervention — --resume routes it straight to the merge step. * chore(#3677): checkpoint final acceptance-evidence mapping (gitignored, dev-only) * fix(#3677): make crash-window duplicate-dispatch guard behaviorally provable and durably recoverable Orthogonal review (Spec finding): the crash-window regression test added earlier this phase only asserted readStep('worktree-dispatch.md') + regex matches against the markdown prose — proving the DOCUMENTATION says the right thing, never that the runtime condition (pending status + on-disk SUMMARY.md + absent STATE row) is actually handled correctly. #3677's own "Alternatives considered" explicitly rejects "document recovery without fault injection" for exactly this reason. Extracts the filtering decision into a pure, independently testable function, filterAlreadyExecuted(eligibleIds, executedIds) in src/quick-batch-dispatch.cts, wired to a new `quick-batch filter-executed` CLI verb (src/quick-batch-command-router.cts) — the same pure-decision- then-CLI-wired pattern computeSpawnPlan/computeMergeOrder already establish. worktree-dispatch.md now calls this verb explicitly instead of only describing the decision in prose. A genuine fixture-based test in tests/quick-batch.test.cjs constructs a REAL BATCH.json (createBatch), writes a REAL SUMMARY.md on disk at the item's real item_dir, calls the REAL resumeBatch, and proves both that resumeBatch alone still reports the item eligible AND that filterAlreadyExecuted (fed a real filesystem check) correctly excludes it. The prior prose-assertion tests are kept — they now prove the workflow markdown is correctly WIRED to the verb — but are no longer the only proof. Self-discovered defect while building that fixture (fixed inline, not deferred): tracing merge-wave.md against /gsd:quick's own prior art (QUICK_WORKTREE_MANIFEST=$(mktemp ...), quick.md:415) showed $QUICK_BATCH_WORKTREE_MANIFEST is a fresh PER-PROCESS temp file. A resumed coordinator correctly does not re-dispatch an already-executed item (this fix), but nothing durably recorded that item's worktree_path/branch/base either — Step 7 in the resumed process would have had no data to build its cleanup-wave entry from. Adds dispatched_worktree/dispatched_branch/ dispatched_base to QuickBatchItem (src/quick-batch.cts) — deliberately NOT a reuse of the pre-existing `worktree` field, whose loadBatch validation requires the path to exist on disk (verified empirically: reusing it made the batch permanently unloadable the moment a legitimately-merged worktree was removed). worktree-dispatch.md persists the triple once a worktree is created; merge-wave.md falls back to it when the ephemeral manifest lacks an entry, clears it after a successful merge, and fails closed rather than guessing if no record exists anywhere. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.1 and §9.3 for the full trace, empirical verification notes, and rejected alternatives (reusing `worktree` directly). * test(#3677): prove the arbitrary-worktree-ownership boundary against two real sibling worktrees Orthogonal review (Security finding): the two existing ownership-tampering tests didn't test ownership — one was trivially rejected by WORKTREE_AGENT_BRANCH_RE's shape check before any git call (proves branch- NAME filtering, not ownership), the other pointed at a wholly separate, never-linked foreign repo, so merge-base failed immediately because the branch didn't exist as a ref at all. Neither exercised the real scenario: a manifest entry whose worktree_path/branch are swapped to point at a DIFFERENT, GENUINELY-REGISTERED sibling worktree of the SAME repoRoot, with a branch name passing the shape check and a base in allowed_bases. Investigated executeWorktreeWaveCleanupPlan (src/worktree-safety.cts) directly: this is NOT a reachable gap. Git enforces branch-per-worktree uniqueness, so a swapped-in entry.branch can only match worktree_path's ACTUAL checked-out branch if it names that sibling's own real, uniquely- generated branch name — which manifest tampering confined to one batch's own record has no way to know (branch names are agent-<quick_id>[-<timestamp>]-shaped, and quick_id allocation is collision-checked GLOBALLY across every existing quick task and batch, not merely within one batch). Adds a stronger test that empirically proves this: two REAL, concurrently- alive sibling worktrees of the same repo (both via real `git worktree add`, both WORKTREE_AGENT_BRANCH_RE-passing, both sharing one merge-base), with worktree_path/branch swapped between them in both directions. Both attempts are blocked via branch_mismatch; both real worktrees, their branches, and one sibling's real uncommitted-to-main commit survive completely untouched. Supplements (does not replace) the original two tests, which still prove distinct, real boundaries. See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.2 for the full trace, including the one explicitly-documented (not fixed) trust boundary this investigation surfaced: the primitive defends against fabricated data, not a caller bug that misattributes a real-but-wrong item's own triple to a different item. * chore(#3677): checkpoint design-doc addendum for review pass 2 findings (gitignored, dev-only) * docs(#3677): add changeset for PR 4240 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
8.2 KiB
How to batch quick tasks
/gsd-quick-batch runs several /gsd-quick-shaped tasks together as ONE
coordinated run: one coordinator parses the task list, plans and dispatches
each item (planner, and optionally researcher/plan-checker/verifier leaves),
merges them in a deterministic order, and owns every shared write
(BATCH.json, STATE.md, worktree create/merge/cleanup) so the leaves never
race each other (ADR-1239 "Quick-batch binding").
Use it instead of running /gsd-quick N separate times when you have several
independent (or lightly interdependent) small tasks you want planned and
executed together, with parallelism where the tasks allow it.
For the single-task case, see Handle quick and fast tasks.
Basic use
Pass an inline task list — a bulleted or numbered list, at least 2 items, one per line:
/gsd-quick-batch
- Fix the login timeout on mobile Safari
- Add a retry banner when the API call fails
- Update the README's setup instructions
Or point at a file containing the list:
/gsd-quick-batch --file .planning/my-tasks.md
Each item gets its own quick id, its own directory under
.planning/quick/, and (when isolation is available) its own worktree — the
same artifact shape a standalone /gsd-quick task produces, just planned and
dispatched together.
Flags
| Flag | What it does |
|---|---|
--jobs auto|N |
auto (default) uses the negotiated dispatch capacity as-is. N caps effective concurrency at min(task count, N, capacity) — never more than the number of tasks, never more than what the runtime negotiated. A non-numeric or non-positive N is rejected before any dispatch. |
--validate |
Enables the per-item plan-checker loop (max 2 iterations, same cap as /gsd-quick --validate) and post-merge verification. |
--research |
Dispatches a focused researcher per item before planning. |
--resume <batch-id> |
Skips task-list parsing and batch creation entirely — loads the existing batch and dispatches only its still-eligible items. |
Not supported in v1: --discuss and --full are rejected with a usage
error before any dispatch. If a task genuinely needs a discussion phase, run
it through /gsd-quick --discuss on its own instead of including it in a
batch.
/gsd-quick-batch --jobs 2 --validate --research # research, up to 2 concurrent, plan-checked + verified
/gsd-quick-batch --resume 260101-abc # resume an interrupted batch
How capacity and isolation interact
Effective concurrency is computed from three things: --jobs, the
negotiated dispatch capacity (how many subagents your runtime can run at
once), and — for the mutating stage (worktree create → execute → merge) —
the isolation mode:
| Isolation | Effect on the mutating (executor/worktree) stage |
|---|---|
harness-worktree / orchestrator-worktree |
Runs up to the effective concurrency computed above. |
none (no worktree isolation available, or workflow.use_worktrees=false) |
Forced to concurrency 1, regardless of --jobs or capacity — everything executes sequentially on the primary checkout. |
This cap applies only to the mutating stage. Planning and research are
never worktree-isolated, so they run at full effective concurrency even when
isolation is none.
Worktree creation, merging, and cleanup are always serialized one at a time
(git worktree add/git merge/git worktree remove never overlap) —
concurrency is about how many already-created worktrees' agents run at once,
not about the git operations themselves. Merges apply in the same
deterministic order the batch's dependency/file-overlap waves were computed
in, never in whichever order an executor happens to finish first.
Dependencies and file overlap
Before planning, /gsd-quick-batch has no signal about which items depend
on each other or touch the same files — every item starts in the same wave.
Each item's planner is shown the full batch's task catalog (every item's id
and description) and is required to declare, in its plan's frontmatter,
which sibling items (if any) it depends on and which files it will touch.
After each planning round, the coordinator recomputes execution waves from
those declarations — independent items with disjoint files run in parallel;
a dependent item's wave always comes strictly after its dependency's.
Resuming and failure recovery
A batch's BATCH.json (.planning/quick-batches/<batch-id>/) tracks every
item's status. Re-run with --resume <batch-id> at any point — including
after a crash — and the coordinator re-derives which items are still
runnable:
| Outcome | What happens | Recoverable via --resume? |
|---|---|---|
| Item completes normally | Marked complete; a Quick Tasks Completed STATE.md row is appended. |
N/A |
Verifier reports human_needed (--validate only) |
Terminal for that item — no STATE row is appended. Review it yourself, then fix and re-run if needed. | Yes, once resolved |
Verifier reports gaps_found (--validate only) |
The item is marked failed. Its already-merged commit is not rolled back, and there is no automatic gap-fix retry. |
Yes — resume re-evaluates it |
| A merge conflicts, or a committed diff includes an undeclared file deletion | The item is marked failed with a reason; its worktree is preserved (never deleted) so you can inspect what happened. |
Yes, after you resolve the worktree by hand |
| An item this item depends on failed | The dependent item is automatically marked blocked on the next --resume. |
Yes, once the blocking item is resolved |
Items unrelated to a failure continue normally in the same or a later batch run — one item's problem never blocks the rest of the batch.
Diagnosing a preserved worktree
When a merge conflicts or a committed diff includes an undeclared file deletion, the coordinator preserves that item's worktree instead of deleting it, so you can inspect exactly what the executor did:
- Find it. The preserved directory is
<repo-root>/../<quick_id>-<slug>-wt/(or wherever your runtime's worktree layout places it) — the item's own quick directory,.planning/quick/<quick_id>-<slug>/, still has thePLAN.mdthe executor was given, which tells you what it was trying to do. - See what actually changed. From the preserved worktree:
git log <base>..HEADshows the executor's real commit(s);git diff <base>...HEAD --statshows exactly which files it touched (compare that againstPLAN.md's declaredfiles_modifiedif you want to confirm whether the failure was a genuine conflict or an out-of-scope change). - Read the item's own
SUMMARY.mdin its quick directory — the executor wrote it before the merge was attempted, so it still describes what the executor believed it accomplished, independent of whether the merge itself succeeded. - Decide how to resolve it:
- If the work is good and only the automated merge failed (a real
conflict, or a deletion that should have been declared): merge the
worktree's branch by hand (
git merge <branch> --no-ff), resolve any conflicts, then remove the worktree yourself (git worktree remove <path> --force) and its branch (git branch -D <branch>). - If the work should be discarded: remove the worktree and branch the same way, without merging.
- If a coordinator crash left the item's
SUMMARY.mdwritten but itsBATCH.jsonstatus stillpending(the executor finished before the coordinator process crashed, before the merge step ran) — this is NOT a preserved-worktree failure and needs no manual merge.--resumerecognizes the on-diskSUMMARY.mdand routes the item straight to the merge step on its own; it will not re-dispatch a second executor for it.
- If the work is good and only the automated merge failed (a real
conflict, or a deletion that should have been declared): merge the
worktree's branch by hand (
- Re-run
/gsd-quick-batch --resume <batch-id>once you're done. Afailed/merge_failed/scope_violationitem you resolved manually (merged and cleaned up yourself) is picked up as already-merged on the next resume; an item you decided to abandon staysfailedand is skipped.