尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

no-mistakes Daemon 运行时深入解析:单例锁、有界事件流与生命周期守卫

发布时间:2026/9/16 11:39:39

资讯中心
01
ARTICLE

no-mistakes Daemon 运行时深入解析:单例锁、有界事件流与生命周期守卫

no-mistakes Daemon 运行时深入解析:单例锁、有界事件流与生命周期守卫
no-mistakes Daemon 运行时深入解析单例锁、有界事件流与生命周期守卫【免费下载链接】no-mistakesgit push no-mistakes项目地址: https://gitcode.com/GitHub_Trending/no/no-mistakesno-mistakes的核心是一个长期运行的守护进程daemongit 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只能有一个活着的 daemondaemon 是机器级的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 OSs 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_SecondDaemonForSameRootFailsWithoutStealingSocketsingleton_test.go第二个 daemon 针对同一根目录失败且不偷 socketTestRunWithOptions_RequiresSingletonLockBeforeRecoverystartup_recovery_test.go锁必须先于恢复获取TestRecoverOnStartup_DoesNotDeleteActiveRunWorktree恢复不得删除活跃运行的 worktreeTestServe_SecondListenerForLiveSocketDoesNotStealIt、TestDialConnectTimeoutFailsFastAndNamesSocket、TestIsRunningFailsFastWhenSocketAcceptsButDoesNotRespond、TestIsRunningSurfacesExistingDeadSocketIPC 层的 dial/健康探测行为TestDaemonRunRootFromArgs_EnvDoesNotForceDaemonModeForProbes环境变量不得把探测强制成 daemon 模式TestValidateDaemonPIDFallback_RefusesToKillOwnProcessPID 回退校验拒绝杀死自己的进程。二、启动就绪协议进程启动 ≠ 就绪daemon 的启动被拆成两个可区分阶段这是 daemon.go 的关键设计。2.1 PID 发布早就绪判定晚发布 PIDacquireSingletonLock成功之后、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 workerdaemon 执行是显式-only的入口是隐藏命令no-mistakes daemon run --root。设计约束是绝不能让继承的环境把--version、status之类的探测调用重新解释为 daemon worker否则每次探测都会误启一个 daemon 实例。这由TestDaemonRunRootFromArgs_EnvDoesNotForceDaemonModeForProbes守护。三、启动恢复中的 worktree 清理DB-aware绝不误删活跃运行recoverOnStartupdaemon.go中的 worktree 清理以本地状态 DB 为准运行行状态为pending或running的 worktree绝不删除skipWorktreeCleanup见 daemon.goRunManager.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.logdaemon 生命周期输出32 MiBLifecyclePolicy3managed-server.log托管 Rovo Dev / OpenCode 的 stdout/stderr16 MiBManagedServerPolicy2daemon-bootstrap.log服务 bootstrap / 直接崩溃输出1 MiBBootstrapPolicy2策略定义在 rotate.go备份后缀为.1最新到.N。4.1 原地截断保 inodeRotatingWriter轮转时把当前文件快照进备份链然后在原地截断当前 inoderotateLocked见 rotate.go而不是删除重建。这一点对 systemd/launchd/Task Scheduler 等 service manager 和 daemon 拉起的子进程至关重要它们已经持有了当前路径的打开描述符若 inode 被替换那些描述符会继续写进一个无人管理的旧文件导致日志文件持续膨胀但轮转不生效。原地截断让所有已持有描述符的写入者继续写入这个有界的当前文件。Windows 上O_APPEND句柄缺少FILE_WRITE_DATASetEndOfFile会访问拒绝因此通过稳定路径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.ClassOfevents.goEventClass是事件流丢包容忍度的唯一分类标准这条事件能否被丢弃的答案只在这里定义daemon 的 overflow 策略与每个消费者的对账策略按构造一致而不是靠两份容易漂移的事件名列表ClassActivity临时输出如EventLogChunk无状态效果丢失它不会让消费者渲染出 daemon 不持有的状态是唯一允许丢弃的类ClassState状态转换所有其他已知类型。payload 只是 wakeup hint字段都能从get_run重建但状态变了这个事实必须送达每个订阅者否则消费者会一直渲染 daemon 早已离开的状态ClassControlbroker 自身生成的流元数据EventStreamGap先于排队 payload 投递。未识别类型 fail-safe 到ClassState未来新增的、当前构建不认识的事件类型绝不会被静默丢弃TestClassOfUnknownEventFailsSafeToState。5.2 订阅者信箱internal/daemon/eventmailbox.go每个订阅者一个信箱边界是64 个事件 且 1 MiBmailboxMaxEvents 64mailboxMaxBytes 1 20另有 128 字节事件固定开销计入字节预算。核心性质非阻塞发布executor 发布事件绝不阻塞绝不被慢订阅者 stall只驱逐 activity满员时丢弃/驱逐的新事件是 activity日志块丢最新的而非最旧的保持消费者尚未读到的日志前缀连续state 永不静默丢失state 事件要么送达要么折叠进单个 sticky、coalescing 的stream_gap信号。gap 就是一个布尔 一个高水位StateRev任意数量的同时转换都会塌缩进去——这解释了为什么预留一个槽位的方案不够固定边界下任何试图保留 N 个 must-not-lose payload 的方案都会在第 N1 个失败a reserved slot fails at the second simultaneous transitionring mutex 而非 buffered channelchannel 无法从生产者侧驱逐而不与读取者竞争同一槽位而 ring 能对边界做精确的计数 字节双重记账gap 先于排队 payload drainnext()发现 gap 时先返回它见 eventmailbox.go合并的失效信号必须先于消费者渲染更多过期帧到达而排在其后的 delta 会被 revision 守卫变成无害 no-op。5.3 StateRev单调版本 先采样后读库每个 state 事件与每个get_run快照都携带单调递增的StateRevrunSnapshot在DB 读之前采样 revision——这之所以成立是因为每个 producer 都是先写 state 再 emit回归TestExecutor_StateEventsAreEmittedAfterTheirDatabaseWrite消费者只在 revision 更新时应用 delta因此先于快照入队的 delta不可能让渲染状态在快照之后发生回退regress每个订阅以 gap 打开newEventMailbox初始gap: true, gapRev: startRev所以 attach 与 reconnect 的先 reconcile 权威状态成为服务器端不变量而非每个消费者要记住的规则reconnect 因而必然收敛。5.4 唯一无界 payload 的按需化StepDifffix-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.goAXIaxi 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必须尊重该 contextTestAxiRun_WaitInterruptsSubscriptionAcknowledgement。六、破坏性生命周期守卫internal/lifecycle/guard.godaemon 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 --forceupdate -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: trueuser-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 走MethodGetStepDiff512 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),仅供参考
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。