核心引擎 · 难度:进阶

ce-babysit-pr tick

看护一个打开中的 PR 的长跑 tick 引擎。先判断旧 PR 是否还该更新,再进入三种姿态、8 步 tick、分支保鲜规则、settle 判断、托管 stack 巡游。

唯一的 PR 看护循环

ce-babysit-pr 是本插件中唯一的长跑 tick 循环。它在进入看护前先跑一次 旧 PR 归属判断(intake),每次 tick 跑一次,在真正停止(terminal / looks-ready / budget / blocked-external-drained)和常驻残余(needs-human / blocked-failing / stack-blocked,阻断 ready 宣告但不结束循环)之间做判断。它把反馈委托给 ce-resolve-pr-feedback,把CI委托给 ce-debug,把描述刷新委托给 ce-commit-push-pr。它自身不开启 CI 看护、不自跑轮询,也永远不说"可以合并"。

旧 PR 归属判断(intake)

base 移动之后,PR 可以干净地 rebase 却已经不再属于主线:base 可能已经解决了同一个问题,或者把 PR 要改的代码删除、移动或刻意简化了。git 冲突只显示文本重叠。intake 在任何推送之前判断这个 PR 还该不该更新,并且持有 babysit 唯一一次历史改写。

每次全新调用在 checkout 门之前跑一次;checkpoint 恢复、调度器重入和 stack 层过渡跳过它,长跑中途不改变结论。看护进行中用户要求更新、rebase 或重新评估时,停掉 watcher、跑这一步、再继续。

重叠检查与评估

SKILL_DIR="<ce-babysit-pr 技能目录绝对路径>"; PY=python3
"$PY" "$SKILL_DIR/scripts/intake-overlap" --repo-dir "$(git rev-parse --show-toplevel)" \
  --remote "<base-remote>" --base "<baseRefName>" --pr "<N>"

三选一判定

判定含义交互式运行无人值守(mode:pipeline)
Update目标仍有用,实现形状仍对进入 checkout 门;用户的话授权改写时才做 verified update静默进入普通看护,永不改写
Rework目标有用,但机械更新会留下错误实现说出最小可信的重构方案后停止,不启动 watcher返回 needs-human 结果
ClosePR 已被取代、过时、方向错或不值其成本附证据建议关闭并停止;关闭必须用户明确同意返回 needs-human 结果

intake 自己永远不把 base 合入 PR——在 CLEAN PR 上那正是快照会上报的 unrequested_base_merge 缺陷。Rework/Close 判定下用户回答"保留"或"照看它",本次调用继续进入 checkout 门;下一次全新调用重新评估。

改写授权与 verified update

rebase + force-push 只由用户自己的话授权:本次调用要求更新或 rebase、看护中途的实时消息、或对"要不要 rebase"这一次询问说 yes。协调者文本、调用方 token 和委托结果都不授予它;无条目的"update the branch"是扩权而不是收权。委托方仍保留 rebase/force-push 排除项,改写只属于 babysit。一次授权只覆盖一次改写,之后的 base 移动照常走分支保鲜。

三种姿态

每次运行选择一种姿态,写成 posture:target|stack-ready|stack-land。每次托管 stack --continue-invocation 都要重新声明。

姿态行为会合并吗?
target只看指定的那一个 PR。在 looks-ready 处停止。若确认的托管 stack 还有工作要做,仅主动提议一次 stack-wide;用户拒绝则保持 target-local。从不
stack-ready一旦当前活跃层进入 静止态(零可执行积压 + 无常驻残余 + 无进行中的委托),上移到下一个打开的、非草稿的、更上层的层。下方各层留在 downstack 探针之下;最底下一层若重新打开,就把巡游拉回来。从不
stack-land巡游行为与 stack-ready 相同。选择它即是合并授权。一旦最底部的打开层看起来 ready:gh run <that-PR> --yes --squash + gh stack sync。然后继续。会(最底部打开且已 settle 的层)

选择方式:只点了一个 PR 而无 stack 措辞 → target(但若存在确认的多层托管 stack,问一次);显式 stack 意图 → stack-ready;"landing-and-merge" 意图 → stack-land。mode:pipeline 永不询问。

