no-mistakes Daemon 运行时深入解析:单例锁、有界事件流与生命周期守卫
【免费下载链接】no-mistakesgit push no-mistakes项目地址: https://gitcode.com/GitHub_Trending/no/no-mistakes
no-mistakes的核心是一个长期运行的守护进程(daemon):git push no-mistakes把推送交给本地 gate 仓库的 post-receive 钩子,钩子通知 daemon 后立即返回,而 worktree 创建、pipeline 执行、TUI 事件流、状态持久化、清理与崩溃恢复等全部耗时工作都由 daemon 独占。本文基于仓库内.agents/skills/daemon-runtime/SKILL.md及 internal/daemon、internal/ipc、internal/cli、internal/logstore、internal/lifecycle 等目录的源码,系统讲解 daemon 的四个运行时核心机制:单例锁与启动就绪协议、有界日志、丢包感知的有界事件订阅、破坏性生命周期命令守卫,并给出对应的回归测试与用户可见文档线索,帮助读者既会正确使用no-mistakes daemon系列命令,也能理解其故障恢复与并发安全设计。
一、Daemon 单例锁:一个NM_HOME只能有一个活着的 daemon
daemon 是机器级的(machine-wide),同一时刻对同一个NM_HOME根目录只允许存在一个存活进程。这个不变量的实现是 internal/daemon/lock.go 中的singletonLock——独占式 OS 文件锁,锁定文件为<NM_HOME>/daemon.lock。
1.1 锁的获取时机与持有期
在 daemon.go 的RunWithOptions中,acquireSingletonLock是第一个动作,严格早于:
- stale-run 恢复(
recoverOnStartup,全局崩溃恢复); - IPC socket 绑定(
srv.Listen)。
锁被持有整个进程生命周期,defer lock.Release()直到进程退出才执行。代码注释明确解释了为什么顺序如此苛刻:如果第二个 daemon 抢在恢复之前拿到 socket 并运行全局崩溃恢复,它会把自己当作"主人",把第一个还活着的 daemon 正在跑的运行标记为 crashed,并删除其 worktree——这是灾难性的双主场景。
1.2 内核自清理:锁永远不会"过期"
与 PID 文件不同,该锁不需要任何 staleness 启发式判断:
// The underlying lock (tryLockFile/unlockFile, platform-specific in // lock_unix.go / lock_windows.go) is the OS's native file lock, which the // kernel releases automatically when the owning process exits or dies for // any reason (including SIGKILL)内核会在持有进程以任何方式退出或死亡(包括被SIGKILL)时自动释放文件锁,因此"锁被持有"恒等于"持有者进程还活着",不存在 PID 文件那种"进程死了但文件还在"的过期状态。平台实现位于 lock_unix.go 与 lock_windows.go。
acquireSingletonLock使用非阻塞tryLockFile:一旦发现锁被占用,会尽力读取锁持有者写入的诊断记录(lockHolderRecord,含pid与started_at),包装成ErrSingletonLockHeld返回,错误信息形如:
a no-mistakes daemon is already running for this NM_HOME (pid 1234, started 2026-09-15T06:00:00Z)让操作者(或第二个 daemon 的调用方)能直接定位现存 daemon。诊断记录写入本身是 best-effort:它不参与安全机制(OS 锁才是),写入失败不影响锁的正确性。
1.3 独立安全层:socket 不窃取
单例锁之外还有一层独立防护。internal/ipc的listen()在 unlink socket 文件之前先 dial 它:如果 socket 上还有东西在应答,就拒绝接管;只有证明是死 socket(无任何监听者)才删除并重新绑定。这样即使单例锁被绕过,也还有一层防线。
1.4 关键回归测试
原文档列出的单例锁回归测试分布在 internal/daemon 下:
TestAcquireSingletonLock_*(lock_test.go):锁的获取/占用/释放语义;TestRunWithResources_SecondDaemonForSameRootFailsWithoutStealingSocket(singleton_test.go):第二个 daemon 针对同一根目录失败且不偷 socket;TestRunWithOptions_RequiresSingletonLockBeforeRecovery(startup_recovery_test.go):锁必须先于恢复获取;TestRecoverOnStartup_DoesNotDeleteActiveRunWorktree:恢复不得删除活跃运行的 worktree;TestServe_SecondListenerForLiveSocketDoesNotStealIt、TestDialConnectTimeoutFailsFastAndNamesSocket、TestIsRunningFailsFastWhenSocketAcceptsButDoesNotRespond、TestIsRunningSurfacesExistingDeadSocket:IPC 层的 dial/健康探测行为;TestDaemonRunRootFromArgs_EnvDoesNotForceDaemonModeForProbes:环境变量不得把探测强制成 daemon 模式;TestValidateDaemonPIDFallback_RefusesToKillOwnProcess:PID 回退校验拒绝杀死自己的进程。
二、启动就绪协议:进程启动 ≠ 就绪
daemon 的启动被拆成两个可区分阶段,这是 daemon.go 的关键设计。
2.1 PID 发布早,就绪判定晚
- 发布 PID:
acquireSingletonLock成功之后、recoverOnStartup之前,daemon 立即把身份记录(PID + 进程启动时间)原子写入<NM_HOME>/daemon.pid(临时文件 + rename,见writeDaemonPIDFile)。这使启动调用方能把"已启动的子进程"与"IPC 就绪"区分开,也能在排他恢复仍在进行时及时发现托管子进程提前退出。 - 排他恢复:
recoverOnStartup完成 stale-run 恢复、孤儿进程/worktree 清理、gate 迁移等全局操作,此时 socket 尚未绑定。 - 就绪判定:
srv.Listen绑定 socket 后,confirmLocalIPCHealth以 2 秒超时循环探测本地 IPC 健康;只有拿到真实 IPC 健康响应,daemon 才打印daemon ready。PID 文件存在或 socket 已绑定都不算就绪证据。
2.2 45 秒生产预算
daemon start为冷环境准备与恢复预留45 秒生产预算。配套语义包括:
- 提前退出快速失败:子进程在就绪前退出会立即被报告,而不是等到超时;
- 超时清理:detached 启动超时后,命令会先 kill 并 reap 该子进程再返回,不会遗留僵尸;
- fallback 保留双错误:托管(managed service)启动失败时先清理托管尝试,再尝试 detached fallback;两条路径都失败时,
errors.Join保留两个错误原因供诊断(回归:TestStartDetachedDaemonDetectsChildExitPromptly、TestStartDetachedDaemonTimeoutKillsAndReapsChild、TestStartPreservesManagedAndDetachedFallbackErrors、TestColdDetachedStartupProductionGateCardinality)。
2.3 停止语义:进程消失才算停止
"成功的 stop"意味着daemon 进程已经退出,而不只是 IPC 健康消失——因为只有进程退出才会释放单例锁。因此:
- 请求 shutdown 前先捕获 daemon 实例;
- 在等待前先关闭 shutdown client——daemon 退出时会 drain 在途 handler,若 client 仍挂着会互相等待。
对应实现为waitForDaemonStop与stopDetachedDaemon,回归测试在 e2e 层:TestDaemonStopLeavesNoDaemonProcessOwningTheRoot、TestDaemonRestartReplacesTheDaemonWithExactlyOneOwner。
2.4 客户端探测的快速失败
CLI 客户端对 socket 的 dial 用daemon_connect_timeout(默认3s,可用环境变量NM_DAEMON_CONNECT_TIMEOUT覆盖)约束:socket 存在但无任何应答(死 socket / 卡死 daemon)时快速失败,而不是悄悄启动替代 daemon。EnsureDaemon会把错误连同daemon start的恢复提示一起返回;health RPC 本身由ipc.DefaultDialTimeout单独约束。daemon start则具备自愈能力,可以清理死 socket 重新绑定。
2.5 显式执行模式:探测不能被误解释为 daemon worker
daemon 执行是显式-only的:入口是隐藏命令no-mistakes daemon run --root。设计约束是:绝不能让继承的环境把--version、status之类的探测调用重新解释为 daemon worker(否则每次探测都会误启一个 daemon 实例)。这由TestDaemonRunRootFromArgs_EnvDoesNotForceDaemonModeForProbes守护。
三、启动恢复中的 worktree 清理:DB-aware,绝不误删活跃运行
recoverOnStartup(daemon.go)中的 worktree 清理以本地状态 DB 为准:
- 运行行状态为
pending或running的 worktree绝不删除(skipWorktreeCleanup,见 daemon.go); RunManager.startRun总是先插入 run 行、后创建 worktree 目录,因此在单 daemon 前提下,"没有对应 run 行"的目录必然是残留,可以立即安全删除;ci_monitor_interrupted状态特判:若 worktree HEAD 与已推送的HeadSHA不一致,说明其中可能含有未推送的 CI auto-fix 提交,必须保留(fail-safe 到保留)。
no-row 即可删规则只对<NM_HOME>/worktrees默认树成立:该目录归 no-mistakes 所有,通过 walk 发现(defaultTreeOrphanWorktrees)。而配置的worktree_roots目录是操作者自己的目录,其中清理与 eject 只作用于 run 记录精确点名的目录(recordedOrphanWorktrees、leftoverRecordedRunWorktrees),绝不枚举任何其他内容,防止误删操作者的 scratch checkout 或其他工具的文件。
worktree 清理前还会用procreap.SweepRunWorktrees做一次进程快照清理,捕获"脱离了进程组、reparent 到 init、仍把已删除 worktree 当作 cwd"的逃逸进程。
四、有界 daemon 日志:internal/logstore单一所有者
所有 daemon 进程的字节上限与保留策略统一由 internal/logstore 拥有,避免生命周期日志、托管依赖日志与 bootstrap 日志各自的策略悄悄漂移。三个 sink 与策略如下:
日志文件(位于<NM_HOME>/logs/) | 用途 | 当前文件上限 | 备份数 |
|---|---|---|---|
daemon.log | daemon 生命周期输出 | 32 MiB(LifecyclePolicy) | 3 |
managed-server.log | 托管 Rovo Dev / OpenCode 的 stdout/stderr | 16 MiB(ManagedServerPolicy) | 2 |
daemon-bootstrap.log | 服务 bootstrap / 直接崩溃输出 | 1 MiB(BootstrapPolicy) | 2 |
策略定义在 rotate.go,备份后缀为.1(最新)到.N。
4.1 原地截断保 inode
RotatingWriter轮转时把当前文件快照进备份链,然后在原地截断当前 inode(rotateLocked,见 rotate.go),而不是删除重建。这一点对 systemd/launchd/Task Scheduler 等 service manager 和 daemon 拉起的子进程至关重要:它们已经持有了当前路径的打开描述符,若 inode 被替换,那些描述符会继续写进一个无人管理的旧文件,导致"日志文件持续膨胀但轮转不生效"。原地截断让所有已持有描述符的写入者继续写入这个有界的当前文件。Windows 上O_APPEND句柄缺少FILE_WRITE_DATA,SetEndOfFile会访问拒绝,因此通过稳定路径os.Truncate保持文件身份。
4.2 日志级别约定
- 成功的只读 IPC 方法(如 health、run-state 读取)只在
debug级别出现; - mutations(变更)、stream 启动、生命周期转换在
info可见; - 每次请求失败在
warn可见。
级别可在全局配置中调整:
log_level: debug # debug | info | warn | error对应回归测试:internal/logstore/rotate_test.go、TestDetachedDaemonUsesBoundedDedicatedLogSinks、TestManagedServerOutputIsSeparatedFromLifecycleFailureSummary、TestSuccessfulReadRequestsDoNotLogAtInfo、TestRequestLoggingKeepsMutationsAndFailuresVisible。
五、有界、丢包感知的事件订阅
daemon 向 TUI / AXI 客户端推送 run 事件,但慢或卡死的客户端绝不能拖垮 executor。这套机制由两个"单一所有者"构成。
5.1 事件分类法:ipc.ClassOf(events.go)
EventClass是事件流丢包容忍度的唯一分类标准,"这条事件能否被丢弃"的答案只在这里定义,daemon 的 overflow 策略与每个消费者的对账策略按构造一致,而不是靠两份容易漂移的事件名列表:
ClassActivity:临时输出(如EventLogChunk),无状态效果,丢失它不会让消费者渲染出 daemon 不持有的状态,是唯一允许丢弃的类;ClassState:状态转换(所有其他已知类型)。payload 只是 wakeup hint(字段都能从get_run重建),但"状态变了"这个事实必须送达每个订阅者,否则消费者会一直渲染 daemon 早已离开的状态;ClassControl:broker 自身生成的流元数据(EventStreamGap),先于排队 payload 投递。
未识别类型 fail-safe 到ClassState:未来新增的、当前构建不认识的事件类型绝不会被静默丢弃(TestClassOfUnknownEventFailsSafeToState)。
5.2 订阅者信箱:internal/daemon/eventmailbox.go
每个订阅者一个信箱,边界是64 个事件 且 1 MiB(mailboxMaxEvents = 64,mailboxMaxBytes = 1 << 20,另有 128 字节事件固定开销计入字节预算)。核心性质:
- 非阻塞发布:executor 发布事件绝不阻塞,绝不被慢订阅者 stall;
- 只驱逐 activity:满员时丢弃/驱逐的新事件是 activity(日志块);丢最新的而非最旧的,保持消费者尚未读到的日志前缀连续;
- state 永不静默丢失:state 事件要么送达,要么折叠进单个 sticky、coalescing 的
stream_gap信号。gap 就是一个布尔 + 一个高水位StateRev,任意数量的同时转换都会塌缩进去——这解释了为什么"预留一个槽位"的方案不够:固定边界下,任何试图保留 N 个 must-not-lose payload 的方案都会在第 N+1 个失败("a reserved slot fails at the second simultaneous transition"); - ring + mutex 而非 buffered channel:channel 无法从生产者侧驱逐而不与读取者竞争同一槽位,而 ring 能对边界做精确的计数 + 字节双重记账;
- gap 先于排队 payload drain:
next()发现 gap 时先返回它(见 eventmailbox.go),合并的失效信号必须先于消费者渲染更多过期帧到达,而排在其后的 delta 会被 revision 守卫变成无害 no-op。
5.3 StateRev:单调版本 + 先采样后读库
- 每个 state 事件与每个
get_run快照都携带单调递增的StateRev; runSnapshot在DB 读之前采样 revision——这之所以成立,是因为每个 producer 都是先写 state 再 emit(回归:TestExecutor_StateEventsAreEmittedAfterTheirDatabaseWrite);- 消费者只在 revision 更新时应用 delta,因此"先于快照入队的 delta"不可能让渲染状态在快照之后发生回退(regress);
- 每个订阅以 gap 打开(
newEventMailbox初始gap: true, gapRev: startRev),所以 attach 与 reconnect 的"先 reconcile 权威状态"成为服务器端不变量,而非每个消费者要记住的规则,reconnect 因而必然收敛。
5.4 唯一无界 payload 的按需化:StepDiff
fix-review 的 worktree diff 是唯一从不持久化的 gate context,也是曾经唯一的无界 payload。一帧超过 1 MiB transport 行上限就会终结整个订阅并隐藏之后的所有事件。因此它被从事件流中移出,改为按需服务:ipc.MethodGetStepDiff→RunManager.StepDiff,上限 512 KiB。回归测试:TestStepDiff_*、TestSubscribeOversizedFrameEndsTheStreamAndHidesLaterEvents、internal/tui/overflow_contract_test.go。
5.5 事件驱动的 AXI run 驱动:internal/cli/run_reconciler.go
AXI(axi run/axi respond)的 run 状态刷新是subscribe-first的:run_reconciler.go 是事件对账、重连、重复事件合并与慢丢失事件心跳的唯一所有者。设计要点:
- 禁止重新引入固定间隔
get_run轮询;正常唤醒来自 run 事件,30 秒一次的心跳(driveHeartbeatInterval)只是"丢失事件"的后备兜底; - 重连有界:
driveReconnectInterval = 500ms、driveReconnectTimeout = 30s,死 daemon 变成可操作的 AXI 错误,而不是无限静默等待; - 重复与延迟事件无害:事件 payload 只是 wakeup hint,权威状态永远来自
get_run全量读; - 慢回复 ≠ 死 daemon:驱动前的
get_active_run/get_run状态读错过单次尝试 deadline 时,先分类为超时,再 probe 健康,健康则重试(callWithSlowReplyRetry),而不是把活着的 daemon 当作 I/O 失败处理;TestDriveRun_SlowGetRunRetriesAfterHealthProbe、TestAxiRun_SlowActiveRunReadRetriesInsteadOfStartingAnotherRun、TestAxiRespond_SlowInitialRunReadRetries等回归覆盖; axi run/axi respond默认--wait 8m彼此独立,hold 不会坐满 10 分钟 harness cap;订阅确认(acknowledgement)必须尊重该 context(TestAxiRun_WaitInterruptsSubscriptionAcknowledgement)。
六、破坏性生命周期守卫:internal/lifecycle/guard.go
daemon stop、daemon restart、update都属于"破坏性"命令:daemon 是机器级的,停掉它会同时失败所有正在进行的 pipeline。因此三者默认拒绝执行,形成统一守卫:
6.1 守卫行为
- 默认拒绝:存在
pending/running运行行时,命令拒绝并列出所有活跃运行(通过共享的lifecycle.ActiveRuns与lifecycle.RunListhelpers,见 guard.go,输出每条的 ID、status、branch、短 head SHA); - 显式
--force才放行:daemon stop --force、daemon restart --force、update --force; update -y不绕过守卫:-y/--yes只回答"daemon 已从不同可执行路径运行,是否替换"这一个提示,故意不绕过活跃运行守卫(TestUpdaterRunRefusesWithActiveRunsAndListsThem、TestUpdaterActiveRunGuardAllowsForce,位于internal/update);- 递归遏制:派生自活跃 validation-step agent 的进程不能 start/stop/restart/update daemon——任何生命周期变更前即拒绝,
--force/--yes均不可绕过。
6.2 调用方归属:事故取证痕迹
三个命令的每一次调用(无论是否强制)都通过logLifecycleInvocation把调用方归属——PID、PPID、父进程命令行——写入<NM_HOME>/logs/cli.log。这是 incident 取证轨迹:日后事故发生时能确认是哪个 agent 或进程触发了生命周期变更,不得删除或削弱。
对应回归测试:TestDaemonStopRefusesWithActiveRunsAndListsThem、TestDaemonStopForceOverridesActiveRunGuard、TestDaemonRestartRefusesWithActiveRuns、TestLifecycleCommandsWriteCallerAttributionToCLILog(均在internal/cli/daemon_lifecycle_test.go)。
七、与用户可见文档的关系
.agents/skills/daemon-runtime/SKILL.md是面向 daemon 相关代码修改的内部工程契约(metadata.internal: true,user-invocable: false);用户可读的功能模型(守护进程为什么存在、如何启停、崩溃恢复语义、并发 push 处理、日志文件位置、shutdown 流程)位于 docs/src/content/docs/concepts/daemon.md。两者互补:
- 单例锁的 rationale(为什么用 OS 文件锁而非 PID 文件)沉淀在 internal/daemon/lock.go 与 daemon.go 的注释里;
- 用户侧的命令形态(
no-mistakes daemon start|stop|restart|status,以及no-mistakes、init、attach、rerun、axi run、axi respond、update的自动确保语义)以 docs/src/content/docs/concepts/daemon.md 为准。
八、实践要点速查
- 排查"第二个 daemon":先看
<NM_HOME>/daemon.lock持有者的pid与started_at,正常情况下锁被持有即代表进程存活,无需猜 stale; - 判断启动是否成功:以
daemon.log中真实 IPC 健康响应后的daemon ready为准,PID 文件与 socket 存在都不算数;冷启动预算 45 秒; - 日志轮转无效?检查是否由删除重建代替了原地截断——必须保留 inode,否则 service manager / 子进程持有的描述符会把日志写进无界旧文件;
- 订阅丢日志不丢状态:
ClassActivity可丢,ClassState只会折叠成stream_gap,重连后必然先 reconcile;一帧超过 1 MiB 会终结订阅,因此大 diff 走MethodGetStepDiff(512 KiB 上限); - 更新/停止被拒:daemon 列出了活跃运行,说明有
pending/running的 pipeline 在跑;确认可以牺牲它们后再用--force,-y无法绕过; - 追查谁动了 daemon:读
<NM_HOME>/logs/cli.log的 PID/PPID/父命令行归属记录。
通过以上设计,no-mistakes 的 daemon 在"单例所有权、启动就绪、事件投递、日志有界、破坏性操作可审计"五个维度上都做到了有界、可推理、可恢复,且每一层都有对应的回归测试锁定行为。
【免费下载链接】no-mistakesgit push no-mistakes项目地址: https://gitcode.com/GitHub_Trending/no/no-mistakes
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考