get-shit-done 状态机修复实战:让 `state.begin-phase` 在 wave 续跑场景下保持幂等
本文围绕项目 changeset 修复记录(issue #3127),剖析 get-shit-done 是如何让
state.begin-phase变为幂等操作的:当它被重复调用在一个"已经在执行中"的阶段上(典型如--wave N续跑场景)时,不再用上一次plan-phase的旧值覆盖Current Plan、stopped_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 Phase、Current Phase Name、Total Plans in Phase、Current Plan、Status;## Current Position位置叙事:Phase: N (name) — EXECUTING、Plan: x of M(含"哪些 plan 已 SHIPPED"的进度叙事)、Status: Executing Phase N、Last activity: ...;- 另有 frontmatter 进度(
progress.total_phases、progress.completed_phases、progress.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 8、stopped_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 修复记录 给出的修复语义是:
- 写入前先读取当前
Status字段; - 若
Status已经包含Executing Phase N(对应当前阶段号),说明该阶段处于在飞(in-flight)状态,此时只更新安全字段:Last Activity日期、以及一条"恢复(resume)"专用的活动行; - 全部执行进度字段(
Current Plan、plan 正文行、Last Activity Description、stopped_at、progress 计数)一律保留不动; - 只有当
Status ≠ Executing(首次执行)时,才继续按原有逻辑写入所有字段。
3.2 源码中的守卫实现
该逻辑落在 get-shit-done/bin/lib/state.cjs 的 cmdStateBeginPhase 中。关键代码如下(已按实际文件摘录核心片段):
// 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 4或Ready to execute),仍走首次执行的完整写入路径。phaseNumber在拼入正则前经过escapeRegex转义,避免特殊字符注入。 - "安全字段"被提权到守卫之前:
Status与Last 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) |
|---|---|---|
Status → Executing Phase N |
✅ | ✅(安全,即使原值一致也幂等) |
Last Activity 日期 |
✅ | ✅(安全) |
Last Activity Description |
✅ Phase N execution started |
❌ 保留 |
Current Phase / Current Phase Name |
✅ | ❌ 保留 |
Current Plan → 1 |
✅ | ❌ 保留真实进度 |
Total Plans in Phase |
✅ | ❌ 保留 |
**Current focus:** 行 |
✅ 重写 | ❌ 保留 |
## Current Position 的 Phase:/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。四个用例分别是:
- 在飞阶段上调用 begin-phase 不得把
Current Plan重置为 1:先写入"Phase 5、Current Plan: 3、Status: Executing Phase 5"的样本,执行后断言文件中Current Plan不等于1; - 不得覆盖
stopped_at叙事:断言执行后文件仍包含Plan 02 SHIPPED/Wave 2 GREEN这类原始叙事文本; - 尚未执行的阶段上调用仍应把
Current Plan置为 1(正常路径不受影响):对Status: Ready to execute的样本执行,断言Current Plan为1,确保守卫没有误伤首次启动; - 两条路径都必须刷新
Last Activity日期:断言执行后文件中包含当天日期字符串。
这组测试同时锁定了修复的"两个不得一个必须":不得回退进度、不得丢叙事,但必须记录续跑动作本身发生的时间。
6. 从命令层到 SDK 层的完整链路
state.begin-phase 并不仅存在于 CJS 运行时,SDK 查询层同样把它注册为标准命令:
- sdk/src/query/command-family-handlers.ts 将
state.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.ts 将
state.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. 运维与二次开发注意事项
围绕这次修复,以下几点值得在集成或二次开发时留意:
- 续跑必须显式走命令而非手写状态:
--wave N续跑本质上是一次"对已在飞阶段的再次 begin",这正是幂等守卫要覆盖的调用形态。手动编辑STATE.md会绕过守卫,也不具备readModifyWriteStateMd的原子性。 - 字段语义要区分"指针"与"叙事":
Current Plan/Status是指针类字段,stopped_at/Last Activity Description/plan 正文行是叙事类字段。本次修复确立的原则是——在飞状态下,叙事与进度字段只能被真正的进度推进事件(如 SHIPPED、advance-plan)修改,而不能被"启动类"命令回写。 - 修改守卫判定时同步更新回归测试:判定规则(如从"正则包含"改为"字段精确比较")会直接影响在飞识别行为,务必保持 tests/bug-3127-state-begin-phase-idempotent.test.cjs 四个用例全部通过,尤其"正常首次路径不受影响"这一反向用例。
- 注意 CJS 与 SDK 两条实现线的行为对齐:本修复的守卫实际落在 CJS 运行时
cmdStateBeginPhase内,而 SDK 查询层另有stateBeginPhase镜像实现,二者通过 golden 对照测试对齐。若在两个实现中分别演进,应确保幂等语义保持一致,避免出现"命令行安全、SDK 不安全"的割裂。
8. 小结
issue #3127 揭示了一个容易被忽略但破坏性极强的状态机缺陷:一个"启动阶段"的命令被复用于"续跑阶段"时,会无差别覆盖执行进度,导致 Current Plan、stopped_at 叙事、Plan: N of M 正文与进度百分比整体回退。修复以一句"先读 Status 再决定写什么"的幂等守卫,在语义上将首次启动与在飞续跑两条路径彻底分离:续跑只刷新 Last Activity 日期并追加 execution resumed (wave continue) 活动行,其余字段保持原样;首次执行则完整落盘所有启动字段。配套的四项行为级回归测试、SDK 金样本对照与命令清单,共同保证了这次修复在 CJS 运行时与 SDK 查询层均可持续、可验证地成立。
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
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00