Skip to content

refactor(workflow): DAG 编排剥成 @helios/workflow,依赖只有 ports(A-9 部分) - #56

Merged
fujiwarazz merged 4 commits into
mainfrom
refactor/workflow-package
Sep 1, 2026
Merged

refactor(workflow): DAG 编排剥成 @helios/workflow,依赖只有 ports(A-9 部分)#56
fujiwarazz merged 4 commits into
mainfrom
refactor/workflow-package

Conversation

@fujiwarazz

Copy link
Copy Markdown
Owner

A-9。base 是 main#49#55 已全部合入)。

先说 A-9 的前提只对了一半

原表述:「workflow.ts 落在 packages/kernel/src/agentSpec/,但 DAG 编排比 session 高一层,kernel 的职责是『跑一个 run』」。

查下来不成立。workflow 不是 kernel 的上层,它是 kernel 注册给模型的一个工具agentSpec/agentTools.ts),运行时嵌在 session 里、靠会话级 DerivedAgentExecutor 干活。

而且照字面搬会包级循环依赖workflow.ts 的唯一调用方在 kernel 内,而它自己又反过来 import kernel 的 load / validate / selectTools / portResolver / derivedAgentExecutor

所以这期只剥出纯算法层。把它抬到 kernel 之上(做成 extension)依赖 A-8 —— agentTools.ts 现在靠 executorFor: (sessionId) => … 取会话级执行器,那正是 A-8 要删的 service locator。已在 docs/follow-up-work.md 把 A-9 标为「部分 DONE」并记下这条依赖。

做了什么

新包 @helios/workflow依赖只有 @helios/ports,方向 kernel → workflow 单向:

文件 职责
compile.ts 纯图算法:解引用、查环、拓扑分层、定唯一终止节点
schedule.ts 只按计划调度;对"派生执行器"的需求以窄接口表达(WorkflowControl / NodeOutcome / WorkflowRunContext),不 import kernel 的类

三个共用纯函数落到 ports/agentSpec.ts(两个包都要用,公共部分只能放共同下层,与 skill.tsparseFrontmatter 同一先例):isWorkflowSpec / resolveDefinition / workflowNodeIdworkflowNodeId 带前缀是刻意的 —— nodeIdsession.ts 里指消息树节点,同名不同义。

顺带收紧两处:

  • CompileContextallTools + portResolver 两个字段收成一个 checkAssembly。「这个 agent 装得起来吗」是 kernel 的知识(它才持有工具池与 Port 白名单),workflow 只要一个「是/否 + 原因」。字段变少了,边界反而更清楚。
  • WorkflowRunContext.executor死字段runWorkflow 从来没用过它(测试里一路 executor: {} as never 传着)。删掉。

护栏

packages/workflow/test/dependency-direction.test.ts —— 对源码 import 与 package.json 的静态断言,含一条「护栏本身不是空的」自检(防 readdir 指错目录时前两条永远绿)。

⚠️ 这类反向依赖 typecheck 抓不到:pnpm workspace 里 @helios/kernel 本来就解析得到,TS 会愉快编过去,只在拆包 / 发版 / 拓扑构建时才炸。

变异 9/9 命中:源码 import kernel / package.json 加 kernel 依赖 / 护栏扫错目录 / 分层按完成顺序 / 多终止节点放过 / 上游拼接乱序 / 节点失败仍跑下游 / checkAssembly 不调 / 嵌套深度上限失效。

顺带修掉三处测试质量问题

这三处都是这轮跑回归时被逼出来的,不是顺手重构。

① workflow live 用例长期假红

它把代码属性模型能力混在一条断言里(toContain("PINEAPPLE-42") 同时要求「数据沿 DAG 流动」和「叶子 agent 会去调 Read」)。

补两条确定性断言:joiner 的 prompt 里确实有 <upstream node="pw"> 块 —— 那正是本次搬走的 buildNodeInput 产出的。然后按语义判据分流:

  • 上游 pw 自己都没拿到口令 → 叶子没干活(模型问题)→ skip 并打印原因
  • 上游有、终点没有 → 数据真没流过来 → 硬红

不按标记形态识别是吃过教训的:实测已见四种形态(<|DSML|invoke> / <use_mcp_tool> / <function_calls> / <tool_use>),我追加过一轮又漏了第四种 —— 那是无穷枚举。

验证 skip 没吞掉真回归:变异掉 buildNodeInput 的拼接后,用例仍在 <upstream> 断言上变红。

② eval 的分母一直是错的

以前一律拿全集 DEFECTS.length 作分母,于是每轮往 eval set 加缺陷都会把所有场景的分数稀释一遍。我曾把 39/41 → 41/44 → 24/46 当趋势报给你 —— 那是三个不同的量,denominator 和被审文件集都在变,不可比。这是我的测量错误。

现在各场景按自己的文件范围算分母(分子也要过滤,否则打出 10/8 这种数字)。修完各场景是满分:

