Timers
timer 模板systemd/[email protected] 部署为 N 个实例:
[email protected] 到 [email protected](N = max_concurrency,上限
MAX_RUNNER_INSTANCES = 5——Issue #827)。每个实例每 5 分钟触发
一次,全天 24 小时(OnCalendar=*-*-* *:00/5、AccuracySec=30s、
Persistent=false——错过的 tick 被丢弃,从不排队),并各自启动自己的
service 实例([email protected] → [email protected],
[email protected] → [email protected],……),所以可以同时运行
N 个独立的 Runner 实例。实例不会同时触发:模板设置了
RandomizedDelaySec=240 与 FixedRandomDelay=true,systemd 按
machine id、用户和 timer 名称推导出各自的延迟(systemd.timer(5)),
在该 timer 的多次触发和 manager 重启之间保持稳定。启动前的 drift
自愈会修复已安装的 unit,但绝不改变 timer 的 enable 状态——operator
禁用的 timer 保持禁用。每个 tick 的 service 实例:
-
先同步 engine source(
ExecStartPre,在 Runner 进程外)。两个 步骤,都包在一个短生命周期的flock(共享状态目录下的base-sync.lock,Python 侧同步取同一把锁)里;两个实例可能在同一 tick 运行ExecStartPre,并且自 Issue #171 起,所有更新共享 remote-tracking ref 的 fetch 都走同一把锁:Runner 侧的freeze_base/verify_pr/merge_gate/confirm_mergedfetch 统一经fetch_base_ref(先取锁 → fetch → 释放,锁或 fetch 出错即 fail fast),implement/review 两个 prompt 指示 Pi 用flock <BASE_SYNC_LOCK> git fetch origin <base_branch>执行 base 新鲜度 fetch(锁路径从配置的repo_dir渲染进两个 prompt)。两个 并发 Runner(或 Runner 与 Pi 会话)因此不再争抢共享 ref(不会出现cannot lock ref ... is at <X> but expected <Y>失败)。两个步骤 作用的 checkout 都是部署 home——{{ORBI_REPO_DIR}}占位符在 安装时渲染的是deploy_home路径(缺省repo_dir),所以外部单仓 库模式(Issue #330)下同步、锁和 CLI 自愈全部作用于 orbi 源码 checkout,绝不作用于交付 checkout(用户仓库 X)。- 步骤 1 —— CLI 自愈(Issue #248):探测已安装的 console
script(
orbi --version当且仅当包可导入时成功),探测失败时在 同一把base-sync.lockflock 下执行精确的 editable 强制重装 (uv tool install --force --reinstall --editable --python <interpreter> <repo_dir>——<interpreter>遵循兼容选取规则: 系统 Python 满足>= 3.14下限时为python3,否则为 uv 自动 供应的3.14,与orbi setup同一规则,Issue #861)。这是 console script 连import orbi都失败时唯一能动手的地方——#248 现场:#168 的 src 布局迁移让已安装的 editable finder 过期,而 #158 的 Runner 内刷新(在 Runner 内部运行)根本不可达。它排在同步之前, 因为同步子命令需要可用的 CLI。健康的 CLI 退出 0,其余什么都不做 (不逐 tick 重装);重装失败则 preflight 失败、service 不启动 (fail fast,无回退)。 - 步骤 2 —— engine source 同步(Issue #535):
orbi sync-engine-source把部署 home 带到配置的 engine source 更新 通道。通道是 deploy homeorbi.toml里的 host/deploy-only 配置 键engine_source_track(仓库的.github/orbi.toml永远 不允许携带它;模式与失败行见下文 engine source 更新通道)。缺省main通道保持 #535 之前的精确行为:git fetch --no-auto-maintenance origin main && git merge --ff-only origin/main——fast-forward 到origin/main。每次 fetch 都带--no-auto-maintenance,避免 Git 子进程脱离 preflight 和共享锁的生命周期。checkout 不干净、fetch 失败、tag/SHA/branch 缺失、无法 fast-forward、或任何无法对照解析 结果验证的状态都会让 preflight 失败:service 不启动,原因写入 systemd journal(fail closed)。每种失败各写一条结构化、可操作的 行(deploy_home_dirty files=... fix=...、engine_source_unresolved reason=...、engine_source_not_fast_forwardable ...);成功时同步写一条结构化engine_source_synced engine_source_track=... resolved=... head=...行。通道不是缺省的main(branch:/release/tag:/sha:)时, Runner 也会跳过 merge 之后对部署 checkout 的 fast-forward:通道 拥有该 checkout,下一个 tick 的步骤 2 会重新施加它。
- 步骤 1 —— CLI 自愈(Issue #248):探测已安装的 console
script(
-
运行一个 tick:恢复一个已打开的 PR(review/fix/merge 步骤),或
领取一个
ai-readyIssue,然后退出。一个 tick 至多执行一步交付,绝不 sleep 等待 GitHub(Issue #788):implement tick 在 PR 打开后结束并释放 槽位;review tick 是唯一在 Pi 运行期间持有槽位的阶段;CI pending 或 mergeable UNKNOWN 记一行 journal 即返回——下一 tick 重读(waiting 原语)。在任何 slot 或领取之前,tick 还 运行启动前 git transport 检查(Issue #114、#580):部署 checkout 的originremote 必须匹配配置的传输方式(orbi.tomlgit_transport:ssh——默认,[email protected]:owner/repo.git;或https——https://github.com/<repo>.git,凭据走ghcredential helper),且对第一个配置 source repo 的git ls-remote <expected-url>退出 0(传输可达且已认证)。 传输损坏记录结构化transport_check_failed ... reason=...行并让 启动失败——不取 slot、不领取、不改标签,没有回退(GitHub API 操作永远留在ghtoken 上)。同样在任何 slot 或领取之前,tick 运行 runner 执行代码新鲜度门禁(Issue #525):证明本进程执行 的代码与配置的 engine source 通道一致(Issue #535:engine_source_track,缺省origin/main——绝不是交付目标的base_branch)——见下文 Runner 执行代码新鲜度与防线分层。
engine source 更新通道
Issue #535 用一条显式通道取代了硬编码的”永远跟随origin/main”。
host/deploy-only 配置键 engine_source_track 位于 deploy home 的
orbi.toml——仓库的 .github/orbi.toml 里绝不允许出现(runner 在那里
按 host-only 键拒绝它):
release与tag:——跟随与锁定:release(别名stable)跟随 最新已发布版本,新 tag 发布后自动跟进——只在发版时变化(main每次 提交都变)。tag:<name>锁死一个精确版本,不改值就永不移动——版本 变更永远是一次有意操作;要求部署保持可复现时用它锁定,回滚就是把 上一个 tag 改回去。- 安装与更新:
install.sh新安装复制src/orbi/example_config.toml,其中 携带engine_source_track = "release"——新部署跟随最新 release,而 不是 main 的中间态。Orbi 自有 dogfood 部署显式固定main通道。 通道值在下一次 service 启动时生效(ExecStartPre 同步施加它);没有 守护进程,也不会重启正在运行的 Runner。 - 回滚就是把 track 改回旧的
tag:/sha:值(或release/main)——git 状态是唯一的存储,没有别的状态要清理。 - fail closed(service 不启动,每行都带
fix=):deploy_home_dirty files=... fix=...——tracked 本地编辑会阻塞 同步;用git -C <deploy_home> stash && systemctl --user start [email protected]保存后重试(之后stash pop),或用git -C <deploy_home> checkout -- .丢弃。engine_source_unresolved reason=tag_not_found|tag_conflict|sha_not_found|branch_ref_missing|no_official_release_tag—— 通道无法解析(先 fetch、核对名字,或发布 release tag)。engine_source_not_fast_forwardable——分支在本地已分叉(部署 home 永远不应携带本地 commit)。engine_source_unverified——HEAD 无法证明等于解析出的 commit。
- 成功证据:每次启动一条结构化
engine_source_synced engine_source_track=... resolved=... head=...journal 行。 - doctor 只读上报通道:
engine_source: track=... resolved=... head=...(通道本地无法解析时输出带同样 reason 的FAILED行)。 - GitHub 拒绝按未广播的 SHA fetch(
not our ref),所以sha:通道 一次性 fetch 全部已广播 ref,再在本地验证 commit;只能从未合并的 PR 分支可达的 SHA 因此 fail closed——请锁定 tag/release。 - 交付目标不受影响:
base_branch继续管辖任务 worktree 与交付 PR (engine/delivery 的语义隔离是 Issue #543 的主题)。
Runner 执行代码新鲜度与防线分层
2026-09-07 事故:editable install 解析到了一个旧 issue 的 worktree,而ExecStartPre preflight 一直把部署 home 同步得很好——Runner 执行着旧
代码,每笔交付看起来却都是新的(base_sha 新鲜、引擎陈旧),一个早已
修复的 bug 在生产复现。“checkout 被同步了”和”进程执行的是那个
checkout”是两个不同的事实,所以 tick 在任何 slot 或领取之前必须证明
第二件(Issue #525):
- editable 安装(官方部署形态):门禁解析运行中进程的 import
源(
orbi.__file__→src/orbi包目录,绝不是 WorkingDirectory), 用git rev-parse --show-toplevel定位其所在 checkout,并要求该 checkout 的HEAD等于配置的 engine source 通道的期望 commit:main/branch:通道取已 fetch 的refs/remotes/origin/<branch>头,release/tag:通道取解析出的 release/tag commit,sha:通道 取精确 SHA。worktree 与部署 home 共享 refs,所以事故场景(install 绑到旧 worktree)在这里被拒绝。 - 非 editable 安装(uv tool / PyPI 拷贝):安装的发行版本
(
importlib.metadata)不得旧于通道对应的 release tag——跟踪分支 ref 上可达的最新 tag,或锁定的release/tag:版本本身。sha:锁无法映射到版本,按不可验证 fail closed。相等或更新即通过。 - 全部是本地 git 读(
rev-parse/describe/tag --list): 已 fetch refs/tags 的新鲜度由ExecStartPre同步供给,checkout 一 致时零网络请求。任何无法证明新鲜的状态(origin ref 缺失、无 release tag、import 源无法定位)都以同样方式失败并带reason=字段——绝不静默放行。 - 落后或无法证明时输出结构化
runner_source_stale install=... source=... engine_source_track=... head=... origin_main=... allowed=false fix=...行并 fail fast:不取 slot、不领取、不改标签。fix是对 deploy home 的精确 editable 强制 重装命令。allow_stale_runner: true(getting-started)把同一行降级 为告警,供离线/受限网络机器使用——绝不跳过检查。
unit_drift 对比恰恰运行在可能被冻结的进程里。因此分层是严格的,
从外到内:
ExecStartPrepreflight(CLI probe 自愈 + engine source 同步, Issue #535)是最外层防线:由 systemd 在 Runner 进程存在之前 执行——probe 步骤是包裹已安装 console script 的纯 shell,探测失败 就从 checkout 重装它,因此连import orbi都失败的 CLI 也会先在 这里被修复。保持已安装 unit 跟上模板(install-units与启动前检查 的unit_drift自愈),这道 preflight 就永远存在。- runner 执行代码新鲜度门禁(本节,Issue #525)是 in-runner 不变量:从它发布的版本起,代码陈旧的 runner 拒绝领取任务,而不是 无声地失败。
暂停、迁移或移除部署
部署不只有 editableuv tool:用户级 systemd unit 会继续从配置的
deploy_home 启动 timer。迁移 checkout 时,必须先停止旧部署,再安装
新部署。deploy_home 应放在持久目录(例如 home 目录下),不要放在
/tmp 或其他重启后可能消失的临时目录。
暂停部署
只想暂停 Orbi、保留所有文件时,先停用并停止 setup 可能启用的每个 timer 实例:orbi.toml 或
<repo_dir>/.orbi/ 状态。要恢复同一套部署,保留已安装的 unit,重新启用
需要的 timer(或执行 orbi setup --config <deploy_home>/orbi.toml)。
迁移到新的 deploy home
- 在旧部署上执行上面的暂停命令。必须先做这一步,再删除或改名旧目录; 否则启用中的 timer 会继续唤醒并持续记录失败日志。
-
确认新的
deploy_home是持久目录,在那里 clone 或移动 Orbi checkout, 并更新其中的orbi.toml。保留repo_dir指向交付 checkout;改变deploy_home不应移动或删除该 checkout、它的 worktree 或.orbi/状态。 -
在新的 deploy home 安装 unit 并启用所需数量的 timer:
max_concurrency = 1会启用[email protected];max_concurrency = 2还会 启用[email protected]。用下面的命令确认结果:systemctl --user list-timers 'orbi@*.timer'。 -
如果新部署使用与旧部署相同的
unit_name,setup 已经替换了 unit 文件;不要再删除它们,否则也会删除新部署的 unit。如果明确改用了 新的unit_name,只有在新部署验证正常后才能删除旧 unit。先按上面的 步骤停用旧 timer,再执行systemctl --user daemon-reload。除非已经 单独确认数据可以丢弃,否则不要把 repo、worktree 或.orbi/目录作为 unit 清理的一部分删除。
unit_name,把下面命令中的 orbi@1/2 换成对应名称(例如
orbi-website@1/2)。
完全移除本机上的 Orbi
先暂停部署。默认 unit 名称下,只有在所有可能的 timer(@1..@N, N = max_concurrency)都停用后,才删除已安装的模板:repo_dir、任务 worktree、orbi.toml 或
<repo_dir>/.orbi/;这些内容只有在确认不再需要后才另行删除。如果本机以后
完全不再使用 Orbi CLI,再把卸载 editable tool 作为单独的最后一步(可选):
uv tool uninstall orbi-cli(tool 名跟随 PyPI 发布包名 orbi-cli,
Issue #874)不属于暂停或迁移步骤,也不会清理 systemd unit 或
仓库状态。没有 orbi uninstall 或 orbi teardown 命令。
Unit 模板变更后(Issue #131、#142)
unit 模板(systemd/[email protected] 和 systemd/[email protected])
是部署配置,不是普通代码——但模板变更合并后不需要任何人工步骤。模板
位于部署 home(deploy_home,缺省 repo_dir——Issue #330),漂移检查
对比的是这些模板与已安装 unit。下一次 timer 触发时 ExecStartPre
同步部署 home checkout,启动前
unit_drift 检查自动自愈:用同一个幂等安装(把两个模板复制到用户
unit 目录、daemon-reload、按 max_concurrency enable 或 disable timer
实例——从不启动、停止或重启运行中的 Runner)、再用同一个哈希检查复核,tick 继续,每个 unit
记录一行结构化 unit_drift result=auto_synced(before/after sha256、部署
commit)。不再出现“每 5 分钟重复同一个 unit_drift 错误直到人工
介入”的循环(#131/#140 实例:每次启动都因 unit_drift 失败,直到
人工运行同步命令)。同一个安装还会一次性迁移 #149 之前的非模板
unit(orbi.service / orbi.timer):停掉旧 timer(停
的是 timer,绝不停 service)并删除旧文件,旧单实例调度不会再拉起旧
service。
orbi install-units --config orbi.toml 保留为手工入口
(首次 setup、需要立即同步时)。自愈后仍漂移(安装步骤失败、模板
缺失、复核前 unit 再次漂移)时,启动仍然 fail fast(结构化
unit_drift 行、非零退出、不取 slot、不领取 Issue):哈希校验和
哨兵边界不变。
日志(journal)
journal 是本地记录。一个 run 的每行都以 run id 前缀[<run_id>] 开头,
一条 grep 还原完整时间线:
key=value 行:
run_start/run_end— 开始时的完整现场(branch、worktree、 session 文件),结束时的结果(PR URL、commit);activity/heartbeat/model_wait/resumed— session 运行 期间的实时 Pi 活动(phase、最近动作、elapsed、idle)。首个响应到达 前phase是启动子阶段:session_pending(Pi 已启动但还没创建 session 文件)和request_pending(session 已存在、首个响应还没 到);首个响应到达后按原规则用工具 phase(pr、bash…);process_spawned/provider_config_loaded/session_created/first_request_started/first_response_received— 启动阶段行 (Issue #176):每个阶段一行、按顺序,每行带 issue、role、Pi 实际 选择的 provider/model(session 的model_change记录出现前为-)、距 spawn 的 elapsed(process_spawned另带 pid)。provider_config_loaded由 Runner 在 spawn 前记录(provider 文件已 加载并物化;未配置时用 Pi 自己的 agent dir);其余四行由 streamer 在 session JSONL 跨过每个里程碑时记录(session 记录、第一条 user 消息——首个请求发出——第一条 assistant 消息——首个响应到达)。卡在starting的 run 只靠这些行加 progress 评论的phase就能定位到 启动阶段;只记录标识符——从不记录 key、prompt 或模型输出;startup_failed— run 在首个响应之前失败时的一行(Issue #176): 说明启动卡在哪(session_created=true|false、first_request=true|false)加可区分的reason=——session_not_created(Pi 退出前没创建 session 文件)、no_first_request(session 存在但没有首个请求)、provider_rate_limited(provider 429——Issue #321)、auth_failure/network_timeout(从 Pi 的 stderr 分类——只记类别、 从不记文本)、pi_exit_<N>(提前退出码)、timeout(run deadline)、first_response_timeout(冻结的model_wait在响应前被杀——挂死的 首个请求)、model_wait_swallowed和idle_recovery_stale(既有 kill 路径)。既有的run_failed现场行和抛出的失败不变——startup_failed是额外的可观测性,不是新的失败路径;resume_continue— 恢复的 run 继续中断的工作(Issue #219)时的一次 INFO:worktree 的 run state 文件(.orbi/run-state.json) 验证了同一个 run(issue 编号 + 仓库名——仓库改名后 slug 变了也能 找到旧 worktree),且 worktree 带有未提交改动或之前的 session; 行带 worktree、changed_files(未提交路径)、reused_runs(之前的 session 文件数)和previous_session(session id 或-); 新 session 在 prompt 里收到 resume context(继续既有工作——从不 重做、从不丢弃)。同一个 run 无法验证(state 文件缺失或损坏)时 行是resume_continue_failed:fail fast,Issue 进入ai-blocked并带原因——从不静默 fresh 重做;pi_idle— 超过 300 秒(PI_IDLE_WARN_SECONDS=300)没有 model/session 活动且模型不期望回复时的一次 WARNING;活跃的慢模型 (model_wait)从不告警;model_wait_swallowed— 冻结的model_wait被/slots探针确认 吞请求(Issue #233)时的一次 WARNING + 杀掉 Pi 会话:model_wait_probe_url端点报告所有 slot 持续model_wait_probe_seconds宽限(默认 60 秒)都空闲 (is_processing=false)——请求被上游接受但从未被调度进 slot, 模型进程活着、连接 ESTABLISHED,但没有东西在生成(#231 现场); 行带 issue、role、idle 秒数、探针宽限、action=kill_pi、session、 run id、upstream_alive=true|false和reason=swallowed_model_request。它快速触发(远早于model_wait_dead_seconds),吞请求约 1 分钟就能恢复而不是约 30 分钟。探针是纯旁路(Issue #79):探测失败视为无证据(无此行,model_wait_dead兜底仍生效),有 slot 在处理会重置空闲窗口(慢 模型不是吞请求);未设置model_wait_probe_url时禁用;model_wait_dead— 冻结的model_wait(最近一条事件是 tool result,无新事件)超过配置的model_wait_dead_seconds(默认 1800 秒,PI_MODEL_WAIT_DEAD_SECONDS,Issue #228)时的一次 WARNING- 杀掉 Pi 会话:模型请求挂死——连接还活着、模型服务进程还活着都
只是行上的证据(
upstream_alive=true|false),不是豁免(进程活着 ≠ 在回话,Issue #218);行带 issue、role、idle 秒数、实际配置的 阈值、action=kill_pi、session、run id 和reason=hung_model_request。 阈值度量的是完整 session 事件之间的静默(Pi 不向 JSONL 写 token 级进度),不是 token 级模型进度——持续追加事件的慢生成永不 触发(慢本地模型,如 27B Q4 GGUF 配 llama-server、请求超时 1200 秒:RX 7900 XTX 全层上 GPU 时 12K 上下文约 57 tokens/s,部分上 GPU 或纯 CPU 时远低于 20 tokens/s;默认配置下慢端也能存活超过 10 分钟的完整消息);
- 杀掉 Pi 会话:模型请求挂死——连接还活着、模型服务进程还活着都
只是行上的证据(
pi_idle_wait— idle-stall 恢复的宽限行(Issue #181):仍在 deadline 内的 coreutilstimeout <seconds> ...包裹是合法的长命令, 不是 hang。Runner 每次 stall 记一条pi_idle_wait行(run id、 pid、cmdline、deadline),并在 progress 评论里报告recovery=wait; 先过一个完整 idle 窗口的宽限再升级,之后每个窗口重新评估。等待期间 session-kill 耗尽计数同样暂停(Issue #1089):kill 的边界是包裹自身 的 deadline——自等待开始起以PI_IDLE_WAIT_MAX_SECONDS(默认 3600 秒)封顶,超过上限的声明 deadline 照常升级——而不是PI_IDLE_RECOVERY_CYCLES个 idle 窗口;pi_retry_429— provider 限流重试每次一次 WARNING(Issue #321): Pi 非零退出且 stderr 带 429 标记(429/quota/RESOURCE_EXHAUSTED/retry in)——这是瞬时限流,不是任务失败。 同一个 Pi 会话在同一个 run 里退避后重新拉起:优先用 429 响应自带的 重试提示(retry in Ns/retryDelay: Ns),没有就默认递增 (30 秒翻倍),两者都封顶 5 分钟。行带 run id、issue、role、attempt、next_retry_in、重试上限limit(5)和 session; 等待期间持有 slot(本 run 等待时不认领新 Issue)。attempt计数是 RUN 的,不是进程的(Issue #698):它持久化在工作树的.orbi/运行目录里,跨 runner 重启持续累加,上限才能真正触达终态。5 次退避 重试仍以 429 退出(连续 6 次 429 退出)之后,交付在每个阶段都是 终态——ai-blocked,绝不走可恢复续跑——失败评论带累计次数与修复 动作(配额窗口恢复后把 Issue 重新标为ai-ready;终态会清掉持久化 计数,重排的尝试从全新预算开始),run_failed现场标记reason=provider_rate_limited。长期额度耗尽不在这里处理 (#313 的轮换语义接管);run_failed— 完整现场加原因(pi_exit_N、timeout_...s,或model_wait_swallowed_idle_...s——/slots探针确认所有 slot 持续 空闲(Issue #233)Runner 快速杀掉 Pi,model_wait_dead_stale_...s——冻结的model_wait超过配置的model_wait_dead_seconds(默认 1800 秒,Issue #228)判定模型请求挂死,Runner 杀掉 Pi,idle_recovery_stale_...s——session 连续PI_IDLE_RECOVERY_CYCLES(默认 3)个 idle 窗口保持空闲、Runner 杀掉 Pi,或provider_rate_limited——provider 429 在本轮全部退避重试后仍失败 (Issue #321))。
事件参考
每个结构化 journal 事件都经由唯一发射点src/orbi/journal.py 的
journal.event() 发出;事件名即该文件中的 JOURNAL_EVENTS 注册表。
本表与注册表完全一致——双向强制(名字只出现在代码或只出现在文档都会
让 tests/test_journal_events.py 变红),Prometheus exporter 的
KNOWN_KINDS 是它的子集:
idle 告警和模型请求挂死 kill 是日志/健康阈值——它们不是每 5 分钟
调度,也不是业务任务 timeout。
CLI(orbi)
正式入口是安装后的 orbi console script,以 editable
uv tool 安装——为什么必须 editable(ExecStartPre checkout 同步、
免重装的源码变更、打包输入刷新)定义在快速开始
第 2 步。orbi doctor 报告一致性(cli_source: clean 或
cli_source: DRIFT + 结构化 cli_source_drift 行:实际导入路径、
期望 deploy_home、精确的 editable 重装命令),orbi setup 校验或
重装。期望来源是部署 home(deploy_home,缺省 repo_dir,
Issue #330):外部单仓库模式下 cli_source clean 表示 editable 安装从 orbi
checkout 导入,即使交付 checkout 是外部仓库 X。checkout 根目录没有 orbi.py(与 package 同名的 flat
文件会遮蔽已安装 package);直接执行入口 python3 -m orbi.cli
运行与 console script 完全相同的代码,保留为开发/兼容路径。
--config 或 ORBI_CONFIG 环境变量接收配置
(默认 orbi.toml)。status 和 session 是调试附件——
journal 和 GitHub 仍是正常可观测路径。
跨部署的 task pool 重叠(Issue #1519)
Runner 没有跨部署锁:两个不同命名的部署若服务同一个 GitHub 仓库, 会争抢同一批 Issue。orbi doctor 把本部署的 source_repos 与当前
用户已安装 Orbi 单元中发现的其他部署比对(--installed-dir 可覆盖
标准单元目录)。比对只读取那些配置里的 source_repos——从不加载
它们的 .orbi/env、解析其 provider 密钥,也不发起 GitHub/模型调用。
配置路径会做规范化:重复的 service/timer 实例、符号链接别名以及本
部署自己的配置都算作一个部署;GitHub 仓库标识符按大小写不敏感
比较:
source_pool: healthy deployments=N—— 没有别的部署共享该 pool。source_pool: FAILED repo=<owner/name> this_config=... other_config=... fix=...—— 两个部署服务同一个 task pool;为该 pool 只选一个部署,并用它的max_concurrency控制并发。source_pool: INCOMPLETE config=... reason=... fix=...—— 发现的配置 缺失、不可读或格式错误。修复或移除该单元的ORBI_CONFIG引用; pool 不可读时 doctor 绝不报告 all-clear。
人工推进 active milestone
orbi milestone set <title>(Issue #895、#1306)是 milestone 生命周期
中的人工命令:当 auto_next_milestone = false 让推进进入待确认状态,
或任何一次版本切换应当是运维的明确动作时,它把 active_milestone
改为给定的 GitHub Milestone 标题——不再手工编辑文件。新值落在
Runner 实际读取它的地方,优先级是仓库策略,然后主机配置:仓库
默认分支上的 .github/orbi.toml 声明了 active_milestone 时
(Runner 按按键分域读取的覆盖值),命令用 contents PUT 重写该键;
否则重写主机 orbi.toml 的那一行。只有 active_milestone 这一行会被
重写:注释、空行和其他所有字段保持逐字节不变。该标题必须在唯一
配置的 source repo 上恰好对应一个 Milestone(GitHub Milestone 标题
不唯一,exact title 重名是硬错误)。
wrote: 行指明写入目标:repo policy .github/orbi.toml(对仓库默认
分支做 contents PUT)或 host config /path/to/orbi.toml。Milestone
列表读取不再是唯一的网络调用:命令还会读取仓库策略,且当策略
声明了该键时提交重写后的 blob,绝不触碰主机配置。
ORBI_ACTIVE_MILESTONE Actions variable 由 Runner 下一 tick 的
preflight 发布(bypass 契约),因此命令成功不代表 variable 已同步:
用输出里给出的 gh api 探针验证。所有失败都以非零退出,在 stderr
输出一行结构化 milestone_set_failed reason=... fix=...,目标保持
原样——目标不存在、exact title 重名、GitHub API 或权限失败、两个
来源都没有 active_milestone 行(该字段从不被隐式创建)、策略 PUT
失败、文件不可写,全部带实际原因快速失败:
auto_next_milestone 为 true(默认值)时,已关闭的目标会被拒绝
(Issue #933):空闲路径会在下一个 tick 把已关闭的 active_milestone
自动推进到最新的开放版本,接受它等于报告一个引擎一 tick 后就会撤销的
成功。修复方式:重新打开该 Milestone,或先设置
auto_next_milestone = false。auto_next_milestone = false 时关闭的
目标仍然允许——那正是本命令所服务的确认流。
从确认 Issue 推进 active milestone
auto_next_milestone = false 时,已关闭的 active Milestone 会产生一张
确认 Issue,票内自带命令(Issue #1290):评论中以行首开始的
/milestone <标题> 会执行维护者本来要做的三步,托管租户无需主机访问
权限,也无需 CLI。
- 命令锚定在行首:长评论的任意一行都生效,
>引用回复永远不触发; 匹配前先剥离 ```/~~~ 围栏代码块,因此「怎么用这条命令」的说明不会真的 触发。 - 只接受该 Issue 候选列表里的标题,且版本号一律取自评论字面量——命令 从不推断下一个版本。
- 评论者需对该仓有 write 权限(OWNER/MEMBER/MAINTAINER/COLLABORATOR);其他人、 候选外的标题、格式错误的命令行,都会在 Issue 上留下一条可读原因; Runner 自己的评论永不触发。
- 三步各自幂等:创建缺失的 Milestone(open);该 Milestone 下没有
release 票时按
.github/release-ticket-template.md开一张 (version_file取version_file配置项,否则按仓内文件探测;两者都 取不到时该步失败并在回执中给出修复方式,不再猜测pyproject.toml; 仓库没有该模板时退回内置最小字段集并在票中注明); 最后落地active_milestone——当前值来自仓库.github/orbi.toml时写 该文件,否则写部署配置。 - 某一步失败时,Issue 上说明失败在哪一步及原因,已成功的步骤不回滚; 下一个 tick 会幂等地重跑整条命令。
milestone_command_applied 与 milestone_command_failed。
票内命令
票内可以携带的命令集中在一张显式注册表TICKET_COMMANDS
(src/orbi/ticket_command.py)。共享层负责第二个命令不该重复实现的部分:
行首锚定匹配、权限门禁、围栏/引用处理、后发命令覆盖先发、以及回执。命令
只提供 verbs(绝不是正则)、一个声明式权限级别、一个 validate 回调
(命令自己的业务规则:返回可读原因,参数被接受时返回 None)和一个
apply 回调。只有调度入口 process_commands(...) 能触达 handler。
新增一个命令:
- 在命令自己的模块里定义
CommandSpec(/milestone是第一个注册者)并 调用register_command(spec)。注册即校验:名称重复、verb 已被其他命令 占用、权限级别不在PERMISSION_LEVELS中,都会在 import 时快速失败。 - 在
JOURNAL_EVENTS(src/orbi/journal.py)中登记事件类型<name>_command_applied与<name>_command_failed,并在上面的”事件参考” 中补上对应行;event()会拒绝未登记的 kind。 - 在本表(及中文页)补上该命令行:绑定测试
tests/test_ticket_command.py会读取两张表,出现偏差即失败。 - 确保命令模块在处理票据前被 import(调用点所在模块是
orbi/milestone.py):register_command在 import 时执行。唯一的process_commands(...)调用传入整张TICKET_COMMANDS注册表,已登记的 命令无需改动调用点就会被调度。
已完成但没有 release 票的 Milestone
Issue #856:active Milestone 已无 open Issue,却没人开它的 release 票时, 引擎会静默等待——队列为空,每个 tick 都报no_ready_issue,没有任何人
被告知有一个决定待做。在仓库 .github/orbi.toml 里设置
release_confirmation = true 后,空闲路径会通过与推进等待相同的
决策通知机制把这个状态呈现出来:恰好一条通知 Issue,指纹相同
(orbi-milestone-advance old=<active> candidates=<active>),同样自带
票内 /milestone <版本> 回复。引擎绝不自己创建 release 票——通知告知
维护者,受信任的 /milestone <active> 评论执行上面记录的三步确定性
操作。
- 幂等:每个已完成的 Milestone 只存在一条 open 通知,绝不每 tick 一条。
- release 票缺失时通知保持打开;等待结束后自动关闭——该 Milestone 的 release 票已出现,或该 Milestone 不再处于等待状态(又有新的 open Issue,或 release 后已关闭)。关闭评论会说明是哪种情况。
- 等待状态记录
active_milestone_release_pending old=<active> repo=<repo>, 与推进等待记录的是同一个old=字段。通知写入失败属于纯旁路(pending_milestone_issue_failed),绝不让 tick 失败。 - 这个等待是有意为之,不是故障:待做的决定是「为已完成的 Milestone
打开 release 票」。未开启该键(默认
false)时行为与 #856 之前完全 一致。 /milestone的 release-ticket 步骤无法解析version_file时,通知会 收到version_file_unresolved回执并给出修复方式;设置version_file(或"none")后重新回复即可。
公开表面
以下接口和持久化命名都是公开表面。任何变更都必须在 release notes 中声明,以便下游自动化有计划地迁移:- **CLI:**裸
orbitick,以及add、suggest、status、doctor、setup、install-units、session、milestone set、check、sync-engine-source子命令,包括--config和ORBI_CONFIG环境变量。 - Suggestions:
orbi suggest --json打印的文档(repo、base_sha、session_dir与最多三条建议)、保留的运行目录<deploy_home>/.orbi/suggest/<run_id>/,以及产出它的 skillintegrations/claude-plugin/skills/suggest-ai-ready-issues/(Issue #1576);orbi setup的提议只在终端上运行,否则只打印一行提示。 - 配置:配置参考中的每个 key 都是公开表面,
另加由 systemd
EnvironmentFile加载的 gitignored.orbi/env文件。 - 部署:
systemd/[email protected]和systemd/[email protected]模板,及其[email protected]实例(N =max_concurrency,上限为MAX_RUNNER_INSTANCES = 5)。 - **票内命令:**milestone 确认 Issue 上的
/milestone <标题>评论 (Issue #1290)。 - 标签和生命周期:
labels.toml与src/orbi/delivery_labels.py, 以及ai-ready、ai-in-progress、ai-pr-opened、ai-fix-needed、ai-merged、ai-blocked状态机。 - **可观测性:**journal 每行以
[run_id]开头,并使用run_start、run_end、activity、heartbeat、resumed、run_failed等稳定事件名,以及日志(journal)中记录的启动/恢复事件。 Pi 实时记录位于<worktree>/.pi-session/*.jsonl。 - **交付命名和标记:**稳定的任务分支是
orbi/<owner>-<repo>-issue-<N>;带 run 的 worktree 目录是.worktrees/orbi-<owner>-<repo>-issue-<N>-<run_id>。Issue/PR 评论和 PR body 使用<!-- orbi:run=<run_id> -->及可见的run_id=<run_id>;PR body 还必须包含Fixes #<N>。
Worktree 与 base 新鲜度
每次领取先 fetch 并冻结origin/<base_branch>,任务 worktree 和按
Issue 稳定命名的 feature branch 都从那个精确 SHA 创建——绝不来自主
工作区当前 HEAD。分支在重试之间保持稳定;worktree、Pi session 目录和
run 产物带 run id,所以重试的 Issue 得到新的独立 run,旧现场保留。
.worktrees/ 已 gitignore。
任务 worktree 继承部署 checkout 的单一 origin remote,所以 git
transport 只配置一次、所有 worktree 继承(Issue #114——双通道边界与
启动前检查:见上文 Timers 第 2 条;凭据:安全)。
agent 在提交的交付处停止;PR 前的 base 新鲜度是 Runner 的确定性收口
(Issue #186,deliver_pr):Runner 在 base-sync 锁下重新 fetch
origin/<base_branch>,用普通 git merge 吸收前进的 base(冲突时
中止,PR 在 agent 的 head 上打开——已有的审查会话在会话内吸收
base),然后推送 task branch 并创建 PR。创建 PR 之前 Runner 会直读
源票的 state:交付期间被人工关闭的票在此收口——已推送的分支保留工作
产物,关掉的票上留一条说明评论,不开 PR(Issue #746)。「PR 开出后票
才被关」的相反时序由每 tick 的孤儿 PR 扫描兜底(见工作流),
报告出来由人决定。
任务 worktree 回收(Issue #760)
worktree 过去只增不减:每次交付都会留下repo_dir/.worktrees/orbi-{slug}-issue-{N}-{run_id} 目录,没有任何
机制删除它。现在每个 tick 开头——与启动前检查并列、在任何 slot 或
领取之前——Runner 跑一次有界、幂等的回收(reclaim_released_worktrees):
只考虑 repo_dir/.worktrees 下、目录名匹配 orbi 命名且 slug 属于已
配置 source repo 的已注册 worktree,当以下条件全部成立时用
git worktree remove --force 移除:Issue 已关闭(每个涉及的 repo
一次批量 gh issue list --state closed)、其 closedAt 距今至少
worktree_retain_hours(部署 home 的 orbi.toml,默认 72)小时,且
不携带任何在途标签(ai-in-progress、ai-pr-opened、
ai-fix-needed——人工关闭在途 Issue 时标签还在,现场因此保留)。
本进程绑定 run 的 worktree(按 run id 或 stop-scene 路径)永远不是
候选,而已关闭的 Issue 永远不会被恢复扫描选中,所以被回收的现场是
不可达的垃圾,绝不是恢复状态。移除按关闭时间从旧到新排序、每 tick
最多 25 个,积压在多个 tick 内消化,单个 tick 不会无限耗时。整个
回收是纯旁路:GitHub 读取失败记录
worktree_reclaim_failed reason=... 且一个都不删,单个移除失败记录
worktree_reclaim_failed path=... reason=... 后继续,有移除的 tick
记录结构化 worktree_reclaimed count=N freed=<bytes> 行。
远端交付分支回收(Issue #1479)
已合并的交付过去会把它的orbi/<slug>-issue-<n> 分支永久留在远端
(2026-09-28 有 255 个这样的分支)。每个 tick 开始时,紧挨 worktree
回收,Runner 运行一个有界、幂等的扫描
(reclaim_merged_delivery_branches):它列出远端 orbi/* 头
(git ls-remote --heads origin 'orbi/*')与 source repo 的已合并 PR
(gh pr list --state merged),仅当某个分支当前的远端 oid 等于
某个已合并 PR 的 headRefOid 时才删除它
(gh api -X DELETE repos/<repo>/git/refs/heads/<ref>)。oid 匹配保护
重新打开的 Issue 的新尝试:交付分支名按 Issue 固定,因此新尝试会推到
同名分支,而旧的已合并 PR 仍在报告该名字。只有一个 OPEN、
CLOSED-unmerged 或根本没有 PR 的分支永远不会进入已合并映射,也就
永远不会被碰。每次 tick 最多删除 25 个,积压在多个 tick 内消化。整个
扫描是纯旁路:读取失败记录
branch_reclaim_failed reason=... (deleting nothing),单个删除失败
记录 branch_reclaim_failed branch=... reason=... 后继续,有删除的
tick 记录 branches_reclaimed count=N。
故障恢复
run 产物(plan、test log、session JSONL)留在任务 worktree 作为本地
记录;GitHub 承载交付记录。
Run 卡在 session_pending:首个模型请求发出前挂死(Issue #311)
症状。 Runner 拉起 Pi 后,会话始终不创建 session 文件、也不发出
首个模型请求。Run 停在 session_pending,15 分钟后被 idle-stall 恢复
杀掉(idle_recovery_stale_15m),Issue 落到 ai-blocked——每次失败
白烧 15 分钟 slot,且需人工重新派活。在线 provider 会中招(z.ai 与
google gemini 均复现);127.0.0.1 的本地模型不受影响。特征:session
JSONL 路径为空,而 pi 进程还活着。
30 秒取证(对卡死的 run):
~/.pi/agent/settings.json packages 里声明的用户级插件。
pi-mcp-adapter 插件启动时初始化 @napi-rs/keyring → Secret Service
(org.freedesktop.secrets / kwalletd6)D-Bus 调用;在 systemd
--user 服务上下文该调用偶发永不返回,node 在发出任何模型请求前就挂
在 poll_schedule_timeout。
修复。 从 ~/.pi/agent/settings.json packages 移除
pi-mcp-adapter(已验证:同一 run resume 后 15 秒内 session_created
first_response)。永久修复是 Issue #249——orbi 自声明 extensions ([[pi_extensions]])并以--no-extensions启动 Pi,用户级插件从此 无法泄漏进无人值守会话。上游:向 pi-mcp-adapter 报 bug(无 MCP server 配置时不应硬初始化 keyring,或改惰性初始化)。
并发
max_concurrency(默认 1)限定本机并发交付数。slot 是
<repo_dir>/.orbi/slots/slot-N 上的排他 flock(2) 锁,在任何
领取之前取得,整个交付生命周期(implement → review → merge)持有;
进程无论如何退出,内核都会释放它。拿不到 slot 的 Runner 记录
capacity_full 后退出,不领取 Issue。max_concurrency = 2 时两个 timer
实例可能在同一 tick 启动两个 Runner 实例;为 1 时只启用 @1.timer。
它们竞争同一组 slot。max_concurrency 的范围是 1 到
MAX_RUNNER_INSTANCES = 5;容量和启用的 timer 数都由 max_concurrency 决定。