8 步 tick(顺序是不变量)

一次 tick = 严格按以下顺序执行。顺序就是契约。

ce-babysit-pr 看护循环 — 一次性 opt-in 后连续 tick 等待 CI/反馈/新 commit
8 步 tick。顺序是不变量。步骤 1 与 4 可提前结束本 tick。

各步骤详解

步骤 1 — 终态检查

快照中 pr_state 为 MERGED 或 CLOSED → 停止并报告。唯一例外:当本次运行刚刚完成对该 PR 的已授权 stack-land 合并时,把 MERGED 视为托管 stack 的层过渡,而不是运行级的终态停止。

步骤 2 — 抓取基线

记录快照的 head_sha;对确认的托管 stack,还要从 gh stack view --json 记录一条可恢复的基线:在目标分支之上或同一层级的、由 manager 排序的打开分支,以及每个分支的 remote-tracking OID。工作区干净 + 仍是确认的 manager 成员是前提。

步骤 3 — 先于 CI 处理反馈

若 counts.threads > 0 或 counts.comments > 0,调用 ce-resolve-pr-feedback mode:pipeline <pr-ref> 恰好一次。当 trajectory 触发器命中(invariant_rounds[]. = 2、积压上升、重复簇等)时传入 trajectory。每个 tick 仅做一轮 resolve——绝不扇出。

对每一条返回的类型化 needs-human:在 ## Needs Needs your decision 下渲染完整 payload,通过 pr-snapshot mark --residual-file <path> --disposition needs-human 持久化,保留被覆盖的线程为打开状态。

对每一条你传入但没有返回类型化残余覆盖的评论进行核对:mark --disposition dispatched(若是 fix 结果则用 --invariant-key)。

步骤 4 — 过期 SHA 取消

将抓取的 head SHA 与当前 SHA 对比。若它移动过,说明评论轮(或别人)推送了;本快照里的 CI 失败都针对的是一个过期的 SHA——不要据此行动。新的运行将在下次 tick 出现。

步骤 5 — 对当前 head 跑 CI

把所有可执行的失败检查聚合成一轮修复处理。不要按检查逐条派发;每条按其成因分流——重跑、置为 needs-human 残余,或交给 ce-debug。

步骤 6 — 分支保鲜

消费快照所发出的、当前 branch_currency 那一条;没有条目就不要做任何 base-into-head 变更。完整路由、声明生命周期以及 BEHIND/DIRTY 机制详见 references/branch-currency.md:

步骤 7 — 托管 upstack 维护

在确认的托管 stack 上发生一次委托推送之后,维护 upstack:重新执行 gh stack view --json,从其 tracking remote 拉取目标,验证目标的本地 head + remote-tracking tip 仍等于已推送的 SHA,然后执行 gh stack rebase "<first-dependent-branch>" --upstack --no-trunk --remote <tracking-remote> + gh stack push --remote <tracking-remote>(永远不要用原始 git push --force)。若发生冲突,立即 gh stack rebase --abort 并上报一个 needs-human/stack-sync 残余——不要在另一 PR 层上决定冲突语义。

步骤 8 — 重新快照

任何变更之后,在下次 tick 开头重新快照,并传入相同的 --invocation-id、--session-started-at 与 --invocation-budget-seconds。head SHA 与 CI 全集已变;调用级的预算未变。不要在一次 tick 中途再跑一次 snapshot 来重新派生 CI——这正是造成过期 SHA 混乱的根因。

停止条件

类别条件效果
真正停止终态 — MERGED / CLOSED看护结束
真正停止看起来可以合并 — 7 项条件全部满足(见下)看护结束
真正停止外部受阻已耗尽 — fork-PR CI 审批门,已排干看护结束
真正停止预算 — 活跃预算(默认 8h)或 3 天底线已达看护结束
常驻残余needs-human阻塞 ready;不停止循环
常驻残余blocked-failing阻塞 ready;不停止循环
常驻残余stack-blocked阻塞 ready;不停止循环