readonly 8/8 · async 3/3 · robustness 3/3 · trust 3/3 · cross-file 3/3
numeric 3/3 · layering 3/3 · duplication 3/3 · loader 3/3
registration-order 3/3 · dependency-direction 2/2

另外 readonly 广度扫描已经撞了 600s 上限被截断(单跑必超时,批内勉强跑完但结论不全)—— 那才是「24/46」的真因,不是模型变差。范围钉死到 4 个文件;它只负责回答「一次广度扫描能不能捞到有意义的东西」,新类别请配套新增专项场景而不是往这个 scope 里加文件。

③ CLI e2e 两个用例间歇假红

runCliArgs 完全没有内部看门狗,子进程不退出就靠 vitest 的 5s 兜底 —— 而那条路径不杀子进程,留下一个还在跑的 tsx 孤儿,报错也只有无信息量的「Test timed out」。

configDiscovery 更荒谬:内部看门狗 10s > 外层 5s,于是那句精心写的诊断(CLI did not become ready. Output:...永远不可能出现。内层预算大于外层 = 把诊断关掉了。

已加看门狗(20s,小于 testTimeout 30s)并放宽外层。

验证(三层退出码均为 0)

结果
typecheck 0
单测 951 passed
live 全套 20 passed / 4 files
CLI 真机 3 turn、零失败调用

eval set 44 → 46,新增第十一类「依赖方向」:

id 形状
upward-import plan.ts(底层图算法)import 了上层 orchestrator,形成相互依赖
leaked-upper-type compile 收了上层整个 Orchestrator,实际只用它一个方法

layering-review 那一类的区别:那边判据写在 README 的架构约定里,这边写在被违反的那个文件自己的 JSDoc 里,而违反的痕迹只有一行 import —— 不看另一个文件就不知道那是"上层"。新场景 dependency-direction-review 命中 2/2。

zhangzihao added 2 commits August 31, 2026 20:58
## A-9 的前提只对了一半

原表述是「DAG 编排比 session 高一层,kernel 的职责是跑一个 run」。查下来不成立:
workflow 不是 kernel 的上层,它是 kernel **注册给模型的一个工具**
(`agentSpec/agentTools.ts`),运行时嵌在 session 里、靠会话级 `DerivedAgentExecutor` 干活。

而且照字面搬会**包级循环依赖**:workflow.ts 的唯一调用方在 kernel 内,它自己又反过来
import kernel 的 load / validate / selectTools / portResolver / derivedAgentExecutor。

所以这期只剥纯算法层。把它抬到 kernel 之上(做成 extension)**依赖 A-8**:
`agentTools.ts` 现在靠 `executorFor: (sessionId) => …` 取会话级执行器,
那正是 A-8 要删的 service locator。

## 做了什么

新包 `@helios/workflow`,**依赖只有 `@helios/ports`**,方向 kernel → workflow 单向:

- `compile.ts` 纯图算法(解引用、查环、拓扑分层、定唯一终止节点)
- `schedule.ts` 只按计划调度,对"派生执行器"的需求以**窄接口**表达
  (`WorkflowControl` / `NodeOutcome` / `WorkflowRunContext`),不 import kernel 的类

三个共用纯函数落到 `ports/agentSpec.ts`(两个包都要用,公共部分只能放共同下层,
与 `skill.ts` 的 `parseFrontmatter` 同一先例):`isWorkflowSpec` / `resolveDefinition` /
`workflowNodeId`。**带前缀是刻意的**——`nodeId` 在 `session.ts` 里指消息树节点,同名不同义。

顺带收紧两处:

- `CompileContext` 的 `allTools` + `portResolver` 两个字段收成一个 `checkAssembly`
  (「这个 agent 装得起来吗」是 kernel 的知识,workflow 只要「是/否 + 原因」)
- `WorkflowRunContext.executor` 是**死字段**,`runWorkflow` 从来没用过它,删掉

## 护栏

`packages/workflow/test/dependency-direction.test.ts`:对源码 import 与 package.json
的静态断言,含一条「护栏本身不是空的」自检。

⚠️ 这类反向依赖 **typecheck 抓不到**:pnpm workspace 里 `@helios/kernel` 本来就解析得到,
TS 愉快编过,只在拆包/发版/拓扑构建时才炸。

变异 9/9 命中(源码 import kernel / package.json 加 kernel 依赖 / 护栏扫错目录 /
分层按完成顺序 / 多终止节点放过 / 上游拼接乱序 / 节点失败仍跑下游 / checkAssembly 不调 /
嵌套深度上限失效)。

## 顺带修掉三处测试质量问题

**① workflow live 用例长期假红。** 它把代码属性和模型能力混在一条断言里。补两条确定性断言
(joiner 的 prompt 里确实有 `<upstream node="pw">` 块 —— 那正是本次搬走的 `buildNodeInput`),
再按**语义判据**分流:上游自己都没拿到口令 = 叶子没干活(模型问题)→ skip;
上游有、终点没有 = 数据没流过来 → 硬红。

不按标记形态识别是吃过教训的:实测已见四种形态(`<|DSML|invoke>` / `<use_mcp_tool>` /
`<function_calls>` / `<tool_use>`),我追加过一轮又漏了第四种——无穷枚举。

**② eval 的分母一直是错的。** 以前一律拿全集 `DEFECTS.length` 作分母,于是每轮加缺陷都会
把所有场景的分数稀释一遍。我曾把 `39/41 → 41/44 → 24/46` 当趋势读,那是三个不同的量。
现在各场景按自己的文件范围算分母(分子也过滤,否则会打出 `10/8` 这种数字)。

而且 readonly 广度扫描已经**撞了 600s 上限被截断**(单跑必超)——随 eval set 增长必然再撞。
范围钉死到 4 个文件,它只负责回答「一次广度扫描能不能捞到有意义的东西」。

**③ CLI e2e 两个用例间歇假红。** `runCliArgs` 完全没有内部看门狗,子进程不退出就靠 vitest
的 5s 兜底——而那条路径**不杀子进程**,留下 tsx 孤儿,报错还只有「Test timed out」。
`configDiscovery` 更荒谬:内部看门狗 10s > 外层 5s,那句带 CLI 输出的诊断**永远不可能出现**。
已加看门狗(20s,小于 testTimeout 30s)并放宽外层。

## 验证(三层退出码均 0)

- typecheck 0
- 单测 **951 passed**
- live 全套 **20 passed / 4 files**;各场景满分:readonly 8/8、其余 3/3、方向专项 2/2
- CLI 真机 3 turn、零失败调用
- eval set 44 → 46,新增第十一类「依赖方向」(upward-import / leaked-upper-type,
  落在 `src/graph/{orchestrator,plan}.ts`)。这一类与 layering 那一类的区别:
  那边判据写在 README 的架构约定里,这边写在**被违反的那个文件自己的 JSDoc 里**,
  违反痕迹只有一行 import——不看另一个文件就不知道那是"上层"。
  新场景 `dependency-direction-review` 命中 2/2。
在飞的节点收不到编排的中断:`runWorkflow` 只在派每一批之前查 `signal.aborted`,
挡得住"还没启动的层",但 `NodeRunInput` 里没有 signal 字段,已经启动的节点用什么
信号完全取决于调用方 —— kernel 侧 `runWorkflowNode` 传的是 `toolCtx.signal`
(主会话那次工具调用的信号),不是编排自己的。

真机后果:后台派一个 workflow,等节点跑起来后 kill 编排,8 秒后编排已是 killed 而
慢节点仍停在 `running`,既没被杀也没跑完,还在烧 token。

改动:`NodeRunInput` 加必填 `signal`,`runWorkflow` 填 `control.signal`,kernel 侧
用它取代 `toolCtx.signal`。后者是前者的上游(`track()` 把编排的 AbortController 链在
`toolCtx.signal` 下),所以这是严格超集:主会话中断照样传到,另外多覆盖"编排自己被 kill"。

同一真机场景复测:编排 killed,慢节点 running → killed,已跑完的快节点保留 completed。

顺带修一条 live 假红:`derived-agent` 的跨层取值断言原先断在 `res.output` 上,
那要求终止节点的模型把上游值转述出来,是模型行为而非代码属性 —— 本地网关已见第五种
把 tool call 当纯文本吐出来的形态(`<tool_calls>`)。改断 joiner 的 prompt,回到
`buildNodeInput` 的代码属性上,对模型措辞免疫(变异掉拼接仍变红,已实测)。

测试
- 新增 packages/workflow/test/kill-propagation.test.ts(3 例:层间闸门、signal 传到每个
  节点、在飞节点收到中断),3/3 变异全被抓住
- eval set 46 → 49:新增"取消信号传播"类(src/tasks/pipeline.ts 三个缺陷)与
  cancel-propagation-review 场景,首跑 2/3(漏 abort-before-listen)
- typecheck 0 错 · 单测 954 passed · live 全量 20 passed · CLI 真机 workflow 跑通
zhangzihao and others added 2 commits August 31, 2026 22:18
原注释写「8 秒后节点仍 running / 复测已 killed」,把一个**同步**保证描述成了最终一致。
探针里的 8s 是我随手挑的,不是从任何超时推出来的:修复前它证明「等 8s 仍没停」(这个
方向成立),修复后它证明「8s 内停了」(这个方向什么都没说,真实延迟可能是 50ms)。

实际路径是同步的:kill 编排 → abort.abort() → 同 tick 派发 abort 事件 → track() 的
onAbort 调 kill(nodeId) → markEnd 立刻标 killed,不需要等待。

同时划清射程:本文件保证的是调度器把信号递到了、记录状态同步翻转;信号递到之后那次
LLM 请求与 Bash 子进程各自多久真正停下,是执行器与工具的事。
fix(workflow): kill 编排时把中断信号传给在飞的节点
@fujiwarazz
fujiwarazz merged commit 57fb7fc into main Sep 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant