
get-shit-done 并发锁重试白名单修复acquireStateLock / withPlanningLock 对 Docker overlay-fs 与 NFS 瞬时 errno 的处理【免费下载链接】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本文基于 changeset 记录 .changeset/3776-fix-acquirestatelock-retry-allowlist.md深入剖析 get-shit-doneTÂCHES 出品的面向 Claude Code 的上下文工程与规格驱动开发系统中 STATE.md 与 .planning 工作区写入锁的实现。读者将掌握该修复引入的「可重试 errno 白名单」语义哪些文件系统错误码会在锁竞争时自动重试哪些致命错误码会立即抛出以及这套机制在 Docker overlay-fs、NFS 与 Windows/macOS 杀毒软件场景下的工程取舍。一、这次修复改了什么该 changeset 类型为Fixed关联 PR #3777关闭 issue #3776原文完整内容如下acquireStateLock与withPlanningLock现在会在瞬时性文件系统错误上自动重试新增的错误码覆盖Docker overlay-fs的ENOENT/EINVAL/EIO以及NFS的ESTALE此前已支持的EPERM/EBUSY重试行为保持不变真正致命、不可恢复的错误码EMFILE/ENOSPC/EROFS/EACCES仍会立即抛出绝不因重试而吞掉。一句话概括锁获取不再把「瞬时性存储故障」当成「锁被占用」以外的新错误盲目抛出也不再把「真正致命错误」拖入无休止重试而是给两者划出清晰边界。这一行为在两个运行时模块中各有镜像实现是本文后续逐一拆解的对象。二、为什么要修并发写入下的锁与错误码混杂问题get-shit-done 的规划与状态数据都落在工作区文件里例如STATE.md与.planning目录下的STATE.md/ROADMAP.md/PROJECT.md等。并行 Agent或同一用户的多个会话都会对这些文件做更新若直接裸写互相覆盖会造成丢失更新。因此两个关键模块都实现了基于锁文件的互斥机制state.cjs 中的acquireStateLock(statePath)为STATE.md写入提供互斥planning-workspace.cjs 中的withPlanningLock(...)为.planning规划工作区的操作加锁。两处都以O_CREAT | O_EXCL原子创建目标文件.lock用「谁先成功创建锁文件谁就持有锁」的朴素方式实现分布式互斥。但实际运行环境远比「锁文件已存在EEXIST」复杂——在容器、共享存储、桌面操作系统上open会抛出各种各样语义截然不同的错误而早期实现的处理方式过于粗糙由此引出系列回归#3772acquireStateLock在遇到非EEXIST的openSync错误如高负载下的EMFILE/EINTR/ENOSPC时静默返回一个假成功的锁路径调用方以为拿到了锁实际写入没有互斥保护造成丢失更新#3773EPERM/EBUSY典型场景是 Windows / macOS 杀毒软件临时占用锁文件被识别为可重试#3776本 changeset把 Docker overlay-fs 与 NFS 下的瞬时错误也纳入可重试范围同时坚决排除致命错误码。这些背景信息可从配套回归测试的头部注释得到印证tests/state-acquirestatelock-non-eexist.test.cjs 中明确写着该测试「为 #3772 编写并在 #3776 中扩展以覆盖 Docker overlay-fs 和 NFS 瞬时 errno 码」。三、重试白名单可重试 errno 与致命 errno 的完整清单修复的核心是一张集中维护的错误码集合常量。state.cjs中定义如下get-shit-done/bin/lib/state.cjs// Transient errno codes that indicate a temporary filesystem condition under // concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS // (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are // recoverable; acquireStateLock retries instead of propagating them. // Truly fatal codes (EMFILE, ENOSPC, EROFS, EACCES) are NOT in this set and // will still throw immediately. const ACQUIRE_LOCK_RETRY_ERRNOS new Set([ EPERM, // Windows / macOS AV scanner holds the file open during delete EBUSY, // Windows: file in use by another process EAGAIN, // POSIX: resource temporarily unavailable EINTR, // POSIX: syscall interrupted by signal EINVAL, // Docker overlay-fs: transient during concurrent O_EXCL creation EIO, // Docker overlay-fs / NFS: transient I/O error ENOENT, // Docker overlay-fs: parent dir transiently missing during race ESTALE, // NFS: stale file handle (self-resolves on retry) ]);而在 planning-workspace.cjs 中存在内容与注释完全一致的PLANNING_LOCK_RETRY_ERRNOS供withPlanningLock使用。两套集合刻意保持同步这正是「runtime 层两模块共享同一锁策略」的直接证据。可重试 errno 分类表错误码触发场景据源码注释为什么可重试EPERMWindows / macOS 杀毒软件在删除期间持有文件属于瞬时占用稍后即释放EBUSYWindows文件正被另一进程使用占用结束时即可创建成功EAGAINPOSIX资源暂时不可用标准瞬时信号EINTRPOSIX系统调用被信号中断重试即可完成调用EINVALDocker overlay-fs并发O_EXCL创建期间的瞬时状态重试可越过竞争窗口EIODocker overlay-fs / NFS瞬时 I/O 错误存储层抖动重试即恢复ENOENTDocker overlay-fs竞争期间父目录瞬时缺失目录创建完成后重试成功ESTALENFS文件句柄过期重试会重新解析自行恢复仍立即抛出的致命 errno与白名单相对以下四个错误码刻意不进入集合一旦出现直接向上抛出这是本次修复与既有 #3772 语义一致的关键点错误码含义为什么立即抛出EMFILE进程文件描述符耗尽重试不会释放 fd只会加剧资源耗尽ENOSPC磁盘已满重试无法凭空腾出空间EROFS只读文件系统任何重试都不可能成功EACCES权限不足需要人工介入而非机械重试值得强调的是白名单的保守性集合对集合外的一切未知错误码保持「不匹配」从而走抛出分支避免把未曾预料到的错误误判成可重试、导致无限循环或假成功。这一点同样被测试显式锁定见下文 C6。四、源码级拆解acquireStateLock 的完整重试循环state.cjs中的acquireStateLock主体实现位于 get-shit-done/bin/lib/state.cjs关键工程参数如下参数值作用锁文件路径statePath .lock与目标状态文件同目录同前缀retryDelay200ms每次重试的基础等待时间staleThresholdMs1000010 秒判断锁是否由崩溃进程残留陈旧锁maxWaitMs3000030 秒对存活持有者的最长等待预算jitter0–50ms 随机打散多进程同时重试的羊群效应创建方式fs.constants.O_CREAT \| O_EXCL \| O_WRONLY原子创建杜绝「检查再创建」竞态其核心逻辑可拆解为以下流程尝试原子创建锁文件并写入持有者 PIDfs.writeSync(fd, String(process.pid))创建成功后把锁路径登记到进程级_heldStateLocks集合用于退出时清理避免崩溃留下陈旧锁注释中关联 #1916捕获错误后先查白名单if (ACQUIRE_LOCK_RETRY_ERRNOS.has(err.code)) { continue; }—— 命中瞬时错误码立即进入下一轮重试不抛出、不误判为锁占用非EEXIST且不在白名单 → 直接抛出if (err.code ! EEXIST) throw err;代码注释点明原因——「静默绕过会造成丢失更新」EEXIST锁真实存在进入陈旧锁判定读取锁文件的statSync().mtimeMs若已超过 10 秒陈旧阈值则尝试unlinkSync后重试崩溃持有者清理若在 stat 与 unlink 之间锁已被释放静默继续若锁仍被存活进程持有且等待已超过 30 秒maxWaitMs抛出超时错误消息中会带上锁路径与已等待毫秒数否则以retryDelay jitter休眠后进入下一轮。用一张简化的分支决策图理解捕获逻辑catch (err) ├─ err.code 命中 RETRY_ERRNOSEPERM/EBUSY/EAGAIN/EINTR/ │ EINVAL/EIO/ENOENT/ESTALE → continue立即重试 ├─ err.code ! EEXISTEMFILE/ENOSPC/EROFS/EACCES/未知→ throw └─ err.code EEXIST ├─ 锁文件 mtime 超过 10s陈旧 → unlink 后 continue ├─ 等待已超过 30s → throw 超时 └─ 否则 → sleep(200msjitter) 后 continue释放侧则由releaseStateLock完成get-shit-done/bin/lib/state.cjs从_heldStateLocks移除并尝试unlinkSync即使锁已被他人清理也静默容错。五、withPlanningLock 与规划工作区的一致性实现规划工作区是另一条同样高频的写入路径。在 planning-workspace.cjs 中PLANNING_LOCK_RETRY_ERRNOS与ACQUIRE_LOCK_RETRY_ERRNOS保持同一份 8 项白名单与同一份注释约定其捕获分支在 planning-workspace.cjs 处使用同样的PLANNING_LOCK_RETRY_ERRNOS.has(err.code)决策方式。withPlanningLock所保护的.planning目录布局在该文件中有明确定义planning-workspace.cjs规划根目录下包含STATE.md、ROADMAP.md、PROJECT.md、config.json、REQUIREMENTS.md与phases/子目录并支持GSD_PROJECT/GSD_WORKSTREAM环境变量实现多项目 / 多 workstream 隔离。锁定规划目录的写入是为了防止并行会话在这些关键产物上互相践踏——这是本 changeset 把重试语义同时落到两个函数的原因。六、SDK 侧的 TypeScript 镜像实现同一系统在 sdk/src/query/state-mutation.ts 提供 TypeScript 版acquireStateLock。两处实现策略略有分工值得对比运行时 CJS 版上文维护集中 errno 白名单通过「命中重试、非EEXIST抛出」严格区分瞬时错误与致命错误SDK TS 版以maxRetries 10200ms退避为上限专门处理EEXIST竞争并在注释中声明其非EEXIST分支采用「与 CJSstate.cjs对齐的降级语义」源码注释D3: Graceful degradation on non-EEXIST errors (match CJS state.cjs:889)同时内置「持有 PID 已死则解锁」与「锁文件 mtime 超过 10 秒则视为陈旧并清理」的启发式逻辑。两个镜像各自被其调用链使用CJS 版服务于安装/运行时工作流TS 版服务于 SDK 查询层被 phase-lifecycle.ts 等模块引用。需要读者留意的是本 changeset 针对的白名单重试修复落点主要是运行时 CJS 模块文章中的 errno 分类表与决策分支均以 state.cjs 为准。七、回归测试如何锁住这套契约配套测试 tests/state-acquirestatelock-non-eexist.test.cjs 是一个「architectural-invariant」架构不变量测试acquireStateLock是未导出的私有函数无法通过公开 CLI 稳定触发时序敏感行为因此测试采取源码级检查这一权威方式直接从 get-shit-done/bin/lib/state.cjs测试中通过path.join(__dirname, .., get-shit-done, bin, lib, state.cjs)定位提取函数体与常量集合逐项断言测试组断言内容C1非EEXIST错误必须throw不得返回假成功锁路径#3772 回归C2 / C3成功路径返回锁路径EEXIST重试/等待语义不受本修复影响C4a–C4fEAGAIN/EINTR/EINVAL/EIO/ENOENT/ESTALE必须在白名单中#3776 新增C5a–C5dEMFILE/ENOSPC/EROFS/EACCES必须不在白名单中致命C6未知错误码ESOMETHING不得进入白名单保守默认面向上层抛错C7a–C7bEPERM/EBUSY仍在白名单#3773 回归守护C8重试决策必须使用ACQUIRE_LOCK_RETRY_ERRNOS.has()而非旧的硬编码EPERM||EBUSY内联比较C8 尤为关键——它确保后续开发者不会退化成「改条件表达式」的脆弱写法而是持续维护唯一事实来源的集合常量。此外锁行为在更高层还有多套集成测试呼应如 concurrency-safety.test.cjs、planning-workspace.test.cjs 以及针对陈旧锁清理历史缺陷的 locking-bugs-1909-1916-1925-1927.test.cjs共同构成从错误码分类到并发互斥的完整验证体系。八、工程启示什么时候该重试什么时候该抛错从这次修复中可以提炼出三类经验适用于一切基于文件系统原子操作的分布式锁实现errno 不是非黑即白必须显式分类。EEXIST锁占用与EIO/ESTALE存储抖动虽然都让open(O_EXCL)失败但语义与恢复路径完全不同前者要走「等待 陈旧锁清理」后者应走「立即重试」而ENOSPC/EROFS这类错误重试只会放大故障。「假成功」比报错更危险。历史上最严重的问题不是抛错而是在异常时返回一个虚假的锁路径让上层误以为拿到互斥权导致并发写互相覆盖、状态文件损坏。任何拿不到锁的分支都必须显式要么重试要么抛出。白名单应保持保守并配测试守护。集合外未知错误码默认抛出对新增代码的归属判断交给源码级断言测试防止回归。九、总结与验证建议acquireStateLock/withPlanningLock的 errno 重试白名单修复是 get-shit-done 在异构运行环境Docker overlay-fs 容器、NFS 共享存储、Windows/macOS 桌面下保证状态文件写入互斥可靠性的关键一环。修复后瞬时文件系统抖动会被自动吸收而致命错误会第一时间暴露给上层避免静默损坏状态文件。若你正在排查本系统的锁超时或异常建议按如下路径复查确认运行环境命中哪类错误码——容器 overlay-fsENOENT/EINVAL/EIO、NFSESTALE还是杀毒软件占用EPERM/EBUSY对照 state.cjs 的ACQUIRE_LOCK_RETRY_ERRNOS检查期望的重试行为若出现「等待 30 秒后超时」错误错误消息会包含锁路径与已等待毫秒数可据此判断是陈旧锁清理失败还是存活持有者写死修改前先跑 state-acquirestatelock-non-eexist.test.cjs其中 C4/C5/C8 会直接守护白名单集合的完整性。【免费下载链接】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),仅供参考