* 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:
5
.changeset/vivid-voles-wander.md
Normal file
5
.changeset/vivid-voles-wander.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
80
docs/how-to/enable-concurrent-chunked-planning.md
Normal file
80
docs/how-to/enable-concurrent-chunked-planning.md
Normal 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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
342
tests/chunked-planning-parallel.test.cjs
Normal file
342
tests/chunked-planning-parallel.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user