首页
/ get-shit-done 状态机修复实战:让 `state.begin-phase` 在 wave 续跑场景下保持幂等

get-shit-done 状态机修复实战:让 `state.begin-phase` 在 wave 续跑场景下保持幂等

2026-09-07 20:09:48作者:吴年前Myrtle

本文围绕项目 changeset 修复记录(issue #3127),剖析 get-shit-done 是如何让 state.begin-phase 变为幂等操作的:当它被重复调用在一个"已经在执行中"的阶段上(典型如 --wave N 续跑场景)时,不再用上一次 plan-phase 的旧值覆盖 Current Planstopped_at 叙事、Plan: N of M 正文行与 Last Activity Description 等执行进度字段。读完本文,你将掌握该状态机的字段语义、幂等保护的前后差异,以及它对应的源码实现与回归测试证据。

1. 背景:STATE.md 与阶段状态机

get-shit-done 是一个基于 Claude Code 的元提示(meta-prompting)、上下文工程与规范驱动开发(spec-driven development)系统。它的阶段/计划执行状态统一收敛在 .planning/STATE.md(若存在 .gsd 目录则为 .gsd/STATE.md),由状态文件驱动"阶段(Phase)→ 计划(Plan)→ 波次(Wave)"的推进节奏。

从仓库回归测试 bug-3127 测试样本 里可以直接看到 STATE.md 的真实版式,涉及两类核心区块:

  • ## Configuration 头部字段Current PhaseCurrent Phase NameTotal Plans in PhaseCurrent PlanStatus
  • ## Current Position 位置叙事Phase: N (name) — EXECUTINGPlan: x of M(含"哪些 plan 已 SHIPPED"的进度叙事)、Status: Executing Phase NLast activity: ...
  • 另有 frontmatter 进度progress.total_phasesprogress.completed_phasesprogress.percent)以及一个叙事字段 stopped_at,用于记录"当前停在哪一步、为什么停、下一步做什么"这类带上下文的自然语言说明。

其中 state.begin-phase 是负责"把一个阶段标记为开始执行"的状态变更命令:它把 Status 写成 Executing Phase N、把 Current Phase/Current Plan 等指针定位到新阶段的起点(Current Plan: 1),并重写 Current Position 区块。该命令在 sdk/src/query/command-aliases.generated.ts 中的规范名为 state.begin-phase(别名 state begin-phase,标记为 mutation: true),同时在 get-shit-done/bin/lib/state.cjs 中提供 CJS 运行时实现,在 sdk/src/query/state-mutation.ts 中提供 SDK 查询层镜像。

2. 问题:重复 begin 为什么会有破坏性

2.1 触发场景:--wave N 续跑

本阶段并不是一次性执行完的:当阶段内计划较多或会话被中断时,execute-phase 会以波次(wave)方式推进,例如 --wave N 表示续跑第 N 波。续跑的本质是再次进入"该阶段正在执行"的状态,于是系统会再次调用 state.begin-phase

问题在于:续跑不等于首次执行,此时 STATE.md 里已经积累了真实的执行进度——Current Plan 可能已经是 3、正文写着 Plan: 3 of 8stopped_at 里记录着"Plan 02 SHIPPED — Wave 2 GREEN"这样的详细叙事。如果 begin-phase 对所有字段"无脑全量覆盖",就会把最新鲜的进度信息退化成 plan-phase 刚结束时的陈旧快照

2.2 旧行为的五类数据回退

根据 bug-3127 回归测试 头部注释的精确描述,修复前的 state.begin-phase 被续跑调用时会造成:

字段 破坏性表现
stopped_at / Last Activity Description 被重置为 "context gathered; ready for plan-phase" 这类过时文案
Current Plan 从正在执行的计划编号(例如 3)被强制重置回 1
Plan: N of M 正文行 被重置为 Plan: 1 of M,丢失"前几个 plan 已 SHIPPED"的叙事
Last activity 时间戳 被回退到更早的值,丢失续跑发生的真实时间
progress.percent 可能发生回退(完成进度百分比不增反降)

这属于典型的状态机"写穿"(write-through)竞态:状态命令本应只负责"推进状态"这一种语义,却把"首次启动"与"中途续跑"两种语义混为一谈。

3. 修复方案:写入前的幂等守卫

3.1 核心判定规则

