首页
/ get-shit-done 状态机修复实战:让 `state complete-phase` 幂等化,彻底杜绝 STATE.md 被重复执行回滚

get-shit-done 状态机修复实战:让 `state complete-phase` 幂等化,彻底杜绝 STATE.md 被重复执行回滚

2026-09-07 20:03:48作者:钟日瑜

本篇技术指南围绕 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 ActivityLast 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>不具备幂等性

  1. 当阶段 <N> 已被合法标记完成、且项目随后已推进(例如插入了后续阶段 02.2.1,或下一阶段已经开始);
  2. 此时若某个下游工具因重跑而再次对 <N> 执行 complete-phase
  3. 旧实现会无条件把 STATE.md 重写一遍,将其内容回滚到"阶段 <N> 完成那一刻"的取值,静默破坏:
    • Status
    • Last Activity
    • Last 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(如 3033A3.310.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 依旧会执行完整的状态更新(源码):

  1. StatusPhase <N> complete
  2. Last Activity → 当天日期(new Date().toISOString().split('T')[0]
  3. Last Activity DescriptionPhase <N> marked complete
  4. ## Current Position 正文(通过正则定位到下一个 ## 或文件尾):
    • Phase:Phase: <N> — COMPLETE
    • Status:Status: Phase <N> complete
    • Last activity:Last activity: <today> -- Phase <N> marked complete

更新结束后返回 { updated: [...], phase: <N> },其中 updated 会列出实际被修改的字段(如 StatusLast ActivityLast Activity DescriptionCurrent 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 是部署产物,直接断言其字面文本即是对部署契约的测试。

工程经验总结

  1. 状态写入型命令必须自证幂等。像 complete-phase 这类"推进状态机"的命令,天然会被自动化工作流多次触发(例如下游工具的兜底重跑),没有幂等防护时,重复执行造成的不是"重复写入"而是"状态回滚"这类更难察觉的数据倒退。
  2. 判据要选规范字段。修复以 STATE.md 的 Current Phase 作为唯一事实判据,而不是依赖调用者传参;只有在规范字段明确指向"已越过目标阶段"时才判定 no-op,既不会误伤同阶段重写,也能向后兼容缺少该字段的旧文件。
  3. no-op 也要有机器可读的协议idempotent: true + updated: [] + 人类可读的 note,让任何下游 Agent / 工具都能在不解析正文的情况下识别幂等调用,这是回归测试可以直接断言的关键设计。
  4. 回归测试要同时覆盖正向与负向。只测试"不再回滚"是不够的,还必须证明"首次正常完成"不被误拦——这正是本仓库测试套件中 tests/state.test.cjstests/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 的字段契约,是避免"进度被静默回拨"类事故的关键前提。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388