fix(plan-phase): recover safely from quota exhaustion

This commit is contained in:
Jakub Zych
2026-09-24 08:21:32 +02:00
parent 238bee7b03
commit 952f6a9fce
3 changed files with 103 additions and 3 deletions

View File

@@ -591,7 +591,8 @@ map is refreshed first. (`drift_action: auto-remap` stays at `execute:wave:post`
ls "${PHASE_DIR}"/*-PLAN.md 2>/dev/null || true
```
**If exists AND no `--reviews` flag:** Offer: 1) Add more plans, 2) View existing, 3) Replan from scratch.
**If exists AND no `--reviews` flag:** Offer: 1) Verify existing plans (resume an interrupted run;
proceed to step 10), 2) Add more plans, 3) View existing, 4) Replan from scratch.
## 7. Use Context Paths from INIT
@@ -729,8 +730,15 @@ Read+execute `gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md` (d
`gsd_stall_should_recover`/`gsd_stall_watch`, and how `{outputFile}` below is bound;
independent of the teams-status guard above, AC2).
## 7.995. Agent Recovery and Stage Timing
Read+execute `gsd-core/workflows/plan-phase/steps/agent-recovery-and-timing.md`. Its retry guard,
quota classifier, and stage timers apply to planner, checker, and revision dispatches.
## 8. Spawn gsd-planner Agent
Run `gsd_plan_stage_start planner` immediately before dispatch.
Display banner:
```
### GSD ► PLANNING PHASE {X}
@@ -966,6 +974,8 @@ If `section_manifest` is `null` or `"chunked-planning-mode"` is in its `included
## 9. Handle Planner Return
Call `gsd_plan_stage_end` with the outcome. Before fallback or retry, apply step 7.995.
- **`## PLANNING COMPLETE`:** Display plan count. If `--skip-verify` or `plan_checker_enabled` is false (from init): skip to step 13. Otherwise: step 10.
- **`## PHASE SPLIT RECOMMENDED`:** The planner determined the phase exceeds the context budget for full-fidelity implementation of all source items. Handle in step 9b.
- **`## ⚠ Source Audit: Unplanned Items Found`:** The planner's multi-source coverage audit found items from REQUIREMENTS.md, RESEARCH.md, ROADMAP goal, or CONTEXT.md decisions that are not covered by any plan. Handle in step 9c.
@@ -978,7 +988,7 @@ If `section_manifest` is `null` or `"chunked-planning-mode"` is in its `included
**Triggered when:** Agent() returns but the return contains no recognized marker (`## PLANNING COMPLETE`, `## PHASE SPLIT RECOMMENDED`, `## ⚠ Source Audit`, `## CHECKPOINT REACHED`, `## PLANNING INCONCLUSIVE`).
```bash
DISK_PLANS=$(gsd_run query find-phase "${PHASE_NUMBER}" | jq -r '.plan_count_all // 0')
DISK_PLANS=$(gsd_run query find-phase "${phase_number}" | jq -r '.plan_count_all // 0')
```
If `DISK_PLANS` is greater than 0 (a known Windows stdio hang pattern — the planner wrote plans to disk but the return never arrived), offer: 1) Accept plans (treat as `## PLANNING COMPLETE`), 2) Retry planner (return to step 8), 3) Stop. If it is 0 and no marker, treat as `## PLANNING INCONCLUSIVE`. Full banner text: `gsd-core/workflows/plan-phase/detail/elaboration.md` § 9a.
@@ -993,6 +1003,8 @@ When the planner returns `## ⚠ Source Audit: Unplanned Items Found`, items fro
## 10. Spawn gsd-plan-checker Agent
Run `gsd_plan_stage_start checker` immediately before dispatch.
Display banner:
```
### GSD ► VERIFYING PLANS
@@ -1094,6 +1106,8 @@ Agent(
## 11. Handle Checker Return
Call `gsd_plan_stage_end` with the outcome. Before fallback or retry, apply step 7.995.
- **`marker_received` + `## VERIFICATION PASSED`:** Display confirmation, proceed to step 13.
- **`marker_received` + `## ISSUES FOUND`:** Display issues, check iteration count, proceed to step 12.
- **`stalled`:** Automatically surface 11a's recovery choice (Accept verification / Retry checker / Stop) — no manual interrupt needed.
@@ -1106,7 +1120,7 @@ Agent(
**Triggered when:** Checker Agent() returns but the return contains neither `## VERIFICATION PASSED` nor `## ISSUES FOUND`.
```bash
DISK_PLANS=$(gsd_run query find-phase "${PHASE_NUMBER}" | jq -r '.plan_count_all // 0')
DISK_PLANS=$(gsd_run query find-phase "${phase_number}" | jq -r '.plan_count_all // 0')
```
If `DISK_PLANS` is greater than 0 (plans exist on disk; a known Windows stdio hang pattern), offer: 1) Accept verification (treat as `## VERIFICATION PASSED`, continue to step 13), 2) Retry checker (return to step 10), 3) Stop. If it is 0, something is seriously wrong — display error and stop. Full banner text: `gsd-core/workflows/plan-phase/detail/elaboration.md` § 11a.
@@ -1145,6 +1159,9 @@ Set `prev_issue_count = issue_count`.
Revision prompt:
Before dispatch, run the stale-run guard from step 7.995, then `gsd_plan_stage_start revision`.
On return, call `gsd_plan_stage_end` and apply step 7.995 before any retry.
```markdown
<revision_context>
**Phase:** {phase_number}

View File

@@ -0,0 +1,51 @@
# Agent Recovery and Stage Timing
Initialize once before the first planning agent is dispatched:
```bash
PLAN_PHASE_STARTED_AT=$(date +%s)
PLAN_PHASE_STAGE_STARTED_AT=$PLAN_PHASE_STARTED_AT
PLAN_PHASE_BASELINE_SUMMARIES=$(gsd_run query find-phase "${phase_number}" | jq -r '.summary_count // 0')
gsd_plan_stage_start() {
PLAN_PHASE_STAGE="$1"
PLAN_PHASE_STAGE_STARTED_AT=$(date +%s)
echo "◆ Stage ${PLAN_PHASE_STAGE} started"
}
gsd_plan_stage_end() {
_now=$(date +%s)
echo "◆ Stage ${PLAN_PHASE_STAGE:-unknown}: $((_now - PLAN_PHASE_STAGE_STARTED_AT))s (${1:-complete}); plan-phase total: $((_now - PLAN_PHASE_STARTED_AT))s"
}
gsd_plan_external_execution_started() {
_current_summaries=$(gsd_run query find-phase "${phase_number}" | jq -r '.summary_count // 0')
[ "$_current_summaries" -gt "$PLAN_PHASE_BASELINE_SUMMARIES" ]
}
gsd_plan_classify_return() {
gsd_run query agent.classify-failure -- "$1"
}
```
Call `gsd_plan_stage_start <planner|checker|revision>` immediately before each corresponding
Agent dispatch. Call `gsd_plan_stage_end <marker|stalled|quota-exceeded|unrecognized>` exactly
once when that dispatch ends, before retrying, stopping, or moving on. This exposes elapsed time
even when an agent fails or exhausts quota.
Before every retry or revision dispatch, call `gsd_plan_external_execution_started`. If it
succeeds, stop this stale planning run immediately: another session produced a new SUMMARY.md
since this run started. Report baseline/current counts and direct the user to `/gsd:progress`.
For every empty, truncated, errored, or unrecognized Agent return, read the complete return body
(for a background agent, read its bound `{outputFile}`), then run:
```bash
PLAN_AGENT_FAILURE=$(gsd_plan_classify_return "$AGENT_RETURN_BODY")
PLAN_AGENT_FAILURE_CLASS=$(printf '%s' "$PLAN_AGENT_FAILURE" | jq -r '.class // "unknown-failure"')
```
If the class is `quota-exceeded`, call `gsd_plan_stage_end quota-exceeded`, show `sentinel` and
`retryAfterSeconds` when present, and stop cleanly. Never offer or perform an immediate retry.
Preserve PLAN.md files and tell the user to rerun `/gsd:plan-phase {PHASE}` after reset; step 6
offers verification of existing plans. Non-quota failures continue to the existing fallback.

View File

@@ -0,0 +1,32 @@
// allow-test-rule: source-text-is-the-product — workflow markdown is the runtime contract.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const workflow = fs.readFileSync(path.join(root, 'gsd-core/workflows/plan-phase.md'), 'utf8');
const recovery = fs.readFileSync(path.join(root, 'gsd-core/workflows/plan-phase/steps/agent-recovery-and-timing.md'), 'utf8');
describe('plan-phase agent recovery and timing', () => {
test('classifies quota failures and forbids immediate retry', () => {
assert.match(recovery, /agent\.classify-failure/);
assert.match(recovery, /class is `quota-exceeded`/);
assert.match(recovery, /Never offer or perform an immediate retry/);
});
test('stops stale planning after execution starts', () => {
assert.match(recovery, /PLAN_PHASE_BASELINE_SUMMARIES/);
assert.match(recovery, /summary_count/);
assert.match(recovery, /find-phase "\$\{phase_number\}"/);
assert.match(recovery, /stop this stale planning run immediately/);
});
test('times all three agent stages and quota exits', () => {
for (const stage of ['planner', 'checker', 'revision']) assert.match(workflow, new RegExp(`gsd_plan_stage_start ${stage}`));
assert.match(recovery, /gsd_plan_stage_end quota-exceeded/);
assert.match(recovery, /plan-phase total/);
});
test('existing plans resume directly at verification', () => {
assert.match(workflow, /Verify existing plans[\s\S]{0,160}proceed to step 10/);
assert.doesNotMatch(workflow, /find-phase "\$\{PHASE_NUMBER\}"/);
});
});