changeset 修复记录 给出的修复语义是:

  1. 写入前先读取当前 Status 字段
  2. Status 已经包含 Executing Phase N(对应当前阶段号),说明该阶段处于在飞(in-flight)状态,此时只更新安全字段Last Activity 日期、以及一条"恢复(resume)"专用的活动行;
  3. 全部执行进度字段(Current Plan、plan 正文行、Last Activity Descriptionstopped_at、progress 计数)一律保留不动
  4. 只有当 Status ≠ Executing(首次执行)时,才继续按原有逻辑写入所有字段。

3.2 源码中的守卫实现

该逻辑落在 get-shit-done/bin/lib/state.cjscmdStateBeginPhase 中。关键代码如下(已按实际文件摘录核心片段):

// Idempotency guard (#3127): if the phase is already mid-flight, do NOT
// overwrite execution-progress fields (Current Plan, plan body line,
// Last Activity Description). Only update fields that are safe to
// refresh on resume (Last Activity date, Status if inconsistent).
const currentStatus = stateExtractField(content, 'Status') || '';
const isAlreadyExecuting = new RegExp(
  `Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`,
  'i'
).test(currentStatus);

// Status / Last Activity 两处更新对两条路径都执行(安全字段)
const statusValue = `Executing Phase ${phaseNumber}`;
content = stateReplaceField(content, 'Status', statusValue);
content = stateReplaceField(content, 'Last Activity', today);

