From f9f72cb54c5fbd274cce8f912a02f5b2f3aaff46 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 5 Sep 2026 18:57:17 -0400 Subject: [PATCH] enhance(#3777): opt-in concurrent per-plan planners in chunked mode (#4346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * docs(#3777): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: sim Co-authored-by: Claude Sonnet 5 --- .changeset/vivid-voles-wander.md | 5 + docs/CONFIGURATION.md | 43 ++- docs/README.md | 1 + .../enable-concurrent-chunked-planning.md | 80 ++++ .../bin/shared/config-schema.manifest.json | 1 + gsd-core/references/planner-chunked.md | 6 +- gsd-core/references/planning-config.md | 3 +- .../plan-phase/steps/chunked-planning-mode.md | 108 +++++- tests/chunked-planning-parallel.test.cjs | 342 ++++++++++++++++++ 9 files changed, 573 insertions(+), 16 deletions(-) create mode 100644 .changeset/vivid-voles-wander.md create mode 100644 docs/how-to/enable-concurrent-chunked-planning.md create mode 100644 tests/chunked-planning-parallel.test.cjs diff --git a/.changeset/vivid-voles-wander.md b/.changeset/vivid-voles-wander.md new file mode 100644 index 000000000..332e470c6 --- /dev/null +++ b/.changeset/vivid-voles-wander.md @@ -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) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 41cc2a3f0..dcd8d14bb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 `` 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 diff --git a/docs/README.md b/docs/README.md index 391e51349..2d4692ac7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/how-to/enable-concurrent-chunked-planning.md b/docs/how-to/enable-concurrent-chunked-planning.md new file mode 100644 index 000000000..680a227a0 --- /dev/null +++ b/docs/how-to/enable-concurrent-chunked-planning.md @@ -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). diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 8313928d5..393b23105 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -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", diff --git a/gsd-core/references/planner-chunked.md b/gsd-core/references/planner-chunked.md index 1b227a3b5..aa170a787 100644 --- a/gsd-core/references/planner-chunked.md +++ b/gsd-core/references/planner-chunked.md @@ -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 diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index ff7ce352a..70d807710 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -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. | --- diff --git a/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md b/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md index 163a7ed1f..cd88e9506 100644 --- a/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +++ b/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md @@ -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. diff --git a/tests/chunked-planning-parallel.test.cjs b/tests/chunked-planning-parallel.test.cjs new file mode 100644 index 000000000..c7c41a7db --- /dev/null +++ b/tests/chunked-planning-parallel.test.cjs @@ -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'); + }); +});