get-shit-done 状态机修复实战:让 `state complete-phase` 幂等化,彻底杜绝 STATE.md 被重复执行回滚
本篇技术指南围绕 get-shit-done(一个基于 Claude Code 的轻量 meta-prompting、上下文工程与 spec-driven 开发系统)中的一次真实缺陷修复展开:当 state complete-phase --phase <N> 对已经标记完成的阶段被二次调用时,曾会把规划状态文档 STATE.md 静默回滚到该阶段完成时刻的旧内容。文章从缺陷症状、根因、修复后的"先读后写"防护逻辑到回归测试逐一展开,读者读完可以掌握该系统中 STATE.md 状态推进的底层机制,以及如何为一个"状态写入型命令"设计与验证幂等语义。
背景:STATE.md 在 get-shit-done 中的核心地位
在 get-shit-done 中,.planning 目录下的 STATE.md 是贯穿规划与执行的单一事实来源。它会记录:
Status(当前整体状态)Current Phase(当前阶段,规范字段)Last Activity与Last Activity Description(最近活动及说明)## Current Position(当前进展正文,内含Phase:、Status:、Last activity:等行)
这段正文被众多下游消费者信任与读取——包括 /gsd-progress、planner(规划器)、以及下一阶段开始时的 discuss-phase 上下文加载器。一旦 STATE.md 被写入错误的历史内容,整套进度流都会被引导回错误阶段。
本次修复(PR #3489)即属于该状态机的一个关键子命令:state complete-phase。其职责是把"当前阶段"在 STATE.md 中正式标记为 COMPLETE,让项目可以推进到下一阶段。
缺陷复现:重复执行一次,进度回滚一次
原始实现中,state complete-phase --phase <N>(等价于通过 SDK 通道执行 gsd-sdk query state.complete-phase --phase <N>)不具备幂等性:
- 当阶段
<N>已被合法标记完成、且项目随后已推进(例如插入了后续阶段02.2.1,或下一阶段已经开始); - 此时若某个下游工具因重跑而再次对
<N>执行complete-phase; - 旧实现会无条件把 STATE.md 重写一遍,将其内容回滚到"阶段
<N>完成那一刻"的取值,静默破坏:StatusLast ActivityLast Activity Description## Current Position正文
结果就是:依赖 STATE.md 的下游消费者(/gsd-progress、planner、下一阶段 discuss-phase 的上下文加载器)全部被路由回已回滚的旧阶段,造成进度倒退、上下文错位。
该行为在源码中留有注释佐证,见 get-shit-done/bin/lib/state.cjs:注释明确指出重新调用在旧版本中会把 STATE.md 回滚到 <N> 完成瞬间,破坏上述四个字段。
根因分析:cmdStateCompletePhase 的写路径
修复前,命令处理器 cmdStateCompletePhase 的处理流程位于 get-shit-done/bin/lib/state.cjs。它的主流程大致是:
function cmdStateCompletePhase(cwd, raw, overridePhase) {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
const resolvedPhase = resolvePhaseIdForCompletePhase(content, overridePhase);
if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw);
return;
}
// ... 此处过去直接进入 readModifyWriteStateMd 写路径
readModifyWriteStateMd(statePath, (content) => {
// 更新 Status / Last Activity / Last Activity Description / ## Current Position
...
return content;
}, cwd);
output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
}
其中阶段解析函数 resolvePhaseIdForCompletePhase(源码)会依次回退读取 --phase 参数、Current Phase 字段、Phase 字段,并只接受规范的阶段 token(如 3、03、3A、3.3、10.2):
function resolvePhaseIdForCompletePhase(content, overridePhase) {
const candidate = overridePhase ||
stateExtractField(content, 'Current Phase') ||
stateExtractField(content, 'Phase') ||
'';
// Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2)
const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
return phaseMatch ? phaseMatch[1] : null;
}
问题在于:解析出目标阶段后,旧代码没有把"目标阶段"与"STATE.md 当前正处于的阶段"做比较,直接进入 readModifyWriteStateMd(见 get-shit-done/bin/lib/state.cjs 的读写改写封装)把文件整体重写一遍——即使该阶段早已完成且项目早已前进。
修复方案:写入前先读,发现已推进就直接 no-op
修复的核心思路非常直接:写之前先读 STATE.md,用其规范字段 Current Phase 判断项目是否已经越过目标阶段。
修复后,处理器在进入写路径之前插入了一道防护(get-shit-done/bin/lib/state.cjs):
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;
}
防护逻辑的判定语义可以归纳为下表:
Current Phase 现状 |
传入的 --phase |
行为 |
|---|---|---|
| 不存在(历史/旧格式文件) | 任意 | 跳过防护,走原写路径 |
| 与目标阶段相同 | 目标阶段 | 正常执行完成写入(首次合法完成,或对同一阶段重写同值) |
| 与目标阶段不同(已推进到其他阶段) | 较早阶段 <N> |
no-op,直接返回,完全不触碰 STATE.md |
其中字段提取函数 stateExtractField / stateReplaceField 位于 get-shit-done/bin/lib/state-document.generated.cjs,用于在 STATE.md 中定位和替换 **Field:** value 形式的规范字段。
no-op 的返回值协议
当防护触发时,命令不做任何文件写入,并返回结构化 JSON 载荷以让下游消费者可检测:
{ "updated": [], "phase": "<N>", "idempotent": true, "note": "phase already superseded; no-op" }
语义说明:
updated: []—— 明确声明本次没有任何字段被更新;phase: "<N>"—— 回显本次被请求完成的阶段;idempotent: true—— 幂等标志,下游工具据此识别"这是一次无害的重复调用";note: "phase already superseded; no-op"—— 人类可读的原因。
合法完成时仍会正常更新哪些字段
防护只拦截"项目已推进过去"的重复调用,不影响正常完成流程。当目标阶段确实是当前进行中的阶段时,readModifyWriteStateMd 依旧会执行完整的状态更新(源码):
Status→Phase <N> completeLast Activity→ 当天日期(new Date().toISOString().split('T')[0])Last Activity Description→Phase <N> marked complete## Current Position正文(通过正则定位到下一个##或文件尾):Phase:→Phase: <N> — COMPLETEStatus:→Status: Phase <N> completeLast activity:→Last activity: <today> -- Phase <N> marked complete
更新结束后返回 { updated: [...], phase: <N> },其中 updated 会列出实际被修改的字段(如 Status、Last Activity、Last Activity Description、Current Position)。
命令的派发与可达路径
state complete-phase 经由 get-shit-done/bin/lib/state-command-router.cjs 路由,直接解析 --phase 命名参数后调用 CJS 处理器:
'complete-phase': () => {
const { phase: p } = parseNamedArgs(args, ['phase']);
state.cmdStateCompletePhase(cwd, raw, p || args[2]);
},
从该路由注释可见,complete-phase 目前标注为"CJS-only — no SDK counterpart",即由运行时库直接承载(该行注释仅说明其在 SDK 层没有独立 TypeScript 对等实现,CLI 与 gsd-sdk query 通道仍可触发同一处理器,与本次变更说明中的两条调用形式一致)。需要执行时的两种等价形式为:
# 直接 CLI 形式
gsd state complete-phase --phase <N>
# 通过 SDK query 通道
gsd-sdk query state.complete-phase --phase <N>
不传 --phase 时,处理器会回退到从 Current Phase / Phase 字段解析;解析失败或命中字面量 phase 时,会返回 { error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' } 并提示显式传参。
回归测试如何锁定幂等语义
本次修复配套的回归测试位于 tests/bug-3489-complete-phase-idempotent.test.cjs,通过 runGsdTools(['state', 'complete-phase', '--phase', ...], tmpDir) 在临时工程内真实执行命令,断言 STATE.md 内容与 JSON 输出:
测试一:重复对已完成阶段执行不得回滚 STATE.md
- 前置 STATE.md:
Current Phase: 02.2.1(阶段02.2已合法完成,后续又插入了02.2.1作为进行中阶段); - 执行
state complete-phase --phase 02.2; - 硬断言:文件与调用前快照逐字节一致(
after === before),即不允许任何重写发生; - 输出断言:
payload.updated为空数组、payload.phase === '02.2'、payload.idempotent === true。
测试二:完成真正进行中的阶段仍正常工作(防误伤)
- 前置 STATE.md:
Current Phase: 03; - 执行
state complete-phase --phase 03; - 断言 STATE.md 中
**Status:** Phase 03 complete被写入; - 断言输出
payload.idempotent !== true(首次完成绝不能被标记为幂等),且payload.updated为非空数组。
两组用例合起来验证了防护的双向正确性:既阻止了历史阶段的重复回滚,又不会对合法完成产生"假阳性幂等"。测试文件顶部还注明了一条项目约束:STATE.md 是部署产物,直接断言其字面文本即是对部署契约的测试。
工程经验总结
- 状态写入型命令必须自证幂等。像
complete-phase这类"推进状态机"的命令,天然会被自动化工作流多次触发(例如下游工具的兜底重跑),没有幂等防护时,重复执行造成的不是"重复写入"而是"状态回滚"这类更难察觉的数据倒退。 - 判据要选规范字段。修复以 STATE.md 的
Current Phase作为唯一事实判据,而不是依赖调用者传参;只有在规范字段明确指向"已越过目标阶段"时才判定 no-op,既不会误伤同阶段重写,也能向后兼容缺少该字段的旧文件。 - no-op 也要有机器可读的协议。
idempotent: true+updated: []+ 人类可读的note,让任何下游 Agent / 工具都能在不解析正文的情况下识别幂等调用,这是回归测试可以直接断言的关键设计。 - 回归测试要同时覆盖正向与负向。只测试"不再回滚"是不够的,还必须证明"首次正常完成"不被误拦——这正是本仓库测试套件中 tests/state.test.cjs、tests/bug-3489-complete-phase-idempotent.test.cjs 等文件长期维护的验证传统。
该修复已随 v1.42.1 相关变更记录在 docs/RELEASE-v1.42.1.md(条目:"Phase completion is idempotent and refreshes state")。对于任何基于 get-shit-done 构建多阶段流水线的团队,理解 complete-phase 的幂等语义与 STATE.md 的字段契约,是避免"进度被静默回拨"类事故的关键前提。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00