diff --git a/.changeset/clever-herons-chatter.md b/.changeset/clever-herons-chatter.md new file mode 100644 index 000000000..80abb9afc --- /dev/null +++ b/.changeset/clever-herons-chatter.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 4585 +--- +**Planner stall detection can now be disabled explicitly** — set `planner.stall_detection_enabled` to `false` to use the runtime-native completion wait without watchdog polling; the default remains enabled, and disabling it gives up bounded automatic recovery. diff --git a/CONTEXT.md b/CONTEXT.md index 5036628ba..cbb78e750 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -280,6 +280,8 @@ Module owning agent-presence resolution and verification, extracted from the Cor ### Config Loader Module Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Loading is **not side-effect-free by default**: normalizing a legacy key marks the config dirty and writes the migrated shape back to disk, which is how a legacy project is migrated by ordinary use. `options.persist: false` (#3648) suppresses exactly that write while leaving resolution identical — the option a caller passes when it is only ASKING the config something (the Git Query Module's protected-branch predicate runs on every `execute-phase` and `ship`, and a question must not rewrite the file it asks about). It is opt-OUT, so every pre-existing caller keeps the historical write-back. Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`). +`planner.stall_detection_enabled` (#4570) is a central, default-on boolean projected as `planner_stall_detection_enabled` by this module. Root/workstream overlay happens before its type resolution; only a real boolean is honored, while an absent or hand-edited non-boolean value fails safe to the manifest default `true`. The plan-phase workflow reads it through `query config-get`, never by parsing config JSON locally, so root/project/workstream routing remains owned by the canonical configuration seams. + ### Planning Publication Gate (`planning.pr_strict`) The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). A third gate is deliberately NOT derived from those declarations: `PLANNING_DELETIONS` (#3679) counts DELETED `.planning/` paths via `git diff --name-status --no-renames` and must be `0` in every mode — a deleted planning path is data loss, not filtering, and name-only counting cannot see status. The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 295c0bc8e..57ff668b8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -26,6 +26,9 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new "search_gitignored": false, "sub_repos": [] }, + "planner": { + "stall_detection_enabled": true + }, "context": null, "workflow": { "research": true, @@ -545,6 +548,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.subagent_timeout` | number | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes) | | `executor.stall_detect_interval_minutes` | number | `5` | Minutes between executor stall checks while an executor agent is active. The execute-phase orchestrator uses this cadence to inspect recent commits and avoid waiting forever on a silent agent. | | `executor.stall_threshold_minutes` | number | `10` | Minutes without executor completion or expected-branch commit activity before execute-phase offers recovery choices for a possible stalled executor. | +| `planner.stall_detection_enabled` | boolean | `true` | Controls bounded stall detection for the standard planner, chunked outline/per-plan planners, plan-checker, and revision planner. Set it with `gsd config-set planner.stall_detection_enabled false` to skip watchdog polling and await each agent through the runtime-native completion mechanism instead. **Warning:** `false` gives up bounded recovery if the runtime loses the completion handoff; you may need to interrupt and use the existing filesystem fallback. Planner execution and result handling are never skipped. | | `planner.stall_detect_interval_minutes` | number | `5` | Minutes between planner/plan-checker stall checks while a planner or plan-checker agent is active. The plan-phase orchestrator uses this cadence to inspect on-disk `*-PLAN.md` activity and avoid waiting forever on a silent agent (#2650). | | `planner.stall_threshold_minutes` | number | `10` | Minutes without a completion marker or fresh on-disk plan activity before plan-phase automatically surfaces the accept-plans/retry/stop recovery choice for a possible stalled planner or plan-checker (#2650). | | `workflow.inline_plan_threshold` | number | `3` | Maximum number of tasks in a phase before the planner generates a separate PLAN.md file instead of inlining tasks in the prompt | diff --git a/docs/ja-JP/CONFIGURATION.md b/docs/ja-JP/CONFIGURATION.md index ee4ab899f..ed96b8b83 100644 --- a/docs/ja-JP/CONFIGURATION.md +++ b/docs/ja-JP/CONFIGURATION.md @@ -105,6 +105,7 @@ GSD はプロジェクト設定を `.planning/config.json` に保存します。 | `workflow.discuss_mode` | string | `'discuss'` | `/gsd-discuss-phase` のコンテキスト収集方法を制御。`'discuss'`(デフォルト)は質問を1つずつ行います。`'assumptions'` はまずコードベースを読み取り、信頼度レベル付きの構造化された仮説を生成し、誤っている点のみ修正を求めます。v1.28 で追加 | | `workflow.skip_discuss` | boolean | `false` | `true` の場合、`/gsd-autonomous` は discuss-phase を完全にスキップし、ROADMAP のフェーズ目標から最小限の CONTEXT.md を作成します。開発者の要望が PROJECT.md/REQUIREMENTS.md に十分に記載されているプロジェクトに適しています。v1.28 で追加 | | `workflow.text_mode` | boolean | `false` | AskUserQuestion の TUI メニューをプレーンテキストの番号付きリストに置き換えます。TUI メニューが表示されない Claude Code リモートセッション(`/rc` モード)で必要です。discuss-phase で `--text` フラグを使用してセッションごとに設定することもできます。v1.28 で追加 | +| `planner.stall_detection_enabled` | boolean | `true` | 標準プランナー、チャンク化されたアウトライン/プラン別プランナー、プランチェッカー、改訂プランナーの有界な停止検出を制御します。`gsd config-set planner.stall_detection_enabled false` を設定すると watchdog ポーリングを省略し、各エージェントをランタイムネイティブの完了機構で待機します。**警告:** `false` はランタイムが完了通知を失った場合の有界な復旧を放棄します。既存のファイルシステムフォールバックを使うために中断が必要になることがあります。 | ### 推奨プリセット diff --git a/docs/ko-KR/CONFIGURATION.md b/docs/ko-KR/CONFIGURATION.md index 909737878..f7121c417 100644 --- a/docs/ko-KR/CONFIGURATION.md +++ b/docs/ko-KR/CONFIGURATION.md @@ -105,6 +105,7 @@ GSD는 프로젝트 설정을 `.planning/config.json`에 저장합니다. `/gsd- | `workflow.discuss_mode` | string | `'discuss'` | `/gsd-discuss-phase`의 컨텍스트 수집 방식을 제어합니다. `'discuss'` (기본값)는 질문을 하나씩 합니다. `'assumptions'`는 코드베이스를 먼저 읽고 신뢰도 수준이 있는 구조화된 가정을 생성하여 틀린 부분만 수정하도록 요청합니다. v1.28에서 추가 | | `workflow.skip_discuss` | boolean | `false` | `true`로 설정하면 `/gsd-autonomous`가 discuss 단계를 완전히 건너뛰고 ROADMAP 단계 목표로부터 최소한의 CONTEXT.md를 작성합니다. 개발자 선호사항이 PROJECT.md/REQUIREMENTS.md에 모두 캡처된 프로젝트에 유용합니다. v1.28에서 추가 | | `workflow.text_mode` | boolean | `false` | AskUserQuestion TUI 메뉴를 일반 텍스트 번호 목록으로 대체합니다. TUI 메뉴가 렌더링되지 않는 Claude Code 원격 세션 (`/rc` 모드)에 필요합니다. discuss 단계에서 `--text` 플래그로 세션별 설정도 가능합니다. v1.28에서 추가 | +| `planner.stall_detection_enabled` | boolean | `true` | 표준 플래너, 청크형 개요/플랜별 플래너, 플랜 검사기, 수정 플래너의 제한된 정지 감지를 제어합니다. `gsd config-set planner.stall_detection_enabled false`로 설정하면 watchdog 폴링을 건너뛰고 각 에이전트를 런타임 네이티브 완료 메커니즘으로 기다립니다. **경고:** `false`는 런타임이 완료 전달을 잃을 때의 제한된 복구를 포기합니다. 기존 파일 시스템 폴백을 사용하려면 중단해야 할 수 있습니다. | ### 권장 프리셋 diff --git a/docs/pt-BR/CONFIGURATION.md b/docs/pt-BR/CONFIGURATION.md index e360631e9..239cfdb1b 100644 --- a/docs/pt-BR/CONFIGURATION.md +++ b/docs/pt-BR/CONFIGURATION.md @@ -25,6 +25,9 @@ O GSD armazena as configurações do projeto em `.planning/config.json`. Criado "search_gitignored": false, "sub_repos": [] }, + "planner": { + "stall_detection_enabled": true + }, "context": null, "workflow": { "research": true, @@ -267,6 +270,9 @@ Todos os controles de fluxo de trabalho seguem o padrão **ausente = habilitado* | `workflow.subagent_timeout` | number | `600` | Timeout em segundos para invocações individuais de subagente. Aumente para fases de pesquisa ou execução de longa duração | | `executor.stall_detect_interval_minutes` | number | `5` | Minutos entre verificações de travamento do executor enquanto um agente executor está ativo. O orquestrador de fase de execução usa essa cadência para inspecionar commits recentes e evitar espera eterna por um agente silencioso. | | `executor.stall_threshold_minutes` | number | `10` | Minutos sem conclusão do executor ou atividade de commit no branch esperado antes que a fase de execução ofereça opções de recuperação para um possível executor travado. | +| `planner.stall_detection_enabled` | boolean | `true` | Controla a detecção limitada de travamento do planejador padrão, dos planejadores de esboço/por plano em chunks, do verificador de planos e do planejador de revisão. Use `gsd config-set planner.stall_detection_enabled false` para ignorar o polling do watchdog e aguardar cada agente pelo mecanismo de conclusão nativa do runtime. **Aviso:** `false` abre mão da recuperação limitada se o runtime perder a entrega da conclusão; pode ser necessário interromper e usar o fallback existente do sistema de arquivos. A execução e o tratamento do resultado nunca são ignorados. | +| `planner.stall_detect_interval_minutes` | number | `5` | Minutos entre verificações de travamento enquanto um planejador ou verificador de planos está ativo. O orquestrador inspeciona a atividade em disco de `*-PLAN.md` nessa cadência (#2650). | +| `planner.stall_threshold_minutes` | number | `10` | Minutos sem marcador de conclusão ou atividade recente em planos antes que a fase de planejamento ofereça automaticamente as opções aceitar/tentar novamente/parar (#2650). | | `workflow.inline_plan_threshold` | number | `3` | Número máximo de tasks em uma fase antes que o planejador gere um arquivo PLAN.md separado em vez de incorporar tasks no prompt | | `workflow.drift_threshold` | number | `3` | Número mínimo de novos elementos estruturais (novos diretórios, exportações barrel, migrações, módulos de rota) introduzidos durante uma fase antes que o gate de deriva pós-execução da base de código tome ação. Consulte [#2003](https://github.com/open-gsd/gsd-core/issues/2003). Adicionado na v1.39 | | `workflow.drift_action` | string | `warn` | O que fazer quando `workflow.drift_threshold` é excedido após `/gsd-execute-phase`. `warn` imprime uma mensagem sugerindo `/gsd-map-codebase --paths …`; `auto-remap` gera `gsd-codebase-mapper` com escopo para os caminhos afetados. Adicionado na v1.39 | diff --git a/docs/zh-CN/CONFIGURATION.md b/docs/zh-CN/CONFIGURATION.md index 93058d030..880ed4484 100644 --- a/docs/zh-CN/CONFIGURATION.md +++ b/docs/zh-CN/CONFIGURATION.md @@ -25,6 +25,9 @@ GSD 将项目设置存储在 `.planning/config.json` 中。该文件在 `/gsd-ne "search_gitignored": false, "sub_repos": [] }, + "planner": { + "stall_detection_enabled": true + }, "context": null, "workflow": { "research": true, @@ -267,6 +270,9 @@ API 密钥字段接受字符串值(密钥本身)。也可以设置为哨兵 | `workflow.subagent_timeout` | number | `600` | 单个 subagent 调用的超时秒数。对于长时间运行的研究或执行阶段可适当增加 | | `executor.stall_detect_interval_minutes` | number | `5` | 执行器 agent 活跃时,执行器停滞检测的间隔分钟数。执行阶段编排器以此频率检查最近的提交,避免无限等待静默的 agent。 | | `executor.stall_threshold_minutes` | number | `10` | 执行器完成或预期分支提交活动缺失超过此分钟数后,执行阶段为可能停滞的执行器提供恢复选项。 | +| `planner.stall_detection_enabled` | boolean | `true` | 控制标准规划器、分块大纲/逐计划规划器、计划检查器和修订规划器的有界停滞检测。运行 `gsd config-set planner.stall_detection_enabled false` 可跳过 watchdog 轮询,改用运行时原生完成机制等待每个 agent。**警告:** `false` 会放弃运行时丢失完成回传时的有界恢复;届时可能需要中断并使用现有文件系统回退。规划器执行和结果处理不会被跳过。 | +| `planner.stall_detect_interval_minutes` | number | `5` | 规划器或计划检查器活跃时的停滞检查间隔分钟数。规划阶段编排器按此频率检查磁盘上的 `*-PLAN.md` 活动(#2650)。 | +| `planner.stall_threshold_minutes` | number | `10` | 没有完成标记或新的磁盘计划活动超过此分钟数后,规划阶段自动提供接受计划/重试/停止恢复选项(#2650)。 | | `workflow.inline_plan_threshold` | number | `3` | 阶段中任务数量的最大值,超过此值后规划器生成单独的 PLAN.md 文件而非在提示词中内联任务 | | `workflow.drift_threshold` | number | `3` | 阶段期间引入的新结构元素(新目录、桶形导出、迁移、路由模块)的最小数量,超过此值后执行后代码库漂移门禁采取行动。参见 [#2003](https://github.com/open-gsd/gsd-core/issues/2003)。v1.39 新增 | | `workflow.drift_action` | string | `warn` | `/gsd-execute-phase` 后超过 `workflow.drift_threshold` 时的处理方式。`warn` 打印建议运行 `/gsd-map-codebase --paths …` 的消息;`auto-remap` 派生 `gsd-codebase-mapper` 限定于受影响路径。v1.39 新增 | diff --git a/docs/zh-CN/references/planning-config.md b/docs/zh-CN/references/planning-config.md index adf77d06d..2b74e7966 100644 --- a/docs/zh-CN/references/planning-config.md +++ b/docs/zh-CN/references/planning-config.md @@ -24,6 +24,12 @@ | `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | 里程碑策略的分支模板 | + + +`planner.stall_detection_enabled` 默认为 `true`,控制标准规划器、分块规划器、计划检查器和修订规划器的有界停滞检测。运行 `gsd config-set planner.stall_detection_enabled false` 可跳过 watchdog 轮询,并通过运行时原生完成机制等待每个 agent。**警告:** `false` 会放弃运行时丢失完成回传时的有界恢复;可能需要中断并使用现有文件系统回退。 + + + **当 `commit_docs: true`(默认):** @@ -197,4 +203,4 @@ fi - \ No newline at end of file + diff --git a/gsd-core/bin/shared/config-defaults.manifest.json b/gsd-core/bin/shared/config-defaults.manifest.json index 1b26ab4b7..40c1a6555 100644 --- a/gsd-core/bin/shared/config-defaults.manifest.json +++ b/gsd-core/bin/shared/config-defaults.manifest.json @@ -68,6 +68,9 @@ "sub_repos": [], "granularity": "standard" }, + "planner": { + "stall_detection_enabled": true + }, "hooks": { "context_warnings": true, "workflow_guard": false diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 8be668320..063712283 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -72,6 +72,7 @@ "workflow.context_guard_mode", "executor.stall_detect_interval_minutes", "executor.stall_threshold_minutes", + "planner.stall_detection_enabled", "planner.stall_detect_interval_minutes", "planner.stall_threshold_minutes", "workflow.inline_plan_threshold", diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index 4ed69afe7..f7e2a2f75 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -40,6 +40,7 @@ Configuration options for `.planning/` directory behavior. | `git.quick_branch_template` | `null` | Optional branch template for quick-task runs | | `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. Note: if your branch is ahead of `origin/HEAD` (a diverged milestone or feature branch), GSD auto-degrades to sequential and prints a warning; set `worktree.baseRef:"head"` in `.claude/settings.local.json` to restore parallel execution. See the branch-divergence note below. | | `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). | +| `planner.stall_detection_enabled` | `true` | Default-on bounded stall detection for planner and plan-checker agents. Set to `false` to await runtime-native completion without watchdog polling; this gives up bounded automatic recovery if the completion handoff is lost. _Loader projection:_ `planner_stall_detection_enabled`. | | `workflow.inline_plan_threshold` | `2` | Plans with this many tasks or fewer execute inline (Pattern C) instead of spawning a subagent. Avoids ~14K token spawn overhead for small plans. Set to `0` to always spawn subagents. | | `workflow.test_command` | `null` | Custom shell command run as the regression/test gate by execute-phase, audit-fix, and post-merge-gate. When unset, GSD auto-detects (Makefile / package.json / Cargo.toml / go.mod / pyproject.toml). Example: `npm test`. | | `workflow.build_command` | `null` | Custom shell command run as the build gate by the post-merge gate. When unset, the build step is skipped/auto-detected. Example: `npm run build`. | @@ -313,6 +314,14 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research": | `workflow.security_block_on` | string | `"high"` | `"critical"`, `"high"`, `"medium"`, `"low"`, `"none"` | Minimum threat severity that blocks phase advancement. The auditor counts only open threats at or above this severity toward the blocking gate (SECURITY.md `threats_open`); `none` disables severity blocking. | | `workflow.post_planning_gaps` | boolean | `true` | `true`, `false` | Post-planning gap report (#2493). After plans are generated, scans REQUIREMENTS.md and CONTEXT.md `` against all PLAN.md files and emits a unified `Source \| Item \| Status` table. Non-blocking. Set to `false` to skip Step 13e of plan-phase. _Alias:_ `post_planning_gaps` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.post_planning_gaps` is the canonical namespaced form. | +### Planner Fields + +Set via the `planner.*` namespace. These settings affect only planner/plan-checker waits; executor stall controls are independent. + +| Key | Type | Default | Allowed Values | Description | +|-----|------|---------|----------------|-------------| +| `planner.stall_detection_enabled` | boolean | `true` | `true`, `false` | Default-on bounded stall detection for the standard planner, chunked outline/per-plan planners, plan-checker, and revision planner. `false` skips `gsd_stall_watch` polling but still awaits and consumes the real runtime-native agent result. It gives up bounded automatic recovery if the runtime loses the completion handoff. _Loader projection:_ `planner_stall_detection_enabled`. | + ### Ship Fields Set via `ship.*` namespace in config.json. These fields affect `/gsd:ship` PRD-style pull request body composition only. diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 17264dbcc..6b6060eb2 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -941,6 +941,9 @@ Every task MUST include these fields — they are NOT optional: **If `CHUNKED_MODE` is `false` (default):** Spawn the planner as a single long-lived Agent: +**Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:** +- **`true` (default):** use `run_in_background=true` in the Agent() call shown below, then use `gsd_stall_watch` as specified after it. + ```text Agent( prompt=filled_prompt, @@ -951,7 +954,9 @@ Agent( ) ``` -**ORCHESTRATOR RULE — ALL RUNTIMES:** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## PLANNING COMPLETE" "## PHASE SPLIT RECOMMENDED" "## ⚠ Source Audit" "## CHECKPOINT REACHED" "## PLANNING INCONCLUSIVE")` while waiting/active — `marker_received` -> step 9; `stalled` -> 9a. +**ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## PLANNING COMPLETE" "## PHASE SPLIT RECOMMENDED" "## ⚠ Source Audit" "## CHECKPOINT REACHED" "## PLANNING INCONCLUSIVE")` while waiting/active — `marker_received` -> step 9; `stalled` -> 9a. + +- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and pass the real returned result to step 9. Skip `gsd_stall_watch` entirely. This is not fire-and-forget; empty, truncated, or unrecognized returns still use step 9a. **If `CHUNKED_MODE` is `true`:** Skip the Agent() call above — proceed to step 8.5 instead. @@ -1080,7 +1085,12 @@ Agent( ) ``` -**ORCHESTRATOR RULE — ALL RUNTIMES:** `TS=$(date +%s)`; repeat `CHECKER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## VERIFICATION PASSED" "## ISSUES FOUND")` while waiting/active. +**Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:** +- **`true` (default):** use `run_in_background=true` in the Agent() call above, then use `gsd_stall_watch` below. + +**ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** `TS=$(date +%s)`; repeat `CHECKER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## VERIFICATION PASSED" "## ISSUES FOUND")` while waiting/active. + +- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and pass the real returned result to step 11. Skip `gsd_stall_watch` entirely. Treat a recognized returned marker exactly like `marker_received`; empty, truncated, or unrecognized returns still use step 11a. ## 11. Handle Checker Return @@ -1172,7 +1182,12 @@ Agent( ) ``` -**ORCHESTRATOR RULE — ALL RUNTIMES:** (7.99; no marker, mtimes only) `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md')` while waiting/active — `stalled` -> 1) Accept as revised, to step 13, 2) Retry, 3) Stop. +**Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:** +- **`true` (default):** use `run_in_background=true` in the Agent() call above, then use `gsd_stall_watch` below. + +**ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** (7.99; no marker, mtimes only) `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md')` while waiting/active — `stalled` -> 1) Accept as revised, to step 13, 2) Retry, 3) Stop. + +- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and pass the real returned result into the existing revision-return handling. Skip `gsd_stall_watch` entirely; an empty, truncated, or unrecognized result keeps the existing filesystem fallback. **If the planner returns `## REVISION_CONFLICT`:** follow the shared Conflict Return protocol in `gsd-core/references/revision-loop.md`, with this workflow's bindings: 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 c8ecd91b0..9aa87c18d 100644 --- a/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +++ b/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md @@ -50,6 +50,9 @@ Display: Spawn the planner in **outline-only** mode — it must write only the outline manifest, not any PLAN.md files: +**Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:** +- **`true` (default):** use `run_in_background=true` in the Agent() call below, then use `gsd_stall_watch` after it. + ```javascript Agent( prompt="{same planning_context as step 8, plus:} @@ -71,7 +74,9 @@ Agent( ) ``` -**ORCHESTRATOR RULE — ALL RUNTIMES:** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$OUTLINE_FILE" "## OUTLINE COMPLETE")` while waiting/active. +**ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$OUTLINE_FILE" "## OUTLINE COMPLETE")` while waiting/active. + +- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and consume the real returned result. Skip `gsd_stall_watch` entirely and treat a recognized `## OUTLINE COMPLETE` return exactly like `marker_received`; empty or unrecognized returns keep the existing Retry/Stop path. Handle return: - **`marker_received`:** Read `PLAN-OUTLINE.md`, extract plan list. Continue to 8.5.2. @@ -137,7 +142,8 @@ path regardless of `CHUNKED_PARALLEL` — there is nothing to batch. 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): + `run_in_background=true` exactly as today when `PLANNER_STALL_DETECTION_ENABLED` is `true` + (default)): ```javascript Agent( prompt="{same planning_context as step 8, plus:} @@ -161,7 +167,7 @@ path regardless of `CHUNKED_PARALLEL` — there is nothing to batch. **Concurrent dispatch:** issue every runnable entry's Agent() call together, in this one message, before waiting on any of them. -5. **ORCHESTRATOR RULE — ALL RUNTIMES, per batch:** for every entry dispatched in this round, +5. **ORCHESTRATOR RULE — ALL RUNTIMES, per batch (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** 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 @@ -170,6 +176,13 @@ path regardless of `CHUNKED_PARALLEL` — there is nothing to batch. Retry/Stop recovery for that one plan; it does not block verifying/committing sibling entries in the same batch that already reached `marker_received`. + - **`false`:** when `PLANNER_STALL_DETECTION_ENABLED` is `false`, omit `run_in_background` + from every call, skip `gsd_stall_watch`, and await each real returned result through the + ordinary runtime-native completion mechanism. A concurrent batch still issues the runnable + calls together and awaits all of their ordinary results before step 6. Treat each recognized + `## PLAN COMPLETE` return exactly like `marker_received`; empty or unrecognized returns keep + step 8's existing Retry/Stop path. This is never fire-and-forget. + 6. **Verify disk, per entry:** check `${PHASE_DIR}/{plan_id}-PLAN.md` exists for each entry that reached `marker_received`. Unchanged per-plan check. @@ -189,4 +202,3 @@ gsd_run query commit "docs(${PADDED_PHASE}): plan ${plan_id} (chunked)" --files 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/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md b/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md index e9490dd52..bbd0ff492 100644 --- a/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +++ b/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md @@ -1,8 +1,9 @@ # Bounded Stall-Detection Helpers (#2650) -Every planner/plan-checker spawn in `plan-phase.md` dispatches with -`run_in_background=true`, records `TS=$(date +%s)`, and then repeatedly -calls `gsd_stall_watch` until it returns something other than +`planner.stall_detection_enabled` controls the wait policy for the five scoped +planner/plan-checker spawns. It defaults to `true`. In that default-on mode each +spawn dispatches with `run_in_background=true`, records `TS=$(date +%s)`, and +then repeatedly calls `gsd_stall_watch` until it returns something other than `waiting`/`active`. This mirrors the already-shipped `executor.stall_*` pattern (`execute-phase.md`, bug #3212, commit `e7942c21b`) but — unlike that prose-only surveillance, which cannot run during a *blocking* `Agent()` @@ -11,6 +12,15 @@ issued as its own tool call, so it returns control to the orchestrator on its own schedule regardless of whether the backgrounded agent's own completion notification ever arrives. +When the key is explicitly the JSON boolean `false`, each scoped call instead +omits `run_in_background`, waits through the runtime-native ordinary Agent() +completion mechanism, and consumes that real returned result. It never calls +`gsd_stall_watch`, so there are no periodic shell sleeps. This is still a wait, +not fire-and-forget. It deliberately gives up #2650's bounded automatic +recovery: if the runtime loses the completion handoff, the user may need to +interrupt and use the existing filesystem fallback. The completion markers and +empty/truncated/unrecognized-return fallback remain unchanged. + **Binding `{outputFile}` (load-bearing, not optional):** every `gsd_stall_watch` call below takes `{outputFile}` as its second argument — a literal token the orchestrator must substitute with the REAL path from the immediately preceding @@ -71,10 +81,18 @@ config-get calls. This block is independent of, and never gated behind, the `query teams-status` / `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` guard used for the researcher spawn — the stall path applies on every runtime, teams-active or -not (AC2). +not (AC2). The toggle is resolved through the canonical `config-get` seam, so +root/project/workstream selection stays with the Config Loader rather than a +workflow-local JSON parser. Only the exact boolean result `false` disables; +missing, malformed, string, numeric, or otherwise unrecognized values fail safe +to `true`. ```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; }; _gsd_id_ok() { case "$("$1" runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') return 0;; *) return 1;; esac; }; _gsd_homes() { _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}"; }; 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 _gsd_homes; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; [ -n "$_G" ] && _gsd_id_ok "$_G"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and no identity-proving gsd_run is on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; _gsd_id_ok gsd_run && GSD_IDENTITY_STATUS=ok; 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 +PLANNER_STALL_DETECTION_ENABLED=$(gsd_run query config-get planner.stall_detection_enabled --raw 2>/dev/null || echo "true") +# Defense in depth for hand-edited config and old runtimes: only the exact +# canonical false token may disable the default-on recovery policy. +[ "$PLANNER_STALL_DETECTION_ENABLED" = "false" ] || PLANNER_STALL_DETECTION_ENABLED=true PLANNER_STALL_INTERVAL_MINUTES=$(gsd_run query config-get planner.stall_detect_interval_minutes --raw 2>/dev/null || echo "5") PLANNER_STALL_THRESHOLD_MINUTES=$(gsd_run query config-get planner.stall_threshold_minutes --raw 2>/dev/null || echo "10") # Both values are config-controlled (.planning/config.json, editable by any repo diff --git a/gsd-core/workflows/settings-advanced.md b/gsd-core/workflows/settings-advanced.md index 73c651df8..72a581503 100644 --- a/gsd-core/workflows/settings-advanced.md +++ b/gsd-core/workflows/settings-advanced.md @@ -1,8 +1,8 @@ Apply response_language to all user-facing prose — narration between tool calls, status updates, progress notes, and findings included; preserve code, paths, and identifiers. -Interactive configuration of GSD power-user knobs — plan bounce, node repair, subagent timeouts, -inline plan threshold, cross-AI execution, base branch, branch templates, response language, +Interactive configuration of GSD power-user knobs — plan bounce, planner stall detection, node +repair, subagent timeouts, inline plan threshold, cross-AI execution, base branch, branch templates, response language, context window, gitignored search, graphify build timeout, runtime model tier overrides, and model policy configuration (provider + budget → canonical tier mapping, or manual model ID assignment per cost tier). @@ -49,6 +49,7 @@ Parse the following current values. If a key is absent, fall back to the documen shown in parentheses: Planning Tuning: +- `planner.stall_detection_enabled` (default: `true`) - `workflow.plan_bounce` (default: `false`) - `workflow.plan_bounce_passes` (default: `2`) - `workflow.plan_bounce_script` (default: `null`) @@ -126,8 +127,21 @@ stored verbatim as a string. ### Section 1 — Planning Tuning +Before offering the disabled choice, display this warning verbatim: + +`Warning: disabling planner stall detection gives up bounded automatic recovery if the runtime loses an agent completion handoff. Planning still waits through the runtime-native completion mechanism, but you may need to interrupt and use the filesystem fallback.` + ```text AskUserQuestion([ + { + question: "Enable bounded planner/plan-checker stall detection? (current: )", + header: "Planner Watchdog", + multiSelect: false, + options: [ + { label: "Yes (default: true)", description: "Poll for completion markers and plan-file activity, with bounded recovery." }, + { label: "No (false)", description: "Await runtime-native completion without polling. Gives up bounded automatic recovery if completion handoff is lost." } + ] + }, { question: "Run external plan-bounce validator against generated PLAN.md? (current: )", header: "Plan Bounce", @@ -500,6 +514,7 @@ keys and sibling sub-objects. ```bash # Example — only write keys the user changed. "Keep current" selections are skipped. +gsd_run query config-set planner.stall_detection_enabled false gsd_run query config-set workflow.plan_bounce_passes 5 gsd_run query config-set workflow.subagent_timeout 300000 gsd_run query config-set git.base_branch main @@ -518,6 +533,10 @@ anything not listed in Sections 1–8 MUST survive the update): ```json { ...existing_config, + "planner": { + ...existing_planner, + "stall_detection_enabled": + }, "workflow": { ...existing_workflow, "plan_bounce": , @@ -752,6 +771,7 @@ Display: | Setting | Value | |--------------------------------------------|-------| +| planner.stall_detection_enabled | {true/false} | | workflow.plan_bounce | {on/off} | | workflow.plan_bounce_passes | {n} | | workflow.plan_bounce_script | {path/null} | @@ -805,6 +825,7 @@ UI/AI phase gates), use /gsd:settings. - [ ] Current config read from resolved `$GSD_CONFIG_PATH` - [ ] Eight sections rendered (Planning, Execution, Discussion, Cross-AI, Git, Runtime/Output, Runtime Model Tiers, Model Policy) - [ ] Every field pre-selected to its current value (or documented default if absent) +- [ ] Disabling planner stall detection shows the bounded-recovery warning before writing `false` - [ ] Numeric inputs validated — non-numeric rejected and re-prompted - [ ] Branch-template inputs validated — non-default must contain a placeholder - [ ] Null-allowed fields accept an empty input as a clear diff --git a/src/config-loader.cts b/src/config-loader.cts index 7922ab880..358bb4ebf 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -162,9 +162,22 @@ const CONFIG_DEFAULTS = { research_before_questions: _getNestedConfigDefault('workflow', 'research_before_questions'), // #3894 smart_zone_tokens: _getNestedConfigDefault('workflow', 'smart_zone_tokens'), inline_plan_threshold: _getNestedConfigDefault('workflow', 'inline_plan_threshold'), // #3801 + planner_stall_detection_enabled: _getNestedConfigDefault('planner', 'stall_detection_enabled'), max_prompt_tokens: _getNestedConfigDefault('review', 'max_prompt_tokens'), }; +/** + * Resolve the planner watchdog policy from hand-edited configuration. + * Only a real JSON boolean may override the default; strings/numbers/null + * fail safe to the manifest-owned default-on behavior (#4570). + */ +function resolvePlannerStallDetectionEnabled(value: unknown): boolean { + if (typeof value === 'boolean') return value; + // A missing or skewed manifest must never convert an absent override into + // permission to disable the watchdog. The documented contract is default-on. + return true; +} + /** * Deep-merge two plain config objects. `overlay` wins on key conflict. * Explicit `null` in overlay overrides base (null means "unset this key"). @@ -932,6 +945,9 @@ function loadConfigResolvedInternal(cwd: string, options: Record | undefined)?.['stall_detection_enabled'], + ), model_overrides: (globalDefaults['model_overrides']) || null, models: (globalDefaults['models']) || null, granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null, @@ -1191,6 +1210,7 @@ export = { _getNestedConfigDefault, _getConfigValue, _getConfigNested, + resolvePlannerStallDetectionEnabled, _deepMergeConfig, _warnedUnknownConfigKeys, _warnedShadowedGlobalKeys, diff --git a/src/config.cts b/src/config.cts index 5974fe94e..3fe1c166d 100644 --- a/src/config.cts +++ b/src/config.cts @@ -17,7 +17,7 @@ import cliExitMod = require('./cli-exit.cjs'); const { ExitError } = cliExitMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import configLoader = require('./config-loader.cjs'); -const { CONFIG_DEFAULTS } = configLoader; +const { CONFIG_DEFAULTS, resolvePlannerStallDetectionEnabled } = configLoader; import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); @@ -107,6 +107,7 @@ const SCHEMA_DEFAULTS: Record = { 'context_window': 200000, 'executor.stall_detect_interval_minutes': 5, 'executor.stall_threshold_minutes': 10, + 'planner.stall_detection_enabled': CONFIG_DEFAULTS.planner_stall_detection_enabled, 'planner.stall_detect_interval_minutes': 5, 'planner.stall_threshold_minutes': 10, 'git.create_tag': true, @@ -185,12 +186,15 @@ function resolveSchemaDefault(cwd: string, kp: string): { found: boolean; value: * Centralizing emission here means masking can't be missed at a call site. */ function emitResolvedDefault(kp: string, value: unknown, raw: boolean): void { + const resolvedValue = kp === 'planner.stall_detection_enabled' + ? resolvePlannerStallDetectionEnabled(value) + : value; if (isSecretKey(kp)) { - const masked = maskSecret(value as Parameters[0]); + const masked = maskSecret(resolvedValue as Parameters[0]); output(masked, raw, masked); return; } - output(value, raw, String(value)); + output(resolvedValue, raw, String(resolvedValue)); } // ─── Validation helpers ─────────────────────────────────────────────────────── @@ -955,6 +959,14 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | } } + // Planner watchdog opt-out (#4570) — only a real boolean may change the + // default-on policy. In particular, string "false" must not disable it. + if (kp === 'planner.stall_detection_enabled') { + if (typeof parsedValue !== 'boolean') { + error(`Invalid planner.stall_detection_enabled '${val}'. Must be a boolean (true or false).`); + } + } + // #3086 — git.create_tag: boolean only if (kp === 'git.create_tag') { if (typeof parsedValue !== 'boolean') { @@ -1251,6 +1263,10 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND); } + if (kp === 'planner.stall_detection_enabled') { + current = resolvePlannerStallDetectionEnabled(current); + } + // Never echo plaintext for sensitive keys via config-get. Plaintext lives // in config.json on disk; the CLI surface always shows the masked form. if (isSecretKey(kp)) { diff --git a/tests/fixtures/compact-content-benchmark-baseline.json b/tests/fixtures/compact-content-benchmark-baseline.json index 76931b26b..aea093193 100644 --- a/tests/fixtures/compact-content-benchmark-baseline.json +++ b/tests/fixtures/compact-content-benchmark-baseline.json @@ -28,9 +28,9 @@ "reductionPct": 13.53 }, "plan-phase": { - "offTokens": 28000, - "onTokens": 24649, - "reductionPct": 11.97 + "offTokens": 28406, + "onTokens": 25055, + "reductionPct": 11.8 }, "verify-work": { "offTokens": 13445, @@ -39,8 +39,8 @@ } }, "aggregate": { - "offTokens": 108858, - "onTokens": 91988, - "reductionPct": 15.5 + "offTokens": 109264, + "onTokens": 92394, + "reductionPct": 15.44 } } diff --git a/tests/fixtures/compact-content-variant-benchmark-baseline.json b/tests/fixtures/compact-content-variant-benchmark-baseline.json index bd2c6ba60..e22344f9c 100644 --- a/tests/fixtures/compact-content-variant-benchmark-baseline.json +++ b/tests/fixtures/compact-content-variant-benchmark-baseline.json @@ -23,9 +23,9 @@ "reductionPct": 20.79 }, "agents/gsd-code-fixer.md": { - "offTokens": 10740, - "onTokens": 6690, - "reductionPct": 37.71 + "offTokens": 10835, + "onTokens": 6781, + "reductionPct": 37.42 }, "agents/gsd-code-reviewer.md": { "offTokens": 4408, @@ -38,9 +38,9 @@ "reductionPct": 9.76 }, "agents/gsd-debug-session-manager.md": { - "offTokens": 5027, - "onTokens": 4738, - "reductionPct": 5.75 + "offTokens": 5088, + "onTokens": 4799, + "reductionPct": 5.68 }, "agents/gsd-doc-classifier.md": { "offTokens": 2901, @@ -73,9 +73,9 @@ "reductionPct": 17.83 }, "agents/gsd-eval-auditor.md": { - "offTokens": 2941, - "onTokens": 2808, - "reductionPct": 4.52 + "offTokens": 3002, + "onTokens": 2869, + "reductionPct": 4.43 }, "agents/gsd-eval-planner.md": { "offTokens": 1674, @@ -93,9 +93,9 @@ "reductionPct": 42.1 }, "agents/gsd-intel-updater.md": { - "offTokens": 4353, - "onTokens": 4115, - "reductionPct": 5.47 + "offTokens": 4414, + "onTokens": 4176, + "reductionPct": 5.39 }, "agents/gsd-mempalace-curator.md": { "offTokens": 1160, @@ -113,14 +113,14 @@ "reductionPct": 15.37 }, "agents/gsd-project-researcher.md": { - "offTokens": 5464, - "onTokens": 5135, - "reductionPct": 6.02 + "offTokens": 5525, + "onTokens": 5196, + "reductionPct": 5.95 }, "agents/gsd-research-synthesizer.md": { - "offTokens": 3122, - "onTokens": 2917, - "reductionPct": 6.57 + "offTokens": 3183, + "onTokens": 2978, + "reductionPct": 6.44 }, "agents/gsd-roadmapper.md": { "offTokens": 6008, @@ -133,9 +133,9 @@ "reductionPct": 10.54 }, "agents/gsd-ui-auditor.md": { - "offTokens": 4145, + "offTokens": 6729, "onTokens": 3926, - "reductionPct": 5.28 + "reductionPct": 41.66 }, "agents/gsd-ui-checker.md": { "offTokens": 4499, @@ -143,9 +143,9 @@ "reductionPct": 32.25 }, "agents/gsd-ui-researcher.md": { - "offTokens": 5432, - "onTokens": 4815, - "reductionPct": 11.36 + "offTokens": 5493, + "onTokens": 4876, + "reductionPct": 11.23 }, "agents/gsd-user-profiler.md": { "offTokens": 1859, @@ -163,14 +163,14 @@ "reductionPct": 33.87 }, "gsd-core/workflows/help/modes/full.md": { - "offTokens": 9950, - "onTokens": 6579, - "reductionPct": 33.88 + "offTokens": 9933, + "onTokens": 6573, + "reductionPct": 33.83 } }, "aggregate": { - "offTokens": 118958, - "onTokens": 94602, - "reductionPct": 20.47 + "offTokens": 121986, + "onTokens": 95053, + "reductionPct": 22.08 } } diff --git a/tests/plan-phase-stall-detection.test.cjs b/tests/plan-phase-stall-detection.test.cjs index b5e34958f..bb0086e5c 100644 --- a/tests/plan-phase-stall-detection.test.cjs +++ b/tests/plan-phase-stall-detection.test.cjs @@ -47,14 +47,21 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const fc = require('fast-check'); -const { cleanup, readFileNormalized, readWorkflowCombined } = require('./helpers.cjs'); +const { cleanup, readFileNormalized, readWorkflowCombined, runGsdTools } = require('./helpers.cjs'); const REPO_ROOT = path.join(__dirname, '..'); const PLAN_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md'); const STALL_HELPERS_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md'); const CHUNKED_PLANNING_MODE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md'); const CONFIG_SCHEMA_MANIFEST_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); +const CONFIG_DEFAULTS_MANIFEST_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'config-defaults.manifest.json'); const CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'CONFIGURATION.md'); +const PT_BR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'pt-BR', 'CONFIGURATION.md'); +const ZH_CN_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'CONFIGURATION.md'); +const JA_JP_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ja-JP', 'CONFIGURATION.md'); +const KO_KR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ko-KR', 'CONFIGURATION.md'); +const ZH_CN_PLANNING_CONFIG_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'references', 'planning-config.md'); +const SETTINGS_ADVANCED_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'settings-advanced.md'); function readPlanPhase() { return readFileNormalized(PLAN_PHASE_PATH); @@ -461,6 +468,148 @@ describe('bug #2650 config schema — planner.stall_* keys mirror executor.stall }); }); +describe('enhancement #4570 config contract — planner stall detection has a typed default-on opt-out', () => { + function makeProject(t, config = {}) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-config-')); + t.after(() => cleanup(tmp)); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config, null, 2)}\n`); + return tmp; + } + + test('central schema and canonical defaults manifest register planner.stall_detection_enabled=true', () => { + const schema = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_MANIFEST_PATH, 'utf8')); + const defaults = JSON.parse(fs.readFileSync(CONFIG_DEFAULTS_MANIFEST_PATH, 'utf8')); + assert.ok(schema.validKeys.includes('planner.stall_detection_enabled')); + assert.equal(defaults.planner?.stall_detection_enabled, true); + assert.equal(schema.validKeys.includes('executor.stall_detection_enabled'), false, + 'the planner opt-out must not introduce an executor sibling outside approved scope'); + }); + + test('config-get defaults absent values to true; config-set false round-trips as boolean false', (t) => { + const tmp = makeProject(t); + const env = { HOME: tmp, USERPROFILE: tmp }; + + const absent = runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env); + assert.equal(absent.success, true, absent.error); + assert.equal(absent.output, 'true'); + + const noConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-no-config-')); + t.after(() => cleanup(noConfig)); + const absentFile = runGsdTools( + ['config-get', 'planner.stall_detection_enabled', '--raw'], + noConfig, + { HOME: noConfig, USERPROFILE: noConfig }, + ); + assert.equal(absentFile.success, true, absentFile.error); + assert.equal(absentFile.output, 'true'); + + const set = runGsdTools(['config-set', 'planner.stall_detection_enabled', 'false'], tmp, env); + assert.equal(set.success, true, set.error); + const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf8')); + assert.equal(onDisk.planner.stall_detection_enabled, false); + assert.equal(typeof onDisk.planner.stall_detection_enabled, 'boolean'); + + const roundTrip = runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env); + assert.equal(roundTrip.success, true, roundTrip.error); + assert.equal(roundTrip.output, 'false'); + }); + + test('property: every non-boolean CLI value is rejected without modifying config', (t) => { + const tmp = makeProject(t, { planner: { stall_detection_enabled: true }, sentinel: 'preserve' }); + const configPath = path.join(tmp, '.planning', 'config.json'); + const before = fs.readFileSync(configPath, 'utf8'); + const printable = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[]{}._- '; + const invalidValue = fc.array(fc.constantFrom(...printable), { minLength: 1, maxLength: 16 }) + .map((chars) => chars.join('')) + .filter((value) => !['true', 'false', 'null'].includes(value)); + + fc.assert( + fc.property(invalidValue, (value) => { + const result = runGsdTools( + ['config-set', 'planner.stall_detection_enabled', value], + tmp, + { HOME: tmp, USERPROFILE: tmp }, + ); + return result.success === false && fs.readFileSync(configPath, 'utf8') === before; + }), + { numRuns: 20 }, + ); + }); + + test('hand-edited non-booleans fail safely to true on both config-get and Config Loader reads', (t) => { + const tmp = makeProject(t, { planner: { stall_detection_enabled: 'false' } }); + const result = runGsdTools( + ['config-get', 'planner.stall_detection_enabled', '--raw'], + tmp, + { HOME: tmp, USERPROFILE: tmp }, + ); + assert.equal(result.success, true, result.error); + assert.equal(result.output, 'true', 'a string "false" must not disable the watchdog'); + + const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs'); + assert.equal(loadConfig(tmp).planner_stall_detection_enabled, true); + }); + + test('manifest skew cannot make a non-boolean planner setting disable detection', () => { + const { resolvePlannerStallDetectionEnabled } = require('../gsd-core/bin/lib/config-loader.cjs'); + for (const value of [undefined, null, 'false', 0, {}]) { + assert.equal(resolvePlannerStallDetectionEnabled(value), true); + } + }); + + test('root, GSD_PROJECT, and workstream reads retain canonical scope precedence', (t) => { + const tmp = makeProject(t, { planner: { stall_detection_enabled: false } }); + const projectDir = path.join(tmp, '.planning', 'product-a'); + const workstreamDir = path.join(tmp, '.planning', 'workstreams', 'alpha'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(workstreamDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n'); + fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n'); + const home = { HOME: tmp, USERPROFILE: tmp }; + + assert.equal(runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, home).output, 'false'); + assert.equal(runGsdTools( + ['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, + { ...home, GSD_PROJECT: 'product-a' }, + ).output, 'true'); + assert.equal(runGsdTools( + ['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, + { ...home, GSD_WORKSTREAM: 'alpha' }, + ).output, 'true'); + + const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs'); + assert.equal(loadConfig(tmp).planner_stall_detection_enabled, false); + assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, true); + + fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{}}\n'); + assert.equal(runGsdTools( + ['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, + { ...home, GSD_WORKSTREAM: 'alpha' }, + ).output, 'false', 'an omitted workstream value must inherit the root value'); + assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, false); + }); + + test('English and enumerating localized docs state default, CLI opt-out, effect, and recovery loss', () => { + for (const docsPath of [CONFIGURATION_DOCS_PATH, PT_BR_CONFIGURATION_DOCS_PATH, ZH_CN_CONFIGURATION_DOCS_PATH, JA_JP_CONFIGURATION_DOCS_PATH, KO_KR_CONFIGURATION_DOCS_PATH]) { + const docs = fs.readFileSync(docsPath, 'utf8'); + assert.match(docs, /`planner\.stall_detection_enabled`\s*\|\s*boolean\s*\|\s*`true`/); + assert.match(docs, /config-set planner\.stall_detection_enabled false/); + assert.match(docs, /runtime-native|nativa do runtime|运行时原生|ランタイムネイティブ|런타임 네이티브/i); + assert.match(docs, /bounded recovery|recupera[cç][aã]o limitada|有界恢复|有界な復旧|제한된 복구/i); + } + assert.match(fs.readFileSync(ZH_CN_PLANNING_CONFIG_PATH, 'utf8'), /planner\.stall_detection_enabled/); + }); + + test('advanced settings warns about recovery loss before offering to persist false', () => { + const settings = fs.readFileSync(SETTINGS_ADVANCED_PATH, 'utf8'); + assert.match(settings, /planner\.stall_detection_enabled/); + assert.match(settings, /default:\s*`true`/); + assert.match(settings, /bounded automatic recovery[\s\S]{0,500}false/i); + assert.match(settings, /config-set planner\.stall_detection_enabled false/); + }); +}); + describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch in the background with bounded stall surveillance', () => { let workflow; @@ -475,10 +624,32 @@ describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch test('stall-detection-helpers.md resolves PLANNER_STALL_INTERVAL_MINUTES / PLANNER_STALL_THRESHOLD_MINUTES from config', () => { const helpersDoc = readStallHelpersDoc(); + assert.match(helpersDoc, /PLANNER_STALL_DETECTION_ENABLED=.*planner\.stall_detection_enabled/); assert.match(helpersDoc, /PLANNER_STALL_INTERVAL_MINUTES=.*planner\.stall_detect_interval_minutes/); assert.match(helpersDoc, /PLANNER_STALL_THRESHOLD_MINUTES=.*planner\.stall_threshold_minutes/); }); + test('invalid or absent toggle values normalize to default-on; only boolean false disables', (t) => { + const helpersBash = extractStallHelpersBash(); + for (const [stored, expected] of [[undefined, 'true'], [true, 'true'], [false, 'false'], ['false', 'true'], [0, 'true'], [null, 'true']]) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-resolve-')); + t.after(() => cleanup(tmp)); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + const config = stored === undefined ? {} : { planner: { stall_detection_enabled: stored } }; + fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config)}\n`); + const result = runBashScript( + `${helpersBash}\nprintf '%s\\n' "$PLANNER_STALL_DETECTION_ENABLED"\n`, + [], + { + cwd: tmp, + env: { ...process.env, HOME: tmp, USERPROFILE: tmp, RUNTIME_DIR: REPO_ROOT, GSD_PROJECT: '', GSD_WORKSTREAM: '' }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), expected, `stored ${JSON.stringify(stored)} resolved incorrectly`); + } + }); + test('standard planner spawn (step 8) dispatches with run_in_background=true and calls gsd_stall_watch', () => { const idx = workflow.indexOf('## 8. Spawn gsd-planner Agent'); assert.notEqual(idx, -1); @@ -593,6 +764,34 @@ describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch `expected exactly 5 gsd_stall_watch "$TS" "{outputFile}" spawn-site invocations across plan-phase.md + steps/*.md, found ${callCount}`); }); + test('all five spawn classes gate background surveillance and retain a runtime-native blocking result path', () => { + const mainSections = [ + ['standard planner', '## 8. Spawn gsd-planner Agent', '## 9. Handle Planner Return'], + ['plan-checker', '## 10. Spawn gsd-plan-checker Agent', '## 11. Handle Checker Return'], + ['revision planner', '## 12. Revision Loop', '## 12.5. Plan Bounce'], + ]; + const chunkedDoc = readChunkedPlanningMode(); + const sections = mainSections.map(([label, start, end]) => { + const startAt = workflow.indexOf(start); + return [label, workflow.slice(startAt, workflow.indexOf(end, startAt))]; + }); + sections.push( + ['chunked outline', chunkedDoc.slice( + chunkedDoc.indexOf('### 8.5.1 Outline Phase'), + chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'), + )], + ['chunked per-plan', chunkedDoc.slice(chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'))], + ); + + for (const [label, section] of sections) { + assert.match(section, /PLANNER_STALL_DETECTION_ENABLED/, `${label}: missing toggle gate`); + assert.match(section, /`true`[\s\S]*run_in_background=true[\s\S]*gsd_stall_watch/, + `${label}: default-on branch must retain background watcher behavior`); + assert.match(section, /`false`[\s\S]{0,700}(?:omit|without) `?run_in_background`?[\s\S]{0,700}(?:ordinary|runtime-native)[\s\S]{0,500}(?:return|result)/i, + `${label}: explicit-off branch must omit backgrounding and await the real runtime result`); + } + }); + test('step 7.99 documents that {outputFile} must be bound from the real Agent() return (not passed literally)', () => { const idx = workflow.indexOf('## 7.99. Bounded Stall-Detection Helpers'); assert.notEqual(idx, -1); diff --git a/tests/workflow-fragments-emission.install.test.cjs b/tests/workflow-fragments-emission.install.test.cjs index e46965e45..ce931c337 100644 --- a/tests/workflow-fragments-emission.install.test.cjs +++ b/tests/workflow-fragments-emission.install.test.cjs @@ -62,6 +62,9 @@ const REPO_ROOT = path.join(__dirname, '..'); const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md'); const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL); +const PLAN_PHASE_REL = path.join('gsd-core', 'workflows', 'plan-phase.md'); +const CHUNKED_PLAN_REL = path.join('gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md'); +const STALL_HELPERS_REL = path.join('gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md'); // plan-phase.md was the original #2930 pilot but was reverted to unmarked // (chore/2930 retarget: it sits 36 B under the ADR-857 Phase-6 PRE_PHASE6 // gate and cannot absorb marker overhead) — it was a genuinely unmarked @@ -284,6 +287,22 @@ test('noSectionMarkerLeaksIntoEmittedArtifacts', (t) => { false, `${runtime}: emitted execute-phase.md still contains a gsd:section marker token`, ); + + // #4570: assert against the real installed workflow bytes for every runtime, + // after composition and runtime conversion. The background parameter itself + // is runtime vocabulary (`run_in_background` becomes `background` on Hermes), + // so parity is pinned on the shared gate and its two semantic branches. + for (const relativePath of [PLAN_PHASE_REL, CHUNKED_PLAN_REL, STALL_HELPERS_REL]) { + const installedPath = path.join(configDir, relativePath); + assert.ok(fs.existsSync(installedPath), `${runtime}: emitted ${relativePath} is missing`); + const installed = fs.readFileSync(installedPath, 'utf8'); + assert.match(installed, /PLANNER_STALL_DETECTION_ENABLED/, + `${runtime}: ${relativePath} lost the planner stall-detection gate during conversion`); + assert.match(installed, /gsd_stall_watch/, + `${runtime}: ${relativePath} lost the default-on watcher branch during conversion`); + assert.match(installed, /runtime-native/, + `${runtime}: ${relativePath} lost the explicit-off ordinary completion branch during conversion`); + } cleanup(root); } });