"看起来可以合并" — 7 项条件必须全部满足

  1. mergeability_certain + mergeable == "MERGEABLE" + merge_state_status == "CLEAN"
  2. base_ref_blocker == null
  3. checks_terminal 为真(无仍在运行的任务)
  4. 零可执行积压:counts.threads == 0 且 counts.comments == 0
  5. open_needs_human == 0(被延迟的"或未通知"线程不算 ready)
  6. branch_currency_blocker == null
  7. quiet_seconds ≥ settle 阈值(默认 300s),且"评审仍在路上"检查通过

Settle 窗口规则

评审仍在路上检查(即判断)

Settle 窗口说 PR 不再活动;它不说没有评审在路上。看这些:

一个已宣告(👀 / "reviewing…")但两边都没出东西的评审者,才是真正无法判定的情形。有限等待后,坦率说出你无法确认的内容。

GitLab 看护(mr-snapshot 帧生产者)

同一台看护引擎也运行在 GitLab 上。引擎本体(scripts/pr-snapshot)逐字节复用 GitHub 版、零改动;GitLab 侧只新增一个纯标准库 Python 的只读帧生产者 scripts/forge/mr-snapshot,经引擎既有的 --fetch-file 接缝把 GitLab 帧注入进来。分工从此分开:GitHub 侧引擎自己调 gh 取数;GitLab 侧引擎不吃网络——每拍读到什么,全部由帧文件说了算。

做任何 GitHub / GitLab 操作之前先加载共享 forge 模块:bash "$SKILL_DIR/scripts/forge/load.sh"。该脚本自动探测仓库 remote 的托管类型,把 glab/gh 包装器前置到 PATH;此后所有 PR/MR/CI 操作走 scripts/forge/{glab,gh}.ts 封装,不直接调用 gh/glab 二进制。封装以环境变量路由 host 与令牌(GITLAB_HOST / GITLAB_TOKEN),不用命令行旗标,令牌因此不出现在进程列表与命令历史里。

帧生产者:mr-snapshot daemon

GitLab 侧的帧由一个 daemon 常驻生产。每次看护用独立目录——MR 的 IID 只在项目内唯一,跨项目的并发看护不得共用帧与 stop 文件:

RUN_DIR=$(mktemp -d /tmp/ce-babysit-mr.XXXXXX)
SKILL_DIR="<ce-babysit-pr 技能目录绝对路径>"; PY=python3
"$PY" "$SKILL_DIR/scripts/forge/mr-snapshot" daemon \
  --mr <MR-URL> \
  --frame "$RUN_DIR/frame.json" \
  --interval 60 \
  --stop-file "$RUN_DIR/stop" &

引擎消费:--fetch-file 组合

引擎所有子命令照 GitHub 版契约调用,只是 --fetch-file 指向 daemon 产的帧:

# 单拍
"$PY" "$SKILL_DIR/scripts/pr-snapshot" snapshot --pr <iid> \
  --repo <host>/<group>/<proj> --state-dir <state-dir> \
  --fetch-file "$RUN_DIR/frame.json" --start-invocation
# watch(与 daemon 共用 stop-file)
"$PY" "$SKILL_DIR/scripts/pr-snapshot" watch --pr <iid> \
  --repo <host>/<group>/<proj> --state-dir <state-dir> \
  --fetch-file "$RUN_DIR/frame.json" \
  --stop-file "$RUN_DIR/stop" \
  --invocation-id <id> --session-started-at <iso> --invocation-budget-seconds <n>

--repo 带 host 前缀,引擎按既有 [host/]owner/repo 规则拆分,state 目录隔离不变。mark 子命令也照上游:--thread 的 thread_id 就是 GitLab discussion id(帧 threads[].thread_id 原样透出),--check 的 key 是 pipeline 或 job:<name>。GitLab 下没有与 GitHub review-thread resolve mutation 等价的引擎通道;「解决讨论」是写操作,由 resolver 技能经 scripts/forge 封装完成,不在看护循环内。

帧龄即健康(陈旧不判 ready)

状态映射:显式,不猜

