get-shit-done 状态机修复实战:让state complete-phase幂等化,彻底杜绝 STATE.md 被重复执行回滚
【免费下载链接】get-shit-doneA light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code by TÂCHES.项目地址: https://gitcode.com/GitHub_Trending/getshi/get-shit-done
本篇技术指南围绕 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 的字段契约,是避免"进度被静默回拨"类事故的关键前提。
【免费下载链接】get-shit-doneA light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code by TÂCHES.项目地址: https://gitcode.com/GitHub_Trending/getshi/get-shit-done
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考