enhance(#4570): allow disabling planner stall detection (#4585)

* enhance(#4570): allow disabling planner stall detection

* docs(#4570): add changelog fragment

Emitted-Drift-Ack-Growth: plan-phase.md — the explicit opt-out gate covers all five planner and checker spawn classes
Emitted-Drift-Ack-Growth: settings-advanced.md — the toggle prompt and bounded-recovery warning expose the new setting

* chore(#4570): refresh compact-content baseline

* fix(#4570): preserve default-on watchdog fallback

* docs(#4570): qualify the chunked-mode orchestrator rules with the toggle

The two chunked-planning-mode stall-watch imperatives read as
unconditional, with the opt-out stated only in a following bullet. State
the PLANNER_STALL_DETECTION_ENABLED condition inline, matching the three
sites already qualified in plan-phase.md.

* chore(#4570): refresh compact-content baselines against rebased next

* fix(#4570): sync planner stall launcher

Keep the stall-detection helper aligned with the canonical runtime launcher.

* chore(#4570): refresh compact baseline after rebase

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-23 19:46:23 -03:00
committed by GitHub
parent 17bf25a3d2
commit 1e3e1f7cd8
21 changed files with 417 additions and 53 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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.
<purpose>
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: <value or true>)",
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: <value or false>)",
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": <new|existing>
},
"workflow": {
...existing_workflow,
"plan_bounce": <new|existing>,
@@ -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