no-mistakes Daemon 与 Worktree:后台守护进程的架构、生命周期管理与崩溃恢复实战指南
【免费下载链接】no-mistakesgit push no-mistakes项目地址: https://gitcode.com/GitHub_Trending/no/no-mistakes
本文基于 daemon.md 展开,结合仓库中 internal/daemon 与 internal/cli/daemon_cmd.go 的源码实现,系统讲解 no-mistakes 守护进程(daemon)为什么存在、如何被托管、如何管理 pipeline 运行与 worktree、如何处理并发推送、如何从崩溃中恢复,以及完整的日志体系。读完你可以掌握 daemon 的全部生命周期操作(启动/停止/重启/状态)、理解单例锁与就绪语义、掌握崩溃恢复与日志排障的完整思路,并知道在多大程度上可以放心地把"运行长时间流水线"这件事交给它。
Daemon 为什么存在
no-mistakes 的核心理念是让git push no-mistakes保持快速,并且让 gate(本地守门仓库)在 shell 命令返回之后仍然能继续工作。这决定了守护进程必须存在:
- Git 将推送交给本地 gate 仓库的 post-receive hook;
- hook 通知 daemon 后立即退出,push 命令本身不被长时间阻塞;
- daemon 独占所有"长尾"工作:创建 detached worktree、执行 pipeline、向 TUI 推送事件、持久化状态、清理残留以及崩溃恢复。
在源码层面,这个模型体现在 internal/cli/daemon_cmd.go 的notify-push隐藏命令上:hook 通过no-mistakes daemon notify-push(内部走ipc.MethodPushReceivedRPC)把 gate 路径、ref、old/new SHA 以及--push-option传给 daemon,随即返回。daemon 则通过 internal/daemon/daemon.go 中的RunManager接管后续一切:NewRunManager(d, p, stepFactory)之后,pipeline 执行、worktree 创建与清理、TUI 事件流都由这一个长驻进程统一拥有。
托管服务与平台差异
安装器(installer)倾向于把 daemon 安装为"受管后台服务":
- macOS:per-user
launchdagent,产物形如~/Library/LaunchAgents/com.kunchenguid.no-mistakes.daemon.<suffix>.plist; - Linux:per-user
systemduser service,形如~/.config/systemd/user/no-mistakes-daemon-<suffix>.service; - Windows:Task Scheduler 任务,形如
no-mistakes-daemon-<suffix>。
命名隔离:多安装不冲突
这些服务标识不是固定值,而是由NM_HOME派生一个短而稳定的后缀。从源码看,internal/daemon/service.go 定义了三个基名常量:
const ( launchdServiceLabelBase = "com.kunchenguid.no-mistakes.daemon" systemdServiceNameBase = "no-mistakes-daemon" windowsTaskNameBase = "no-mistakes-daemon" )实际标识由launchdServiceLabel/systemdServiceName/windowsTaskName拼接NM_HOME派生的后缀(serviceInstanceSuffix)得到。这样同一台机器上多个 no-mistakes 安装只要使用不同的NM_HOME根目录,服务名、socket、锁、PID 文件就不会互相碰撞。
最小环境下的网络可达性
受管服务以最小环境启动(只有HOME和一个精选的PATH)。为了让 daemon 及其 spawn 的 agent(例如claude --print)能通过公司代理或本地 HTTP(S) 代理访问网络,安装/刷新服务时会把代理变量烘焙进服务定义:
- 转发的变量包括
HTTP_PROXY、HTTPS_PROXY、NO_PROXY、ALL_PROXY以及它们的全小写拼写(http_proxy等)——因为工具对大小写读取不一致(curl 的普通 HTTP 请求只认小写http_proxy); - 一旦烘焙进服务定义,之后例行
daemon restart或二进制升级都不会剥离它们;只有当你想修改/移除代理时才需要重新导出变量; - 由于代理 URL 可能内嵌凭据(如
http://user:pass@host),只要向服务文件转发了代理值,生成的服务文件就限定为属主可读的0600权限;未设置代理变量时保持常规0644。
Windows 的 Task Scheduler 直接继承登录环境,无需转发。相关实现见 internal/daemon/service.go 的proxyEnvKeys与 internal/daemon/service_render.go 的服务定义渲染/漂移检测逻辑。
登录 shell 环境解析
daemon 启动时(macOS/Linux)会从你的登录 shell 解析环境:保留 shell 的PATH顺序,并追加缺失的常见目录(~/.local/bin、~/go/bin、~/.cargo/bin、~/bin、/opt/homebrew/bin、/usr/local/bin、/usr/bin、/bin)。若登录 shell 解析失败,则退化为"增强的进程环境回退",可能丢失 nvm/fnm/volta 等版本管理器目录,并在日志中告警。Windows 直接复用当前进程环境。因此:
如果你的环境变量没有写进登录 shell 的 rc 文件(
.zprofile、.zshrc、.profile、.bash_profile、.bashrc或 PowerShell profile),daemon 是看不到的。把它们放到登录 shell 会加载的位置,然后重启 daemon 使其生效。
NM_HOME下的环境变量布局可参考 environment.md:全局配置、gate 仓库、worktrees、日志、数据库、socket/PID/锁、servers PID 记录、eval 目录全部随NM_HOME迁移。
回退:detached 进程
如果受管服务的安装或启动不可用、失败,no-mistakes会回退为启动一个分离的(detached)daemon 进程。这条回退路径贯穿daemon start、update以及所有"确保 daemon 运行"的命令。
启动与停止
绝大多数用户不需要直接管理 daemon——no-mistakes、init、attach、rerun、update等常用命令在需要时都会确保服务已安装并运行。显式管理的命令如下:
# 显式管理 no-mistakes daemon start no-mistakes daemon stop no-mistakes daemon restart no-mistakes daemon status # 确保 daemon 运行,尽量走受管服务 no-mistakes no-mistakes init no-mistakes attach no-mistakes rerun no-mistakes axi run no-mistakes axi respond # 替换二进制后重置 daemon no-mistakes update其中daemon status在 internal/cli/daemon_cmd.go 中实现为通过daemon.IsRunning探测,运行中会输出● daemon running (pid NNNN),未运行则输出○ daemon not running。
update 的重启守护
no-mistakes update在 daemon 正在运行或存在陈旧 daemon 产物时,会先停止再启动 daemon,确保新可执行文件被使用。它优先走受管服务路径,服务启动不可用/失败时回退到 detached daemon。
但存在一个关键保护:如果有 pending 或 running 状态的 pipeline 运行,update默认拒绝重启 daemon,并打印每个活跃运行的 ID、状态、分支和短 head SHA。相关守卫函数是 internal/cli/daemon_cmd.go 的guardDestructiveDaemonLifecycle:
func guardDestructiveDaemonLifecycle(p *paths.Paths, stderr io.Writer, action string, force bool) error { runs, err := lifecycle.ActiveRuns(p) if err != nil { return fmt.Errorf("check active pipeline runs: %w", err) } if len(runs) == 0 { return nil } if force { // 打印警告与活跃运行列表,继续执行 return nil } return fmt.Errorf("refusing %s because %d active pipeline runs are in progress; pass --force to stop/restart the daemon anyway\n%s", action, len(runs), lifecycle.RunList(runs)) }要点:
- 想无视活跃运行强推重启,必须显式传
--force(daemon stop、daemon restart各有一个自己的--force),并自行接受这些运行可能失败; -y/--yes不会绕过这个守卫;- 若 daemon 已从不同的可执行路径运行,
update仍会先提示再替换,-y/--yes只用于非交互地回答该提示; - 若无法确定 daemon 可执行路径,
update在替换任何东西之前直接中止。
递归遏制:验证链中的 agent 无权动 daemon
--force覆盖仅对普通顶层调用者开放。一个由活跃验证步骤 agent 派生出来的进程,不能 start/stop/restart/update daemon——递归遏制(recursive containment)会在任何生命周期变更之前拒绝命令,且没有--force或--yes可以绕过。这是为了防止 pipeline 内部 agent 自我管理守护进程造成的死锁或误操作。
可审计:谁动了 daemon
每一次daemon stop、daemon restart或update调用(无论是否--force)都会把调用者的 PID、父 PID 和父进程命令行追加记录到~/.no-mistakes/logs/cli.log,事后发生事故时能定位是哪个 agent 或进程触发的。
进程启动与就绪是两回事
daemon 在~/.no-mistakes下留下三类运行期工件:
- PID 文件
~/.no-mistakes/daemon.pid:写入身份记录(PID + 启动时间),供daemon status展示; - IPC 端点:Unix 域 socket
~/.no-mistakes/socket(Windows 上为 localhost TCP 监听 + 同路径的受保护端点文件); - 单例锁
~/.no-mistakes/daemon.lock(详见下一节)。
连接超时:别让卡死的进程挂住调用者
CLI 客户端等待 socket 接受连接的时间由全局配置daemon_connect_timeout约束(默认3s,可被NM_DAEMON_CONNECT_TIMEOUT环境变量覆盖)。即使 daemon 进程活着但卡住,连接也会在超时后失败而不是无限挂起。NM_DAEMON_CONNECT_TIMEOUT接受任何正的 Go duration 字符串,优先级高于config.yaml;为空、不可解析或非正值时忽略并回退到配置值。排障可参考 troubleshooting.md 中关于陈旧产物(stale artifacts)的检查。
死 socket:fail fast 而非静默换新
"确保 daemon 运行"的命令(no-mistakes、init、attach、rerun、axi run、axi respond)在 socket 文件存在但无人应答时(例如非正常退出留下的死 socket)会快速失败,而不是静默启动一个替代 daemon。唯一的自愈入口是no-mistakes daemon start。
完全停止交接(complete-stop handoff)
收到关闭请求后,daemon stop会等待 daemon 进程本身退出才返回成功。仅有 IPC 健康丢失是不够的——listener 在关闭开始时就已关闭,而单例锁等进程级资源要到进程退出时才释放。daemon restart采用同样的完全停止交接再启动替代进程,从而保证新旧进程不会争抢同一个根目录(root)。
就绪语义与启动窗口
从源码 internal/daemon/daemon.go 的runWithOptionsLocked可见完整的启动顺序:prepareDaemonEnvironment(登录 shell 环境)→ 加载全局配置 → 打开数据库 → 获取单例锁 → 发布 PID 文件 → 执行排他的崩溃恢复(recoverOnStartup)→ 绑定 IPC socket → 通过confirmLocalIPCHealth确认健康后才记录daemon ready。关键点:
- 取得单例锁后、排他恢复开始前,daemon 就发布 PID——但启动成功要以 IPC server 返回真实健康响应为准;
daemon start给冷环境准备与恢复留出最多45 秒;子进程在就绪前退出会被及时报告;- PID 文件存在或 socket 已绑定都不算就绪证明;
- detached 启动超时:命令会杀死并 reap 该子进程后再返回;
- 受管启动失败:先清理受管尝试,再试 detached 回退;两条路径都失败时保留两个错误。
单例锁:一个 NM_HOME 只能有一个活 daemon
同一时刻,一个NM_HOME只能有一个活 daemon 持有。实现见 internal/daemon/lock.go:
- 启动时(崩溃恢复运行前、socket 绑定前)对
~/.no-mistakes/daemon.lock获取排他的 OS 文件锁,持有到进程结束; - 第二个 daemon 试图针对同一 root 启动时,会以
"a no-mistakes daemon is already running for this NM_HOME"失败(附持有者的 PID 与启动时间),而不会偷走第一个 daemon 的 socket、也不会对它的活运行执行崩溃恢复; - 该锁由 OS 内核在进程退出/崩溃时自动释放(即使 SIGKILL),因此永远不会陈旧——这是它优于 PID 文件的地方;
- 作为独立的安全层,daemon 还拒绝在仍有进程应答的 socket 上重复绑定;只有可证明陈旧的 socket 文件(无监听者)才会被移除并重新绑定。
锁文件里还会写入诊断记录(lockHolderRecord:PID + StartedAt),供被拒的第二个 daemon 报告持有者。
Daemon 的核心职责
push 经 post-receive hook 到达后,daemon 按以下流程执行:
- 在
~/.no-mistakes/worktrees/<repoID>/<runID>/创建 detached worktree;若全局配置worktree_roots为该仓库指定了目录,则创建在<root>/<runID>。放置位置在运行创建时一次性解析并记录在运行上,之后编辑该设置不会重定向已存在的运行; - 在该 worktree 内启动 pipeline executor;
- 向所有已连接的 TUI 客户端流式推送事件,并向 AXI 客户端提供请求/响应状态;
- 运行结束(成功或失败)时清理 worktree,遵守下述保留规则。
worktree_roots 的语义与限制
默认情况下,运行 worktree 放在NM_HOME之外、所有 checkout 之外,因此 mise、direnv 等按路径祖先解析的目录级工具链配置不会渗透进来。把 checkout 指向自有目录后,其运行会创建在<value>/<runID>,继承该目录的配置。细节要点(见 global-config.md 的worktree_roots一节):
- 相对值在加载时被拒绝(daemon 的工作目录与配置无关);
- 目录归你所有:no-mistakes 从不枚举它,只操作运行记录点名的确切目录;清理、孤儿进程 reap、
no-mistakes eject都以此为准; - 每个 checkout 需要自己的 root;两个条目指向同一目录、同一 checkout 的两种拼写、root 等于 checkout 自身,都会在加载时被拒绝;
- 两个 daemon 启动时也无法工作的值会被拒绝:位于
NM_HOME内(与 no-mistakes 自身状态冲突,如 worktree 与日志目录混叠)以及位于任何 checkout 内(运行期间 checkout 变脏,分支同步会拒绝移动它); - 修改条目只影响新运行;每个运行记录其创建目录,重启后续跑、读 diff、清理、reap、eject 都沿用该运行实际所在的目录;
no-mistakes init --worktree-root <dir>会打印应添加到全局配置的确切条目(全局配置需手工维护,init 不会替你改写)。
protected_paths 拒绝时的保留
当仓库配置protected_paths存在未解决的拒绝(refusal)时,daemon 会跨 shutdown、被更新 push 取消以及崩溃恢复,保留 index 与工作文件——包括 trusted-config 加载失败导致运行无法恢复的情形。但该保留不削弱恢复校验,也不会让终止的运行保持活跃:孤儿进程清理与测试证据过期仍然适用。被拒绝步骤成功完成后,保留解除;操作员显式 skip 与 abort 维持既有清理行为。
有界事件交付:慢客户端拖不垮运行
事件投递是有界的:慢或卡死的客户端永远不会拖住运行。压力下 daemon 可能丢弃普通日志输出,但绝不会静默丢失状态变更——它会把这些合并成一个 gap 信号,TUI 与axi收到后重新读取权威运行状态。因此实时视图落后时会跳过日志行,但会收敛到运行的真实状态。连接断开后,TUI 以有界延迟重试并在重连时对账;若 daemon 持续不可用,则表面化连接错误而不是无限重试。
提示引导与子进程清理
pipeline agent 被提示(prompted)把有意的写入保持在 detached worktree 内,避免改动系统级状态(Homebrew 包、/Applications下的应用、全局工具配置)。这能减少意外的机器级副作用与 macOS App Management 弹窗,但它是提示引导(prompt steering),不是真正的沙箱。
执行步骤期间,daemon 拥有子进程清理职责:
- 配置的命令与一次性 agent 子进程在完成/失败/取消时作为进程树整体终止,避免泄漏的测试 worker、build watcher 或 dev server 跨运行累积;
- 每个进程先被请求退出,仅在数秒后仍在运行时才强制 kill;
- 进程仍可通过把自己脱离到独立 session 逃逸该树,因此运行结束时 daemon 还会终止该运行 worktree 内仍在存活的任何进程,再移除目录;
- 该清扫按工作目录界定范围:绝不触碰运行仍活跃的 worktree,也绝不可能触达在
~/.no-mistakes/worktrees/之外、或不在运行记录点名的 worktree root 中工作的进程。
并发 push 处理
同一分支上已有活跃运行时的再次 push,daemon 的行为是:
- 取消进行中的运行(原因:"cancelled: superseded by new push");
- 等待其结束;
- 用最新 push 启动新运行。
不同分支的 push 则并发运行。这正是 daemon 存在的另一原因:分支级协调在一个长驻进程内比在相互独立的 hook 调用之间更容易推理正确。
崩溃恢复
daemon 启动时,会检查被留在pending或running状态的运行(意味着 daemon 崩溃时它们正在活跃),恢复流程如下(源码入口 internal/daemon/daemon.go 的recoverOnStartup,按顺序调用reapOrphanedServers→migrateGateConfigs→ReconcileTerminalPRRuns→recoverableParkedRuns→RecoverStaleRunsExcept等):
- 终止型 PR 运行:先完成持久化 PR 状态已为
merged或closed的遗留活跃行(含其 CI 步骤),再做活跃运行恢复与 parked 运行规划; - parked 审批门:只恢复可完整验证的已记录 approval gate(worktree 与步骤历史可验证);不完整或歧义的活跃运行 fail closed;
- forge 配置重新校验:重建被恢复运行前,重新解析并校验已配置的仓库 forge profile,使恢复后的 provider 检查与 agent 使用与仓库绑定的身份模型,而不是持久化的凭据或环境里的活跃账号;
- parked CI 门复查:恢复前通过配置的 provider 重新检查持久化 PR URL——当前已 merged/closed 的 PR 完成这个陈旧 gate;open、未知或不可达的 PR 保持 parked。
protected_paths拒绝异常 阻止自动对账; - ci_monitor_interrupted:正在监控已创建 PR 的 CI 的运行被保留为
ci_monitor_interrupted而非判失败——PR 仍然 open,重启中断不算 pipeline 失败。该运行是终止态,永不恢复; - head 固定与恢复 ref:在失败任何其他陈旧活跃运行之前,校验其受管 worktree head,并把未发布的 descendant 钉在运行专属的恢复 ref 下,避免后续 rerun 或受管 custody 恢复落到陈旧 gate 分支;
- 其余陈旧运行:标记为
failed,消息 "daemon crashed during execution"; - 孤儿 managed agent server:reap 崩溃 daemon 或 setup wizard 遗留的受管 agent server;
- worktree 内残留进程:终止崩溃 daemon 留在"已无运行拥有"的 worktree 中的进程,使用与运行清理相同的工作目录界定,外加十分钟年龄下限——确保与启动并发开始的运行不会被误判为泄漏(见
orphanProcessMinAge,其默认值即procreap.DefaultMinAge); - 孤儿 worktree 目录:通过
git worktree remove --force移除——但绝不移除运行仍为pending/running的目录,也不移除存在未解决 refusal 需要保留的目录。~/.no-mistakes/worktrees/下清理终态运行的遗留物与无运行记录的目录;配置的 worktree root 中只清扫/移除运行记录点名的目录。ci_monitor_interrupted的 worktree 在 checkout 的 commit 与运行最后推送的 commit 不同时也会保留(可能含有未推送的 CI auto-fix commit); - gate 迁移:迁移权威仓库记录点名的 gate,加上严格的
<repoID>.git形状的遗留目录。变更未打戳候选前,先验证目录是裸仓库(不依赖当前目录或祖先 Git 发现);无关与畸形目录被拒绝,不做 hook 或 Git 变更。对验证过的遗留 gate,安装/刷新 no-mistakes 管理的 pre-receive 准入与 post-receive 通知 hook(自定义 pre-receive hook 保留在准入包装之后),启用 push-option 支持并重放 per-worktree hook-path 隔离; - 配置戳(stamp):只在整体迁移成功后记录内容版本化的 gate 配置戳;正常重启时直接从文件系统检查已打戳的 gate,不再重跑会变更 Git 的命令;
- 清除 parked-awaiting-agent 标记:使恢复后的失败运行不再显示为仍在等待
axi respond。
日志体系
| 日志文件 | 内容 | 轮转策略 |
|---|---|---|
~/.no-mistakes/logs/daemon.log | daemon 生命周期日志 | 当前 32 MiB + 3 个备份 |
~/.no-mistakes/logs/managed-server.log | 受管 Rovo Dev 与 OpenCode server 的 stdout/stderr | 当前 16 MiB + 2 个备份 |
~/.no-mistakes/logs/daemon-bootstrap.log | lifecycle logger 就绪前的输出与直接崩溃输出 | 当前 1 MiB + 2 个备份 |
~/.no-mistakes/logs/wizard-agent.log | setup wizard 捕获的受管 agent-server 输出 | — |
~/.no-mistakes/logs/<runID>/<step>.log | 每个 pipeline step 的输出 | — |
~/.no-mistakes/logs/cli.log | daemon stop/restart/update调用者审计(PID、父 PID、父命令行) | — |
备份使用.1作为最新保留文件。细节说明:
- daemon.log 的启动日志记录简洁的阶段耗时、gate 迁移计数,并且只有在 IPC 健康确认成功后才会出现最终的
daemon ready消息; - 只读 IPC 请求(health、运行状态读取)只在
debug级别出现;变更操作、流启动、生命周期迁移与失败请求保持在info/warn可见; - 每个 pipeline step 写
~/.no-mistakes/logs/<runID>/<step>.log,致命步骤错误也会追加进去——即使细节来自命令 stderr,step 日志也包含失败原因; - 日志级别由全局配置控制:
log_level: debug # debug | info | warn | error默认info,取值为debug、info、warn、error(见 global-config.md 的log_level一节)。internal/daemon/daemon.go 的initLogger会先用info初始化 lifecycle handler,再在加载全局配置后用配置级别重设,确保启动诊断始终保留。
关闭流程
no-mistakes daemon stop停止当前 daemon 进程,但不卸载受管服务。下一次no-mistakes daemon start、no-mistakes、init、attach、rerun或update会通过同一个服务管理器再次启动它;若服务不可用则启动 detached daemon。
关闭步骤:
- 取消所有活跃运行;
- 等待 goroutine 结束,上限 30 秒;
- 移除 PID 文件与 socket。
关闭前受"活跃运行守卫"保护:有 pending/running 运行时代默认拒绝,仅顶层调用者可传--force强行通过,而验证步骤 agent 派生的进程被递归遏制规则直接拒绝(internal/cli/daemon_cmd.go 的guardDestructiveDaemonLifecycle是这一守卫的实现)。同时,每次 stop/restart 的调用者身份都会写入cli.log,保证整个关闭链路可审计、可回溯。
小结
no-mistakes 的 daemon 设计围绕"push 要快、运行要稳、崩溃要能恢复"三个目标展开:hook 与 daemon 通过 IPC 解耦,daemon 用 OS 单例锁与 socket 绑定双重防护保证单一活实例,用 PID 发布与 IPC 健康检查分离"进程启动"与"就绪"两个状态,用受管服务 + detached 回退双路径保障跨 CLI 调用的可用性,并用一套分文件、分策略轮转的日志体系让每次生命周期变更与每次崩溃恢复都可观测、可审计。理解这些机制后,无论是日常daemon start/stop/status管理、update时的活跃运行守卫,还是排查"daemon crashed during execution"类型的恢复痕迹,你都能从文档与源码两个层面找到确切依据。
【免费下载链接】no-mistakesgit push no-mistakes项目地址: https://gitcode.com/GitHub_Trending/no/no-mistakes
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考