if (!isAlreadyExecuting) {
  // First-time execution: set all progress fields
  //   Last Activity Description → `Phase N execution started`
  //   Current Phase / Current Phase Name
  //   Current Plan → '1'
  //   Total Plans in Phase
  //   重写 **Current focus:** 与 ## Current Position 整段
} else {
  // Resume path: only update Last activity timestamp in Current Position
  // (do not touch Plan:, stopped_at, progress.percent, or plan counter)
  const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`;
  // ...仅对 ## Current Position 区块内的 Last activity 行做替换
}

需要特别指出两点实现细节:

  • 判定用的是正则而非相等比较:守卫通过 Executing Phase\s+<phaseNumber>\b(忽略大小写)匹配当前阶段号,因此"对阶段 5 调用 begin-phase"只会被"已经是 Executing Phase 5"的状态拦下;若当前状态是其他阶段(如 Executing Phase 4Ready to execute),仍走首次执行的完整写入路径。phaseNumber 在拼入正则前经过 escapeRegex 转义,避免特殊字符注入。
  • "安全字段"被提权到守卫之前StatusLast Activity 日期在分支之前无条件更新。也就是说即便处于 in-flight 状态,续跑动作也会刷新"最后活动日期",并顺带修正可能不一致的 Status——这正是 bug-3127 测试begin-phase always updates Last Activity date (safe on resume) 一项所断言的:无论哪条路径,执行后文件中都应包含当天日期(new Date().toISOString().split('T')[0])。

整段写入被包裹在 readModifyWriteStateMd 中,保证"读-改-写"是一个受控的原子流程,避免多命令并发时互相踩踏。

4. 首次执行路径与续跑路径的字段对照

为了让语义一目了然,下表列出两条路径各自会碰哪些字段:

字段 首次执行(Status ≠ Executing) 续跑/在飞(Status = Executing Phase N)
StatusExecuting Phase N ✅(安全,即使原值一致也幂等)
Last Activity 日期 ✅(安全)
Last Activity Description Phase N execution started ❌ 保留
Current Phase / Current Phase Name ❌ 保留
Current Plan1 ❌ 保留真实进度
Total Plans in Phase ❌ 保留
**Current focus:** ✅ 重写 ❌ 保留
## Current PositionPhase:/Plan:/Status: ✅ 整段重写为 EXECUTING + Plan: 1 of M ❌ 仅替换 Last activity:execution resumed (wave continue)
stopped_at 叙事 由调用方语义决定 ❌ 保留
progress.* 计数 不主动改写 ❌ 保留(不再回退)

5. 回归测试如何证明修复成立

bug-3127 回归测试 直接加载 get-shit-done/bin/lib/state.cjs 中的 cmdStateBeginPhase,并在临时目录构造两种 STATE.md 样本后调用函数断言,属于纯行为级验证,而非脆弱的源码 grep。四个用例分别是:

  1. 在飞阶段上调用 begin-phase 不得把 Current Plan 重置为 1:先写入"Phase 5、Current Plan: 3Status: Executing Phase 5"的样本,执行后断言文件中 Current Plan 不等于 1
  2. 不得覆盖 stopped_at 叙事:断言执行后文件仍包含 Plan 02 SHIPPED / Wave 2 GREEN 这类原始叙事文本;
  3. 尚未执行的阶段上调用仍应把 Current Plan 置为 1(正常路径不受影响):对 Status: Ready to execute 的样本执行,断言 Current Plan1,确保守卫没有误伤首次启动;
  4. 两条路径都必须刷新 Last Activity 日期:断言执行后文件中包含当天日期字符串。

这组测试同时锁定了修复的"两个不得一个必须":不得回退进度、不得丢叙事,但必须记录续跑动作本身发生的时间。

6. 从命令层到 SDK 层的完整链路

state.begin-phase 并不仅存在于 CJS 运行时,SDK 查询层同样把它注册为标准命令:

  • sdk/src/query/command-family-handlers.tsstate.begin-phase 路由到 stateBeginPhase 处理器;
  • sdk/src/gsd-tools.ts 提供 stateBeginPhase(phaseNumber) 工具方法,通过 execRaw('state', ['begin-phase', '--phase', phaseNumber]) 调用底层;
  • sdk/src/golden/golden.integration.test.ts 中包含 state.begin-phase matches gsd-tools.cjs 的金样本(golden)对照测试,用 ['begin-phase', '--phase', '11', '--name', 'State Pilot', '--plans', '3'] 这类参数同时驱动 SDK 分发器与 CJS 实现,校验二者输出一致——这是该命令跨实现层保持一致行为的重要防线;
  • 此外 sdk/src/golden/golden-mutation-covered.tsstate.begin-phase 列入被金样本覆盖的 mutation 命令清单,防止新增变更绕过一致性验证。

结合 sdk/src/query/command-manifest.state.ts{ family: 'state', canonical: 'state.begin-phase', aliases: ['state begin-phase'], mutation: true, outputMode: 'json' })可以看出:这是一条标准的、可 JSON 输出的状态变更命令,任何上层流程(execute-phase、wave 续跑等)只要通过命令路由或 SDK 调用它,都能享受同等的幂等保护。

7. 运维与二次开发注意事项

围绕这次修复,以下几点值得在集成或二次开发时留意:

  1. 续跑必须显式走命令而非手写状态--wave N 续跑本质上是一次"对已在飞阶段的再次 begin",这正是幂等守卫要覆盖的调用形态。手动编辑 STATE.md 会绕过守卫,也不具备 readModifyWriteStateMd 的原子性。
  2. 字段语义要区分"指针"与"叙事"Current Plan/Status 是指针类字段,stopped_at/Last Activity Description/plan 正文行是叙事类字段。本次修复确立的原则是——在飞状态下,叙事与进度字段只能被真正的进度推进事件(如 SHIPPED、advance-plan)修改,而不能被"启动类"命令回写。
  3. 修改守卫判定时同步更新回归测试:判定规则(如从"正则包含"改为"字段精确比较")会直接影响在飞识别行为,务必保持 tests/bug-3127-state-begin-phase-idempotent.test.cjs 四个用例全部通过,尤其"正常首次路径不受影响"这一反向用例。
  4. 注意 CJS 与 SDK 两条实现线的行为对齐:本修复的守卫实际落在 CJS 运行时 cmdStateBeginPhase 内,而 SDK 查询层另有 stateBeginPhase 镜像实现,二者通过 golden 对照测试对齐。若在两个实现中分别演进,应确保幂等语义保持一致,避免出现"命令行安全、SDK 不安全"的割裂。

8. 小结

issue #3127 揭示了一个容易被忽略但破坏性极强的状态机缺陷:一个"启动阶段"的命令被复用于"续跑阶段"时,会无差别覆盖执行进度,导致 Current Planstopped_at 叙事、Plan: N of M 正文与进度百分比整体回退。修复以一句"先读 Status 再决定写什么"的幂等守卫,在语义上将首次启动在飞续跑两条路径彻底分离:续跑只刷新 Last Activity 日期并追加 execution resumed (wave continue) 活动行,其余字段保持原样;首次执行则完整落盘所有启动字段。配套的四项行为级回归测试、SDK 金样本对照与命令清单,共同保证了这次修复在 CJS 运行时与 SDK 查询层均可持续、可验证地成立。

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

项目优选

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