homerail 可审计的本地 DAG Agent 运行时
HomeRail 把语音入口、DAG 编排、容器化 Agent Worker 与本地审计串成一个可观察的运行时。它不把“多 Agent”停留在 prompt 层,而是把节点状态、交接端口、重试预算、会话与证据都写成可验证契约。
它解决什么,凭什么值得拆
HomeRail 的核心不是再包装一个聊天 UI,而是把 Agent 任务变成显式图运行。sources/manager/src/orchestration/dag-engine.ts:3、sources/manager/src/orchestration/dag-engine.ts:12、sources/manager/src/orchestration/dag-engine.ts:16、sources/manager/src/orchestration/dag-engine.ts:18、sources/manager/src/orchestration/dag-engine.ts:20、sources/manager/src/orchestration/dag-engine.ts:23 分别定义节点状态、整张图、交接记录、依赖满足集合、邮箱与失败端口;这些结构让系统可以回答“哪个节点能跑、为什么没跑、失败后流向哪里”,而不是依赖模型在自然语言里自行记忆。
执行面也没有和控制面揉成一团:sources/manager/src/runtime/active-runs.ts:61、sources/manager/src/runtime/active-runs.ts:69、sources/manager/src/runtime/active-runs.ts:73、sources/manager/src/runtime/active-runs.ts:74、sources/manager/src/runtime/active-runs.ts:75、sources/manager/src/runtime/active-runs.ts:76 把运行状态、预算计数和节点会话收进 ActiveRun,sources/worker/src/prompt-runner.ts:167、sources/worker/src/prompt-runner.ts:174、sources/worker/src/prompt-runner.ts:182、sources/worker/src/prompt-runner.ts:192、sources/worker/src/prompt-runner.ts:228、sources/worker/src/prompt-runner.ts:256 则在 Worker 内创建 DAG 工具、审计器、流式通道和具体模型客户端。Manager 决定何时调度,Worker 只执行一轮并通过 handoff 或 node_error 回报,职责切分清楚。
▸核心数据
深度阅读 → 入口展开。
四层控制面与数据面
从源码关系看,UI/Voice 只负责把意图交给 Manager;Manager 在 sources/manager/src/runtime/active-runs.ts:336、sources/manager/src/runtime/active-runs.ts:341、sources/manager/src/runtime/active-runs.ts:357、sources/manager/src/runtime/active-runs.ts:360、sources/manager/src/runtime/active-runs.ts:365、sources/manager/src/runtime/active-runs.ts:377 创建运行、解析限制并发布 READY 事件,随后 Node 层按能力启动 Worker。Worker 的 sources/worker/src/prompt-runner.ts:193、sources/worker/src/prompt-runner.ts:207、sources/worker/src/prompt-runner.ts:231、sources/worker/src/prompt-runner.ts:238、sources/worker/src/prompt-runner.ts:239、sources/worker/src/prompt-runner.ts:240 把文本流、调试流和会话身份封装回 WebSocket,因此前端观察到的是结构化事件,而不是直接耦合某一家模型 SDK。
▸系统拓扑
JSONL Audit← events / tools / usage ←WebSocket Event Bus
▸模块清单
协议层规定消息与工具契约;Manager 维护 DAG 和运行预算;Node 管容器及挂载;Worker 适配具体模型并产出审计证据。四层都能独立测试,故障不会被一个巨型服务吞掉。
▸关键数据结构
3export type NodeState = 4 | "PENDING" 5 | "READY" 6 | "RUNNING" 7 | "COMPLETED" 8 | "FAILED" 9 | "CANCELLED" 10 | "SKIPPED"; 11 12export interface DAGRun { 13 runId: string; 14 graph: DAGGraphData; 15 loopSources: Set<string>; 16 nodeStates: Map<string, NodeState>; 17 handoffedNodes: Set<string>; 18 afterSatisfied: Map<string, Set<string>>; 19 inputSatisfied: Map<string, Set<string>>; 20 mailboxes: Map<string, Map<string, unknown[]>>; 21}
DAG 引擎与 Worker 执行环
▸状态晋升不是“上游结束就跑”,还要确认路由真的命中
节点初始状态由 after_dep 决定,sources/manager/src/orchestration/dag-engine.ts:65、sources/manager/src/orchestration/dag-engine.ts:67、sources/manager/src/orchestration/dag-engine.ts:70、sources/manager/src/orchestration/dag-engine.ts:76、sources/manager/src/orchestration/dag-engine.ts:77、sources/manager/src/orchestration/dag-engine.ts:83 初始化每个节点的状态与邮箱;真正晋升时,sources/manager/src/orchestration/dag-engine.ts:106、sources/manager/src/orchestration/dag-engine.ts:109、sources/manager/src/orchestration/dag-engine.ts:110、sources/manager/src/orchestration/dag-engine.ts:113、sources/manager/src/orchestration/dag-engine.ts:120、sources/manager/src/orchestration/dag-engine.ts:125 同时检查依赖是否完成、显式输入边是否被选中。这个双重条件避免条件分支没有走到某节点时,节点仍因“所有前驱都结束”而误启动。
234export function handoff( 235 run: DAGRun, 236 fromNode: string, 237 port: string, 238 content: unknown, 239): DAGTransitionResult { 240 if (!run.nodeStates.has(fromNode)) { 241 throw new Error(`Unknown node: ${fromNode}`); 242 } 243 244 const matchingDownstream = run.graph.edges.filter( 245 (edge) => 246 edge.from_node === fromNode && 247 edge.label !== "after_dep" && 248 edge.to_node !== "" && 249 edgeMatchesHandoff(edge, port), 250 ); 251 const terminalFailure = isFailurePort(port) && matchingDownstream.length === 0; 252 run.handoffedNodes.add(fromNode); 253 run.nodeStates.set( 254 fromNode, 255 terminalFailure 256 ? "FAILED" 257 : run.loopSources.has(fromNode) 258 ? "RUNNING" 259 : "COMPLETED", 260 );
106function _tryPromote(run: DAGRun, nodeId: string): void { 107 if (run.nodeStates.get(nodeId) !== "PENDING") return; 108 const deps = _incomingAfterDeps(run.graph.edges, nodeId); 109 const satisfiedDeps = run.afterSatisfied.get(nodeId); 110 const satisfied = deps.every((e) => satisfiedDeps?.has(e.from_node)); 111 if (!satisfied) return; 112 113 const routedInputs = run.inputSatisfied.get(nodeId); 114 for (const dep of deps) { 115 const explicitFromDep = _incomingExplicitEdgesFrom( 116 run.graph.edges, 117 nodeId, 118 dep.from_node, 119 ); 120 if (explicitFromDep.length > 0 && !routedInputs?.has(dep.from_node)) { 121 return; 122 } 123 } 124 125 run.nodeStates.set(nodeId, "READY"); 126}
▸Worker 把模型事件折叠为统一流,并把“未 handoff”视为错误
Worker 在 sources/worker/src/prompt-runner.ts:228、sources/worker/src/prompt-runner.ts:229、sources/worker/src/prompt-runner.ts:230、sources/worker/src/prompt-runner.ts:231、sources/worker/src/prompt-runner.ts:243、sources/worker/src/prompt-runner.ts:249 校验模型协议、创建适配器并先保存去密后的会话配置;事件循环从 sources/worker/src/prompt-runner.ts:256、sources/worker/src/prompt-runner.ts:258、sources/worker/src/prompt-runner.ts:263、sources/worker/src/prompt-runner.ts:291、sources/worker/src/prompt-runner.ts:303、sources/worker/src/prompt-runner.ts:312 将文本、调试、工具调用和工具结果分别发往 UI、transcript 与 tool-events。若循环正常结束但 DAG 工具没有 yielded,sources/worker/src/prompt-runner.ts:352、sources/worker/src/prompt-runner.ts:355、sources/worker/src/prompt-runner.ts:360、sources/worker/src/prompt-runner.ts:364 会主动发送 node_error,阻止“模型说完了但图没有前进”的假成功。
228 try { 229 assertAgentRuntimeProtocol(agentBackend, job.llmProtocol); 230 const agent = createAgentClient(agentBackend); 231 const context: AgentRunContext = { 232 systemPrompt: job.systemPrompt, 233 provider: job.llmProvider, 234 protocol: job.llmProtocol, 235 model: job.dagConfig.model, 236 apiKey: job.llmApiKey ?? process.env.LLM_API_KEY ?? "", 237 baseUrl: resolveAgentBaseUrl(job, agentBackend), 238 workspace: process.env.WORKSPACE ?? process.cwd(), 239 sessionId: job.dagConfig.session_id ?? job.runId, 240 abortSignal: deps.abortSignal, 241 }; 242 try { 243 saveSession({ 244 sessionId, 245 runId: job.runId, 246 nodeId: job.dagConfig.node_id, 247 messages: [{ role: "user", content: job.task }], 248 toolCallState: { inFlight: false }, 249 agentConfig: redactAgentContext(context), 250 timestamp: Date.now(), 251 }); 252 } catch { 253 // Best-effort. 254 } 255 let errorMessage: string | null = null; 256 for await (const event of agent.run(job.task, dagTools, context)) { 257 switch (event.type) { 258 case "text": 259 sendContent(event.text); 260 audit?.transcript.write({ event: "text", text: event.text });
167 // Create DAG tools state 168 const dagState = createDagToolsState(job.dagConfig, job.runId, wsSend); 169 const dagTools = createDagTools(dagState); 170 const unregisterInboxHandler = deps.registerInboxHandler?.((content) => { 171 deliverInbox(dagState, content); 172 }); 173 174 // Create audit writers 175 let audit: AuditWriters | null = null; 176 try { 177 audit = createAuditWriters(job.runId, auditDir); 178 } catch { 179 // Non-fatal — audit is best-effort 180 } 181 182 // Write initial transcript entry 183 audit?.transcript.write({ 184 event: "prompt_start", 185 node_id: job.dagConfig.node_id, 186 run_id: job.runId, 187 sender: job.sender, 188 task_preview: job.task.slice(0, 500), 189 }); 190 appendSessionTranscript("prompt_start", { task_preview: job.task.slice(0, 500) });
从 READY 到可审计完成
一轮执行的关键链条是 READY → 构造 DispatchEnvelope → Provider 发送 → RUNNING → Worker 事件流 → handoff → 下游 mailbox。sources/manager/src/runtime/active-runs.ts:1343、sources/manager/src/runtime/active-runs.ts:1344、sources/manager/src/runtime/active-runs.ts:1355、sources/manager/src/runtime/active-runs.ts:1357、sources/manager/src/runtime/active-runs.ts:1360、sources/manager/src/runtime/active-runs.ts:1375 把输入、稳定 sessionId、Agent 配置和能力要求装进 envelope;sources/manager/src/runtime/active-runs.ts:1389、sources/manager/src/runtime/active-runs.ts:1398、sources/manager/src/runtime/active-runs.ts:1407、sources/manager/src/runtime/active-runs.ts:1412、sources/manager/src/runtime/active-runs.ts:1420、sources/manager/src/runtime/active-runs.ts:1445 再逐个调度 READY 节点、执行预算门禁、处理一次瞬时重试并最终切到 RUNNING。
▸成功路径:显式端口驱动下游
▸失败路径:纠正预算优先,耗尽后再自动交接或终止
纠正、重试与恢复测试
仓库不是只测 happy path。纠正测试从 sources/manager/tests/dag-correction-retry.test.ts:76、sources/manager/tests/dag-correction-retry.test.ts:77、sources/manager/tests/dag-correction-retry.test.ts:81、sources/manager/tests/dag-correction-retry.test.ts:84、sources/manager/tests/dag-correction-retry.test.ts:88、sources/manager/tests/dag-correction-retry.test.ts:90 验证缺少 handoff 时节点回到 READY 并收到 correction 输入;sources/manager/tests/dag-correction-retry.test.ts:93、sources/manager/tests/dag-correction-retry.test.ts:99、sources/manager/tests/dag-correction-retry.test.ts:101、sources/manager/tests/dag-correction-retry.test.ts:103、sources/manager/tests/dag-correction-retry.test.ts:105 验证纠正次数耗尽后的自动成功端口;sources/manager/tests/dag-correction-retry.test.ts:108、sources/manager/tests/dag-correction-retry.test.ts:111、sources/manager/tests/dag-correction-retry.test.ts:113、sources/manager/tests/dag-correction-retry.test.ts:117、sources/manager/tests/dag-correction-retry.test.ts:118、sources/manager/tests/dag-correction-retry.test.ts:119 则确认可重试派发失败只重试一次并记录计数。
▸测试覆盖
测试还明确区分瞬时故障和能力不匹配:sources/manager/tests/dag-correction-retry.test.ts:122、sources/manager/tests/dag-correction-retry.test.ts:125、sources/manager/tests/dag-correction-retry.test.ts:127、sources/manager/tests/dag-correction-retry.test.ts:128、sources/manager/tests/dag-correction-retry.test.ts:131、sources/manager/tests/dag-correction-retry.test.ts:135、sources/manager/tests/dag-correction-retry.test.ts:136 用 retryable:false 模拟找不到满足能力的 Worker,要求不重试、运行失败、节点进入 FAILED。这个断言与 sources/manager/src/runtime/active-runs.ts:1421、sources/manager/src/runtime/active-runs.ts:1422、sources/manager/src/runtime/active-runs.ts:1423、sources/manager/src/runtime/active-runs.ts:1439、sources/manager/src/runtime/active-runs.ts:1440 的生产逻辑对应,避免把确定性配置错误伪装成网络抖动反复消耗预算。
76 it("reschedules a node with a correction prompt before failing the run", () => { 77 const parsed = parseDAGYaml(correctionYaml(1)); 78 createActiveRun("run-correction", parsed); 79 const dispatcher = new FlakyDispatcher({ status: "dispatched", targetType: "fake", targetId: "first" }); 80 81 expect(dispatchReadyNodes("run-correction", dispatcher)).toBe(1); 82 expect(getActiveRun("run-correction")?.dagRun.nodeStates.get("start")).toBe("RUNNING"); 83 84 const correction = requestNodeCorrection("run-correction", "start", "agent ended without DAG handoff"); 85 expect(correction.status).toBe("scheduled"); 86 expect(getActiveRun("run-correction")?.dagRun.nodeStates.get("start")).toBe("READY"); 87 88 expect(dispatchReadyNodes("run-correction", dispatcher)).toBe(1); 89 expect(dispatcher.calls).toHaveLength(2); 90 expect(dispatcher.calls[1].inputs.correction?.[0]).toContain("agent ended without DAG handoff"); 91 }); 92 93 it("auto-handoffs on a success port after correction attempts are exhausted", () => { 94 const parsed = parseDAGYaml(correctionYaml(1)); 95 createActiveRun("run-auto-handoff", parsed); 96 const dispatcher = new FlakyDispatcher({ status: "dispatched", targetType: "fake", targetId: "first" }); 97 dispatchReadyNodes("run-auto-handoff", dispatcher); 98 99 expect(requestNodeCorrection("run-auto-handoff", "start", "missing handoff").status).toBe("scheduled"); 100 dispatchReadyNodes("run-auto-handoff", dispatcher); 101 expect(requestNodeCorrection("run-auto-handoff", "start", "missing handoff again").status).toBe("exhausted"); 102 103 const run = autoHandoffAfterCorrectionExhausted("run-auto-handoff", "start", "missing handoff again"); 104 expect(run?.status).toBe("completed"); 105 expect(run?.dagRun.nodeStates.get("start")).toBe("COMPLETED");
▸已知边界 / 失败模式
- 条件边未命中时节点必须 SKIPPED,而不是因 after_dep 满足被误唤醒。
- Agent 正常结束但没有 handoff 会触发 correction;次数受 max_corrections_per_node 限制。
- 瞬时派发错误最多重试一次;能力不匹配等确定性失败不重试。
- Manager 冷重启会重放 handoff 历史,但孤儿 RUNNING 节点会被降级为失败。
值得借鉴的工程亮点
技术亮点之外,别忽略这些
HomeRail 仍是 2026 年 7 月刚创建的早期项目。sources/worker/src/audit/index.ts:57、sources/worker/src/audit/index.ts:63、sources/worker/src/audit/index.ts:70、sources/worker/src/audit/index.ts:77、sources/worker/src/audit/index.ts:79、sources/worker/src/audit/index.ts:85 能检查审计文件、校验和 sidecar 与工具事件,但 PromptRunner 的审计创建在 sources/worker/src/prompt-runner.ts:174、sources/worker/src/prompt-runner.ts:177、sources/worker/src/prompt-runner.ts:178、sources/worker/src/prompt-runner.ts:179 被明确设计为 best-effort:审计器初始化失败不会阻断任务。这有利于可用性,却意味着“任务完成”不自动等于“证据完整”,生产环境必须额外把 completeness 结果纳入发布门禁。