Files
msd-core/docs/how-to/batch-quick-tasks.md
Tom Boucher 515191f07d feat(#3677): quick-batch hardening and acceptance (#4240)
* 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>
2026-09-03 09:47:22 -04:00

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:

  1. 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 the PLAN.md the executor was given, which tells you what it was trying to do.
  2. See what actually changed. From the preserved worktree: git log <base>..HEAD shows the executor's real commit(s); git diff <base>...HEAD --stat shows exactly which files it touched (compare that against PLAN.md's declared files_modified if you want to confirm whether the failure was a genuine conflict or an out-of-scope change).
  3. Read the item's own SUMMARY.md in 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.
  4. 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.md written but its BATCH.json status still pending (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. --resume recognizes the on-disk SUMMARY.md and routes the item straight to the merge step on its own; it will not re-dispatch a second executor for it.
  5. Re-run /gsd-quick-batch --resume <batch-id> once you're done. A failed/merge_failed/scope_violation item you resolved manually (merged and cleaned up yourself) is picked up as already-merged on the next resume; an item you decided to abandon stays failed and is skipped.