fix(#3210): gate unmet preconditions as blocking-human; cap blocker retries at needs_human (#3528)

* fix(#3210): gate unmet preconditions as blocking-human and cap blocker retries at needs_human

* chore(#3210): add changeset fragment for PR #3528

* fix(#3210): restore blocking-human carve-out and CRLF-safe split

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 23:01:30 -04:00
committed by GitHub
parent d2fa696a30
commit 8fc88f663d
13 changed files with 154 additions and 11 deletions

View File

@@ -0,0 +1,6 @@
---
type: Fixed
pr: 3528
---
**Autonomous/auto-mode no longer auto-approves unmet `<precondition>` checkpoints, and the blocker loop now halts `needs_human` instead of retrying forever** — the checkpoint an executor returns when a task's `<precondition>` is unmet (an unmet `user_setup` step, a missing env var, an absent prior-phase artifact) now carries `gate="blocking-human"`, which both auto-mode bypass layers (executor checkpoint protocol and execute-phase checkpoint handling) honor, so it always stops for a human instead of being silently approved with a synthetic "approved" and then failing `<verify>` on the still-missing prerequisite. Independently, `/gsd:autonomous`'s blocker handler now counts "Fix and retry" attempts per phase step and, after 3 failed attempts, escalates to a terminal `needs_human` halt that surfaces the unmet items and records a `## Needs Human` STATE.md row, ending the observed multi-hour retry loops on operator-gated plans. (#3210)

View File

@@ -148,7 +148,7 @@ For each task:
0. **Precondition check (before any other task work):** If the task carries a `<precondition>` element, evaluate that single prose line first — it names a runnable/checkable fact the task assumes (env var set, prior-phase artifact present, server responding to `/health`, `user_setup` step done). Verify with **read-only checks only** — file existence, env var presence (no value output), idempotent `GET /health`-style pings. Do NOT run commands with side effects (writes, network POSTs, secret emission) as the check; if a side-effecting check seems required, halt and surface via checkpoint instead.
- **Met OR absent:** continue with no visible change to execution flow. The precondition is a no-op for the rest of the task loop.
- **Unmet:** STOP — return a `checkpoint:human-verify` (use `checkpoint_return_format`) with `**Blocked by:** Precondition not met: <precondition text>`. Do NOT partial-commit the task. Unmet preconditions are NEVER auto-approved, even under `AUTO_CFG=true` — a missing prerequisite is not a verification step a human can rubber-stamp; it is a fact the executor cannot establish on its own. The human either satisfies the precondition (sets the env var, completes the `user_setup` step, regenerates the artifact) or reruns `/gsd:plan-phase` to restructure.
- **Unmet:** STOP — return a `checkpoint:human-verify` reporting `**Gate:** blocking-human` (use `checkpoint_return_format`) with `**Blocked by:** Precondition not met: <precondition text>`. Do NOT partial-commit the task. Unmet preconditions are NEVER auto-approved, even under `AUTO_CFG=true` — a missing prerequisite is not a verification step a human can rubber-stamp; it is a fact the executor cannot establish on its own. The human either satisfies the precondition (sets the env var, completes the `user_setup` step, regenerates the artifact) or reruns `/gsd:plan-phase` to restructure.
1. **If `type="auto"`:**
- Check for `tdd="true"` → follow TDD execution flow
@@ -328,7 +328,7 @@ For full automation-first patterns, server lifecycle, CLI handling:
**Auto-mode checkpoint behavior** (when `AUTO_CFG` is `"true"`):
- **checkpoint:human-verify** → Auto-approve **except package-legitimacy checkpoints**. If checkpoint has `gate="blocking-human"` OR its purpose indicates package legitimacy verification (`what-built` mentions `Package verification required before install` or `Package install failed — human verification required`), do **not** auto-approve. STOP and return checkpoint_return_format for explicit human confirmation.
- **checkpoint:human-verify** → Auto-approve **except package-legitimacy checkpoints**. If checkpoint has `gate="blocking-human"` OR its purpose indicates package legitimacy verification (`what-built` mentions `Package verification required before install` or `Package install failed — human verification required`), do **not** auto-approve. STOP and return checkpoint_return_format for explicit human confirmation. Precondition-unmet checkpoints report `blocking-human` — never auto-approved.
- **checkpoint:decision** → If checkpoint has `gate="blocking-human"`, do **not** auto-select — STOP and return checkpoint_return_format for an explicit human decision (a `blocking-human` decision exists because its default answer would be wrong to assume). Otherwise auto-select first option (planners front-load the recommended choice), log `⚡ Auto-selected: [option name]`, continue to next task.
- **checkpoint:human-action** → STOP normally. Auth gates cannot be automated — return structured checkpoint message using checkpoint_return_format.
@@ -354,7 +354,7 @@ When hitting checkpoint or auth gate, return this structure:
## CHECKPOINT REACHED
**Type:** [human-verify | decision | human-action]
**Gate:** [blocking | blocking-human] — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it
**Gate:** [blocking | blocking-human] — copy the task's `gate` attribute verbatim (precondition-unmet checkpoints report `blocking-human`)
**Plan:** {phase}-{plan}
**Progress:** {completed}/{total} tasks complete

View File

@@ -9,14 +9,14 @@ Plans execute autonomously. Checkpoints formalize interaction points where human
3. **User only does what requires human judgment** - Visual checks, UX evaluation, "does this feel right?"
4. **Secrets come from user, automation comes from Claude** - Ask for API keys, then Claude uses them via CLI
5. **Auto-mode bypasses verification/decision checkpoints** — When `workflow._auto_chain_active` or `workflow.auto_advance` is true in config: human-verify auto-approves, decision auto-selects first option, human-action still stops (auth gates cannot be automated)
6. **`gate="blocking-human"` is never auto-approved** — a checkpoint carrying this gate stops for a human in *every* mode, including auto-mode, regardless of its type. Rule 5 does not apply to it.
6. **`gate="blocking-human"` is never auto-approved** — a checkpoint carrying this gate stops for a human in *every* mode, including auto-mode, regardless of its type. Rule 5 does not apply to it. The executor's precondition-unmet checkpoint (a task's `<precondition>` evaluated false — unmet `user_setup` step, missing env var, absent prior-phase artifact) reports this gate (#3210).
**The `gate` attribute:**
| Value | Auto-mode behavior | Use for |
|-------|--------------------|---------|
| `gate="blocking"` | Bypassed per rule 5 (human-verify auto-approves, decision auto-selects) | The default. Post-hoc verification and implementation choices that are safe to take the recommended path on when unattended. |
| `gate="blocking-human"` | **Never bypassed.** Stops for a human in auto-mode too. | Irreversible or trust-establishing steps a human must actually see: package-legitimacy verification before install, and any decision whose default answer would be wrong to assume. |
| `gate="blocking-human"` | **Never bypassed.** Stops for a human in auto-mode too. | Irreversible or trust-establishing steps a human must actually see: package-legitimacy verification before install, any decision whose default answer would be wrong to assume, and unmet `<precondition>` facts the executor cannot establish on its own (#3210). |
Reach for `gate="blocking-human"` whenever auto-approving the checkpoint would defeat its purpose. If the checkpoint exists because a human must *decide* something, `blocking` is the wrong gate — auto-mode will decide it for them.

View File

@@ -121,7 +121,7 @@ The executor agent reads `<precondition>` before any other task work:
|---|---|
| **Absent** | No visible change — execute the task exactly as today. Back-compat for every existing plan. |
| **Met** | No visible change — proceed with the task. The precondition is logged in the SUMMARY only if it was non-trivial to verify. |
| **Unmet** | STOP — return a `checkpoint:human-verify` (use `checkpoint_return_format`) with `**Blocked by:** Precondition not met: <precondition text>`. Do NOT partial-commit the task. Unmet preconditions are NEVER auto-approved — a missing prerequisite is not a verification step a human can rubber-stamp, it is a fact the executor cannot establish on its own. |
| **Unmet** | STOP — return a `checkpoint:human-verify` reporting `**Gate:** blocking-human` (use `checkpoint_return_format`) with `**Blocked by:** Precondition not met: <precondition text>`. Do NOT partial-commit the task. Unmet preconditions are NEVER auto-approved — a missing prerequisite is not a verification step a human can rubber-stamp, it is a fact the executor cannot establish on its own. |
## Plan-structure validation

View File

@@ -781,7 +781,7 @@ When any phase operation fails or a blocker is detected, present 3 options via A
2. **"Skip this phase"** — Mark phase as skipped, continue to the next incomplete phase
3. **"Stop autonomous mode"** — Display summary of progress so far and exit cleanly
**On "Fix and retry":** Loop back to the failed step within execute_phase. If the same step fails again after retry, re-present these options.
**On "Fix and retry":** Loop back to the failed step within execute_phase. Track the retry count per phase + step (`RETRY_COUNT`, kept in memory for the run). If the same step fails again after retry, re-present these options. **Retry ceiling (#3210):** once the same phase step has failed 3 "Fix and retry" attempts, do NOT re-present the options — escalate to a terminal `needs_human` halt: display `Phase {N} ⛔ {Name} — needs_human`, list the unmet items (the blocker description from each attempt), append/update a `## Needs Human` section in STATE.md (`| ${PHASE_NUM} | needs_human | resolve blocker, then /gsd:autonomous --from ${PHASE_NUM} |`), and stop autonomous mode with the standard stopped-summary banner. A blocker that survives 3 fix attempts is an operator gate, not an executable gap — retrying it again just burns hours.
**On "Skip this phase":** Log `Phase {N} ⏭ {Name} — Skipped by user` and proceed to iterate.

View File

@@ -1141,7 +1141,7 @@ When executor returns a checkpoint AND `AUTO_MODE` is `true`:
- **decision** → Auto-spawn continuation agent with `{user_response}` = first option from checkpoint details. Log `⚡ Auto-selected: [option]`. **Except `blocking-human`.**
- **human-action** → Present to user (existing behavior below). Auth gates cannot be automated.
**Carve-out — overrides all branches above.** If the returned `Gate:` is `blocking-human`, or its `<what-built>` mentions `Package verification required before install` or `Package install failed — human verification required`, never auto-approve or auto-select, regardless of type. Present to user (standard flow below). Log `⛔ blocking-human gate — auto-mode suspended`.
**Carve-out — overrides all branches above.** If the returned `Gate:` is `blocking-human` (precondition-unmet, #3210), or its `<what-built>` mentions `Package verification required before install` or `Package install failed — human verification required`, never auto-approve or auto-select. Present to user (standard flow). Log `⛔ blocking-human gate — auto-mode suspended`.
**Standard flow (not auto-mode, human-action, or blocking-human):**

View File

@@ -63,7 +63,11 @@ On `checkpoint:human-verify` / `checkpoint:decision` tasks, `gate="blocking"`
`<checkpoint_protocol>` (`agents/gsd-executor.md`), and `checkpoints.md` (the
full gate table) is embedded in the dispatch `<execution_context>` verbatim.
Only `gate="blocking-human"` always surfaces to a human, regardless of
auto-mode.
auto-mode. An unmet `<precondition>` checkpoint (executor step 0, `Blocked by:
Precondition not met` — unmet `user_setup` step, missing env var, absent
prior-phase artifact) reports `blocking-human` and therefore always surfaces
to a human, in every mode (#3210): the missing prerequisite is a fact only a
human can establish, not a verification step to rubber-stamp.
When composing the `Agent()` prompt, do NOT add text refusing or overriding
auto-approval for a `blocking` gate. Orchestrator-composed instructions that contradict the

View File

@@ -286,3 +286,42 @@ describe('autonomous verification deferral contract', () => {
assert.match(iterateStep, /drop deferred phases from the autonomous queue/);
});
});
// ─── Issue #3210: bounded blocker retries, needs_human escalation ────────────
//
// handle_blocker's "Fix and retry" path had no attempt ceiling across
// invocations and no automatic escalation to a terminal needs_human state, so
// a non-converging blocker (e.g. an operator gate the executor cannot satisfy)
// looped indefinitely. Regression coverage lives here because this file owns
// the autonomous.md host-workflow contract.
describe('issue #3210: autonomous handle_blocker has a retry ceiling with needs_human escalation', () => {
function stepOf(content, name) {
const open = `<step name="${name}">`;
const from = content.indexOf(open);
assert.ok(from !== -1, `step "${name}" not found`);
const to = content.indexOf('</step>', from);
assert.ok(to !== -1, `step "${name}" has no closing tag`);
return content.slice(from, to);
}
test('handle_blocker bounds "Fix and retry" attempts per phase step', () => {
const step = stepOf(read(WORKFLOW_PATH), 'handle_blocker');
assert.match(
step,
/\b3\b.*retr|\bretr.*\b3\b|RETRY_COUNT|retry (ceiling|limit|count)/i,
'handle_blocker must track a bounded retry count for the same phase step instead of ' +
're-presenting "Fix and retry" indefinitely (#3210)'
);
});
test('handle_blocker auto-escalates to a terminal needs_human halt once the ceiling is exceeded', () => {
const step = stepOf(read(WORKFLOW_PATH), 'handle_blocker');
assert.match(
step,
/needs_human/,
'once the retry ceiling is exceeded, handle_blocker must halt autonomously in a terminal ' +
'needs_human state (surfacing the unmet items) instead of looping or asking again (#3210)'
);
});
});

View File

@@ -1,6 +1,6 @@
{
"version": 1,
"paths": {
"gsd-executor.md": "#2943: the context7 doc-lookup block's ctx7 CLI-fallback rationale was rewritten to describe the real mechanism (custom subagents cannot see project-scoped .mcp.json; they inherit only user-scoped ~/.claude/mcp.json), replacing the wrong anthropics/claude-code#13898 'tools: frontmatter restriction' attribution. The tool name was also corrected (get-library-docs -> query-docs) and the params renamed (context7CompatibleLibraryId/topic -> libraryId/query). The +95 bytes is the longer-but-accurate mechanism description; it is the literal fix for the mis-attribution, not incidental prose growth, and the rationale must be correct because agents read it to decide when to fall back to the CLI. #3021 amendment: branch allow-list regex widened to accept worktree-wf_* (Workflow backend naming) alongside agent-*/worktree-agent-*."
"gsd-executor.md": "#2943: the context7 doc-lookup block's ctx7 CLI-fallback rationale was rewritten to describe the real mechanism (custom subagents cannot see project-scoped .mcp.json; they inherit only user-scoped ~/.claude/mcp.json), replacing the wrong anthropics/claude-code#13898 'tools: frontmatter restriction' attribution. The tool name was also corrected (get-library-docs -> query-docs) and the params renamed (context7CompatibleLibraryId/topic -> libraryId/query). The +95 bytes is the longer-but-accurate mechanism description; it is the literal fix for the mis-attribution, not incidental prose growth, and the rationale must be correct because agents read it to decide when to fall back to the CLI. #3021 amendment: branch allow-list regex widened to accept worktree-wf_* (Workflow backend naming) alongside agent-*/worktree-agent-*. — #3210 append: the unmet-<precondition> branch now reports the returned checkpoint with **Gate:** blocking-human (both auto-mode bypass layers key on that gate; without it auto-mode silently auto-approved the checkpoint with a synthetic 'approved'), the auto-mode human-verify rule names precondition-unmet checkpoints as exempt, and checkpoint_return_format's Gate line notes precondition-unmet checkpoints report blocking-human; +134 bytes, still under the 49152 cap."
}
}

View File

@@ -0,0 +1,6 @@
{
"version": 1,
"paths": {
"autonomous.md": "#3210: handle_blocker's 'Fix and retry' path gained a retry ceiling — a per-phase-step RETRY_COUNT that escalates to a terminal needs_human halt (STATE.md '## Needs Human' row + unmet items surfaced) after 3 failed fix attempts, instead of re-presenting the options indefinitely. This is the loop-fixing behavior the issue title names; +704 bytes is the ceiling rule itself, not incidental prose. (The matching gsd-executor.md growth is acknowledged in the 2943 fragment and the execute-phase.md growth in the 3370 fragment, which already name those paths.)"
}
}

View File

@@ -1,7 +1,7 @@
{
"version": 1,
"paths": {
"execute-phase.md": "#3370: the step-3 executor-routing line now also cites #3370 — the checkpoint gate rule itself lives in the execute-phase/steps/per-plan-executor-routing.md fragment (loaded per plan in every isolation mode immediately before the dispatch prompt is composed), the same keep-the-host-lean pattern #1689/#3417 used, because the host sits under the frozen ADR-857 Phase 6 ceiling (≤93400). Net growth is 6 bytes (93386 -> 93392): the routing citation only; the rule text, which forbids the orchestrator from composing dispatch text that refuses or overrides auto-approval for the default gate=\"blocking\" (only blocking-human always surfaces), is in the fragment. Supersedes the spent #3324 fragment (merged into next), which also named execute-phase.md and would otherwise double-ack the same path. — #3423 append (epic #1891 F8): grew 12 bytes with the <files_to_read> -> <required_reading> tag rename (4 tag tokens, +3 bytes each), then trimmed 8 bytes of redundant prose (dropped 'current' from the model-inheritance note; #3478's growth had left only 8 bytes of ADR-857 margin) to stay under the Phase 6 margin ceiling; net +4 bytes against the #3370 baseline.",
"execute-phase.md": "#3370: the step-3 executor-routing line now also cites #3370 — the checkpoint gate rule itself lives in the execute-phase/steps/per-plan-executor-routing.md fragment (loaded per plan in every isolation mode immediately before the dispatch prompt is composed), the same keep-the-host-lean pattern #1689/#3417 used, because the host sits under the frozen ADR-857 Phase 6 ceiling (≤93400). Net growth is 6 bytes (93386 -> 93392): the routing citation only; the rule text, which forbids the orchestrator from composing dispatch text that refuses or overrides auto-approval for the default gate=\"blocking\" (only blocking-human always surfaces), is in the fragment. Supersedes the spent #3324 fragment (merged into next), which also named execute-phase.md and would otherwise double-ack the same path. — #3423 append (epic #1891 F8): grew 12 bytes with the <files_to_read> -> <required_reading> tag rename (4 tag tokens, +3 bytes each), then trimmed 8 bytes of redundant prose (dropped 'current' from the model-inheritance note; #3478's growth had left only 8 bytes of ADR-857 margin) to stay under the Phase 6 margin ceiling; net +4 bytes against the #3370 baseline. — #3210 append: the checkpoint_handling blocking-human carve-out names precondition-unmet checkpoints (+29-byte marker '(precondition-unmet, #3210)'), and the decision bullet's 'Except blocking-human' conditional — required by tests/package-legitimacy-gate.test.cjs — was restored (+29), funded by trimming ', regardless of type' (-20, the carve-out already overrides all branches) and '(standard flow below)' -> '(standard flow)' (-6); net +3 bytes (93395 -> 93398), still under the frozen ADR-857 Phase 6 ceiling (<=93400).",
"execute-plan.md": "#3370: the Pattern A dispatch prompt spec gained the gate-semantics clause (gate=\"blocking\" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate=\"blocking-human\" always surfaces to a human; add no instruction overriding that protocol), closing the identically-shaped dispatch-time gap on the single-plan path named in the issue. Growth ~248 bytes (38913 -> 39161, still under the DEFAULT 40 KiB ceiling). Supersedes the spent #2652 fragment (merged into next), which also named execute-plan.md and would otherwise double-ack the same path."
}
}

View File

@@ -908,3 +908,30 @@ describe('bug #3096: ai-integration-phase sequential ordering and Edit-only disc
});
});
}
// ─── Issue #3210: auto-mode carve-out exempts precondition-unmet checkpoints ─
//
// The checkpoint_handling auto-spawn rule dispatched on checkpoint type alone;
// a checkpoint returned because a task's <precondition> was unmet would have
// been auto-approved with a synthetic "approved" — re-approving the very
// checkpoint the executor refused to auto-approve (it reports Gate:
// blocking-human). This file owns the execute-phase.md host-workflow contract.
describe('issue #3210: execute-phase auto-mode carve-out exempts precondition-unmet checkpoints', () => {
test('the checkpoint_handling auto-spawn rule names precondition-unmet checkpoints', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const open = content.indexOf('<step name="checkpoint_handling">');
assert.ok(open !== -1, 'checkpoint_handling step not found');
const close = content.indexOf('</step>', open);
const step = content.slice(open, close);
const splitLines = require('../gsd-core/bin/lib/text-lines.cjs').splitLines;
const carveOut = splitLines(step).find((l) => l.includes('Carve-out'));
assert.ok(carveOut, 'checkpoint_handling must keep the blocking-human carve-out');
assert.match(
carveOut,
/precondition/i,
'the auto-mode carve-out must state that a precondition-unmet checkpoint reports ' +
'blocking-human and is never auto-approved (#3210)'
);
});
});

View File

@@ -231,3 +231,64 @@ describe('issue #1949: parity between plan-md.md and planner-preconditions.md',
assert.ok(ref.includes('<precondition>'), 'planner-preconditions.md must spell <precondition>');
});
});
// ─── Issue #3210: the precondition-unmet checkpoint must be human-gated ──────
//
// The executor's own invariant ("unmet preconditions are NEVER auto-approved,
// even under AUTO_CFG=true") was unenforceable: the checkpoint it returns for an
// unmet <precondition> carried no gate, and both auto-mode bypass rules only
// exempt `gate="blocking-human"`. Auto-mode therefore auto-approved it with a
// synthetic "approved", the task's <verify> failed closed, and the loop read
// the failure as an executable gap — retrying an operator gate forever (#3210).
describe('issue #3210: executor gates the precondition-unmet checkpoint as blocking-human', () => {
const CHECKPOINTS_REF = path.join(ROOT, 'gsd-core', 'references', 'checkpoints.md');
test('unmet-precondition branch stamps the returned checkpoint with Gate: blocking-human', () => {
const exec = read(EXECUTOR);
const unmet = exec.split('\n').find((l) => l.includes('**Unmet:**'));
assert.ok(unmet, 'gsd-executor.md must keep the **Unmet:** precondition branch');
assert.match(
unmet,
/blocking-human/,
'the unmet-precondition branch must report the checkpoint with **Gate:** blocking-human — ' +
'that gate is what both auto-mode bypass layers check (#3210)'
);
});
test('auto-mode human-verify rule explicitly exempts precondition-unmet checkpoints', () => {
const exec = read(EXECUTOR);
const rule = exec.split('\n').find((l) => l.includes('**checkpoint:human-verify**') && /Auto-approve/.test(l));
assert.ok(rule, 'gsd-executor.md must keep the auto-mode human-verify bypass rule');
assert.match(
rule,
/precondition/i,
'the executor auto-mode bypass rule must name precondition-unmet checkpoints as never ' +
'auto-approved, not only gate="blocking-human" tasks (#3210)'
);
});
test('checkpoints.md maps precondition-unmet checkpoints onto the blocking-human gate', () => {
const ref = read(CHECKPOINTS_REF);
const rule = ref.split('\n').find((l) => l.includes('never auto-approved'));
assert.ok(rule, 'checkpoints.md must keep golden rule 6 (blocking-human is never auto-approved)');
assert.match(
rule,
/precondition/i,
'golden rule 6 must name the executor\'s precondition-unmet checkpoint as a carrier of ' +
'gate="blocking-human" (#3210)'
);
});
test('planner-preconditions.md documents the blocking-human gate on the unmet path', () => {
const ref = read(PRECONDITIONS_REF);
const unmet = ref.split('\n').find((l) => l.includes('**Unmet**'));
assert.ok(unmet, 'planner-preconditions.md must keep the executor-behavior Unmet row');
assert.match(
unmet,
/blocking-human/,
'the Unmet row must state the returned checkpoint carries gate="blocking-human", matching ' +
'agents/gsd-executor.md (#3210)'
);
});
});