fix(sdk): make state.complete-phase idempotent (#3489)
Re-invoking `state complete-phase --phase <N>` on a phase that was
already marked complete in STATE.md silently rolled STATE.md back to
that phase's moment-of-completion — clobbering Status, Last Activity,
Last Activity Description, and the ## Current Position body. The bug
fired whenever a follow-up phase had been inserted (or the next phase
had begun) and a downstream workflow re-ran complete-phase on the
already-closed phase. Damage was silent: handler reported
{"updated":["Status","Last Activity","Current Position"]} and no error.
Root cause: cmdStateCompletePhase wrote unconditionally — it never
consulted STATE.md to detect that the requested phase had been
superseded. The handler is a legacy-bridge fallback (no native SDK
registration), so the SDK CLI fell through to gsd-tools.cjs.
Fix: add an idempotency guard at the top of cmdStateCompletePhase.
If STATE.md's canonical Current Phase field already names a phase
distinct from the one we are being asked to mark complete, return a
no-op payload ({updated:[], phase:"<N>", idempotent:true, note:"phase
already superseded; no-op"}) without writing to STATE.md.
The guard is conservative — it only fires when Current Phase is set
and differs from the resolved target. First-time completion (Current
Phase == target, or Current Phase absent) is unaffected, so the four
existing complete-phase test cases (#2761, #3063) continue to pass.
Regression test: tests/bug-3489-complete-phase-idempotent.test.cjs
- re-running complete-phase --phase 02.2 with Current Phase=02.2.1
in STATE.md leaves the file byte-identical and reports idempotent:true
- normal first-time completion is NOT flagged idempotent
Scope: handler-level idempotency only. Does not address the related
stopped_at filename-sort ordering issue called out in the bug report
(filed under suggested fix #2) or porting to the native registry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/3489-complete-phase-idempotent.md
Normal file
5
.changeset/3489-complete-phase-idempotent.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3489
|
||||
---
|
||||
**`state complete-phase` is now idempotent — re-invocation no longer rolls STATE.md back.** Previously, running `gsd-sdk query state.complete-phase --phase <N>` (or `gsd state complete-phase --phase <N>`) a second time on a phase that was already marked complete silently rewound STATE.md to that phase's moment-of-completion, clobbering `Status`, `Last Activity`, `Last Activity Description`, and the `## Current Position` body. Any downstream consumer trusting STATE.md (`/gsd-progress`, planner, the next phase's discuss-phase context loader) was routed back to the rolled-back phase. The handler now reads STATE.md before writing: if the canonical `Current Phase` field already names a phase distinct from the one being completed, the project has clearly advanced past it and the handler returns a no-op (`{ updated: [], phase: "<N>", idempotent: true, note: "phase already superseded; no-op" }`) without touching STATE.md. (#3489)
|
||||
@@ -1805,6 +1805,27 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency guard (#3489). If STATE.md's canonical `Current Phase` field
|
||||
// already names a phase distinct from the one we are being asked to mark
|
||||
// complete, the project has advanced past the requested phase (e.g. a
|
||||
// follow-up phase was inserted, or the next phase began). Re-running
|
||||
// `state complete-phase --phase <N>` in that situation previously rolled
|
||||
// STATE.md back to <N>'s moment-of-completion — silently clobbering Status,
|
||||
// Last Activity, Last Activity Description, and the Current Position body.
|
||||
// The handler is now a no-op in that case so re-invocation from downstream
|
||||
// workflows cannot regress the project state.
|
||||
const existingCurrentPhaseRaw = stateExtractField(content, 'Current Phase') || '';
|
||||
const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null;
|
||||
if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
|
||||
output(
|
||||
{ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' },
|
||||
raw,
|
||||
'false',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const updated = [];
|
||||
|
||||
|
||||
124
tests/bug-3489-complete-phase-idempotent.test.cjs
Normal file
124
tests/bug-3489-complete-phase-idempotent.test.cjs
Normal file
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// State.md is the deployed artifact; asserting on its literal text content
|
||||
// tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Regression test for #3489
|
||||
*
|
||||
* `gsd state complete-phase --phase <N>` was non-idempotent. Re-invoking it
|
||||
* on a phase already marked complete in STATE.md silently rolled STATE.md
|
||||
* back to that phase's moment-of-completion — overwriting Status, Last
|
||||
* Activity, Current Position and the body Status/Phase with stale values
|
||||
* derived from the just-completed phase.
|
||||
*
|
||||
* Expected: when the target phase is already marked complete (and STATE.md
|
||||
* has clearly advanced past it — e.g. a later phase is now in progress or
|
||||
* inserted), `complete-phase` must be a no-op. No STATE.md write at all.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
describe('bug #3489: state complete-phase must be idempotent', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('bug-3489-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running complete-phase on an already-complete phase does not roll STATE.md back', () => {
|
||||
// STATE.md as it would appear AFTER phase 02.2 was legitimately completed
|
||||
// AND a follow-up Phase 02.2.1 has since been inserted as in-progress.
|
||||
// Re-invoking `state complete-phase --phase 02.2` from a downstream tool
|
||||
// (e.g. a re-run of /gsd-execute-phase) must NOT regress this content.
|
||||
const stateMd = [
|
||||
'---',
|
||||
'milestone: v1.0',
|
||||
'---',
|
||||
'',
|
||||
'# State',
|
||||
'',
|
||||
'**Status:** in-progress',
|
||||
'**Current Phase:** 02.2.1',
|
||||
'**Last Activity:** 2026-05-13',
|
||||
'**Last Activity Description:** Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 02.2.1 — Not planned yet',
|
||||
'Status: Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
||||
'Last activity: 2026-05-13 -- Phase 02.2.1 inserted (urgent — gates Phase 5)',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, stateMd, 'utf8');
|
||||
const before = fs.readFileSync(statePath, 'utf8');
|
||||
|
||||
const result = runGsdTools(['state', 'complete-phase', '--phase', '02.2'], tmpDir);
|
||||
assert.ok(result.success, `command should not error, got: ${result.error || result.output}`);
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf8');
|
||||
|
||||
// Hard assertion: file is byte-identical to its pre-call snapshot.
|
||||
assert.equal(
|
||||
after,
|
||||
before,
|
||||
`STATE.md must not be rewritten when phase is already complete.\n\n--- before ---\n${before}\n--- after ---\n${after}`,
|
||||
);
|
||||
|
||||
// Output should advertise the no-op so downstream consumers can detect it.
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(result.output); } catch (_) { /* ignore */ }
|
||||
assert.ok(payload && typeof payload === 'object', `expected JSON payload, got: ${result.output}`);
|
||||
assert.deepEqual(payload.updated, [], `expected empty updated list, got: ${JSON.stringify(payload.updated)}`);
|
||||
assert.equal(payload.phase, '02.2');
|
||||
assert.equal(payload.idempotent, true, `expected idempotent:true flag, got: ${JSON.stringify(payload)}`);
|
||||
});
|
||||
|
||||
test('completing the currently in-progress phase still works normally (no false-positive idempotency)', () => {
|
||||
// Sanity check: the guard must not fire on the legitimate first completion.
|
||||
const stateMd = [
|
||||
'---',
|
||||
'milestone: v1.0',
|
||||
'---',
|
||||
'',
|
||||
'# State',
|
||||
'',
|
||||
'**Status:** in-progress',
|
||||
'**Current Phase:** 03',
|
||||
'**Last Activity:** 2026-05-13',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 03',
|
||||
'Status: Phase 03 executing',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, stateMd, 'utf8');
|
||||
|
||||
const result = runGsdTools(['state', 'complete-phase', '--phase', '03'], tmpDir);
|
||||
assert.ok(result.success, `command failed: ${result.error || result.output}`);
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf8');
|
||||
assert.ok(
|
||||
after.includes('**Status:** Phase 03 complete'),
|
||||
`expected Status updated to "Phase 03 complete", got:\n${after}`,
|
||||
);
|
||||
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.notEqual(payload.idempotent, true, 'first completion must not be flagged idempotent');
|
||||
assert.ok(Array.isArray(payload.updated) && payload.updated.length > 0, 'expected non-empty updated list');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user