oh-my-openagent DAG 调度器演进:dependency-frontier 准入机制如何替代严格 wave 屏障
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
导读
本文基于 oh-my-openagent 仓库中senpi-task包的 DAG(有向无环图)任务调度引擎,详细剖析一次关键的调度语义演进:调度器从“严格 wave 屏障”(strict wave barrier)准入,重构为“依赖前沿”(dependency-frontier)准入——节点只要其全部dependsOn依赖节点完成且有空闲驻留槽位即可启动,不再等待整波(wave)全部落定。文章完整还原该变更的动机(事故dag_530ad299)、实现结构、回归测试矩阵、指纹策略变更与验证证据,并给出仓库内可继续深入阅读的源码路径,帮助你理解如何为并发 DAG 调度器消除“慢节点饿死就绪依赖”这一经典缺陷。
背景:一次真实的调度饿死事故
本次变更起源于 2026-08-25 的一次真实事故,对应事故编号dag_530ad299(sisyphuslabs "omo startup fixes PR wave")。变更分支为fix/dag-dep-frontier,基线为origin/dev@76e713247,并保留了同期兄弟 PR #7320(fix/dag-recovery-nonblocking,即恢复侧的非阻塞修复)未动。
事故的图结构可以概括为:
lane-a与lane-c共享 wave 0(同属首批可运行节点);lane-b只依赖lane-a,属于 wave 1。
在旧实现下,调度器执行runWaves并使用admitAndSettleWave做整波全落定屏障:wave N 的所有节点都必须进入终态,wave N+1 才会被扫描。于是当lane-c长时间运行(生产中可达数小时)时,lane-b明明已经满足依赖条件(lane-a已完成),却被无关联的lane-c挡在屏障之后,饿死数小时。
值得强调的是,DAG 工具对外文档化的契约从来都是“依赖语义”——“节点只有在其全部dependsOn节点完成后才会启动”。严格 wave 屏障带来的这种“无关慢节点阻塞就绪依赖”的行为,是工具契约从未承诺过的。这一点正是本次重构的合法性来源。
变更方案:从runWaves到runFrontier
变更计划(见 .omo/evidence/20260825-dag-dep-frontier/plan.md)核心是替换 packages/senpi-task/src/dag/scheduler.ts 中的runWaves+admitAndSettleWave,改为依赖前沿循环,同时原样保留以下语义:
preAttachedTasks折叠(兄弟 PR 引入,恢复时已运行的子任务直接挂接);watchRevivedInScheduler(send/revive 唤醒路径);- 失败 skip 级联;
- 取消语义(
admissionIdle门控); - retry/send/reentry 控制动词;
- spawn policy、
startSpec、owner 指纹、foldTaskOutcome、primaryFailure排序。
runFrontier主循环
从源码看,新的主循环位于 scheduler.ts 的runFrontier,每次迭代的执行顺序为:
- 取消检查:若已挂起或已发起取消,则返回取消快照;
- skip 级联:仅在“前沿静止”(没有任何挂接任务)时执行
applyDependentSkipCascade; - 补齐完成事件:
emitCompletedWaves先于下一轮准入执行,保证已落定的 wave 在后续 wave 节点交错产生事件之前表现为已结束; - 前沿准入:
admitFrontier; - 全终态检查:所有节点进入终态则退出循环;
- 单次落定等待:
settleOne等待本轮挂接任务之一落定、会话级驻留释放、外部 journal 提交或取消。
循环结束后,按primaryFailure判定整次 run 是dag.run.completed还是dag.run.failed。
admitFrontier一次准入通
admitFrontier(scheduler.ts L559-L628)是一次准入通(admission pass):
- 先按“最先被拒”顺序重试驻留被拒队列
pendingAdmission(先被拒者优先获得释放的槽位); - 再扫描所有新就绪节点:状态为
pending或blocked,且每个dependsOn节点均为completed(对应isRunnable判断,scheduler.ts L909-L913); - 以一次
startOwned批次发起启动; - 对
residency_denied结果:可等待的驻留拒绝(cause: "residents"且列有驻留子任务)进入队列并 journal 一次residency_queued(scheduled -> scheduled过渡,附residents与heldByOtherOwners计数);不命名任何驻留任务的拒绝则直接以residency_denied失败该节点;cause: "lease"的租约争用则立即重探(租约获取本身是有界等待)。
值得注意的实现细节:驻留释放监听(taskManager.residencyChanged(parentSessionId))在批次探测之前就绪,因为唤醒是边沿触发的——批次在途期间若兄弟子任务落定,必须仍能被看到,否则“最后一次机会”落在间隙里时,run 会一直停泊直到某个无关驻留者落定(这正是 #8396 的教训)。
事故回归:RED on pristineorigin/dev
证据文件 .omo/evidence/20260825-dag-dep-frontier/red-origin-dev.txt 记录了在纯净origin/dev上运行回归测试的结果:
- 核心回归用例(
dag_530ad299形态:lane-a + lane-c 共享 wave 0,lane-b 仅依赖 lane-a)超时失败:manager.whenStarted("lane-b")永不 resolve,因为旧runWaves只有在admitAndSettleWave完全落定 wave 0 后才会扫描 wave 1; - 驻留 FIFO 用例(槽位释放后先给最老被拒者)同样失败;
- 信息性 wave 分组用例失败;
- 依赖门控用例(“依赖仍在运行则不启动”)在旧代码与新代码上均通过——契约的另一半从未改变。
这正是“R2 回归”的价值:它把生产事故症状(就绪依赖被无关运行中兄弟节点饿死)精确钉在了产生症状的接缝上,且是事件驱动的(journal 支撑的whenStarted/状态订阅,无 sleep),不可能靠时序运气通过。
GREEN:变更后观察到的行为
证据文件 .omo/evidence/20260825-dag-dep-frontier/green-branch.txt 与回归测试 packages/senpi-task/src/dag/scheduler-frontier.test.ts 共同确认了新行为:
- 依赖前沿生效:
lane-b在lane-a完成的那一刻被准入,而lane-c仍在运行。启动序列为starts == [lane-a, lane-c, lane-b],且lane-c的任务状态仍是running(对应测试断言expect(manager.starts).toEqual(["lane-a", "lane-c", "lane-b"])与expect(manager.recordOf("lane-c")?.status).toBe("running"))。 - 依赖门控保留:
b永远不会在a运行期间启动(对应“依赖仍在运行”用例)。 - 驻留分批保留:释放的槽位优先给最老的被拒节点,之后才是新就绪节点;不命名任何驻留者的拒绝仍以
residency_denied失败,行为与之前完全一致。测试用例“#given frontier admission under the residency cap #when a slot frees #then the oldest denial is retried before newer ready nodes”用attempts序列精确钉住了这个公平性:cap=1 时a先启动,b首次探测被拒,a落定后同一批次中b重探成功、c探测后停泊(attempts依次为["a","b","b","c"])。 - 跨 run 驻留唤醒:测试用例“
#given a sibling run holding every resident slot of the session #when a second run starts #then its node queues for residency and is admitted once a sibling child settles”验证了 #8396 的关键修复——run B 在自身没有任何挂接任务时,若会话所有驻留槽位都被 run A 的子任务占用,b1会被停泊为scheduled(journal 一次residency_queued,residents: 2, heldByOtherOwners: 2)而非失败;当 run A 的某个子任务落定释放槽位,run B 立即被唤醒准入。这证明“run 内无任务”不等于“会话内无驻留”,调度器的等待集是Promise.race([自身落定, residencyChanged(parentSessionId), 外部 journal 提交, 取消]),而不是只等自己的attachedTasks。
wave 事件降级为信息性分组
编译期算出的 wave 在新实现中永不门控执行,只作为信息性事件分组:
dag.wave.started:每次准入通按 wave 索引分组上报本次调度到的节点。由于前沿准入会交错,同一个 wave 索引可以出现在多次 started 事件中(见 emitWaveAdmissions 的注释);dag.wave.completed:每个实例、每个索引至多一次,当该 wave全体成员(含 skipped 与 failed 节点,分组描述的是图而非成功声明)都进入终态时触发,nodeIds携带完整成员列表;仅对本次实例收到过 started 的 wave 上报(emitCompletedWaves)。
因此,wave 1 的分组可以先于 wave 0 完成:测试用例断言了startedWaveOne.seq < completedWaveZero.seq,即交错是预期行为。对应的边界事件构建器在 events.ts(dagWaveStartedEvent/dagWaveCompletedEvent),事件车道归类为boundary。
失败 skip 级联的时机后移
skip 级联(applyDependentSkipCascade)现在只在前沿静止(无任何挂接任务)时运行。原因很微妙:一个失败节点在兄弟节点仍在途时应保持可复活(send→ revive),如果过早级联,会把本可被复活结果补全的依赖标记为skipped——这正是dag_2d12c2f7回归的教训。证据是e2e-failure的 “revive-inside-live-wave” 用例(dag_2d12c2f7)在未修改的情况下依然通过:兄弟节点落定前通过send复活的失败节点,复活完成后仍能解除依赖阻塞。
同时,一轮准入若全部启动失败(无挂接任务、依赖仍 pending),runFrontier会检测到存在可级联依赖并continue回到循环顶部让级联落定 run,而不是抛错让 run 永远停留在running(对应 #8396 的第二个症状:28 个叶子准入失败、2 个聚合器 pending、retry 以run_still_active拒绝)。
外部 journal 提交唤醒(#7412)
另一个被 frontier 测试覆盖的既有修复是外部提交唤醒:当一个节点的完成经由控制动词自己的 journal 实例提交(而非调度器自己的waitFor),活调度器必须通过subscribeDagJournal订阅刷新缓存并唤醒准入循环,否则就会出现“已 journal 完成但依赖仍饿死”的停滞。settleOne对foreignSettlement标志做电平触发消费(scheduler.ts L839-L868),防止边沿触发信号丢失唤醒。
指纹策略变更:strict-barrier→dependency-frontier
准入语义变化必须反映在调度器身份指纹中。变更前SCHEDULER_FINGERPRINT_INPUT.waveAdmission为"strict-barrier",变更后为"dependency-frontier":
- 定义位置在 manager.ts 的
SCHEDULER_FINGERPRINT_INPUT(waveAdmission: "dependency-frontier"、failurePolicy: "continue-independent"、dependencyData: "filesystem-only"),注释明确说明“调度器指纹必须在语义变化时改变”; - 类型定义在 fingerprint.ts 的
DagDefinitionFingerprintInputV1,其中waveAdmission的联合类型已收敛为"dependency-frontier",并注明“自 2026-08-25 起为前沿准入……(此前为 strict-barrier)”; - 指纹计算为“canonicalize → sha256”(
dagFingerprint),定义指纹由名称 + 调度器语义 + 规范化排序后的节点列表组成(dagDefinitionFingerprint)。
策略后果:在旧指纹下键控的 run 不会被新语义复用。重新以相同 key 提交相同定义,会抛出definition_conflict,直到 7 天保留期(retention_days: 7)剪除该 key 为止。这是刻意的破坏性变更——语义变了,旧结果不应被当作等价复用。fingerprint.test.ts的固定断言已同步更新为新输入。
验证门禁(Gates)
| 门禁 | 结果 | 证据 |
|---|---|---|
bun test packages/senpi-task(跑 2 次) | 1749 pass / 1 skip /0 fail(248 个文件共 1750 项) | gate-senpi-task.txt |
tsgo --noEmit -p packages/senpi-task/tsconfig.json | 通过,exit 0 | gate-typecheck.txt |
bun test packages/omo-senpi/src/components/task | 483 pass /0 fail | gate-omo-senpi-task-component.txt |
bun test packages/omo-senpi/src/bundle-size.test.ts | 1 pass / 0 fail | 内联 |
node packages/omo-senpi/scripts/qa/drive.mjs(+--self-test) | {"result":"PASS",...,"realSenpiUntouched":true} | omo-senpi-adapter/20260825-dag-dep-frontier/drive-live.txt |
其中drive.mjs是真实 senpi 进程 + 重新生成的omo-task.jsbundle + 隔离沙箱的活体验证,证明新调度语义在消费方(dag 工具 + 运行时)接线无误。
为什么“这就够了”(WHY IT IS ENOUGH)
- 回归精确钉住生产症状:就绪依赖被无关运行中兄弟饿死,发生在产生症状的同一接缝处;
- 事件驱动、不可靠时序作弊:journal 支撑的
whenStarted/状态订阅,无 sleep; - RED/GREEN 对比成立:纯净
origin/dev上 RED,分支上 GREEN; - 三个可能静默回归的不变量各有存量测试保护:驻留分批、复活窗口、取消语义的测试均未修改即通过;新增的驻留 FIFO 测试显式钉住槽位公平性;
- 文档与代码一致:
dag-tool.ts首行、两份 AGENTS.md 的 DAG 行、调度器头部注释均描述依赖语义。可对照 packages/senpi-task/src/dag/AGENTS.md 的 “Admission semantics” 一节与 packages/AGENTS.md(DAG 相关行)。
明确未做的事(WHAT WAS OMITTED)
- 没有专门的 DAG 准入活体驱动:
dag-gate-proof.ts是 manager 层启动校验;活体验证由drive.mjs(真实 senpi 进程 + 重新生成的 bundle + 隔离沙箱)承担,准入语义在引擎接缝处证明——与兄弟 PR #7320 的 scope 决策一致; bun test packages/omo-senpi全适配器套件存在既有失败(init-deep-advisor、cli-local、product identity、session_start 排序),由兄弟 PR 的证据文档记录;因此改用 DAG 消费者目录(0 fail)代替;- bundle 重新生成是有意的:
packages/omo-senpi/plugin/extensions/omo-task.js(minified 重写 + 构建标记)因为调度器变更被打包其中;git status确认没有其他扩展漂移; - 未运行 root-plugin
bun run build:没有 opencode 表面发生变化。
Rebase 后复验(2026-08-25,dev 移至 f91a4c252)
perf/senpi-task-lazy-barrel(#7274)中途落地使 PR 变脏。Rebase 到origin/dev@f91a4c252后:修复提交干净应用;bundle 提交在omo-task.js上冲突(符合预期——barrel 性能改动同样流入 bundle),通过重新生成(build-extension.mjs)解决,绝不手工合并 minified 输出。复验门禁:bun test packages/senpi-task= 1753 pass / 1 skip / 0 fail(相比首次捕获多出的 +3 个测试来自 #7274 的新文件);dag 套件 250 pass / 0 fail;自动合并重新武装。
如何复现与验证
以下命令均在仓库根目录执行:
# 全量 dag 套件(含 frontier 回归) bun test packages/senpi-task/src/dag # 聚焦调度器(含旧屏障测试替换后的契约对) bun test packages/senpi-task/src/dag/scheduler.test.ts # 包级门禁(建议跑两次确认稳定性) bun test packages/senpi-task # 类型门禁 tsgo --noEmit -p packages/senpi-task/tsconfig.json # 消费方接线(dag 工具 + 运行时) bun test packages/omo-senpi/src/components/task回归测试文件 scheduler-frontier.test.ts 的头部注释说明了它的来源:从 baseline-repro/dag-stall-repro.test.ts(R2)改编而来,并指出了它在纯净origin/dev上的 RED 表现。注意测试文件自带setDefaultTimeout(Windows 60s,其余 20s)——Bun 对 preload 的setDefaultTimeout只在首个测试文件生效,后续文件会静默回到 5s。
总结
dependency-frontier 准入是一次“契约复位”:它让调度器的运行时行为回归到工具文档承诺的依赖语义,把 wave 从执行屏障降级为信息分组,同时完整保留驻留分批、复活窗口、取消与重试等既有不变量。事故dag_530ad299由此关闭,配套的指纹变更、文档同步与 RED/GREEN 回归矩阵,为同类调度器改造提供了一个可复制的工程模板——先有钉住症状的回归,再动调度器本身。
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考