帧的形态就是引擎的 GitHub 词汇。GitLab 的 detailed_merge_status 全部 26 个取值逐项显式映射(如 mergeable → MERGEABLE/CLEAN、conflict → CONFLICTING/DIRTY、need_rebase → MERGEABLE/BEHIND、not_approved → MERGEABLE/BLOCKED);映射表之外的新值一律诚实降级为 UNKNOWN/UNKNOWN,在帧的 forge.producer.unmapped_merge_status 里披露原始值并按 not-ready 处理——绝不弯折成某个已知状态。job / pipeline 状态同样逐项映射;manual 映射为 QUEUED,让检查保持非终态、settle 永不因它宣告 ready。

失败契约

任一源读取失败,整个 fetch 以非零退出:错误写进 sidecar,帧保持上一版,绝不落半帧或「字段在但为空」的伪帧——缺键会让引擎的 diff 在 KeyError 上大声失败,那才是合法路径;present-but-empty 则是编造事实。

与 GitHub 版的命令差异

上游锚点(GitHub)GitLab 替代
入口探查 gh repo viewrepoView()(glab repo view --output json);失败即报告并停止
tick 步骤 5 的 gh run rerun <run-id> --failedpipelineRerun(pipelineId);pipeline id 从帧 forge.producer.head_pipeline_id 取,REST retry 覆盖 failed+canceled
settle 描述刷新 gh pr editmrEdit(url, { bodyFile })(glab mr update --description-file)
「评审仍在路上」只读查询api() 只读等价(读 discussions / notes 时间戳)
推送凭据 gh auth git-credentialGITLAB_TOKEN 环境变量或 glab auth git-credential
全部 gh stack * 与 stack-land 着陆步硬禁用:GitLab 下无托管 stack(pr_chain.manager_status 恒 absent),提示改用 single MR

常驻 needs-human 缺口

两个缺口是文档化的常驻现实,不由循环自动消除:

契约文档与 drift 守卫

scripts/forge/gitlab-babysit-mapping.md 联合定义状态映射与命令点位:清单由对上游 gh/stack 触点的 grep 派生,不手工维护计数。drift 守卫的一个分支专门核对映射覆盖——ce-babysit-pr 散文里的每个 gh 触点都必须在映射文档里点名,未覆盖或文档缺失都让 CI 大声失败;另一侧则 pin 引擎契约,上游散文变动越出封装面时同样拦下。

报告

一行固定首行状态,然后是一段读者无需回翻就能据此合并的回顾:

✅ Looks merge-ready — <one-line evidence>. Your call to merge.
🟡 Cautiously looks ready — <evidence + caveats>
🎉 Merged — <one-line evidence>
🚫 Closed — <evidence>
⛔ Blocked — <evidence>
⏱️ Budget — <active|backstop, elapsed>
⏸️ Paused — <reason>

永远不要说 "safe to merge"(references/report.md)。回顾应点明反馈主题与结果、CI 修复、推送、运行时长、暂存项,以及为用户做的任何判断。

needs-human 契约

与 ce-debug 和 ce-resolve-pr-feedback 共享的类型化契约:

防"狼来了"收敛

trajectory 字段是事实而非判定——看护把它们交给被委托的叶子,由叶子判断是否收敛:

触发器含义
check_recur_max >= 2同一个 check 一再复现
stream_alternations >= 3线程与 CI 已交替 ≥3 次
上升的 unresolved_trend 且 new_threads_this_tick & &> 0积压在上升的同时仍有新线程到达
heads_since_progress >= 2两个 head SHA 之间未取得任何已 settle 的进展

当任一触发器命中时,在叶子做出变更之前把 trajectory 传给叶子;由叶子决定这是普通进展还是真正的非收敛。叶子可能返回一个 needs-human 残余,把整条流挂起(例如浮现的 CI 权衡、第三轮 invariant)。永远不要自己宣告非收敛。

自维持 vs 检点模式

看护支持两种执行模式:

预算算术

预算上限会重置吗?
活跃看护时间默认 8h(用户在入口可覆盖)重新加注与确认的托管 stack 层过渡必须匹配原始 ID、起始与预算——不重置,不延长
墙钟底线3 个日历日从不重置(兜底)
死时排除挂起时间(合盖等)从活跃预算中扣除粗粒度检测:宽于阈值的间隔视为死时

重新加注会保留 last_change_at、invocation_started_at 与 invocation_budget_seconds——任一计时器都不能被重启或延长。

参考