enhance(#3777): opt-in concurrent per-plan planners in chunked mode (#4346)

* test(#3777): add failing-first coverage for concurrent per-plan planner dispatch

Extracts and executes the real bash blocks this PR is about to add to
plan-phase.md and chunked-planning-mode.md (CHUNKED_PARALLEL resolution and
the BATCH_PLAN_IDS dedup guard), plus config-set/config-get coverage for the
new planning.chunked_parallel key. Expected RED against the current shipped
workflow text — the extraction anchors do not exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(#3777): dispatch chunked mode's per-plan planners concurrently within a Wave

Adds opt-in planning.chunked_parallel (default false, byte-identical to the
existing serial loop). When true and the runtime's negotiated dispatch
capacity (dispatch-capacity, #3673) is greater than 1, chunked planning's
per-plan Tasks that share one outline Wave are issued together instead of
one at a time; a later Wave still waits for the current one to be verified
on disk and committed. A host with no declared maxConcurrency (most
non-Claude runtimes today) stays serial regardless of the setting.

Resolution and the Plan-ID dedup guard live in chunked-planning-mode.md
itself (gated on the section's own CHUNKED_MODE skip-check) rather than in
plan-phase.md, so a non-chunked run pays no extra gsd_run calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#3777): repoint extraction at chunked-planning-mode.md after the move

CHUNKED_PARALLEL resolution moved out of plan-phase.md into
chunked-planning-mode.md itself (see the preceding commit); update the
test's extraction path and header comment to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3777): relocate the canonical runtime-launcher preamble before its first use

The CHUNKED_PARALLEL resolution block's two gsd_run calls landed earlier in
the file than the sole existing preamble (in the commit step), which
tests/runtime-launcher-parity.test.cjs's (B) check requires to precede every
gsd_run call in the file. Move the preamble (not duplicate it) to the top of
the resolution block; the commit step's fenced block now just calls
gsd_run directly.

Caught by the GREEN checkpoint gsd-test run before push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3777): strip the canonical preamble from the extracted resolution block

The CHUNKED_PARALLEL resolution fence now carries the relocated
runtime-launcher preamble as its first line (previous commit). Extracting
the whole fence and running it after the test's own gsd_run stub let the
embedded preamble's own resolver logic `unset -f gsd_run` and exit 1 before
reaching the resolution logic, since no real gsd-tools.cjs exists in the
temp script dir — every test calling runChunkedParallelResolution() failed.

Strip the preamble (sourced from gsd-core/workflows/_runtime-launcher.snippet.sh,
the same file scripts/sync-runtime-launcher.cjs treats as canonical) before
splicing in the stub, so this suite tests only the resolution logic it is
actually about.

Caught by the post-rebase gsd-test run before push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3777): add the How-To page the phase gate requires

Enablement is 2 commands (config-set, then --chunked), which this repo's
own doc-quadrant gate flags as how-to-owed: a reference table cannot carry
a sequence. Covers enablement, the dispatch-capacity gate's honest
"most runtimes today: no effect" case, and the two accepted trade-offs.

An earlier reasoning pass (recorded in .gsd/phase/.../70-docs.json before
this commit) had incorrectly claimed #3034 shipped with no equivalent
how-to page, as precedent for skipping one here. That claim was false —
docs/how-to/enable-parallel-reviewer-lanes.md exists and is indexed. The
phase gate caught the omission before merge; corrected here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3777): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-05 18:57:17 -04:00
committed by GitHub
parent 1db726ebbf
commit f9f72cb54c
9 changed files with 573 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 4346
---
**Opt-in concurrent per-plan planners in chunked mode** — `/gsd-plan-phase --chunked` can now dispatch the per-plan planner Tasks within one outline Wave concurrently instead of one at a time, via `planning.chunked_parallel` (default `false`). Gated on the runtime's negotiated dispatch capacity, so hosts that cannot usefully background multiple agents stay serial regardless of the setting. (#3777)

View File

@@ -527,7 +527,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.plan_bounce_passes` | number | `2` | Number of sequential bounce passes to run. Each pass feeds the previous pass's output back into the validator. Higher values increase rigor at the cost of latency. Added in v1.36 |
| `workflow.post_planning_gaps` | boolean | `true` | Unified post-planning gap report (#2493). After all plans are generated and committed, scans REQUIREMENTS.md and CONTEXT.md `<decisions>` against every PLAN.md in the phase directory, then prints one `Source \| Item \| Status` table. Word-boundary matching (REQ-1 vs REQ-10) and natural sort (REQ-02 before REQ-10). Non-blocking — informational report only. Set to `false` to skip Step 13e of plan-phase. |
| `workflow.plan_review_convergence` | boolean | `false` | Enable the `/gsd-plan-review-convergence` command. Disabled by default — the command exits with an enable instruction when this key is `false`. The command automates the manual plan→review→replan loop: it spawns configured reviewers (Codex, Gemini, Claude, OpenCode, Ollama, LM Studio, llama.cpp), counts unresolved HIGH concerns and actionable MEDIUM/LOW findings via the CYCLE_SUMMARY contract, replans with `--reviews` feedback, and repeats until converged or max cycles reached. Enable with `gsd config-set workflow.plan_review_convergence true`. Added in v1.39 |
| `workflow.plan_chunked` | boolean | `false` | Enable chunked planning mode. When `true` (or when `--chunked` flag is passed to `/gsd-plan-phase`), the orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3-5 min each). Each plan is committed individually for crash resilience. If a Task hangs and the terminal is force-killed, rerunning with `--chunked` resumes from the last completed plan. Particularly useful on Windows where long-lived Tasks may hang on stdio. Added in v1.38 |
| `workflow.plan_chunked` | boolean | `false` | Enable chunked planning mode. When `true` (or when `--chunked` flag is passed to `/gsd-plan-phase`), the orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3-5 min each). Each plan is committed individually for crash resilience. If a Task hangs and the terminal is force-killed, rerunning with `--chunked` resumes from the last completed plan. Particularly useful on Windows where long-lived Tasks may hang on stdio. See [`planning.chunked_parallel`](#planning-settings) to dispatch the per-plan Tasks concurrently instead of one at a time. Added in v1.38 |
| `workflow.code_review_command` | string | (none) | Shell command for external code review integration in `/gsd-ship`. Receives changed file paths via stdin. Non-zero exit blocks the ship workflow. Added in v1.36 |
| `workflow.tdd_mode` | boolean | `false` | Enable TDD pipeline as a first-class execution mode. When `true`, the planner aggressively applies `type: tdd` to eligible tasks (business logic, APIs, validations, algorithms) and the executor enforces RED/GREEN/REFACTOR gate sequence. An end-of-phase collaborative review checkpoint verifies gate compliance. Added in v1.36 |
| `workflow.mvp_mode` | boolean | `false` | Persist the MVP-mode flag in config so every phase defaults to MVP framing without requiring `--mvp` on the CLI. Resolved via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → this config value → `false`. When `true`, the planner, executor, verifier, and discovery surfaces treat the phase as an MVP vertical slice (UI → API → DB) of one user-visible capability instead of a horizontal layer. |
@@ -664,6 +664,47 @@ The following combinations of `mode`, `granularity`, `model_profile`, and workfl
| `planning.pr_strict` | boolean | `false` | Filter mode for [`/gsd-pr-branch`](COMMANDS.md#gsd-pr-branch). `false` — the generated PR branch keeps structural planning state (`STATE.md`, `ROADMAP.md`, `MILESTONES.md`, `PROJECT.md`, `REQUIREMENTS.md`, `milestones/**`) and drops the transient subdirectories. `true` — every `.planning/` path is dropped, structural files included, and a commit is carried over only when it touches at least one file outside `.planning/`. Applies to the root repository's PR branch only; `planning.sub_repos` companion branches are unaffected |
| `planning.search_gitignored` | boolean | `false` | Add `--no-ignore` to broad searches to include `.planning/` |
| `planning.sub_repos` | array of strings | `[]` | Paths of nested sub-repos relative to the project root. When set, GSD-aware tooling scopes phase-lookup, path-resolution, and commit operations per sub-repo instead of treating the outer repo as a monorepo |
| `planning.chunked_parallel` | boolean | `false` | Opt-in concurrent per-plan planners in chunked mode. See [Concurrent per-plan planners in chunked mode](#concurrent-per-plan-planners-in-chunked-mode-3777) below. |
### Concurrent per-plan planners in chunked mode (#3777)
[`workflow.plan_chunked`](#workflow-toggles) splits a phase's planning into a short outline Task
followed by N short per-plan Tasks, committing each plan individually for crash resilience. By
default those per-plan Tasks still run **one at a time** — a phase with 6 plans at ~3-5 minutes
each pays roughly the sum of their runtimes, even though each plan writes a disjoint
`{plan_id}-PLAN.md` file with no data dependency on its siblings.
```json
{
"planning": {
"chunked_parallel": true
}
}
```
```bash
gsd config-set planning.chunked_parallel true
/gsd-plan-phase 3 --chunked
```
When `true`, the runnable per-plan planners that share one outline Wave are dispatched together
(one message, `run_in_background=true` on each) instead of one at a time; a later Wave still waits
for every plan in the current Wave to be verified on disk and committed, honoring the outline's
Wave column as the schedule. `Depends On` itself is not separately parsed — it is expected to name
only a plan in an earlier Wave, so batching strictly by Wave already respects it; a same-Wave
`Depends On` would be a defect in the outline, not something this dispatch mechanism detects.
**This is gated, not unconditional.** Concurrent dispatch only fires when the runtime's negotiated
dispatch capacity (`gsd-tools query dispatch-capacity`, #3673) is greater than `1`. A runtime that
declares no `maxConcurrency` — most non-Claude runtimes today — resolves to the fail-closed floor
of `1` and stays serial regardless of this setting, exactly like the pre-#3777 loop. Claude Code
declares a capacity of 20, so this setting has an effect there out of the box.
**Trade-offs accepted by this setting.** Per-plan commits interleave within a batch instead of
landing strictly one-at-a-time, and a stalled plan's retry no longer blocks sibling plans in the
same batch that already completed and committed — a mid-batch interrupt leaves whichever plans
finished first already committed, rather than stopping after the last plan in strict outline
order. Default `false` keeps the original serial behavior byte-for-byte.
### Project-Root Resolution in Multi-Repo Workspaces

View File

@@ -47,6 +47,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Verify a dependency-compatibility claim](how-to/verify-a-dependency-compatibility-claim.md) — act on a compatibility claim the researcher left `[ASSUMED]`, and tell "nothing declared" apart from "a constraint is declared" and "the lookup failed"
- [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents
- [Enable parallel reviewer lanes](how-to/enable-parallel-reviewer-lanes.md) — cut a multi-reviewer `/gsd-review` pass toward its slowest lane, and tell a rate-limited lane apart from one that was never selected
- [Enable concurrent per-plan planners in chunked mode](how-to/enable-concurrent-chunked-planning.md) — dispatch chunked `/gsd-plan-phase`'s per-plan Tasks together within one outline Wave instead of one at a time, and know when the setting has no effect
- [Verify and ship](how-to/verify-and-ship.md) — walk through completed work, diagnose failures, and create the PR
- [Catch complexity before it compounds](how-to/act-on-a-refactor-proposal.md) — enable the post-execute refactor hook, read a proposal's score vs. anchor delta, and accept or decline it
- [Run phases autonomously](how-to/run-phases-autonomously.md) — use autonomous mode for unattended phase execution

View File

@@ -0,0 +1,80 @@
# How to enable concurrent per-plan planners in chunked mode
Speed up a multi-plan phase's chunked planning run by letting independent per-plan Tasks run at
the same time instead of one after another.
---
## Before you start
This only matters if you already use chunked mode (`workflow.plan_chunked: true`, or
`/gsd-plan-phase {N} --chunked`). Chunked mode splits planning into a short outline Task followed
by one short Task per plan, committing each plan individually so an interrupted run resumes from
the last committed plan. By default those per-plan Tasks still run one at a time.
## Enable it
```bash
gsd config-set planning.chunked_parallel true
```
```json
{
"planning": {
"chunked_parallel": true
}
}
```
Then run chunked planning as usual:
```bash
/gsd-plan-phase {N} --chunked
```
That's it — no other setting to touch, and no other capability's config is involved.
## What actually changes
Nothing changes for a phase whose outline has only one plan per Wave — there is nothing to run
concurrently. For a phase with several plans in the same Wave, those plans' Tasks are now issued
together instead of one at a time; a later Wave still waits for the current one to finish and
commit before starting, so cross-Wave ordering is unaffected.
## Whether it actually does anything on your runtime
This setting is gated on the runtime's own negotiated concurrency ceiling
(`gsd-tools query dispatch-capacity`), not on the config value alone:
- **Claude Code** declares a capacity above 1, so turning this on has a real effect there.
- **Every other runtime today** (Codex, Cursor, OpenCode, ...) declares no concurrency ceiling and
falls back to the safe floor of `1` — turning this setting on has no effect there; chunked
planning stays exactly as serial as it was before. This is not a bug to work around: it means
the setting never fires concurrent dispatch on a host that cannot usefully run it.
There is no per-runtime flag to set yourself — the gate is automatic and always correct for the
runtime you're on.
## Trade-offs to know before turning this on
- **Per-plan commits interleave.** With serial dispatch, plans commit strictly in outline order.
With concurrent dispatch, whichever plans in a batch finish first commit first — still one
commit per plan, never a combined commit, but not necessarily in outline order within a batch.
- **Crash-resume granularity is coarser.** If a run is interrupted mid-batch, every plan that
already finished and committed stays committed — but "already finished" is no longer
necessarily "everything up to the last plan in outline order," the way serial mode guarantees.
Resuming the run picks up exactly where it left off either way; only the *shape* of what's
already on disk when you resume can differ.
If either of those matters more to you than the speed-up, leave this setting at its default
(`false`) — chunked mode's crash resilience story is otherwise unchanged.
## Turn it off again
```bash
gsd config-set planning.chunked_parallel false
```
Or just remove the key from `.planning/config.json` — `false` is the default.
See also: [Configuration reference — Concurrent per-plan planners in chunked mode](../CONFIGURATION.md#concurrent-per-plan-planners-in-chunked-mode-3777).

View File

@@ -54,6 +54,7 @@
"planning.pr_strict",
"planning.search_gitignored",
"planning.sub_repos",
"planning.chunked_parallel",
"agent_tools",
"review.default_reviewers",
"review.max_prompt_tokens",

View File

@@ -23,7 +23,11 @@ Return:
| {padded_phase}-02 | [brief objective] | 1 | none | REQ-003 |
```
The orchestrator reads this table, then spawns one single-plan Task per row.
The orchestrator reads this table, groups rows by `Wave` (ascending, blank treated as `1`), then
spawns one single-plan Task per row — one Wave at a time, serially across Waves. Within a Wave,
Tasks are spawned one at a time by default, or together (`run_in_background=true` on each, issued
in one message) when `planning.chunked_parallel: true` and the host's negotiated dispatch capacity
supports it (#3777; see `gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md` §8.5.2).
### single-plan

View File

@@ -299,7 +299,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
| `workflow.build_command` | string\|null | `null` | Any shell command | Build gate command run by the post-merge gate. Unset → build step auto-detected/skipped. |
| `workflow.mvp_mode` | boolean | `false` | `true`, `false` | Persist the MVP-mode flag in config so every phase defaults to MVP framing without requiring `--mvp` on the CLI. Resolved via the chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → this config value → `false`. When `true`, the planner, executor, verifier, and discovery surfaces (progress, stats, graphify) all treat the phase as an MVP vertical slice (UI → API → DB) of one user-visible capability. |
| `workflow.context_guard_mode` | string | `"warn"` | `"auto"`, `"warn"`, `"off"` | Context exhaustion guard mode for `execute-phase`. Before each wave, the orchestrator self-assesses context pressure using degradation signals from `context-budget.md`. `"warn"` (default): emit a warning and recommend `/gsd:pause-work` when POOR tier is detected. `"auto"`: automatically invoke `/gsd:pause-work` before the next wave when POOR tier is detected. `"off"`: disable the guard. The guard is heuristic — no programmatic context-% API exists. |
| `workflow.plan_chunked` | boolean | `false` | `true`, `false` | Enable chunked planning mode. When `true`, the plan-phase orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3–5 min each). Each plan is committed individually for crash resilience. Particularly useful on Windows where long-lived Tasks may hang on stdio. Also activated by the `--chunked` flag. |
| `workflow.plan_chunked` | boolean | `false` | `true`, `false` | Enable chunked planning mode. When `true`, the plan-phase orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3–5 min each). Each plan is committed individually for crash resilience. Particularly useful on Windows where long-lived Tasks may hang on stdio. Also activated by the `--chunked` flag. See `planning.chunked_parallel` below for concurrent per-plan dispatch. |
| `workflow.specless_probe_fallback` | boolean | `true` | `true`, `false` | Gate the SPEC-less probe fallback in `plan-phase`. When `true` (default), a phase that did not supply a `## Edge Coverage` / `## Prohibitions` SPEC section (header absent or present-but-empty) runs the existing probe protocol — the deterministic `edge-probe.cjs` for edges and an in-planner LLM recall pass for prohibitions — and authors the resulting predicates into PLAN.md `must_haves` (section-level precedence: a SPEC-supplied section is never re-run or overwritten). When `false`, the fallback is skipped but the skip is recorded: plan-phase emits a visible "probe fallback disabled" marker, never a silent skip. |
| `workflow.code_review_command` | string\|null | `null` | Any shell command | External code-review command integrated into `/gsd:ship`. The diff is piped to the command via stdin; the command must output JSON with a `verdict` field (`"APPROVED"` or `"REVISE"`). Non-zero exit or `"REVISE"` verdict blocks the ship workflow. When unset, the built-in review flow runs. Example: `my-review-tool --review`. |
| `workflow.inline_plan_threshold` | number | `2` | `0`–`10` | Plans with ≤N tasks execute inline instead of spawning a subagent |
@@ -404,6 +404,7 @@ These can be set at top level or nested under `planning.*` (e.g., `"planning": {
|-----|------|---------|----------------|-------------|
| `planning.commit_docs` | boolean | `true` | `true`, `false` | Alias for top-level `commit_docs` |
| `planning.search_gitignored` | boolean | `false` | `true`, `false` | Alias for top-level `search_gitignored` |
| `planning.chunked_parallel` | boolean | `false` | `true`, `false` | Opt-in for `workflow.plan_chunked`'s per-plan loop (§8.5.2 of `chunked-planning-mode.md`, #3777). When `true`, the runnable per-plan planners within one outline Wave are dispatched concurrently (one message, `run_in_background=true` each) instead of one at a time, honoring the outline's Wave column as the schedule (`Depends On` is expected to name only an earlier Wave and is not separately parsed — batching strictly by Wave already respects it). Gated on the negotiated `dispatch-capacity` query (#3673): a host that declares no `maxConcurrency` (capacity resolves to `1`) stays serial regardless of this setting. Default `false` is byte-identical to the pre-#3777 serial loop. Trade-off: per-plan commits interleave within a batch instead of strictly one-at-a-time, and a stalled plan's retry no longer blocks sibling plans in the same batch from having already committed. |
---

View File

@@ -9,6 +9,27 @@ runs (~3–5 min each), committing each plan individually for crash resilience.
For recovering plans from a prior *non-chunked* run, use step 6's "Add more plans" or
proceed to `/gsd:execute-phase` — don't start a fresh chunked run over them.
Set `CHUNKED_PARALLEL` from config, here rather than in `plan-phase.md`, so the two extra
`gsd_run` calls it costs are paid only on a run that already reached this section (#3777) —
`CHUNKED_MODE` is `true` at this point, per the skip-check above. STRICT equality on `"true"`
is deliberate, matching `review.parallel_lanes` (#3034): a mistyped or non-canonical value
(`"1"`, `"yes"`, `"TRUE"`) gets the conservative behavior, and any tooling failure
(`config-get`/`dispatch-capacity` erroring or printing nothing) fails safe to serial — never the
opposite polarity, since firing concurrent Agent() dispatch is the risky direction here, not the
safe default. `dispatch-capacity` is the same negotiated dispatch-capability query
`quick-batch-dispatch.cts` already consults (#3673) — a host that declares no `maxConcurrency`
(reason `missing`/`undocumented`) resolves to the fail-closed floor of `1`, which degrades this
flag to serial regardless of the config value:
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd_run is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; case "$(gsd_run runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') GSD_IDENTITY_STATUS=ok;; esac; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
CHUNKED_PARALLEL_CFG=$(gsd_run query config-get planning.chunked_parallel --raw 2>/dev/null || echo "false")
DISPATCH_CAPACITY=$(gsd_run query dispatch-capacity --raw 2>/dev/null || echo "1")
CHUNKED_PARALLEL=false
if [[ "$CHUNKED_PARALLEL_CFG" == "true" ]] && [[ "${DISPATCH_CAPACITY:-1}" -gt 1 ]] 2>/dev/null; then
CHUNKED_PARALLEL=true
fi
```
### 8.5.1 Outline Phase (outline-only mode, ~2 min)
**Resume detection:** If `${PHASE_DIR}/${PADDED_PHASE}-PLAN-OUTLINE.md` exists and contains
@@ -58,25 +79,65 @@ Handle return:
### 8.5.2 Per-Plan Tasks (single-plan mode, ~3-5 min each)
For each plan entry extracted from `PLAN-OUTLINE.md`:
Plans are dispatched **wave by wave**, in ascending Wave order (a blank/missing Wave value on a
row is treated as Wave `1` — see §8.5.1's example table). Within one Wave, dispatch is either
serial (today's behavior, always used when `CHUNKED_PARALLEL` is `false`) or concurrent
(`CHUNKED_PARALLEL` is `true` — resolved above from `planning.chunked_parallel` gated on
`dispatch-capacity`, #3777). A Wave containing only one runnable entry always takes the serial
path regardless of `CHUNKED_PARALLEL` — there is nothing to batch.
1. **Resume check:** Skip if `${PHASE_DIR}/{plan_id}-PLAN.md` exists with valid frontmatter
(resume safety) — UNLESS `--reviews` is set, whose purpose is to REPLAN with review
feedback (§6), so existing plans are overwritten, not skipped (#2762).
**For each Wave, in order:**
1. **Build the batch's plan-ID list** from the outline rows sharing this Wave value, in outline
row order, deduplicated defensively — a malformed outline naming the same Plan ID twice must
never produce two concurrent Agent() calls targeting the same `{plan_id}-PLAN.md` output path
(mirrors `review.md`'s `DISPATCH_SLUGS` dedup, #3034):
```bash
# Rewrapped through unquoted command substitution, not consumed as a bare
# `$WAVE_PLAN_IDS`: bash word-splits an unquoted scalar on IFS by default,
# but zsh does not, so a bare re-split collapses every Plan ID onto one
# iteration under zsh (gsd-core#4109 — same fix review.md's DISPATCH_SLUGS
# loop already applies). Unquoted `$(...)` re-splits identically under
# both shells regardless of `SH_WORD_SPLIT`.
BATCH_PLAN_IDS=""
for PLAN_ID in $(printf '%s' "$WAVE_PLAN_IDS"); do
case " $BATCH_PLAN_IDS " in
*" $PLAN_ID "*) continue ;;
esac
BATCH_PLAN_IDS="$BATCH_PLAN_IDS $PLAN_ID"
done
```
`WAVE_PLAN_IDS` is the space-separated Plan ID list for this Wave, in outline row order — the
orchestrator (already reading the outline table to extract plan entries, per §8.5.1) populates
it directly from the table before this block runs.
2. **Resume check, per entry:** for each `plan_id` in `BATCH_PLAN_IDS`, skip it (remove it from
this batch's runnable set) if `${PHASE_DIR}/{plan_id}-PLAN.md` exists with valid frontmatter —
UNLESS `--reviews` is set, whose purpose is to REPLAN with review feedback (§6), so existing
plans are overwritten, not skipped (#2762). Unchanged from before this change, applied per entry
before dispatch rather than immediately before each individual spawn:
```bash
PLAN_FILE="${PHASE_DIR}/${plan_id}-PLAN.md"
if [[ -f "$PLAN_FILE" ]] && head -1 "$PLAN_FILE" | grep -q '^---' && [[ "$ARGUMENTS" != *"--reviews"* ]]; then
: # resume safety — skip this plan, continue to next plan entry — NOT under --reviews (replan)
: # resume safety — skip this plan, remove from the batch's runnable set — NOT under --reviews (replan)
fi
```
If every entry in the Wave is resumed (runnable set empty), skip straight to the next Wave —
nothing to dispatch, nothing to wait on.
2. Display:
3. Display:
```text
◆ Chunked mode: planning {plan_id} ({k}/{N})... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)
```
Serial dispatch (`CHUNKED_PARALLEL` is `false`, or the runnable set has exactly one entry):
print one line per plan, immediately before that plan's own Agent() call, exactly as before.
Concurrent dispatch: print one line per plan in the runnable set, immediately before issuing
the batch's Agent() calls together.
3. Spawn the planner in **single-plan** mode — it must write exactly one PLAN.md file:
4. Spawn the planner in **single-plan** mode — it must write exactly one PLAN.md file. The prompt
is unchanged per plan; what changes is whether the runnable set's Agent() calls are issued one
at a time (serial) or together in one message (concurrent, every call still carrying
`run_in_background=true` exactly as today):
```javascript
Agent(
prompt="{same planning_context as step 8, plus:}
@@ -94,17 +155,38 @@ For each plan entry extracted from `PLAN-OUTLINE.md`:
run_in_background=true
)
```
**Serial dispatch:** issue one Agent() call, wait for it (step 5), verify and commit it
(steps 6-7), THEN move to the next entry in the runnable set — byte-identical to the
pre-#3777 loop.
**Concurrent dispatch:** issue every runnable entry's Agent() call together, in this one
message, before waiting on any of them.
**ORCHESTRATOR RULE — ALL RUNTIMES:** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$PLAN_FILE" "## PLAN COMPLETE")` while waiting/active — `stalled` falls into step 4 (preserves prior committed chunks).
5. **ORCHESTRATOR RULE — ALL RUNTIMES, per batch:** for every entry dispatched in this round,
`TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$PLAN_FILE" "## PLAN COMPLETE")`
while waiting/active for THAT entry. Serial dispatch waits on one entry at a time (unchanged).
Concurrent dispatch waits on every entry issued in step 4 before proceeding — this is the
"per-batch" join the config makes possible: nothing in step 6 runs until every plan dispatched
this round has reached `marker_received` or `stalled`. A `stalled` entry falls into step 7's
Retry/Stop recovery for that one plan; it does not block verifying/committing sibling entries
in the same batch that already reached `marker_received`.
4. **Verify disk:** Check `${PHASE_DIR}/{plan_id}-PLAN.md` exists. If missing: offer 1) Retry, 2) Stop.
6. **Verify disk, per entry:** check `${PHASE_DIR}/{plan_id}-PLAN.md` exists for each entry that
reached `marker_received`. Unchanged per-plan check.
5. **Commit per-plan:**
7. **Commit, per entry, in outline row order (not completion order):** for each verified entry —
never one combined commit for the batch — preserving crash resilience: an interrupt mid-batch
leaves every already-verified entry committed, exactly as a mid-loop interrupt did before this
change.
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd_run is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; case "$(gsd_run runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') GSD_IDENTITY_STATUS=ok;; esac; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
gsd_run query commit "docs(${PADDED_PHASE}): plan ${plan_id} (chunked)" --files "${PHASE_DIR}/${plan_id}-PLAN.md"
```
After all N plans are written and committed, treat this as `## PLANNING COMPLETE` and continue
to step 9.
8. **Recovery:** any entry that did not reach `marker_received` (missing file in step 6, or
`stalled` in step 5) offers 1) Retry, 2) Stop — scoped to that one plan. Sibling entries in the
same batch that already verified and committed keep their commits either way; only the
failed/stalled plan is retried or the run stopped.
Move to the next Wave only once every entry in the current Wave is committed or the run was
stopped. After every Wave's plans are written and committed, treat this as `## PLANNING COMPLETE`
and continue to step 9.

View File

@@ -0,0 +1,342 @@
'use strict';
/**
* Failing-first tests for #3777 (opt-in concurrent per-plan planners in
* chunked mode).
*
* Design: .gsd/phase/feat-3777-chunked-parallel-planners/40-design.md
* Test matrix: .gsd/phase/feat-3777-chunked-parallel-planners/50-test-matrix.md
*
* Two real, shipped bash blocks are extracted and EXECUTED (never re-typed), both from
* chunked-planning-mode.md:
* 1. the `CHUNKED_PARALLEL` resolution (config x dispatch-capacity), read once per
* chunked run, ahead of §8.5.1, so a non-chunked run never pays for it.
* 2. the `BATCH_PLAN_IDS` dedup guard (§8.5.2 step 1).
* Wave grouping itself is orchestrator (LLM) comprehension, not a bash block —
* see the design doc's "Known limits" — so it has no extraction test here.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
createTempDir,
createTempProject,
cleanup,
readFileNormalized,
runGsdTools,
} = require('./helpers.cjs');
const { runHook } = require('./helpers/process-seam.cjs');
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const CHUNKED_MODE_MD_PATH = path.join(
REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md',
);
const RUNTIME_LAUNCHER_SNIPPET_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
// ─── extraction (source-text-is-the-product) ──────────────────────────────
/** Finds the first ```bash/```sh fence in `content` whose text contains every string in `mustInclude`. */
function extractBashFenceContaining(content, mustInclude, label, filePath) {
const lines = content.split(/\r?\n/);
for (const fenced of scanFencedBlocks(lines)) {
if (fenced.closeLineIdx === -1) continue;
if (!['bash', 'sh'].includes((fenced.infoString || '').trim())) continue;
const block = lines.slice(fenced.openLineIdx + 1, fenced.closeLineIdx).join('\n');
if (mustInclude.every((s) => block.includes(s))) return block;
}
throw new Error(`extractBashFenceContaining: no fence matching ${label} found in ${filePath} (looked for ${JSON.stringify(mustInclude)})`);
}
/**
* The canonical runtime-launcher preamble (source of truth for
* scripts/sync-runtime-launcher.cjs) sits as the first line of the
* CHUNKED_PARALLEL resolution fence in production (tests/runtime-launcher-parity.test.cjs
* owns verifying its placement/uniqueness). Strip it here so this suite tests
* only the resolution logic it's actually about, not the preamble's own
* gsd_run-shim-resolution behavior (which would stomp this file's `gsd_run`
* stub — see #3777 investigation).
*/
function stripRuntimeLauncherPreamble(block) {
const preamble = readFileNormalized(RUNTIME_LAUNCHER_SNIPPET_PATH).replace(/\n+$/, '');
if (!block.includes(preamble)) {
throw new Error('stripRuntimeLauncherPreamble: canonical preamble not found in extracted block — extraction anchor or preamble content may have drifted');
}
return block.split(preamble).join('').replace(/^\s+/, '');
}
function extractChunkedParallelResolution() {
const content = readFileNormalized(CHUNKED_MODE_MD_PATH);
const block = extractBashFenceContaining(
content,
['CHUNKED_PARALLEL_CFG', 'DISPATCH_CAPACITY', 'CHUNKED_PARALLEL='],
'#3777 CHUNKED_PARALLEL resolution',
CHUNKED_MODE_MD_PATH,
);
return stripRuntimeLauncherPreamble(block);
}
function extractBatchPlanIdsDedup() {
const content = readFileNormalized(CHUNKED_MODE_MD_PATH);
return extractBashFenceContaining(
content,
['BATCH_PLAN_IDS=', 'WAVE_PLAN_IDS'],
'#3777 BATCH_PLAN_IDS dedup guard',
CHUNKED_MODE_MD_PATH,
);
}
// ─── runners ────────────────────────────────────────────────────────────────
/**
* Runs the real extracted CHUNKED_PARALLEL resolution block with a `gsd_run`
* stub spliced in front of it. Returns the resolved `CHUNKED_PARALLEL` value
* as a string ("true"/"false"), exactly as production code reads it.
*/
function runChunkedParallelResolution(t, opts) {
const scriptDir = createTempDir('gsd-3777-script-');
t.after(() => cleanup(scriptDir));
const block = extractChunkedParallelResolution();
const stub = [
'gsd_run() {',
' if [ "$1" = "query" ] && [ "$2" = "config-get" ] && [ "$3" = "planning.chunked_parallel" ]; then',
' if [ "$STUB_CONFIG_GET_FAILS" = "1" ]; then',
' return 1',
' fi',
' printf %s "$STUB_CONFIG_VALUE"',
' return 0',
' fi',
' if [ "$1" = "query" ] && [ "$2" = "dispatch-capacity" ]; then',
' if [ "$STUB_CAPACITY_FAILS" = "1" ]; then',
' return 1',
' fi',
' printf %s "$STUB_CAPACITY_VALUE"',
' return 0',
' fi',
' return 0',
'}',
].join('\n');
const script = [
'#!/usr/bin/env bash',
'set -u',
stub,
block,
'printf "RESULT:%s" "$CHUNKED_PARALLEL"',
].join('\n');
const scriptPath = path.join(scriptDir, 'resolve.sh');
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
const env = {
...process.env,
STUB_CONFIG_VALUE: opts.configValue === null || opts.configValue === undefined ? '' : opts.configValue,
STUB_CONFIG_GET_FAILS: opts.configGetFails ? '1' : '0',
STUB_CAPACITY_VALUE: opts.capacityValue === null || opts.capacityValue === undefined ? '' : String(opts.capacityValue),
STUB_CAPACITY_FAILS: opts.capacityFails ? '1' : '0',
};
const result = runHook(scriptPath, [], {
interpreter: 'bash',
cwd: scriptDir,
env,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
const stdout = result.stdout || '';
const match = /RESULT:(\S*)/.exec(stdout);
return {
outcome: result.outcome,
exitCode: result.exitCode,
stderr: result.stderr,
chunkedParallel: match ? match[1] : null,
};
}
/**
* Runs the real extracted BATCH_PLAN_IDS dedup block with `WAVE_PLAN_IDS`
* seeded from `waveIds` (already space-separated, matching how the
* orchestrator populates it from outline rows).
*/
function runBatchDedup(t, waveIds) {
const scriptDir = createTempDir('gsd-3777-dedup-script-');
t.after(() => cleanup(scriptDir));
const block = extractBatchPlanIdsDedup();
const script = [
'#!/usr/bin/env bash',
'set -u',
`WAVE_PLAN_IDS='${waveIds}'`,
block,
'printf "RESULT:%s" "$BATCH_PLAN_IDS"',
].join('\n');
const scriptPath = path.join(scriptDir, 'dedup.sh');
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
const result = runHook(scriptPath, [], {
interpreter: 'bash',
cwd: scriptDir,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
const stdout = result.stdout || '';
const match = /RESULT:(.*)$/.exec(stdout);
const batch = match ? match[1].trim() : '';
return {
outcome: result.outcome,
exitCode: result.exitCode,
stderr: result.stderr,
batchIds: batch.length > 0 ? batch.split(/\s+/) : [],
};
}
// ─── #1/#2 — default and explicit-disabled stay serial ────────────────────
describe('#3777 default and explicit-disabled CHUNKED_PARALLEL resolution stays serial', () => {
test('defaultsToSerialWhenKeyUnset', (t) => {
const result = runChunkedParallelResolution(t, { configValue: null, capacityValue: 20 });
assert.equal(result.outcome, 'exited');
assert.equal(result.chunkedParallel, 'false');
});
test('staysSerialWhenExplicitlyDisabled', (t) => {
const result = runChunkedParallelResolution(t, { configValue: 'false', capacityValue: 20 });
assert.equal(result.chunkedParallel, 'false');
});
});
// ─── #3 — opt-in with sufficient capacity ──────────────────────────────────
describe('#3777 opt-in with sufficient dispatch capacity', () => {
test('enablesParallelWhenConfigTrueAndCapacityAboveOne', (t) => {
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 20 });
assert.equal(result.chunkedParallel, 'true');
});
});
// ─── #4/#5/#6 — capacity boundary (limit-1, limit, limit+1) ────────────────
describe('#3777 dispatch-capacity boundary gates the opt-in', () => {
test('staysSerialWhenCapacityIsOne', (t) => {
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 1 });
assert.equal(result.chunkedParallel, 'false', 'capacity=1 (the fail-closed floor) must degrade to serial even when the config opts in');
});
test('enablesParallelAtCapacityTwo', (t) => {
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 2 });
assert.equal(result.chunkedParallel, 'true', 'capacity=2 is already above the floor and must enable concurrent dispatch');
});
test('staysSerialWhenCapacityIsZero', (t) => {
// Defensive: routeDispatchCapacity never legitimately emits 0, but the
// resolution's own arithmetic comparison must not misbehave on it either.
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 0 });
assert.equal(result.chunkedParallel, 'false');
});
});
// ─── #7 — non-canonical truthy values stay serial ──────────────────────────
describe('#3777 non-canonical truthy config values stay serial', () => {
test('nonCanonicalTruthyValuesStaySerial', async (t) => {
const nearMisses = ['TRUE', 'True', '1', 'yes', 'on', ' true', 'true '];
for (const value of nearMisses) {
await t.test(`chunked_parallel="${value}"`, (t2) => {
const result = runChunkedParallelResolution(t2, { configValue: value, capacityValue: 20 });
assert.equal(result.chunkedParallel, 'false', `value "${value}" must not opt into parallel dispatch`);
});
}
});
});
// ─── #8/#9 — broken tooling fails safe to serial ───────────────────────────
describe('#3777 broken config/capacity tooling fails safe to serial', () => {
test('configGetFailureFallsBackToSerial', (t) => {
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 20, configGetFails: true });
assert.equal(result.chunkedParallel, 'false');
});
test('capacityQueryFailureFallsBackToSerial', (t) => {
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 20, capacityFails: true });
assert.equal(result.chunkedParallel, 'false');
});
});
// ─── #10/#11/#12 — BATCH_PLAN_IDS dedup guard ──────────────────────────────
describe('#3777 duplicate Plan ID in a Wave dispatches once', () => {
test('duplicatePlanIdInBatchDispatchesOnce', (t) => {
const result = runBatchDedup(t, '03-01 03-02 03-01');
assert.equal(result.outcome, 'exited');
assert.deepEqual(result.batchIds, ['03-01', '03-02'], 'no two plans in a parallel batch may declare the same output path');
});
});
describe('#3777 batch dedup preserves outline row order', () => {
test('preservesOutlineOrderInBatch', (t) => {
const result = runBatchDedup(t, '03-03 03-01 03-02');
assert.deepEqual(result.batchIds, ['03-03', '03-01', '03-02']);
});
});
describe('#3777 an empty Wave produces an empty batch', () => {
test('emptyWaveProducesEmptyBatch', (t) => {
const result = runBatchDedup(t, '');
assert.equal(result.outcome, 'exited');
assert.deepEqual(result.batchIds, []);
});
});
// ─── #13/#14/#15 — config-set registers planning.chunked_parallel ─────────
describe('#3777 planning.chunked_parallel config key', () => {
test('configSetAcceptsAndPersistsChunkedParallel', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const setResult = runGsdTools('config-set planning.chunked_parallel true', tmpDir);
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(config.planning?.chunked_parallel, true);
assert.equal(typeof config.planning?.chunked_parallel, 'boolean');
const getResult = runGsdTools('config-get planning.chunked_parallel --raw', tmpDir);
assert.ok(getResult.success, `config-get failed: ${getResult.error}`);
assert.equal((getResult.output || '').trim(), 'true');
});
test('configSetPersistsBooleanFalse', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const setResult = runGsdTools('config-set planning.chunked_parallel false', tmpDir);
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(config.planning?.chunked_parallel, false);
assert.equal(typeof config.planning?.chunked_parallel, 'boolean');
});
test('rejectsUnregisteredNeighbouringKey', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
// Extra "l" — proves the whitelist is load-bearing and the two tests
// above are not vacuous (they'd pass even for an unregistered key if
// config-set accepted anything).
const result = runGsdTools('config-set planning.chunked_parallell true', tmpDir);
assert.equal(result.success, false, 'an unregistered near-miss key must be rejected');
});
});