* test(#2296): failing-first coverage for provider escalation on quota-exceeded
Covers the provider-escalation ladder layered onto EXEC.CLASSIFY: back-compat
(no escalation block without --failure-class), cap boundaries at
min(max_escalations, list length) at limit-1/limit/limit+1, opt-in gating,
malformed/hostile provider_escalation config, the --failure-class CLI negative
matrix, config-key registration, and a fast-check budget-limit property.
Red until the resolver, CLI flag, and manifest key land.
Refs #2296
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#2296): config-gated provider escalation on quota-exceeded
The dynamic_routing tier ladder escalates within one provider, which does not
help when that provider is what ran out of quota. Add an opt-in provider ladder
layered on the existing EXEC.CLASSIFY seam.
- model-resolver: resolveProviderEscalation walks dynamic_routing.provider_escalation
capped at min(max_escalations, list length), reporting from/to/attempted/exhausted.
Invalid entries are dropped (ADR 227 shape validation). Stays a leaf module —
the quota-class policy decision is the caller's, per the CONTEXT.md contract.
- agent-command-router: export a frozen AGENT_FAILURE_CLASSES so the new CLI
validator cannot drift from the classifier that produces the values.
- resolve-execution: --failure-class flag; emits an escalation block ONLY when
passed, so the existing JSON contract is byte-identical for every caller.
- config-schema.manifest: register dynamic_routing.provider_escalation.
- execute-phase step 7.1: auto-escalate, honor Retry-After, fail loudly naming
every model tried once the ladder is spent.
Refs #2296
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2296): extract quota recovery to a reference fragment; regen goldens
The step 7.1a addition pushed gsd-core/workflows/execute-phase.md from 93390 to
95111 LF bytes, past the frozen ADR-857 Phase 6 ceiling (hard <93600, margin
<=93400) asserted by tests/fix-2285-claude-orchestration-wiring.test.cjs. The
base sat 10 bytes under the margin, so no inline wording would have fit.
That gate's own rationale is that optional-feature detail belongs in a fragment,
not the host loop. Moved BOTH the new provider-escalation branch and the
pre-existing manual recovery prompt into
gsd-core/references/execute-phase-quota-recovery.md, leaving step 7.1 as a
one-line pointer. execute-phase.md is now 92880 bytes — 510 SMALLER than base.
Also regenerates the fixtures that legitimately moved because three shipped
files changed (gsd-tools.cjs, config-schema.manifest.json, execute-phase.md):
golden-install-parity + install-tree for all 16 runtimes, INVENTORY.md +
INVENTORY-MANIFEST.json for the new reference, and the workflow size baseline.
Refs #2296
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#2351): make the C1 orphan-reaping test load-independent
tests/run-with-timeout.test.cjs C1 asserted the child heartbeat file exists
after a 1s group-kill window, but the child only wrote it on the first 100ms
setInterval tick. Nothing synchronized the two: on a loaded container the group
is SIGKILLed before that tick lands, the file never appears, and the assertion
fails for a reason unrelated to reaping. Observed failing on both linux-node22
and linux-node24.
The behavior actually under test is the FREEZE assertion (heartbeat stops
advancing => descendant was reaped, not orphaned). That is unaffected by
sampling once more at t=0.
Child now writes its first heartbeat synchronously at startup before arming the
interval, and the kill window widens 1s -> 3s to cover child boot under load.
Both remove the timing dependency; neither weakens what the test proves.
Refs #2296
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#2296): backfill pr:2458 in .changeset/rapid-jays-bark.md
* chore(#2296): regenerate fixtures after rebase onto #2402
The rebase conflicted on the generated golden-install-parity fixtures and
workflow-size-baseline.json because #2402 (b6e6a22fc) regenerated the same
artifacts. Conflict resolution picked a side to unblock the rebase; a true
regeneration on the combined tree then produced further drift, confirming the
resolved content was stale and would have dropped #2402's fixture changes.
Regenerated goldens, install-tree, size baseline, and INVENTORY-MANIFEST from
the merged tree. docs/INVENTORY.md keeps BOTH new reference rows.
execute-phase.md is 92782 LF bytes with both #2402's and this PR's extractions
applied — under the frozen ceiling (hard <93600, margin <=93400).
Refs #2296
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -160,7 +160,8 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
|
||||
| `dynamic_routing.enabled` | boolean | `true`, `false` | `false` | Master switch for [dynamic routing with failure-tier escalation](#dynamic-routing-with-failure-tier-escalation-dynamic_routing--added-in-v140). When `true`, agents resolve to `tier_models[default_tier]` and escalate one tier up on orchestrator-detected soft failure. Added in v1.40 ([#3024](https://github.com/open-gsd/gsd-core/pull/3031)) |
|
||||
| `dynamic_routing.tier_models.<tier>` | enum | `opus`, `sonnet`, `haiku` | (none) | Tier alias for `light`, `standard`, or `heavy`. Used when `dynamic_routing.enabled: true`. Added in v1.40 |
|
||||
| `dynamic_routing.escalate_on_failure` | boolean | `true`, `false` | `true` | When `false`, escalation is disabled even if `enabled: true` — every attempt uses the default tier. Added in v1.40 |
|
||||
| `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Added in v1.40 |
|
||||
| `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Also caps `provider_escalation`. Added in v1.40 |
|
||||
| `dynamic_routing.provider_escalation` | string[] | ordered model IDs | (none) | Opt-in fallback providers tried when a run dies on a quota / rate limit — see [provider escalation](#provider-escalation-on-quota-exceeded--added-in-v143). Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) |
|
||||
| `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 |
|
||||
| `phase_id_convention` | enum | `"milestone-prefixed"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. |
|
||||
| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32 |
|
||||
@@ -1242,7 +1243,40 @@ The `dynamic_routing` block is **disabled by default** — `enabled: false` (or
|
||||
| `dynamic_routing.tier_models.standard` | enum | (none) | Tier alias for standard. Typically `sonnet`. |
|
||||
| `dynamic_routing.tier_models.heavy` | enum | (none) | Tier alias for heavy. Typically `opus`. |
|
||||
| `dynamic_routing.escalate_on_failure` | boolean | `true` | When false, escalation is disabled (every attempt uses the default tier). |
|
||||
| `dynamic_routing.max_escalations` | integer | `1` | Hard cap on retries per agent invocation. Prevents runaway loops. |
|
||||
| `dynamic_routing.max_escalations` | integer | `1` | Hard cap on retries per agent invocation. Prevents runaway loops. Also caps the provider ladder below. |
|
||||
| `dynamic_routing.provider_escalation` | string[] | (none) | Ordered fallback model IDs tried when a run dies on a provider **quota / rate limit**. Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) |
|
||||
|
||||
#### Provider escalation on quota-exceeded — added in v1.43
|
||||
|
||||
The tier ladder above escalates *within one provider*. That does not help when the
|
||||
provider itself is what ran out: a heavier tier on the same throttled account is still
|
||||
throttled. `provider_escalation` is a separate, opt-in ladder for exactly that case.
|
||||
|
||||
```json
|
||||
{
|
||||
"dynamic_routing": {
|
||||
"enabled": true,
|
||||
"tier_models": { "light": "haiku", "standard": "sonnet", "heavy": "opus" },
|
||||
"provider_escalation": ["gpt-5", "nvidia/llama-3.3"],
|
||||
"max_escalations": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When an executor dies and `gsd-tools agent classify-failure` classifies the error body as
|
||||
`quota-exceeded`, `execute-phase` re-resolves the model from this list instead of waiting
|
||||
for a quota reset, logs the switch (`sonnet → gpt-5`), and honors any `Retry-After` the
|
||||
provider sent. The ladder is capped at `min(max_escalations, provider_escalation.length)`;
|
||||
once spent, GSD reports every model it tried and falls back to the manual recovery prompt
|
||||
rather than silently retrying the last one.
|
||||
|
||||
- **Opt-in.** With no `provider_escalation` configured, quota failures keep today's manual
|
||||
wait-for-reset prompt exactly as before.
|
||||
- **Quota only.** Other failure classes (`classify-handoff-bug`, `unknown-failure`) never
|
||||
consult this ladder — they keep the tier ladder.
|
||||
- **`escalate_on_failure: false`** disables this ladder too.
|
||||
- Entries are opaque model IDs passed to the runtime. Blank and non-string entries are
|
||||
dropped; the surviving order is preserved.
|
||||
|
||||
#### When to use which
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
"execute-mvp-tdd.md",
|
||||
"execute-phase-between-wave-reset.md",
|
||||
"execute-phase-context-guard.md",
|
||||
"execute-phase-quota-recovery.md",
|
||||
"execute-phase-requirement-revert.md",
|
||||
"execute-phase-response-language.md",
|
||||
"execute-phase-wave-guard.md",
|
||||
|
||||
@@ -317,6 +317,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum
|
||||
| `execute-phase-context-guard.md` | Context exhaustion guard step for `execute-phase` wave loop — `workflow.context_guard_mode` dispatch table (warn/auto/off) and POOR-tier pause-work trigger (#1452). |
|
||||
| `execute-phase-requirement-revert.md` | Gap-report step for `execute-phase` — reverts this phase's own shared requirement IDs out of `Complete` in REQUIREMENTS.md before rendering a `gaps_found` report, scoped to `PHASE_REQ_IDS` so other phases' rows are untouched (#2388). |
|
||||
| `execute-phase-response-language.md` | Response-language directive for `execute-phase` orchestrator output (questions, narration, report-template prose); extracted to keep the workflow under the frozen pre-phase-6 byte ceiling (#2402). |
|
||||
| `execute-phase-quota-recovery.md` | Step 7.1 detail for `execute-phase` — `quota-exceeded` recovery: the opt-in `dynamic_routing.provider_escalation` ladder (swap provider, honor `Retry-After`, fail loudly when spent) and the default manual wait-for-reset prompt (#2296). |
|
||||
| `continuation-format.md` | Session continuation/resume format. |
|
||||
| `domain-probes.md` | Domain-specific probing questions for discuss-phase. |
|
||||
| `edge-probe.md` | Spec-phase edge-completeness probe — 8-category edge taxonomy, shape classification, and the `requirements → checks → verifier` resolution model (Step 5.5). |
|
||||
|
||||
@@ -152,6 +152,35 @@ Every attempt uses `tier_models[default_tier]` regardless of outcome — useful
|
||||
|
||||
`dynamic_routing` is **disabled by default**. Omitting the block or setting `enabled: false` preserves static resolution.
|
||||
|
||||
### Keep going when a provider throttles you
|
||||
|
||||
The tier ladder above escalates within one provider. When the provider itself is the thing
|
||||
that ran out of quota, a heavier tier on the same account is still throttled. Add
|
||||
`provider_escalation` — an ordered list of fallback model IDs — to keep the phase moving
|
||||
instead of stopping for a manual restart:
|
||||
|
||||
```json
|
||||
{
|
||||
"dynamic_routing": {
|
||||
"enabled": true,
|
||||
"tier_models": { "light": "haiku", "standard": "sonnet", "heavy": "opus" },
|
||||
"provider_escalation": ["gpt-5", "nvidia/llama-3.3"],
|
||||
"max_escalations": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When an executor dies on a rate limit, GSD classifies the error body, switches to the next
|
||||
model in the list, logs the swap (`sonnet → gpt-5`), and waits out any `Retry-After` the
|
||||
provider sent. The walk is capped at `min(max_escalations, provider_escalation.length)`.
|
||||
Once the list is spent, GSD names every model it tried and hands you the normal recovery
|
||||
prompt — it never silently retries the exhausted one.
|
||||
|
||||
This is most useful on providers without a guaranteed SLA (Nvidia NIM, OpenRouter, and
|
||||
other third-party OpenCode models), where a throttle mid-phase is routine. It only fires on
|
||||
quota / rate-limit failures; other failures keep the tier ladder. Leaving
|
||||
`provider_escalation` unset preserves the manual wait-for-reset behaviour exactly.
|
||||
|
||||
---
|
||||
|
||||
## Using GSD on non-Anthropic runtimes
|
||||
|
||||
Reference in New Issue
Block a user