news 2026/9/19 10:34:32

oh-my-openagent DAG 调度器演进:dependency-frontier 准入机制如何替代严格 wave 屏障

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
oh-my-openagent DAG 调度器演进:dependency-frontier 准入机制如何替代严格 wave 屏障

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-alane-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 屏障带来的这种“无关慢节点阻塞就绪依赖”的行为,是工具契约从未承诺过的。这一点正是本次重构的合法性来源。

变更方案:从runWavesrunFrontier

变更计划(见 .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 指纹、foldTaskOutcomeprimaryFailure排序。

runFrontier主循环

从源码看,新的主循环位于 scheduler.ts 的runFrontier,每次迭代的执行顺序为:

  1. 取消检查:若已挂起或已发起取消,则返回取消快照;
  2. skip 级联:仅在“前沿静止”(没有任何挂接任务)时执行applyDependentSkipCascade
  3. 补齐完成事件emitCompletedWaves先于下一轮准入执行,保证已落定的 wave 在后续 wave 节点交错产生事件之前表现为已结束;
  4. 前沿准入admitFrontier
  5. 全终态检查:所有节点进入终态则退出循环;
  6. 单次落定等待settleOne等待本轮挂接任务之一落定、会话级驻留释放、外部 journal 提交或取消。

循环结束后,按primaryFailure判定整次 run 是dag.run.completed还是dag.run.failed

admitFrontier一次准入通

admitFrontier(scheduler.ts L559-L628)是一次准入通(admission pass):

  • 先按“最先被拒”顺序重试驻留被拒队列pendingAdmission(先被拒者优先获得释放的槽位);
  • 再扫描所有新就绪节点:状态为pendingblocked,且每个dependsOn节点均为completed(对应isRunnable判断,scheduler.ts L909-L913);
  • 以一次startOwned批次发起启动;
  • residency_denied结果:可等待的驻留拒绝(cause: "residents"且列有驻留子任务)进入队列并 journal 一次residency_queuedscheduled -> scheduled过渡,附residentsheldByOtherOwners计数);不命名任何驻留任务的拒绝则直接以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 共同确认了新行为:

  1. 依赖前沿生效lane-blane-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"))。
  2. 依赖门控保留b永远不会在a运行期间启动(对应“依赖仍在运行”用例)。
  3. 驻留分批保留:释放的槽位优先给最老的被拒节点,之后才是新就绪节点;不命名任何驻留者的拒绝仍以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"])。
  4. 跨 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_queuedresidents: 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 完成但依赖仍饿死”的停滞。settleOneforeignSettlement标志做电平触发消费(scheduler.ts L839-L868),防止边沿触发信号丢失唤醒。

指纹策略变更:strict-barrierdependency-frontier

准入语义变化必须反映在调度器身份指纹中。变更前SCHEDULER_FINGERPRINT_INPUT.waveAdmission"strict-barrier",变更后为"dependency-frontier"

  • 定义位置在 manager.ts 的SCHEDULER_FINGERPRINT_INPUTwaveAdmission: "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 0gate-typecheck.txt
bun test packages/omo-senpi/src/components/task483 pass /0 failgate-omo-senpi-task-component.txt
bun test packages/omo-senpi/src/bundle-size.test.ts1 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-pluginbun 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 10:32:29

桌面CRM开发实战:沟通时间线设计、Tauri与SQLite实现客户管理工具

1. 为什么我会做 DeskcommCRM做了这么多年销售和客户支持&#xff0c;团队里最烦的事不是客户难搞&#xff0c;而是客户资料和沟通记录乱成一锅粥。我在2024年下半年开始着手做 DeskcommCRM 这个项目&#xff0c;原因其实特别简单&#xff1a;市面上那些大而全的客户管理系统&a…

作者头像 李华
网站建设 2026/9/19 10:32:04

光模块测试供电方案:AT66333A三路可编程直流电源与程控自动化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 10:31:24

表面形貌数据处理:Ra/Rz/Sq/Sa参数的ISO合规计算方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 10:29:01

Windows下CMake安装与PATH配置实战:解决“无法识别”报错

如果你在 Windows 上折腾过 C/C 工程的构建环境&#xff0c;大概率见过这样一条报错&#xff1a;cmake : 无法将“cmake”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。请检查名称的拼写&#xff0c;如果包括路径&#xff0c;请确保路径正确&#xff0c;然后再试一次。…

作者头像 李华
网站建设 2026/9/19 10:28:40

C++实现字母异位词分组算法与优化技巧

1. 问题背景与核心需求字母异位词分组是算法面试中的经典问题&#xff0c;也是实际开发中处理文本数据的基础操作。给定一个字符串数组&#xff0c;我们需要将所有字母异位词组合在一起。字母异位词指的是字母相同但排列不同的单词&#xff0c;比如"eat"、"tea&…

作者头像 李华