From 43fd72bbd7db99ae94eb8c278ddf44ca7f4a175c Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 16:01:19 +0800 Subject: [PATCH 1/9] docs(audit): add three-module functional audit evidence (DAG/MEMORY/GOAL) --- docs/audit-dag-memory-goal-2026-08-18.md | 537 +++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 docs/audit-dag-memory-goal-2026-08-18.md diff --git a/docs/audit-dag-memory-goal-2026-08-18.md b/docs/audit-dag-memory-goal-2026-08-18.md new file mode 100644 index 000000000..2762d3a45 --- /dev/null +++ b/docs/audit-dag-memory-goal-2026-08-18.md @@ -0,0 +1,537 @@ +# 功能审计:DAG / MEMORY / GOAL 三模块缺陷与证据 + +审计日期:2026-08-18 +审计对象:`origin/dev` = `f1c2c8c33`(内容等同 `origin/main` = `25a711b40`,即 PR #332 发布批次之后的当前状态) +审计范围:**功能性运行时缺陷**。配置类问题(YAML 模板内容、config knob 命名/默认值、prompt 文案、文档措辞、`LeXwDeX/opencode-dag-config` 仓库内容)不在本次范围内。 + +## 方法与证据纪律 + +1. 本地 `dev` 落后 `origin/dev` 25 个提交(缺 PR #313–#332)。审计在 `origin/dev` 的 detached worktree 上进行,避免对着过期代码下结论。 +2. 该 worktree 以 `mode=fast` 重新索引为 codebase-memory 项目 `audit-dmg-20260818`(29826 nodes / 132182 edges,0 skipped)。 +3. 三个模块由三个独立 auditor 子代理并行做首轮结构化排查(图工具 + coverage 校验)。 +4. **本文档中每一条 `file:line` 引用与代码引文,均由主会话在上述 worktree 中直接读取源码复核过。** 子代理提出但复核不成立、或严重性被证据推翻的候选项已剔除或降级(见「复核中被推翻/降级的候选项」)。 +5. 测试覆盖结论来自直接读取 `packages/opencode/test/**`(`fast` 索引不含 `*.test.ts`,因此这部分不依赖图索引)。 + +## 缺陷汇总 + +| ID | 严重性 | 置信度 | 模块 | 一句话描述 | 与 tracker 关系 | +|---|---|---|---|---|---| +| DAG-01 | High | Confirmed | DAG | 无 `output_schema` 的 reporting checkpoint 上的等值门恒为 false,整棵下游子树被静默跳过且工作流报 COMPLETED | PR #331 的不完整修复 | +| DAG-02 | High | Confirmed | DAG | `replan` / `extend` 完全不跑 `checkpointGateDiagnostics`,checkpoint 门禁在每次图变更路径上失效 | #325 的不完整修复 | +| DAG-03 | Medium | Confirmed | DAG | replan 裁决门在持久化 pause 终态失败时 **fail-open**,显式把内存调度器置为未暂停 | PR #331/#327 的不完整修复 | +| DAG-04 | Medium | Confirmed(机制)| DAG | summary publisher 把 interrupt 当成功日志吞掉;生产关停路径 uninterruptible 且无超时 | Known-#316(机制补齐,触发源仍未钉死)| +| MEM-01 | High | Confirmed | MEMORY | 周期 `prepare` 在 fence+lock 下内联跑 **3 次**模型调用(比 #324 描述的更广,含首轮 match) | Known-#324 debt 2,未偿付 | +| MEM-02 | Medium | Confirmed | MEMORY | `search` 跨 matcher 模型调用持有跨进程 identity flock | Known-#324 debt 2 后半 | +| MEM-03 | Low | Confirmed | MEMORY | 周期维护失败后用**维护前**快照渲染注入,仅 logWarning | New | +| GOAL-01 | High | Confirmed | GOAL | 崩溃丢失的 continuation 使目标被持久边界门永久搁死;**测试把错误行为钉住了** | PR #289 的过度修正 | +| GOAL-02 | Medium | Confirmed | GOAL | ESC pause 重试耗尽后仍保留 lease 注册与 active 行,却无条件清掉 `turnDriven` | PR #284 的不完整修复 | +| GOAL-03 | Low | Confirmed | GOAL | judge 传输/解析失败仍消耗 `turns_used` 并盖上 `last_judged_msg` | New | +| GOAL-04 | Low | Confirmed | GOAL | 启动扫描对非 idle 会话静默跳过,无日志、无重新武装 | New | + +--- + +## DAG + +### DAG-01(High)等值条件门在字符串输出上恒为 false,静默跳过整棵子树并把工作流标为 COMPLETED + +**位置**:`packages/opencode/src/dag/runtime/eval.ts:133-147`、`packages/opencode/src/dag/runtime/loop.ts:141-156`、对照点 `packages/opencode/src/dag/runtime/loop.ts:664-672` + +**证据 1 — 路径解析在字符串上返回 `undefined`,不报错**(`eval.ts:133-147`): + +```ts +function resolvePath(path: string, source: Record): unknown { + const parts = path.split(".") + let current: unknown = source + if (parts[0] && parts[0] in source) { + current = source[parts[0]] + parts.shift() + } + for (const part of parts) { + if (current == null) return undefined + current = (current as Record)[part] + } + return current +} +``` + +**证据 2 — 数值比较会 loudly fail,等值比较不会**(`eval.ts:56-70`): + +```ts + if (op === ">" || op === "<" || op === ">=" || op === "<=") { + if (typeof lhs !== "number" || !Number.isFinite(lhs)) + return { ok: false, error: `condition "${condition}": left operand resolved to ${describeOperand(lhs)}, expected a finite number` } + ... + } + if (op === "==") return { ok: true, value: lhs === rhs } +``` + +`undefined === "ACCEPT"` → `false`,`{ ok: true, value: false }`,调度层走 skip 分支(`loop.ts:152-155`): + +```ts + if (!condResult.value) { + yield* dag.nodeSkipped(dagID, nodeID, "condition_false").pipe(Effect.ignore) + continue + } +``` + +**证据 3 — 同一文件 500 行后的姊妹门做了字符串解析,本处没有**(`loop.ts:664-672`,PR #331 只补了这一处): + +```ts + // A checkpoint output can arrive as a raw string (no + // output_schema, or a string-typed child reply); parse it + // before matching the verdict so a string-typed + // {"verdict":"replan"} cannot bypass the gate (the spin + // behind issue #322). + const gateOutput = typeof node?.output === "string" + ? Option.getOrUndefined(parseJsonOption(node.output)) + : node?.output +``` + +**证据 4 — 无 `output_schema` 的节点确实以裸字符串完成**(`spawn.ts:482-516`):`if (input.outputSchema)` 分支走 `settleCapturedOutput`;`else` 分支 `const rawText = result.parts.findLast(...)`,最终 `dag.nodeCompleted(input.dagID, input.nodeID, rawText)`。 + +**证据 5 — authoring 主动把作者引导到这个形状**(`validation.ts:600-604`): + +```ts + hint: + `Gate "${dependent.id}" with condition: "${checkpoint.id}.output. == ..." (e.g. on its verdict),` +``` + +`checkpointGateDiagnostics`(`validation.ts:584-609`)只检查 `conditionReference(dependent.condition) === checkpoint.id`,**从不要求该 checkpoint 声明 `output_schema`**;`conditionReferenceErrors`(`validation.ts:459-467`)同样只检查引用 id 在 `depends_on` 里。 + +**可达性**:Block 编译路径上 `verify` → `VERIFICATION_SCHEMA`、`review` 决策节点 → `GENERAL_VERDICT_SCHEMA`/`DIFF_REVIEW_SCHEMA`、`coding`/`prototype` → `IMPLEMENTATION_SCHEMA`(`blocks.ts:251,271,305-309`),**这些默认路径是安全的**。暴露面是: +- `synthesize` block:`reportToParent: block.report_to_parent ?? block.kind === "synthesize"`(默认 **true**)而 `outputSchema` 落到 `undefined`(`blocks.ts:300-309`)——一旦它有 dependents,就同时是「reporting checkpoint」且「无 schema」; +- 任何被作者显式设成 `report_to_parent: true` 的 `explore`/`plan`/`debug`/`synthesize` block(ultra-flow 的 gate checkpoint 正是这种形状,见 #323 里的 `cp-after-exploration`); +- 全部 low-level `nodes:` 手写 checkpoint。 + +**为何是缺陷**:违反 `dag/CONTEXT.md` 不变量「Dependents of a reporting checkpoint must be gated on its output」。门存在但结构上惰性——它不是「按裁决放行」,而是**无条件否决**。与 PR #331 建立的一致性也自相矛盾:字符串归一化只补在裁决匹配上,没补在门禁真正依赖的 `evaluateCondition` 上。 + +**运行时影响**:checkpoint 通过 → 所有被门控的 dependent 以 `condition_false` 被跳过 → `spawnReady` 的 cascade 定点循环逐波发布 `NodeSkipped(orphan_cascade)`(`loop.ts:119-126`)→ `checkCompletion` 认为 `isComplete()` → `dag.complete(dagID, { skipReviewGate: true })`(`loop.ts:338`,**显式绕过 review gate**)。操作者看到的是一个状态为 **COMPLETED** 的工作流,而 checkpoint 之后的整个半图从未运行。无错误、无失败、无告警。 + +**测试覆盖**:未覆盖。`test/dag/dag-checkpoint-gate.test.ts` 全部是 authoring 层断言(`action: "start"`),没有任何用例在运行时把一个无 schema 的 checkpoint 输出喂给 `evaluateCondition`。 + +**建议修法**:`loop.ts:141-152` 在构造 `outputs` 时对字符串输出做与 `loop.ts:667` 相同的 `parseJsonOption` 归一化;并在 `checkpointGateDiagnostics` 中要求被门控引用的 checkpoint 声明 `output_schema`(否则该门在运行时不可满足),把它变成 authoring 期错误。 + +--- + +### DAG-02(High)`replan` / `extend` 跳过 `checkpointGateDiagnostics`,门禁在每次运行时图变更路径上失效 + +**位置**:`packages/opencode/src/dag/authoring.ts:136`、`packages/opencode/src/dag/validation.ts:974-984` + +**证据 1 — 非 `start` 动作整体关闭结构检查**(`authoring.ts:136`): + +```ts + structural: input.action === "start", +``` + +动作集合恰为 `start | extend | replan`(`authoring.ts:197-215` `decodeAction`)。 + +**证据 2 — `structural === false` 把 checkpoint 门与其余结构检查一起跳过**(`validation.ts:974-984`): + +```ts + const diagnostics = + input.structural === false + ? [] + : [ + ...structuralDiagnostics({ ... }), + ...checkpointGateDiagnostics(input.nodes, input.config.node_defaults), + ] +``` + +**证据 3 — 全仓唯一调用点**: + +``` +packages/opencode/src/dag/validation.ts:584:export function checkpointGateDiagnostics( +packages/opencode/src/dag/validation.ts:983: ...checkpointGateDiagnostics(input.nodes, input.config.node_defaults), +``` + +(其余命中只有 ADR 文档 `docs/adr/0003-reporting-checkpoint-gating.md:30`,其自述「Enforcement lives in `checkpointGateDiagnostics`, wired only into …」。) + +**为何是缺陷**:`dag.ts:570-576` 的注释声称 replan 走的是「the create/replan parity the spec requires: one authority, two entry points」,但这份 parity 恰好在 checkpoint 门上不成立。ADR-0003 把 enforcement point 限定在 authoring 边界,而 authoring 边界又对 replan/extend 自我关闭——两者叠加后,**没有任何权威**在图变更路径上施加这条不变量。而 replan 正是编排器在每个纠偏周期都要走的路径,包括 replan 裁决门自己指示 parent 去做的那次。 + +**运行时影响**:一次 replan 可以把 dependent 直接挂到 reporting checkpoint 上且不带 `condition`。引擎会在 checkpoint 完成的瞬间 spawn 该 dependent——早于 parent 读到裁决。运行时兜底网(`loop.ts:670-703`)只认字面 `verdict: "replan"`;返回 `reject` / `fail` / `needs_changes` 的 checkpoint 会让未门控的 dependent 在已被否决的方向上继续跑,无门、无暂停、无诊断。 + +**测试覆盖**:未覆盖。`dag-checkpoint-gate.test.ts` 的 7 个用例全部使用 `action: "start"`。 + +--- + +### DAG-03(Medium)replan 裁决门在 pause 终态失败时 fail-open + +**位置**:`packages/opencode/src/dag/runtime/loop.ts:681-703` + +**证据**: + +```ts + const paused = yield* Effect.gen(function* () { + const attemptPause = dag.pause(dagID).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ) + if (yield* attemptPause) return true + if (yield* attemptPause) return true + const wf = yield* store.getWorkflow(dagID).pipe(Effect.orDie) + if (wf?.status !== "paused") + yield* Effect.logWarning("DagLoop pause on replan verdict failed", { dagID, nodeID }) + return wf?.status === "paused" + }) + entry.runtime.setPaused(paused) +``` + +两次尝试都失败且持久行不是 `paused` 时,`paused === false`,第 697 行**显式把内存 runtime 置为未暂停**,唯一后果是一条 WARN。调度抑制只作用于本次事件(`loop.ts:703`): + +```ts + if (!gateReplan && !entry.runtime.isStepMode()) yield* spawnReady(dagID) +``` + +**可达性**:`spawnReady` 会被后续任意刺激再次触发——`NodeCancelled`(`loop.ts:739` 附近)、`WorkflowStepped`(`loop.ts:787` 附近)、`WorkflowResumed`、`WorkflowReplanned`(`loop.ts:882` 附近)、`recoverWorkflow`(`loop.ts:465` 附近);`getReadyNodes()` 只在 `this.paused` 时返回空,而该标志刚被置 false。 + +**为何是缺陷**:裁决门必须 fail-**closed**。PR #331 加固了瞬态情形(重试两次后查持久状态),但终态情形反向失败:正确动作是无论持久 pause 是否被拒都 `setPaused(true)`,代码做的恰好相反。另注:`Effect.catch` 只处理 error channel——`dag.pause` 抛出的 **defect** 会逃到 `guarded("NodeCompleted")`(`loop.ts:356-357`),整个 handler 被丢弃,pause 从未发生且连门专属的 WARN 都不会打。 + +**运行时影响**:checkpoint 返回 `verdict: "replan"`(显式否决)、持久 pause 被拒(例如工作流处于 `stepping`,或与并发控制操作竞争),工作流继续在被自己 checkpoint 否决的方向上调度。 + +**测试覆盖**:未覆盖(无用例注入持久性 pause 失败)。 + +--- + +### DAG-04(Medium,Known-#316)summary publisher 把 interrupt 当成功吞掉;生产关停 uninterruptible 且无超时 + +**位置**:`packages/opencode/src/dag/runtime/summary-publisher.ts:151-170`、`packages/opencode/src/server/global-lifecycle.ts:16-25` + +**证据 1 — listener 边界把 interrupt cause 转成成功的日志行**(`summary-publisher.ts:163-170`): + +```ts + return schedulePublishByDag(dagID, evt.location.workspaceID).pipe( + Effect.catchCause((cause) => + Effect.logWarning("DagSummaryPublisher: failed to publish summaries", { dagID, cause }), + ), + Effect.forkIn(scope), + Effect.asVoid, + ) + }) + yield* Effect.addFinalizer(() => unsubscribe) +``` + +`coalesceLatest` 内层刻意**重新抛出** interrupt(`summary-publisher.ts:111-113`): + +```ts + if (Exit.isFailure(outcome) && Cause.hasInterrupts(outcome.cause)) { + return yield* Effect.failCause(outcome.cause) + } +``` + +——但外层这个 `catchCause` 没有 `Cause.hasInterrupts` 再抛,作者在内层建立的取消语义在外层被抹掉。仓库内正确写法出现过三次(`spawn.ts:255-257`、`spawn.ts:545`、`loop.ts:1409-1411` 附近),此处是唯一例外。 + +**证据 2 — 生产关停路径无超时且不可中断**(`global-lifecycle.ts:17-25`): + +```ts + yield* Effect.gen(function* () { + yield* options?.swallowErrors + ? store.disposeAll().pipe(Effect.catchCause((cause) => Effect.logWarning("global disposal failed", { cause }))) + : store.disposeAll() + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) +``` + +exerciser 用 `bounded("disposeApps", ...)` 兜住,生产路径没有等价保护。这直接回答 #316 的验收项 3:**真实 server 关停走的是同一 dispose,且比测试路径更脆弱**。 + +**未钉死的部分(对 #316 的诚实缺口)**:本次没有定位 dispose 期间持续发 `dag.*` 事件的组件。已排除的候选:`spawnNode` teardown 在 interrupt 时不发节点事件(`spawn.ts:545` 提前返回);publisher 自身发出的 `dag.workflow.summary.updated` 不在 `SUMMARY_TRIGGER_EVENTS` 里,无法自触发。放大机制已证明,触发源未证明。 + +**测试覆盖**:`dag-summary-publisher.test.ts` / `dag-summary-publisher-behavior.test.ts` 存在,但均未覆盖 dispose 期间的 interrupt 语义。 + +--- + +## MEMORY + +### MEM-01(High,Known-#324 debt 2)周期 `prepare` 在 fence+lock 下内联跑 3 次模型调用 + +**位置**:`packages/opencode/src/memory/memory.ts:474-505`;对照的成文规则在 `packages/opencode/src/memory/memory.ts:283-285` + +**证据 1 — 模块自己写下的锁纪律**(`memory.ts:283-285`): + +```ts + // Serialize the identity-liveness recheck and the per-project lock around + // the store write only; the model calls that produce the update run + // outside the fence/lock so a long reasoning call cannot wedge or leak it. +``` + +**证据 2 — `prepareUnsafe` 违反它**(`memory.ts:474-505`): + +```ts + const live = yield* fence.withLiveIdentity( + current.project.id, + Effect.gen(function* () { + yield* lock.withProject(current.project.id)( + Effect.gen(function* () { + const topics = yield* store.readTopics(current.project.id) + const maintained = due + ? yield* maintain({ ... }) + : topics + const rendered = shouldMatch + ? (yield* select({ ... })).rendered + : (data.sessions.get(input.sessionID)?.turn.rendered ?? []) +``` + +`maintain`(`memory.ts:376-397` 的模型半部 `proposeMaintenance`)发起 **2 次** `modelCalls.generate`;`select`(`memory.ts:408` 起)再发 **1 次** matcher 调用。三次模型往返全部在 `memory-identity:` 跨进程 flock + 项目内存互斥锁之内。 + +**比 #324 描述的更广**:issue 只指出 `prepareUnsafe` 的 due 分支跑 maintain。实际上 `shouldMatch` 分支的 `select` 也在锁内——即**每个会话首个真实用户轮**都会跨一次模型调用持有跨进程 identity flock,与 `turn_interval` 无关。 + +**可达性**:`SystemPrompt.memory` → `memory.prepare(...)`(`session/system.ts`)→ `prepare`(`memory.ts:519`)→ `prepareUnsafe`(`memory.ts:442`)。`Memory.node` 已在交付的 httpapi app 图中(PR #313),为生产活代码。 + +**运行时影响**:`model.ts` 已退役墙钟(见「验证为正确」),`CONNECT_TIMEOUT`/`IDLE_TIMEOUT` 各 60s 且每个 chunk 重置——这意味着一条持续流式的慢推理调用可以**任意长时间**持有该锁。等待者在 `EffectFlock` 的 5 分钟后拿到 `LockTimeoutError`:并发的 `/compact` checkpoint、`memory_search`、`/memory on|off`、worktree `remove`/`reset` 的 admission,以及 **identity upgrade**(`ProjectIdentityMigration.migrate` 用同一把 key)都会在 5 分钟僵持后失败。同时,落在 `turn_interval` 边界上的每个 prompt 都要串行等两次模型调用才能组装系统提示。 + +**修复要点**:把 `prepareUnsafe` 的 due 分支改为与 checkpoint 路径同构——复用 `kickMaintenance`/`backgroundMaintain` + `applyUpdate`(只有 commit 拿锁);`select` 同理,只在写 `markMatched` 时拿锁。注意这会改变「周期维护同步」测试的语义(#324 已预告)。 + +--- + +### MEM-02(Medium,Known-#324 debt 2 后半)`search` 跨 matcher 模型调用持有 identity flock + +**位置**:`packages/opencode/src/memory/memory.ts:577-600` + +**证据**: + +```ts + // Cross-process identity guard (see checkpointUnsafe): MemoryIdentityFence + // re-checks identity liveness under the identity lock before matching/writing. + const live = yield* fence.withLiveIdentity( + current.project.id, + Effect.gen(function* () { + return yield* lock.withProject(current.project.id)( + Effect.gen(function* () { + ... + const topics = yield* store.readTopics(current.project.id) + const selected = yield* select({ ... }) +``` + +**为何是缺陷**:与 MEM-01 同类。即使接受「同查询合并」的刻意取舍,**跨进程 identity fence** 也不需要覆盖 matcher 调用,只需覆盖 `markMatched` 写入。代码注释只解释了 liveness recheck 的理由,**没有**声明「刻意跨模型调用持锁」——而 #324 的验收要求正是把这个取舍显式写进规格。 + +**运行时影响**:一次 `memory_search` 会在 matcher 模型调用期间阻塞 `/compact` checkpoint、`/memory` 开关、worktree `remove`/`reset` 的 admission 以及 identity upgrade,上限到 `EffectFlock` 的 5 分钟等待超时。 + +--- + +### MEM-03(Low,New)周期维护失败后用维护前快照渲染注入 + +**位置**:`packages/opencode/src/memory/memory.ts:482-495` + +**证据**: + +```ts + ? yield* maintain({ ... }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("periodic MEMORY maintenance failed", { cause }) + return topics + }), + ), + ) + : topics +``` + +**为何是缺陷**:`maintain` 内部已经执行过 `store.updateTopics` 提交(`memory.ts:386-395`)。若失败发生在提交之后,恢复值 `topics` 是**维护前**快照,随后 `select`/渲染(`memory.ts:496-505`)基于它工作——本轮注入的 Memory 上下文与已落盘的持久修订不一致,且只有一条 `logWarning`,不向用户暴露冲突。 + +**运行时影响**:瞬态不一致(下一次 `prepare` 自愈),不是数据丢失。严重性 Low。 + +--- + +## GOAL + +### GOAL-01(High)崩溃丢失的 continuation 使目标被持久边界门永久搁死;测试把错误行为钉住了 + +**位置**:`packages/opencode/src/goal/loop.ts:245-264`(门)、`packages/opencode/src/goal/goal.ts:746-749`(写入点)、`packages/opencode/test/goal/e2e-loop.test.ts:1836-1906`(钉错的测试) + +**证据 1 — 门的实现与自述理由**(`loop.ts:245-264`): + +```ts + // issue #285 — durable boundary gate (scan path only). ... + // While the session window still ends on that same message, no new progress has landed — + // re-judging would inflate turns_used and dispatch a duplicate continuation. ... + if (scanResume && goalState.last_judged_msg) { + const win = yield* sessions.messages({ sessionID, limit: 20 }).pipe(...) + const lastSeen = [...win].reverse().find((m) => m.info.role === "assistant") + if (lastSeen && lastSeen.info.id === goalState.last_judged_msg) return + } +``` + +**证据 2 — 只有 continue 提交会写 `last_judged_msg`**(`goal.ts:746-749`): + +```ts + // issue #285: record the judged boundary for the durable scan gate. + ...(judged !== undefined ? { last_judged_msg: judged } : {}), +``` + +`blocked` 分支(`goal.ts:713-721`)与 `resume`(`goal.ts:561-575`)都不写不清;`GoalState.advance`(`state.ts:62`)原样带下去。 + +**证据 3 — 测试明确把这个场景当成「应跳过」并断言不派发 continuation**(`e2e-loop.test.ts:1871-1906`): + +```ts + // Commits one continue evaluation ahead of the (re)boot — models a process + // that crashed right after the commit, before the continuation produced an + // assistant message. + const commitPriorBoundary = (sid: SessionID) => ... + + it.instance("scan with an unchanged boundary skips re-evaluation (no inflation)", () => + ... + expect(judgeCalls).toBe(0) + expect(continuationCalls).toBe(0) + const g = yield* goal.load(sid) + expect(g?.turns_used).toBe(1) +``` + +**为何是缺陷**:门把两种状态混为一谈—— +- 「边界已判定,continuation 已完成」→ 跳过是正确的(避免 turn 膨胀 + 重复派发); +- 「边界已判定,continuation 随进程崩溃丢失」→ 跳过是**错误的**,因为重启后不存在任何在飞的 continuation,跳过意味着没有任何东西会驱动这个目标。 + +测试同时断言了 `judgeCalls === 0`(正确:不该重判,否则 turns 膨胀)和 `continuationCalls === 0`(错误:目标被留在 `active` 且无驱动者)。正确行为应是 **跳过 judge、但仍派发 continuation**。 + +**可达性与永久性**:窗口是「continue 提交 / `resume` kick 之后、下一条 assistant 消息落库之前」的崩溃。`/goal resume` 返回 `type: "kick"`(`goal.ts:831-835`),由 prompt.ts 派发,同样落在这个窗口内。搁死是**跨重启永久的**:每次启动扫描都命中同一个门而 `return`,`last_judged_msg` 因为不再判定而永不推进。D6 zombie 守卫也救不了它——`isStaleZombie` 要求 `turns_used === 0`(`loop.ts:90-101`),而此时 `turns_used >= 1`。唯一出路是用户主动向该会话发消息(走 `scanResume=false` 的活 idle 路径)。 + +**运行时影响**:这正是 #283 / #289 想消灭的 silent-stall 类问题——目标持久停在 `active`,无驱动、无日志、无暂停原因,直到用户偶然与该会话交互。 + +**测试覆盖**:**测试钉住了错误行为**(`e2e-loop.test.ts:1889-1906`)。修复必然要改这条断言:把 `expect(continuationCalls).toBe(0)` 改为 `toBe(1)`,同时保留 `judgeCalls === 0` 与 `turns_used === 1`。 + +--- + +### GOAL-02(Medium)ESC pause 重试耗尽后仍保留 lease 注册与 active 行,却无条件清掉 `turnDriven` + +**位置**:`packages/opencode/src/goal/goal.ts:246-270` + +**证据**: + +```ts + if (paused) { + yield* automation.unregister(sessionID, { kind: "goal", id: paused.goal_id ?? "legacy" }).pipe( + Effect.ignore, + ) + } else { + yield* Effect.logError( + "goal pause on cancel failed after retries — goal may resurrect on next idle", + { sessionID, cause: lastCause ? Cause.pretty(lastCause) : "unknown" }, + ) + } + turnDriven.delete(sessionID) + return paused +``` + +**为何是缺陷**:PR #284 加的重试循环 + 大声日志是修复的正确一半。失败分支与模块内其他所有 pause 点不对称——`pauseGoal`(`loop.ts:114-119`)、`pause`(`goal.ts:534` 附近)、派发失败处理(`loop.ts:564` 附近)都把 pause 与 `automation.unregister` 成对处理。这里在耗尽后:持久行仍 `active`、lease 注册仍在,**而 `turnDriven.delete(sessionID)` 无条件执行**——进程内的 ESC 来源信息被丢掉,持久态与 lease 却仍宣称「goal 拥有该会话且处于活跃」。 + +**运行时影响**:ESC + 三次 pause 写入失败后,下一个 idle 事件重入 `afterIdle`,`status === "active"` 通过、claim 成功(注册完好)、`shouldPreempt` 返回 false(ESC 不产生用户消息,`goal.ts:240-245` 的注释已承认这点),目标复活并派发用户已显式中止的 continuation。日志让它可见,但没让它自洽;丢掉 `turnDriven` 还意味着**复活轮上的第二次 ESC 不再走 goal pause 快路径**。 + +**测试覆盖**:只钉了成功路径。`test/goal/turn-scope.test.ts:76-110` 在健康 DB 上验证 pause 与无活跃目标时的 no-op,没有用例注入持续性 DB 失败。 + +--- + +### GOAL-03(Low)judge 传输/解析失败仍消耗 turn 预算并盖上 `last_judged_msg` + +**位置**:`packages/opencode/src/goal/judge.ts:84-89`(fallback)、`packages/opencode/src/goal/goal.ts:733-749`(应用点) + +**证据**: + +```ts + Effect.catchCause(() => + Effect.succeed({ + verdict: "continue", + reason: "judge transport error (timeout or network) — counting toward pause budget", + parseFailed: true, + } satisfies JudgeResult), + ), +``` + +continue 分支随后无条件自增并记录边界: + +```ts + const turnsUsed = GoalState.nni(state.turns_used + 1) + ... + ...(judged !== undefined ? { last_judged_msg: judged } : {}), +``` + +**为何是缺陷**:fail-open 本身是成文的刻意设计(一次抖动不应停摆,由 `MAX_CONSECUTIVE_PARSE_FAILURES` 兜底),`judge.ts:70-84` 的注释解释得很清楚。真正不一致的是**预算记账**:一次 judge 从未返回裁决的轮次,仍然消耗用户 `max_turns` 的一格,并且仍然像真判过边界一样盖上 `last_judged_msg`(后者与 GOAL-01 的搁死风险叠加)。计数器在任一成功时重置(`goal.ts:693` 附近),因此在间歇性成功的不稳定 provider 下可以无限烧预算而永不触发自动暂停。 + +**运行时影响**:不可靠 judge 模型下目标预算被未评估的轮次吃掉,导致提前「预算耗尽」暂停。可通过 `/goal resume` 恢复,严重性 Low。 + +**测试覆盖**:测试把当前行为当作预期钉住(`test/goal/judge.test.ts:99-145` 断言 `parseFailed: true` + `verdict: "continue"`;`test/goal/goal.test.ts:641-710` 断言计数器爬到自动暂停)。「失败 judge 应对预算中性」这一点没有任何断言。 + +--- + +### GOAL-04(Low)启动扫描对非 idle 会话静默跳过,无日志、无重新武装 + +**位置**:`packages/opencode/src/goal/loop.ts:666-679` + +**证据**: + +```ts + const scanForActiveGoals = Effect.fnUntraced(function* (snapshot: ReadonlyArray) { + for (const sessionID of snapshot) { + const current = yield* status.get(sessionID) + if (current.type !== "idle") continue + yield* triggerEvaluation(sessionID, true).pipe( + Effect.catchCause((cause) => + Effect.logWarning("goal startup scan failed for session", { sessionID, cause: Cause.pretty(cause) }), + ), + ) + } + }) +``` + +**为何是缺陷**:注释(`loop.ts:656-659`)以「a session mid-turn is skipped and will be driven by its own turn-end idle event」为理由。这在本进程启动的轮次上成立,但扫描发生在 boot、本进程尚未启动任何轮次之时;注释自己也承认「At startup the status map is empty (get defaults to idle), so this only filters sessions that genuinely flipped busy between bootstrap and the scan」。该 `continue` 是裸跳过:无日志、无重试义务——与 lease 的 `blockedGoalClaims` 重触发机制(记录重试义务)不同。快照在 builder 期一次性捕获,没有任何路径重新武装扫描。 + +**运行时影响**:窄但真实的恢复漏洞——在扫描时刻显示 busy 的会话既不被评估也不被记录,目标持久停在 `active` 且休眠,直到无关的用户交互。因为窗口需要 boot 期恰好 busy,实际概率低,故 Low。 + +--- + +## 验证为正确的部分(本次特意检查并确认无缺陷) + +**MEMORY** +- **#324 debt 1(SSE 逐 chunk 存活判定)已真正偿付。** `model.ts:13-14` 把 `CONNECT_TIMEOUT` 与 `IDLE_TIMEOUT` 分成两个独立 60s 预算;`drainWithLiveness`(`model.ts:84-124`)在遍历 `result.fullStream` 的**每次**迭代都 `arm(input.idleTimeout)`,且先重置再判断 `part.type === "error"`,因此没有任何 chunk 种类(含 reasoning delta)被排除在看门狗重置之外。生产路径已无墙钟:`make`(`model.ts:48-67`)只在 `input.timeout !== undefined` 时套 `Effect.timeoutOrElse`,而生产 `layer` 构造 `make({ execute })` 不传 `timeout`。 +- **#328 的 json 词保证**:`requireJsonToken`(`model.ts:71-74`)在 system/prompt 都不含 `/json/i` 时追加 `JSON_HINT`,且在每次 `generate` 上生效(`model.ts:53`)。 +- **#313 的装配修复在可枚举的图上是完整的**:`Memory.node` 在 httpapi `server.ts` 的 app 图中,`Memory.defaultLayer` 在 `AppLayer`;`BootstrapLayer` 不含 Memory,但其唯一消费者 `project/bootstrap.ts` 走 `Effect.serviceOption` 并按设计 no-op。 +- 迁移「先写持久副本再消费 legacy」三阶段实现正确(`identity-migration.ts:106-184`),`sameContent` 正确忽略 controller-owned 元数据(`identity-migration.ts:52-71`);legacy 文件删除前重读比对(`admission.ts:129-139`、`243-250`);admission 缓存只在 `unresolved === 0` 时写入,worktree `remove`/`reset` 先 invalidate 再 `ensure` 并传完整目录快照;`writeSnapshot` 以 manifest 发布为单一提交点(`store.ts:239-268`);strict/lenient 读分离正确;global identity 下 inert 正确;后台维护 fiber 绑定 layer scope 且槽位释放无泄漏。 + +**GOAL** +- **单事务 transition 语义正确**(`goal.ts:335-413`):读、`decide`、写/删全在一个 `db.transaction(..., { behavior: "immediate" })` 内,外包 `Effect.uninterruptible`,事件在提交后才发布;接口上每个持久变更都走这个 seam。 +- **终态 done 正确**:`goal_outcome` 插入与 `goal_state` 删除同事务,不留中间清理义务。 +- **revision / goal_id 栅栏正确**:`matchesExpected` 在事务内的 `decide` 回调中求值,延迟裁决无法应用到被替换的目标或已 bump 的 revision。 +- **generation fence 未跨 provider 执行**:`prepareIfIdle` 返回延迟的 `AfterFence`,`handoff` 在 `activate` 后释放会话锁再返回 `result`,GoalLoop 在锁外 await;`promptIfIdle` 仍是最终 idle 守卫,Goal 从不用裸 `prompt` 驱动轮次。 +- **lease 优先级正确**:`owner()` 先返回任何 `dag` 再返回 `goal`,最后一个 DAG unregister 的 dag→非 dag 转换在 per-session 锁下原子计算。 +- **loop fiber 生命周期与订阅清理正确**:`registerFiber` 中断前任,`clearFiberIf` 按身份作用域且不中断;idle 订阅与扫描 fiber 都 `forkScoped`。 +- **judge snippet 窗口一致**:`JUDGE_RESPONSE_SNIPPET_CHARS = 4000` 与调用方 `.slice(-4000)` 及 `renderJudgeUserPrompt` 的再切片一致。 +- `/goal resume` 命令路径确实接线(`goal.ts:807-835`),返回 `kick` 由 prompt.ts 派发。 + +**DAG** +- `spawn.ts` 的 `makeDeadlineWatcher` 在各失败模式下正确(store 读重试而非终止监督、瞬态 defect 视为「无法否证所有权」、上限与升级均重试并重抛 interrupt),`Effect.ensuring` 中断 watcherFiber 无泄漏。 +- watcher 替换先中断旧 watcher 再覆写;终态 handler 在 `NodeCompleted` 与 `NodeSkipped` 上都中断。 +- 三个 adoption 入口都在首次 yield 前同步预留 `recovering`、经 `Effect.ensuring` 释放、并以原子 `store.tryClaimAdoption(dagID)` 收口。 +- 陈旧事件仲裁正确:节点终态 handler 重读持久行并丢弃状态已不匹配的事件;`refreshControlFlags` 从 DB 重建 pause/step 标志。 +- rev-view 过滤正确:所有重建输入都用 `store.getCurrentNodes`,被取代的行无法重新播种失败。 +- **有 `output_schema` 的节点若未成功调用 `submit_result` 会 fail(`verdict_fail`)而非以字符串完成**(`capture.ts:143-150` `settleCapturedOutput`),且该判定为 live 路径与崩溃恢复共用——这正是 DAG-01 未命中默认 block 路径的原因。 +- review 裁决门 fail-closed:`reviewVerdict`(`review-lifecycle.ts:323-327`)要求对象并拒绝字符串。 +- `evaluateCondition` 的数值比较在非数/非有限操作数上 loudly fail(与 DAG-01 的等值比较形成对照)。 +- wake 持久性(#326):`loop.ts:1384-1424` 在持有 lease 时于 admit 时刻持久化 `wake_reported`,lease 丢失/generation 竞争降级为稍后重试,正确重抛 interrupt。 + +## 复核中被推翻/降级的候选项 + +- 子代理最初把 DAG-01 判为「默认 block 路径即命中」。复核 `blocks.ts:251,271,305-309` 与 `capture.ts:143-150` 后**推翻**:`verify`/`review`/`coding`/`prototype` 均声明 schema,且缺 `submit_result` 会 fail 而非以字符串完成。暴露面收窄为 `synthesize` 默认 reporting、作者显式 `report_to_parent: true` 的无 schema block、以及 low-level 手写节点。严重性仍为 High(后果是静默 COMPLETED),但可达性描述已按证据改写。 +- 子代理把 GOAL-01 描述为「blocked → resume」路径。复核后发现该路径下 tail assistant 通常已推进、门不命中;**真正的机制**是「continue 提交 / resume kick 之后、下一条 assistant 落库之前崩溃」,且 `e2e-loop.test.ts:1871-1906` 把这个场景当成「应跳过」显式钉住。结论更强而非更弱。 +- 子代理的 MEM-02(原编号)称「维护提交后失败导致渲染陈旧」置信度 Likely。复核确认代码事实成立,但影响为瞬态自愈,**降级为 Low**(本文 MEM-03)。 +- 子代理的 GOAL-02(原编号,启动扫描 busy 跳过)评 Medium。依据代码自述「启动时 status map 为空、默认 idle」,**降级为 Low**(本文 GOAL-04)。 +- 关于 `Goal.resume` 无生产调用方的初步怀疑**推翻**——是我的 `rg -r` 误用(`-r` 是替换标志)污染了输出;实际接线在 `goal.ts:807`。 + +## 局限 + +1. **未运行测试套件。** 所有并发/竞态结论来自静态阅读控制流,未做动态验证。DAG-01/02/03、MEM-01/02、GOAL-01/02 的修复都应配回归测试后再动态确认。 +2. **#316 触发源未钉死。** DAG-04 证明了放大机制与生产暴露面,但未定位 dispose 期间持续发 `dag.*` 事件的组件;未阅读 `EventV2Bridge.listen`、`InstanceStore.disposeAll`、`InstanceState` scope-close 实现。 +3. **`loop.ts`(1668 行)未逐行读完。** 已读约 62-160、300-360、374-500、543-712、725-800、1220-1290、1380-1424 等区段;`~160-300`、`~945-1107`、`~1290-1380`、`~1520-1639` 未读。这些区段内的缺陷不会被本次发现——DAG 的**否证性结论不具备穷尽性**。 +4. **DAG 模块内未审计的文件**:`blocks.ts` 的 `aggregateParallelWriters`(#299 并行 writer 聚合)、`templates/*`、`workflows.ts`、`admission.ts`、`recovery.ts`、`capture.ts` 的 `validateAgainstSchema`(cyclomatic 29 / cognitive 56,且直接在 structured-output 路径上)、`output-ref.ts`、`tool/workflow.ts` 主体、httpapi dag handlers。未验证的不变量:「一个用户目标至多一个 live DAG」、`portable` 不加载环境目录 / `environment` 验证模型可用性的分工、model-facing schema 隐藏身份字段、Runtime Admission 与 Authoring Check 的职责分离。 +5. **Effect v4 / effect-smol 语义未查证参考实现**:DAG-04 关于 scope finalizer LIFO 顺序与 `Effect.forkIn` 在关闭中 scope 上行为的推理未对照 `effect-smol` 源码。`Effect.catchCause` 捕获 interrupt cause 这一点已由代码内三处 `Cause.hasInterrupts` 显式再抛的既有写法反证成立。 +6. **索引覆盖为 best-effort。** `check_index_coverage` 对所引用路径报 `no_recorded_issue`,但按工具自身声明这不构成完整性证明;`*.test.ts` 全部不在 `fast` 索引内,测试相关结论均来自直接文件读取。 +7. **未审计 `packages/opencode/src` 之外的消费者**(TUI / desktop / CLI 各自的组合根),因此若存在 packages/opencode 之外的 Memory / Goal / Dag 消费者,本次不会发现其装配缺陷。 + +## 建议的处置顺序 + +| 优先级 | 动作 | +|---|---| +| P0 | DAG-01 + DAG-02 一并修:`loop.ts` 条件求值前做字符串归一化;`checkpointGateDiagnostics` 追加「被门控 checkpoint 必须声明 `output_schema`」;把 checkpoint 门接入 `replanStructuralDiagnostics`(或让 `structural` 不再对 replan/extend 整体关闭)。回归用例覆盖 `action: "replan"` 与运行时字符串输出两条。 | +| P0 | GOAL-01:把边界门从「抑制驱动」改为「抑制重判」——命中门时跳过 judge 但仍派发 continuation。必须同步修改 `e2e-loop.test.ts:1889-1906` 的 `continuationCalls` 断言。 | +| P1 | DAG-03:pause 终态失败时改为 `entry.runtime.setPaused(true)` fail-closed;并把 `dag.pause` 的 defect 纳入同一处理。 | +| P1 | MEM-01:`prepareUnsafe` 的 due 分支与 `shouldMatch` 分支改用 `backgroundMaintain` / `applyUpdate` 形状,仅提交拿锁。归入 #324。 | +| P1 | DAG-04:`summary-publisher.ts:166` 补 `Cause.hasInterrupts` 再抛;`global-lifecycle.ts` 的 `disposeAll` 加有界超时。归入 #316(触发源仍需独立定位)。 | +| P2 | GOAL-02:pause 耗尽时保持 `turnDriven` 或同步 unregister,使持久态、lease、进程内标记三者自洽。 | +| P2 | MEM-02:把 identity fence 缩到 `markMatched` 写入;并在规格中显式声明「同查询合并」这一取舍(#324 验收项)。 | +| P3 | MEM-03、GOAL-03、GOAL-04。 | From 71ab1bdf6d9d11264733258972de7149072c123a Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 16:47:45 +0800 Subject: [PATCH 2/9] fix(dag): gate equality conditions on string outputs and enforce checkpoint gating at replan/extend (DAG-01/DAG-02) --- docs/findings/dag-batch-findings.md | 32 ++++ packages/opencode/src/dag/CONTEXT.md | 2 +- packages/opencode/src/dag/dag.ts | 3 + packages/opencode/src/dag/runtime/loop.ts | 19 ++- packages/opencode/src/dag/validation.ts | 100 +++++++++++-- .../test/dag/dag-checkpoint-gate.test.ts | 105 +++++++++++++ .../opencode/test/dag/dag-loop-guards.test.ts | 140 ++++++++++++++++++ .../dag/dag-replan-stale-nodefailed.test.ts | 4 +- .../opencode/test/dag/dag-rev-view.test.ts | 9 +- 9 files changed, 395 insertions(+), 19 deletions(-) create mode 100644 docs/findings/dag-batch-findings.md diff --git a/docs/findings/dag-batch-findings.md b/docs/findings/dag-batch-findings.md new file mode 100644 index 000000000..ca9c577b5 --- /dev/null +++ b/docs/findings/dag-batch-findings.md @@ -0,0 +1,32 @@ +# DAG 批次 Findings Register + +- 验收 primary source:`docs/audit-dag-memory-goal-2026-08-18.md`(DAG 章节) +- 分支:`fix/dag-batch` → PR `dev` +- 收敛判据:连续两轮独立审阅(Spec 镜 + Standards 镜)零 findings + 模块门禁全绿 +- 规格:`workflows/audit-fix-loop.md` +- 触发:用户指示在 GOAL run(PR #334)之后立即开工,不等合入 + +## 审计缺陷切片(输入项,非审阅 finding) + +| ID | 严重性 | 切片顺序 | 状态 | 提交 | +|---|---|---|---|---| +| DAG-01 + DAG-02 | High | A(P0,审计明确要求一并修) | 完成(红-绿-变异×3 通过) | 待提交 | +| DAG-03 | Medium | B (P1) | 待办 | — | +| DAG-04 | Medium | C (P1,#316 机制部分;触发源不追查,按审计记录缺口) | 待办 | — | + +## 切片 A 设计要点(探索定案) + +- **运行时(DAG-01)**:`loop.ts` spawnReady 构造条件求值 `outputs` 时,对字符串依赖输出做与 replan-verdict 门(loop.ts:667)相同的 `parseJsonOption` 归一化;解析失败回退原字符串(纯文本输出维持现有 loudly-fail/false 语义)。 +- **authoring(DAG-01)**:`checkpointGateDiagnostics` 追加「被 condition 引用的 checkpoint 必须声明 output_schema」,成为 authoring 期错误。 +- **authoring(DAG-02)**:`validatePostCompile` 的 checkpoint 门不再随 `structural: false` 关闭(replan/extend fragment 内对生效);`replanStructuralDiagnostics` 对 merged 图补跑 checkpoint 门(覆盖新 dependent 挂到既有 checkpoint 的跨 fragment 场景),`ReplanStructuralInput.merged` 类型补 `node_defaults`。 +- 不触碰 audit「验证为正确」清单:数值比较 loudly-fail、review verdict 门 fail-closed、wake 持久化等。 + +## 模块门禁 +- 未开始 + +## 审阅轮次 + +(每轮审阅结果记账于此;全部关闭后才具备发 PR 资格) + +### Round 1 +- 未开始 diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md index 38ac3de76..c6b09297a 100644 --- a/packages/opencode/src/dag/CONTEXT.md +++ b/packages/opencode/src/dag/CONTEXT.md @@ -32,7 +32,7 @@ Workflow Orchestration turns one user objective into one durable DAG. Its model- - Model-facing graph actions expose only `spec_path`; graph fields live in YAML so provider tool-call serialization cannot turn a nested graph into a string. - Legacy YAML may be adapted at the file boundary without making legacy fields valid inline input. - Runtime Admission and Workflow Authoring Check have separate names, state, and responsibilities. -- Dependents of a reporting checkpoint must be gated on its output; authoring rejects ungated shapes at start/validate (enforcement point: authoring boundary only, runtime create deliberately unchanged). +- Dependents of a reporting checkpoint must be gated on its output; authoring rejects ungated shapes at start/validate AND at replan/extend fragment actions, and the runtime replan/extend mutation seam re-checks the merged graph (exempting checkpoints already terminal — their verdict was delivered; runtime create remains deliberately unchanged). A gated checkpoint must declare `output_schema` (authoring obligation). ## Boundaries diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index 28185c5c3..75263d79b 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -583,6 +583,9 @@ export const layer = Layer.effect( addCount: plan.add.length, merged: wfConfig ? computeMergedConfig(wfConfig, normalizedFragment, plan) : { nodes: normalizedFragment.nodes }, config: { mode: wfConfig?.mode, max_total_nodes: wfConfig?.max_total_nodes }, + terminalNodeIds: new Set( + nodes.filter((n) => isNodeTerminalStatus(n.status as NodeStatus)).map((n) => n.id), + ), }) const replanErrors = DagValidation.sortLegacyStructural(replanDiagnostics.filter((d) => d.severity === "error")) for (const warning of replanDiagnostics.filter((d) => d.severity === "warning")) { diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 26d1a7ae8..3090c7354 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -142,7 +142,24 @@ const serviceLayer = Layer.effect( const outputs: Record = {} for (const dep of node.dependsOn) { const depNode = nodesSnapshot.find((n) => n.id === dep) - if (depNode) outputs[dep] = { output: depNode.output } + if (!depNode) continue + // DAG-01: a schema-less checkpoint completes with a raw + // string output. Normalize JSON strings before condition + // evaluation so `.output.` gates read the parsed + // structure instead of resolving undefined — pre-fix the + // equality gate was permanently false, every gated dependent + // was skipped as condition_false, and checkCompletion still + // reported the workflow COMPLETED (silent half-graph loss). + // Mirrors the replan-verdict gate normalization above + // (issue #322). Non-JSON strings fall back to the raw value: + // whole-output equality still works, field paths stay + // undefined (documented condition_false), and numeric + // comparisons keep their loud failure. + outputs[dep] = { + output: typeof depNode.output === "string" + ? Option.getOrElse(parseJsonOption(depNode.output), () => depNode.output) + : depNode.output, + } } const condResult = evaluateCondition(nodeConfig.condition, outputs) if (!condResult.ok) { diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index c92fae884..6436c91a7 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -580,17 +580,35 @@ function conditionDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { * checkpoint completes, so the checkpoint verdict can never act first. * Block-compiled graphs gate dependents on the verdict (issue #294 * REJECT-checkpoint shape); hand-built node graphs must do the same or keep - * the checkpoint as a reporting leaf. */ + * the checkpoint as a reporting leaf. + * + * Options: + * - `exemptCheckpointIds` (DAG-02 runtime path): a checkpoint already + * terminal in the durable graph has delivered its verdict — the ordering + * race the gate protects is in the past, so additive waves / reopens may + * attach dependents without a condition. Authoring never exempts: nothing + * is terminal there yet. + * - `requireOutputSchema` (default true; the runtime replan path passes + * false): DAG-01's "gated checkpoints must declare output_schema" is an + * AUTHORING obligation — runtime-created graphs deliberately bypass + * authoring validation (CONTEXT.md), so the runtime gate polices the + * ordering race only, not the schema declaration. */ export function checkpointGateDiagnostics( nodes: readonly NodeConfig[], defaults?: { readonly report_to_parent?: boolean }, + options?: { + readonly exemptCheckpointIds?: ReadonlySet + readonly requireOutputSchema?: boolean + }, ): Diagnostic[] { const reportsToParent = (node: NodeConfig) => node.report_to_parent ?? defaults?.report_to_parent ?? DEFAULT_WORKFLOW_CONFIG.reportToParent + const requireOutputSchema = options?.requireOutputSchema ?? true return nodes.flatMap((checkpoint) => { if (!reportsToParent(checkpoint)) return [] - return nodes - .filter((dependent) => dependent.depends_on.includes(checkpoint.id)) + if (options?.exemptCheckpointIds?.has(checkpoint.id)) return [] + const dependents = nodes.filter((dependent) => dependent.depends_on.includes(checkpoint.id)) + const ungated = dependents .filter((dependent) => conditionReference(dependent.condition) !== checkpoint.id) .map((dependent) => diagnostic({ @@ -601,9 +619,34 @@ export function checkpointGateDiagnostics( + ` — the engine spawns "${dependent.id}" as soon as "${checkpoint.id}" completes, so the checkpoint verdict cannot be acted on first`, hint: `Gate "${dependent.id}" with condition: "${checkpoint.id}.output. == ..." (e.g. on its verdict),` + + ` declare output_schema on "${checkpoint.id}" so the gate reads a schema-validated verdict,` + ` keep "${checkpoint.id}" a reporting leaf, or set report_to_parent: false on "${checkpoint.id}" if downstream must run unconditionally`, }), ) + // DAG-01: a checkpoint whose output a gate reads must declare + // output_schema. Without a schema the child may complete with a raw + // string; the runtime normalizes JSON strings, but a prose reply + // resolves no fields, so the `.output.` gate is permanently + // false and the gated subtree is silently skipped while the workflow + // still reports COMPLETED. Make the unsatisfiable gate an authoring + // error instead of a runtime trap. + const gatedDependents = dependents.some((dependent) => conditionReference(dependent.condition) === checkpoint.id) + const schemaRequired = + gatedDependents && requireOutputSchema && checkpoint.output_schema === undefined + ? [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: `nodes[${checkpoint.id}].output_schema`, + message: + `reporting checkpoint "${checkpoint.id}" is gated on its output but declares no output_schema` + + ` — without a schema the child may complete with prose that resolves no fields, leaving the gate permanently false and silently skipping the gated subtree`, + hint: + `Declare output_schema on "${checkpoint.id}" (e.g. the verdict shape),` + + ` keep "${checkpoint.id}" a reporting leaf, or set report_to_parent: false if downstream must run unconditionally`, + }), + ] + : [] + return [...ungated, ...schemaRequired] }) } @@ -771,8 +814,18 @@ export interface ReplanStructuralInput { existingNodeCount: number /** New node ids being added by this replan (toward the lifetime ceiling). */ addCount: number - /** Merged config (existing + fragment) for review-lifecycle validation. */ - merged: { name?: string; mode?: ExecutionMode; nodes: readonly NodeConfig[] } + /** Merged config (existing + fragment) for review-lifecycle and + * checkpoint-gate validation. */ + merged: { + name?: string + mode?: ExecutionMode + nodes: readonly NodeConfig[] + node_defaults?: { readonly report_to_parent?: boolean } + } + /** Durable nodes already terminal before this replan. Their reporting + * verdicts are delivered — the checkpoint gate exempts them so additive + * waves/reopens can attach dependents without a condition (DAG-02). */ + terminalNodeIds?: ReadonlySet config: { mode?: ExecutionMode; max_total_nodes?: number } } @@ -798,6 +851,19 @@ export function replanStructuralDiagnostics(input: ReplanStructuralInput): Diagn ...tagLegacyClass(review.warnings, 5), ...(duplicates.length === 0 ? topologyDiagnostics(input.rerunNodes) : []), ...tagLegacyClass(outputSchemaKeywordDiagnostics(input.rerunNodes), 8), + // DAG-02: the checkpoint gate must police the MERGED graph too — a + // fragment can attach a new dependent to an EXISTING reporting + // checkpoint, which the fragment-scoped authoring check cannot see. + // Pre-fix replan/extend skipped this gate entirely, so the dependent + // was spawned the moment the checkpoint completed, before the parent + // could read the verdict. Checkpoints already terminal in the durable + // graph are exempt: their verdict was delivered, the race is past. The + // output_schema obligation is authoring-only (requireOutputSchema:false) + // — runtime-created graphs deliberately bypass authoring validation. + ...checkpointGateDiagnostics(input.merged.nodes, input.merged.node_defaults, { + exemptCheckpointIds: input.terminalNodeIds, + requireOutputSchema: false, + }), ]) } @@ -971,17 +1037,21 @@ export function validatePostCompile(input: { structural?: boolean }): Effect.Effect { return Effect.gen(function* () { - const diagnostics = - input.structural === false + const diagnostics = [ + ...(input.structural === false ? [] - : [ - ...structuralDiagnostics({ - nodes: input.nodes, - mode: input.config.mode, - max_total_nodes: input.config.max_total_nodes, - }), - ...checkpointGateDiagnostics(input.nodes, input.config.node_defaults), - ] + : structuralDiagnostics({ + nodes: input.nodes, + mode: input.config.mode, + max_total_nodes: input.config.max_total_nodes, + })), + // DAG-02: the checkpoint gate is NOT a whole-graph structural check — + // fragment actions (replan/extend) must satisfy it exactly like start. + // Pre-fix it was skipped together with `structural`, so a replan could + // attach an ungated dependent to a reporting checkpoint and the engine + // spawned it the moment the checkpoint completed. + ...checkpointGateDiagnostics(input.nodes, input.config.node_defaults), + ] if (input.profile === "portable") diagnostics.push(...nonportablePromptDiagnostics(input.nodes)) if (input.profile === "environment") { diagnostics.push( diff --git a/packages/opencode/test/dag/dag-checkpoint-gate.test.ts b/packages/opencode/test/dag/dag-checkpoint-gate.test.ts index 18fcebecf..80a8291be 100644 --- a/packages/opencode/test/dag/dag-checkpoint-gate.test.ts +++ b/packages/opencode/test/dag/dag-checkpoint-gate.test.ts @@ -137,3 +137,108 @@ it.effect("flags a condition that gates a different dependency than the checkpoi expect(result.errors.some((d) => d.message.includes('"cp-design-decision"') && d.message.includes('"stage-development"'))).toBe(true) }), ) + +// DAG-01 (authoring half): a checkpoint whose output a condition reads must +// declare output_schema. Without it the child completes with a raw string; +// even with the runtime's JSON normalization a prose reply resolves no +// fields, so the `.output.` gate would be permanently false and the +// gated subtree silently skipped while the workflow reports COMPLETED. +it.effect("rejects a gated reporting checkpoint without output_schema", () => + Effect.gen(function* () { + const result = yield* validate( + spec({ + name: "schemaless-gate", + nodes: [ + { ...checkpoint("cp-decision"), output_schema: undefined }, + stage("stage-next", ["cp-decision"], 'cp-decision.output.verdict == "continue"'), + ], + }), + ) + expect(result.valid).toBe(false) + expect(result.errors.some((d) => d.message.includes('"cp-decision"') && d.message.includes("output_schema"))).toBe(true) + }), +) + +it.effect("accepts a gated reporting checkpoint that declares output_schema", () => + Effect.gen(function* () { + const result = yield* validate( + spec({ + name: "schema-gate", + nodes: [ + checkpoint("cp-decision"), + stage("stage-next", ["cp-decision"], 'cp-decision.output.verdict == "continue"'), + ], + }), + ) + expect(result.errors.filter((d) => d.message.includes("output_schema"))).toEqual([]) + }), +) + +it.effect("does not require output_schema on a reporting leaf checkpoint", () => + Effect.gen(function* () { + const result = yield* validate( + spec({ + name: "schemaless-leaf", + nodes: [{ ...checkpoint("cp-final"), depends_on: [], output_schema: undefined }], + }), + ) + expect(result.errors.filter((d) => d.message.includes("output_schema"))).toEqual([]) + }), +) + +// DAG-02: pre-fix authoring closed ALL structural diagnostics for non-start +// actions (`structural: input.action === "start"`), so a replan/extend could +// attach an ungated dependent to a reporting checkpoint and the engine would +// spawn it the moment the checkpoint completes — the checkpoint gate must +// apply to fragment actions too. +function validateReplan(fragmentGraph: Record) { + return WorkflowAuthoring.make().prepare({ + action: "replan", + source: { + kind: "inline", + value: { fragment: fragmentGraph }, + source: "", + }, + }) +} + +function validateExtend(value: unknown) { + return WorkflowAuthoring.make().prepare({ + action: "extend", + source: { kind: "inline", value, source: "" }, + }) +} + +it.effect("rejects a replan fragment whose dependent is not gated on the fragment's reporting checkpoint", () => + Effect.gen(function* () { + const result = yield* validateReplan({ + name: "replan-ungated", + nodes: [checkpoint("cp-review"), stage("stage-fix", ["cp-review"])], + }) + expect(result.valid).toBe(false) + expect(result.errors.some((d) => d.message.includes('"cp-review"') && d.message.includes('"stage-fix"'))).toBe(true) + }), +) + +it.effect("rejects an extend fragment whose dependent is not gated on its reporting checkpoint", () => + Effect.gen(function* () { + const result = yield* validateExtend({ + nodes: [checkpoint("cp-review"), stage("stage-fix", ["cp-review"])], + }) + expect(result.valid).toBe(false) + expect(result.errors.some((d) => d.message.includes('"cp-review"') && d.message.includes('"stage-fix"'))).toBe(true) + }), +) + +it.effect("accepts a replan fragment that gates its dependent on the checkpoint output", () => + Effect.gen(function* () { + const result = yield* validateReplan({ + name: "replan-gated", + nodes: [ + checkpoint("cp-review"), + stage("stage-fix", ["cp-review"], 'cp-review.output.verdict == "continue"'), + ], + }) + expect(result.errors.filter((d) => d.message.includes("not gated"))).toEqual([]) + }), +) diff --git a/packages/opencode/test/dag/dag-loop-guards.test.ts b/packages/opencode/test/dag/dag-loop-guards.test.ts index fce8c31d3..5ad837c37 100644 --- a/packages/opencode/test/dag/dag-loop-guards.test.ts +++ b/packages/opencode/test/dag/dag-loop-guards.test.ts @@ -486,3 +486,143 @@ describe("DagLoop replan verdict gate (issue #322)", () => { ) }) }) + +// DAG-01 (runtime half): a schema-less reporting checkpoint completes with a +// RAW STRING output. Pre-fix the condition evaluator resolved +// `gate.output.` on that string to undefined, so an equality gate was +// permanently false: every gated dependent skipped (condition_false), the +// orphan cascade terminalized the subtree, and checkCompletion marked the +// workflow COMPLETED with skipReviewGate — half the graph never ran, with +// no error anywhere. The fix normalizes string outputs through the same +// parseJsonOption the replan-verdict gate already uses. +describe("DagLoop equality gates on schema-less string outputs (DAG-01)", () => { + it("evaluates a .output. condition against a JSON-string checkpoint output", async () => { + await Effect.runPromise( + runGuardTest({ instanceProject: "project-1" }, ({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_project-1", + title: "String equality gate", + config: { + name: "string-equality-gate", + nodes: [ + node({ id: "gate", name: "gate", required: true, report_to_parent: true }), + node({ + id: "downstream", + name: "downstream", + required: false, + depends_on: ["gate"], + condition: 'gate.output.verdict == "continue"', + }), + ], + }, + }) + const gateChild = yield* takeWithin(childPrompts, "gate node did not start") + expect(gateChild.title).toBe("gate") + yield* dag.nodeCompleted(dagID, "gate", JSON.stringify({ verdict: "continue", findings: "confirmed" })) + // The report_to_parent wake and the downstream spawn can land in + // either order; accept the downstream prompt whichever comes second. + const first = yield* takeWithin(childPrompts, "no prompt after continue verdict — gate evaluated false on the string output") + const downstreamChild = first.title === "downstream" + ? first + : yield* takeWithin(childPrompts, "downstream was silently skipped — string output never normalized (DAG-01)") + expect(downstreamChild.title).toBe("downstream") + expect((yield* store.getNode(dagID, "downstream"))?.status).not.toBe("skipped") + yield* Deferred.succeed(downstreamChild.release, "done") + }), + ), + ) + }) + + it("keeps a non-JSON string output gate false without the subtree silently vanishing", async () => { + await Effect.runPromise( + runGuardTest({ instanceProject: "project-1" }, ({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_project-1", + title: "Prose gate", + config: { + name: "prose-gate", + nodes: [ + node({ id: "gate", name: "gate", required: true, report_to_parent: true }), + node({ + id: "downstream", + name: "downstream", + required: false, + depends_on: ["gate"], + condition: 'gate.output.verdict == "continue"', + }), + ], + }, + }) + const gateChild = yield* takeWithin(childPrompts, "gate node did not start") + expect(gateChild.title).toBe("gate") + // Prose (non-JSON) output: normalization falls back to the raw + // string, the field path resolves undefined, the equality gate is + // false — the documented skip, not a crash. + yield* dag.nodeCompleted(dagID, "gate", "All good, shipping it.") + yield* pollWithTimeout( + Effect.gen(function* () { + const downstream = yield* store.getNode(dagID, "downstream") + return downstream?.status === "skipped" ? (true as const) : undefined + }), + "prose-output gate did not settle to condition_false", + ) + }), + ), + ) + }) +}) + +// DAG-02 (runtime half): the checkpoint gate must also police the MERGED +// graph at replan/extend — a fragment may attach a new dependent to an +// existing reporting checkpoint, which the fragment-scoped authoring check +// cannot see. Pre-fix replanStructuralDiagnostics never ran +// checkpointGateDiagnostics, so the engine spawned the dependent the moment +// the checkpoint completed, before the parent could read the verdict. +describe("Dag.replan merged-graph checkpoint gate (DAG-02)", () => { + it("rejects a replan fragment that attaches an ungated dependent to an existing reporting checkpoint", async () => { + await Effect.runPromise( + runGuardTest({ instanceProject: "project-1" }, ({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_project-1", + title: "Merged gate", + config: { + name: "merged-gate", + nodes: [ + node({ id: "gate", name: "gate", required: true, report_to_parent: true, output_schema: { type: "object" } }), + node({ + id: "downstream", + name: "downstream", + required: false, + depends_on: ["gate"], + condition: 'gate.output.verdict == "continue"', + }), + ], + }, + }) + const gateChild = yield* takeWithin(childPrompts, "gate node did not start") + expect(gateChild.title).toBe("gate") + // Fragment adds a dependent on the existing checkpoint WITHOUT a + // condition — the merged graph must reject it. + const attempt = yield* dag + .replan(dagID, { nodes: [node({ id: "late", name: "late", depends_on: ["gate"] })] }) + .pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, message: String(error) }), + onSuccess: () => ({ ok: true as const, message: "" }), + }), + ) + expect(attempt.ok).toBe(false) + expect(attempt.message.includes('"gate"') && attempt.message.includes('"late"')).toBe(true) + expect((yield* store.getNode(dagID, "late"))).toBeUndefined() + yield* Deferred.succeed(gateChild.release, "done") + }), + ), + ) + }) +}) diff --git a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts index 6c9de51b7..c95276154 100644 --- a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts +++ b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts @@ -397,9 +397,11 @@ describe("DagLoop replan vs stale NodeFailed", () => { expect(gateB.title).toBe("b") // Restart b mid-flight, rewiring its dependency from a → c (new node). + // DAG-02: c is a fresh reporting checkpoint, so the rewired dependent + // must gate on its output (the merged checkpoint check at replan). const plan = yield* dag.replan(dagID, { nodes: [ - { ...node("b", ["c"]), restart: true }, + { ...node("b", ["c"]), restart: true, condition: 'c.output == "done"' }, node("c"), ], }) diff --git a/packages/opencode/test/dag/dag-rev-view.test.ts b/packages/opencode/test/dag/dag-rev-view.test.ts index 34867d29f..97be99177 100644 --- a/packages/opencode/test/dag/dag-rev-view.test.ts +++ b/packages/opencode/test/dag/dag-rev-view.test.ts @@ -306,8 +306,15 @@ describe("Train A rev-view (durable data untouched, view = current revision only // Bypass C: new suffix E→G→H off B; D (pending) is dropped by the // fragment and cancels; C (terminal failed, absent from fragment) is // the replaced segment the view must hide. + // DAG-02: E/G are fresh reporting checkpoints, so their new + // dependents gate on their outputs (the merged checkpoint check); + // the E-on-B edge is exempt because B already completed. const plan = yield* dag.replan(dagID, { - nodes: [node("e", ["b"]), node("g", ["e"]), node("h", ["g"])], + nodes: [ + node("e", ["b"]), + { ...node("g", ["e"]), condition: 'e.output == "e done"' }, + { ...node("h", ["g"]), condition: 'g.output == "g done"' }, + ], }) expect(plan.cancel).toEqual(["d"]) expect(plan.add.sort()).toEqual(["e", "g", "h"]) From 1c4f1ad7a88ab2bcae2836de314d9c8d67771f28 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 17:00:18 +0800 Subject: [PATCH 3/9] fix(dag): replan-verdict pause gate fails closed and folds pause defects (DAG-03) --- packages/opencode/src/dag/runtime/loop.ts | 31 +++-- .../opencode/test/dag/dag-loop-guards.test.ts | 113 +++++++++++++++++- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 3090c7354..d6d984e23 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -696,20 +696,37 @@ const serviceLayer = Layer.effect( // corrective nodes — a paused workflow resumes as part // of replan (workflow tool) so corrections can run. const paused = yield* Effect.gen(function* () { - // Pause can fail transiently (e.g. the workflow lock is - // held by a concurrent long replan); retry once before - // falling back to the durable status, so the workflow - // is never silently stranded. + // DAG-03: the checkpoint VETOED this direction — the + // pause must fail CLOSED. Pause can fail transiently + // (e.g. the workflow lock is held by a concurrent + // long replan); retry once, and if it still cannot + // be persisted, HOLD the in-memory pause anyway. + // Pre-fix this returned `wf?.status === "paused"` — + // fail-OPEN: it explicitly un-paused the runtime, so + // the next stimulus calling spawnReady (a NodeFailed + // handler, a step, a resume) spawned the vetoed + // direction with no gate, no pause, no diagnostic. + // catchCause (not catch): a DEFECT from dag.pause + // must fold into the same path — pre-fix it escaped + // to guarded() and dropped this whole handler, so + // the pause was never even attempted and the gate's + // own warning was lost. Interrupts (scope disposal) + // still propagate. const attemptPause = dag.pause(dagID).pipe( Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(false), + ), ) if (yield* attemptPause) return true if (yield* attemptPause) return true const wf = yield* store.getWorkflow(dagID).pipe(Effect.orDie) if (wf?.status !== "paused") - yield* Effect.logWarning("DagLoop pause on replan verdict failed", { dagID, nodeID }) - return wf?.status === "paused" + yield* Effect.logError( + "DagLoop pause on replan verdict failed — holding in-memory pause (fail-closed)", + { dagID, nodeID, durableStatus: wf?.status ?? "missing" }, + ) + return true }) entry.runtime.setPaused(paused) yield* Effect.logWarning("DagLoop paused workflow after gate verdict: replan", { dagID, nodeID }) diff --git a/packages/opencode/test/dag/dag-loop-guards.test.ts b/packages/opencode/test/dag/dag-loop-guards.test.ts index 5ad837c37..a8b875adb 100644 --- a/packages/opencode/test/dag/dag-loop-guards.test.ts +++ b/packages/opencode/test/dag/dag-loop-guards.test.ts @@ -84,6 +84,8 @@ function guardLayer(input: { readonly cancels: string[] /** Injected one-shot defects for DagStore.getWorkflow (P1 survival test). */ readonly failGetWorkflow?: { remaining: number } + /** Injected Dag.pause failures (typed or defect) for the DAG-03 gate test. */ + readonly failPause?: { remaining: number; defect?: boolean } }) { const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) @@ -113,10 +115,31 @@ function guardLayer(input: { Layer.provide(events), Layer.provide(database), ) - const dag = Dag.layer.pipe( + const realDag = Dag.layer.pipe( Layer.provide(bridge), Layer.provide(store), ) + const dag = input.failPause + ? Layer.effect( + Dag.Service, + Effect.gen(function* () { + const real = yield* Dag.Service + return Dag.Service.of({ + ...real, + pause: (id) => + Effect.suspend(() => { + if (input.failPause!.remaining > 0) { + input.failPause!.remaining-- + return input.failPause!.defect + ? Effect.die(new Error("injected pause defect")) + : Effect.fail(new Error("injected pause failure")) + } + return real.pause(id) + }), + }) + }), + ).pipe(Layer.provide(realDag)) + : realDag const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) const childTitles = new Map() const created: string[] = [] @@ -176,6 +199,7 @@ function runGuardTest( /** Project the current instance belongs to. */ readonly instanceProject: string readonly failGetWorkflow?: { remaining: number } + readonly failPause?: { remaining: number; defect?: boolean } }, test: (services: { readonly dag: Dag.Interface @@ -214,7 +238,7 @@ function runGuardTest( yield* loop.init() return yield* test({ dag, loop, store, childPrompts, cancels }) }).pipe( - Effect.provide(guardLayer({ childPrompts, cancels, failGetWorkflow: options.failGetWorkflow })), + Effect.provide(guardLayer({ childPrompts, cancels, failGetWorkflow: options.failGetWorkflow, failPause: options.failPause })), Effect.provideService(InstanceRef, { directory: process.cwd(), worktree: process.cwd(), @@ -626,3 +650,88 @@ describe("Dag.replan merged-graph checkpoint gate (DAG-02)", () => { ) }) }) + +// DAG-03: the replan-verdict gate must FAIL CLOSED. The checkpoint vetoed +// the direction; if the durable pause cannot be persisted (both attempts +// fail/defect and the row still reads non-paused), the in-memory scheduler +// must still HOLD — pre-fix it returned `wf?.status === "paused"` +// (fail-OPEN) and explicitly un-paused the runtime, so the very next +// stimulus that calls spawnReady (here: a NodeFailed handler) spawned the +// vetoed dependent. Defects from dag.pause must also fold into the retry +// path: pre-fix `Effect.catch` only covered the error channel, a defect +// escaped to guarded() and dropped the whole NodeCompleted handler (no +// pause, no gate log). +describe("DagLoop replan verdict gate fail-closed (DAG-03)", () => { + function vetoedGateGraph(title: string, name: string) { + return { + projectID: "project-1", + sessionID: "ses_project-1", + title, + config: { + name, + nodes: [ + node({ id: "gate", name: "gate", required: true, report_to_parent: true, output_schema: { type: "object" } }), + node({ id: "downstream", name: "downstream", required: false, depends_on: ["gate"] }), + node({ id: "probe", name: "probe", required: false }), + ], + }, + } + } + + // Create the graph; gate and probe are both ready at boot, so take both + // prompts and index them by title (order is racy). + function takeBootPrompts(childPrompts: Queue.Queue) { + return Effect.gen(function* () { + const first = yield* takeWithin(childPrompts, "first boot prompt did not arrive") + const second = yield* takeWithin(childPrompts, "second boot prompt did not arrive") + const byTitle = new Map([[first.title, first], [second.title, second]]) + const gate = byTitle.get("gate") + const probe = byTitle.get("probe") + if (!gate || !probe) return yield* Effect.fail(new Error(`expected gate+probe, got ${first.title}/${second.title}`)) + return { gate, probe } + }) + } + + it("holds the in-memory pause when the durable pause exhausts its retries", async () => { + await Effect.runPromise( + runGuardTest( + { instanceProject: "project-1", failPause: { remaining: 99 } }, + ({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create(vetoedGateGraph("Fail-closed gate", "fail-closed-gate")) + const { gate } = yield* takeBootPrompts(childPrompts) + expect(gate.title).toBe("gate") + yield* dag.nodeCompleted(dagID, "gate", { verdict: "replan", findings: "vetoed" }) + // The durable pause never landed + expect((yield* store.getWorkflow(dagID))?.status).toBe("running") + // Post-veto stimulus on an unrelated node. + const probe = (yield* store.getNode(dagID, "probe"))! + yield* dag.nodeFailed(dagID, "probe", "probe exploded", "exec_failed") + yield* Effect.sleep("300 millis") + // Fail-closed: the vetoed dependent was NOT spawned by the + // post-veto stimulus. + expect((yield* store.getNode(dagID, "downstream"))?.status).toBe("pending") + }), + ), + ) + }) + + it("holds the in-memory pause when the pause attempts defect", async () => { + await Effect.runPromise( + runGuardTest( + { instanceProject: "project-1", failPause: { remaining: 99, defect: true } }, + ({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create(vetoedGateGraph("Defect gate", "defect-gate")) + const { gate } = yield* takeBootPrompts(childPrompts) + expect(gate.title).toBe("gate") + yield* dag.nodeCompleted(dagID, "gate", { verdict: "replan", findings: "vetoed" }) + expect((yield* store.getWorkflow(dagID))?.status).toBe("running") + yield* dag.nodeFailed(dagID, "probe", "probe exploded", "exec_failed") + yield* Effect.sleep("300 millis") + expect((yield* store.getNode(dagID, "downstream"))?.status).toBe("pending") + }), + ), + ) + }) +}) From db626d4ba92488fe9d6da57d2a0b5fd92bb67abd Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 17:31:39 +0800 Subject: [PATCH 4/9] fix(dag): rethrow publisher interrupts and bound global disposeAll (DAG-04, #316 mechanism) --- .../src/dag/runtime/summary-publisher.ts | 10 +- .../opencode/src/server/global-lifecycle.ts | 29 ++++-- .../dag-summary-publisher-behavior.test.ts | 38 ++++++- .../test/server/global-lifecycle.test.ts | 99 +++++++++++++++++++ 4 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/test/server/global-lifecycle.test.ts diff --git a/packages/opencode/src/dag/runtime/summary-publisher.ts b/packages/opencode/src/dag/runtime/summary-publisher.ts index 618d37576..7afd0fa1d 100644 --- a/packages/opencode/src/dag/runtime/summary-publisher.ts +++ b/packages/opencode/src/dag/runtime/summary-publisher.ts @@ -160,8 +160,16 @@ export const layer = Layer.effect( ) return Effect.void const dagID = data.dagID return schedulePublishByDag(dagID, evt.location.workspaceID).pipe( + // DAG-04 (#316): interrupt causes are the normal disposal path — + // the inner coalescer deliberately rethrows them (a scoped + // shutdown mid-publish must unwind, not masquerade as a failure). + // Swallowing them here logged a spurious "failed to publish" on + // every normal shutdown; rethrow per F1 discipline (same shape as + // spawn.ts / loop.ts). Real failures still warn. Effect.catchCause((cause) => - Effect.logWarning("DagSummaryPublisher: failed to publish summaries", { dagID, cause }), + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("DagSummaryPublisher: failed to publish summaries", { dagID, cause }), ), Effect.forkIn(scope), Effect.asVoid, diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts index 12b7687bf..aa442a924 100644 --- a/packages/opencode/src/server/global-lifecycle.ts +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -1,6 +1,6 @@ import { GlobalBus } from "@/bus/global" import { InstanceStore } from "@/project/instance-store" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Event } from "./event" export const emitGlobalDisposed = Effect.sync(() => @@ -13,15 +13,30 @@ export const emitGlobalDisposed = Effect.sync(() => }), ) +// DAG-04 (#316): bounded disposal, mirroring the httpapi exerciser's cleanup +// guard (test/server/httpapi-exercise/runner.ts `bounded`, 10s). A wedged +// instance disposal must not hang global shutdown forever; after the timeout +// we abandon the in-flight disposal and move on — the same trade the +// exerciser makes ("resource may leak" beats "never terminates"). +// timeoutOption represents the timeout as Option.none (never as an error), so +// a genuine disposal failure still propagates for callers that do not swallow, +// while a hang is always cut off and the Disposed event below always lands. +const DISPOSE_ALL_TIMEOUT = "10 seconds" + export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")( function* (options?: { swallowErrors?: boolean }) { const store = yield* InstanceStore.Service - yield* Effect.gen(function* () { - yield* options?.swallowErrors - ? store.disposeAll().pipe(Effect.catchCause((cause) => Effect.logWarning("global disposal failed", { cause }))) - : store.disposeAll() - yield* emitGlobalDisposed - }).pipe(Effect.uninterruptible) + const disposeAttempt = options?.swallowErrors + ? store.disposeAll().pipe( + Effect.catchCause((cause) => Effect.logWarning("global disposal failed", { cause })), + ) + : store.disposeAll() + const outcome = yield* disposeAttempt.pipe(Effect.timeoutOption(DISPOSE_ALL_TIMEOUT)) + if (Option.isNone(outcome)) + yield* Effect.logWarning("global disposal timed out — abandoning in-flight disposal", { + timeout: DISPOSE_ALL_TIMEOUT, + }) + yield* emitGlobalDisposed }, ) diff --git a/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts b/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts index 15da4955f..1353cb87f 100644 --- a/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts +++ b/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" -import { DateTime, Deferred, Effect, Layer } from "effect" +import { Cause, DateTime, Deferred, Effect, Layer } from "effect" import { DagStore, type WorkflowRow, type WorkflowSummary } from "@opencode-ai/core/dag/store" import { DagEvent } from "@opencode-ai/schema/dag-event" +import { logLines } from "effect/testing/TestConsole" import { EventV2Bridge } from "@/event-v2-bridge" import { DagSummaryPublisher } from "@/dag/runtime/summary-publisher" import { GlobalBus } from "@/bus/global" @@ -19,6 +20,9 @@ interface SummaryEmission { interface StoreControl { failures: number failuresAfterGate: number + /** DAG-04: fail the summary read with an interrupt cause (the shape a + * scoped disposal delivers mid-publish). */ + interruptRead?: boolean readGate?: { started: Deferred.Deferred release: Deferred.Deferred @@ -91,6 +95,11 @@ function runtime(state: StoreControl, bus: EventControl) { getWorkflowSummaries: (sessionID) => Effect.gen(function* () { state.reads.set(sessionID, (state.reads.get(sessionID) ?? 0) + 1) + if (state.interruptRead) { + // The defined fiber id matters: Cause.interruptors() only collects + // defined ids (the F1 shape pinned by the goal e2e interrupt tests). + return yield* Effect.failCause(Cause.interrupt(0)) + } if (state.failures > 0) { state.failures -= 1 throw new Error("simulated summary read failure") @@ -509,3 +518,30 @@ describe("DagSummaryPublisher behavior", () => { ).pipe(Effect.provide(runtime(state, bus))) }) }) + +// DAG-04 (#316): the coalescer deliberately rethrows interrupt causes (a +// scoped disposal mid-publish must unwind, not be mistaken for a failure). +// The outer listener boundary must preserve that: pre-fix its catchCause +// swallowed the interrupt and logged a spurious "failed to publish +// summaries" on every normal shutdown. F1 discipline, same as spawn.ts. +describe("DagSummaryPublisher interrupt discipline (DAG-04)", () => { + it.instance("an interrupt cause from the read path is rethrown, not reported as a publish failure", () => { + const state = control() + const bus = {} satisfies EventControl + state.interruptRead = true + state.sessions.set("dag-interrupt", "ses-interrupt") + state.summaries.set("ses-interrupt", [summary("dag-interrupt", 1)]) + + return Effect.gen(function* () { + yield* (yield* DagSummaryPublisher.Service).init() + yield* publishNodeEvents(bus, "dag-interrupt", 1) + // Wait for the coalesce window to run the (interrupted) read. + yield* pollWithTimeout( + Effect.sync(() => (state.reads.get("ses-interrupt") === 1 ? true : undefined)), + "interrupted summary read never ran", + ) + yield* Effect.sleep("150 millis") + expect(JSON.stringify(yield* logLines)).not.toContain("failed to publish summaries") + }).pipe(Effect.provide(runtime(state, bus))) + }) +}) diff --git a/packages/opencode/test/server/global-lifecycle.test.ts b/packages/opencode/test/server/global-lifecycle.test.ts new file mode 100644 index 000000000..7f1b2db1f --- /dev/null +++ b/packages/opencode/test/server/global-lifecycle.test.ts @@ -0,0 +1,99 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Fiber, Layer, Option } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { logLines } from "effect/testing/TestConsole" +import { InstanceStore } from "@/project/instance-store" +import { GlobalLifecycle } from "@/server/global-lifecycle" +import { GlobalBus } from "@/bus/global" +import { it } from "../lib/effect" + +function collectDisposed() { + const events: string[] = [] + const handler = (event: { payload?: { type?: string } }) => { + if (event.payload?.type === "global.disposed") events.push(event.payload.type) + } + GlobalBus.on("event", handler) + return { events, stop: () => GlobalBus.off("event", handler) } +} + +const wedgedStoreLayer = Layer.mock(InstanceStore.Service, { + disposeAll: () => Effect.never, +}) + +// DAG-04 (#316): the production shutdown disposal was uninterruptible AND +// had no timeout — a wedged instance could hang the whole path forever (the +// httpapi exerciser already guards its cleanup steps with a 10s bounded +// guard; production had no equivalent). The dispose step is now bounded; a +// HANG is always abandoned (timeout → Option.none, never an error — the +// HttpApi dispose endpoint's error contract stays untouched), the Disposed +// event always lands, and genuine disposal failures still propagate for +// callers that do not swallow. +describe("GlobalLifecycle bounded disposal (DAG-04)", () => { + it.effect("a wedged disposeAll is abandoned at the bounded timeout and the Disposed event still lands (swallow)", () => + Effect.acquireUseRelease( + Effect.sync(collectDisposed), + (collector) => + Effect.gen(function* () { + const fiber = yield* GlobalLifecycle.disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe( + Effect.forkScoped, + ) + yield* TestClock.adjust("11 seconds") + yield* Fiber.await(fiber) + expect(collector.events).toEqual(["global.disposed"]) + expect(JSON.stringify(yield* logLines)).toContain("global disposal timed out") + }).pipe(Effect.provide(wedgedStoreLayer)), + (collector) => Effect.sync(collector.stop), + ), + ) + + it.effect("a wedged disposeAll never hangs the non-swallow caller either", () => + Effect.acquireUseRelease( + Effect.sync(collectDisposed), + (collector) => + Effect.gen(function* () { + const fiber = yield* GlobalLifecycle.disposeAllInstancesAndEmitGlobalDisposed().pipe(Effect.forkScoped) + yield* TestClock.adjust("11 seconds") + const exit = yield* Fiber.await(fiber) + // A hang is abandonment, not failure — the caller completes and the + // Disposed event lands (pre-fix this path hung forever). + expect(Exit.isSuccess(exit)).toBe(true) + expect(collector.events).toEqual(["global.disposed"]) + }).pipe(Effect.provide(wedgedStoreLayer)), + (collector) => Effect.sync(collector.stop), + ), + ) + + it.effect("a genuine disposeAll failure still propagates when not swallowing", () => + Effect.gen(function* () { + const failing = Layer.mock(InstanceStore.Service, { + disposeAll: () => Effect.die(new Error("injected disposal defect")), + }) + const fiber = yield* GlobalLifecycle.disposeAllInstancesAndEmitGlobalDisposed().pipe( + Effect.provide(failing), + Effect.forkScoped, + ) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.effect("a healthy disposeAll completes without touching the timeout", () => + Effect.acquireUseRelease( + Effect.sync(collectDisposed), + (collector) => + Effect.gen(function* () { + let disposed = 0 + const healthy = Layer.mock(InstanceStore.Service, { + disposeAll: () => + Effect.sync(() => { + disposed += 1 + }), + }) + yield* GlobalLifecycle.disposeAllInstancesAndEmitGlobalDisposed().pipe(Effect.provide(healthy)) + expect(disposed).toBe(1) + expect(collector.events).toEqual(["global.disposed"]) + }), + (collector) => Effect.sync(collector.stop), + ), + ) +}) From 337814648ce5740a64044a83b7790f237618119d Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 18:12:18 +0800 Subject: [PATCH 5/9] fix(dag): veto hold survives flag re-syncs, released by parent control; sync ADR-0003 (review R1) --- docs/findings/dag-batch-findings.md | 34 ++++++++--- packages/opencode/src/dag/CONTEXT.md | 2 +- .../adr/0003-reporting-checkpoint-gating.md | 57 ++++++++++++------ packages/opencode/src/dag/runtime/loop.ts | 44 +++++++++++--- packages/opencode/src/dag/validation.ts | 10 ++-- .../opencode/test/dag/dag-loop-guards.test.ts | 60 +++++++++++++++++++ .../test/server/global-lifecycle.test.ts | 2 +- 7 files changed, 167 insertions(+), 42 deletions(-) diff --git a/docs/findings/dag-batch-findings.md b/docs/findings/dag-batch-findings.md index ca9c577b5..c1f8f3730 100644 --- a/docs/findings/dag-batch-findings.md +++ b/docs/findings/dag-batch-findings.md @@ -10,23 +10,39 @@ | ID | 严重性 | 切片顺序 | 状态 | 提交 | |---|---|---|---|---| -| DAG-01 + DAG-02 | High | A(P0,审计明确要求一并修) | 完成(红-绿-变异×3 通过) | 待提交 | -| DAG-03 | Medium | B (P1) | 待办 | — | -| DAG-04 | Medium | C (P1,#316 机制部分;触发源不追查,按审计记录缺口) | 待办 | — | +| DAG-01 + DAG-02 | High | A(P0,审计明确要求一并修) | 完成(红-绿-变异×3 通过) | 71ab1bdf6 | +| DAG-03 | Medium | B (P1) | 完成(红-绿-变异×2 通过) | 1c4f1ad7a | +| DAG-04 | Medium | C (P1,#316 机制部分;触发源不追查,按审计记录缺口) | 完成(红-绿-变异×2 通过) | db626d4ba | -## 切片 A 设计要点(探索定案) +## 切片设计要点(实现后回填) -- **运行时(DAG-01)**:`loop.ts` spawnReady 构造条件求值 `outputs` 时,对字符串依赖输出做与 replan-verdict 门(loop.ts:667)相同的 `parseJsonOption` 归一化;解析失败回退原字符串(纯文本输出维持现有 loudly-fail/false 语义)。 -- **authoring(DAG-01)**:`checkpointGateDiagnostics` 追加「被 condition 引用的 checkpoint 必须声明 output_schema」,成为 authoring 期错误。 -- **authoring(DAG-02)**:`validatePostCompile` 的 checkpoint 门不再随 `structural: false` 关闭(replan/extend fragment 内对生效);`replanStructuralDiagnostics` 对 merged 图补跑 checkpoint 门(覆盖新 dependent 挂到既有 checkpoint 的跨 fragment 场景),`ReplanStructuralInput.merged` 类型补 `node_defaults`。 -- 不触碰 audit「验证为正确」清单:数值比较 loudly-fail、review verdict 门 fail-closed、wake 持久化等。 +- **A(DAG-01+02)**: + - 运行时:spawnReady 条件求值前对字符串依赖输出做 `parseJsonOption` 归一化(与 replan-verdict 门同源);非 JSON 回退原串(整串等值可用、字段路径仍 false、数值比较仍 loudly-fail)。 + - authoring:`checkpointGateDiagnostics` 追加「被门控 checkpoint 必须声明 output_schema」(authoring 期错误;运行时路径不要求——runtime-created 图按 CONTEXT.md 有意豁免 authoring 校验,`requireOutputSchema:false`)。 + - 门禁接线:`validatePostCompile` 的 checkpoint 门不再随 `structural:false` 对 replan/extend 关闭(fragment 内对生效);`replanStructuralDiagnostics` 对 merged 图补跑 checkpoint 门(覆盖 fragment 挂到既有 checkpoint 的场景),**豁免持久图中已终态的 checkpoint**(裁决已交付,加波/重开是受 sanction 的模式——reopenDenial 加性重开的既有语义)。 + - 波及适配 2 个既有 harness(blanket `report_to_parent:true` 的 rev-view / stale-nodefailed 形状按门禁语义补 condition);dag-wake-integration 的加波/重开场景经终态豁免自然兼容,无需改动。 +- **B(DAG-03)**:pause 终态失败 fail-closed(恒 hold),`Effect.catch` → `Effect.catchCause`(hasInterrupts 再抛)折叠 defect;logWarning → logError(含 durableStatus)。 +- **C(DAG-04)**:publisher 外层 catchCause 依 F1 模式 `hasInterrupts` 再抛;`disposeAllInstancesAndEmitGlobalDisposed` 加 10s 有界超时(`timeoutOption`——超时即放弃且不产生错误,保住 HttpApi dispose endpoint 的 `never` 错误通道),去掉 uninterruptible 包裹;Disposed 事件在超时/吞错后仍必落地;真实处置失败在非 swallow 路径仍传播。 + - 中断测试注入方式:scope-disposal 杀 fiber 的 cause 实测为 Die 而非 Interrupt(已实证),改用 `Effect.failCause(Cause.interrupt(0))` 在 store 边界直接注入 interrupt cause(goal e2e 既有模式),精确命中被修复的 catchCause 判别线。 ## 模块门禁 -- 未开始 +- 切片级:每切片 dag 目标测试簇绿 + 包内 typecheck 绿 + 变异翻红验证(见上表) +- 全量套件:进行中 ## 审阅轮次 (每轮审阅结果记账于此;全部关闭后才具备发 PR 资格) ### Round 1 +- Spec 镜:**PASS**,3 条 Low INFO;Standards 镜:**PASS**,6 条 findings(F1-F6)。处置: + - F1 + INFO-2(Medium):ADR-0003 与新 enforcement 矛盾。→ **已同步**:Decision/Consequences/Deferred 改写为「validatePostCompile 全动作 + replanStructuralDiagnostics merged 图(终态豁免)+ create 刻意不动 + output_schema authoring 义务」,Deferred 首项标记 resolved。 + - F2 + INFO-1(Medium,需主裁决):fail-closed 保持会被后续 NodeCompleted/NodeSkipped/stepped 的 durable-row re-sync 解除;且 Replanned 处理器从不重同步 paused(hold 也会闷死 corrective 派发)。用户裁决「解决所有已知问题」。→ **已实装**:`WorkflowEntry.vetoHold`——门设置、两处 re-sync 点(node 终态序言 + refreshControlFlags)尊重保持、三个父控制事件(Replanned/Resumed/Stepped)释放并重同步(Replanned 补上从未有过的 flag 重同步);新增 2 条红绿测试(re-sync 存活 + replan 释放)+ 双向变异验证。 + - F3(Low):global-lifecycle.test.ts 未用 `Option` 导入。→ 已删。 + - F4(Low):loop.ts「normalization above」方向失准。→ 已改为指向 NodeCompleted 处理器。 + - F5(Low):终态豁免措辞「delivered its verdict」对 failed/aborted/skipped 不真。→ 三处改为「settled and immutable」(CONTEXT.md/validation.ts×2)。 + - F6(Low, informational):无 uninterruptible 的取舍记录。→ 无需动作(reviewer 确认 trade 正确)。 + - INFO-3(Low):register 误记「3 个 harness 适配」。→ 已更正为 2(wake-integration 经终态豁免免改)。 +- 结论:非干净轮。修复后进入 Round 2。 + +### Round 2 - 未开始 diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md index c6b09297a..87f180fa9 100644 --- a/packages/opencode/src/dag/CONTEXT.md +++ b/packages/opencode/src/dag/CONTEXT.md @@ -32,7 +32,7 @@ Workflow Orchestration turns one user objective into one durable DAG. Its model- - Model-facing graph actions expose only `spec_path`; graph fields live in YAML so provider tool-call serialization cannot turn a nested graph into a string. - Legacy YAML may be adapted at the file boundary without making legacy fields valid inline input. - Runtime Admission and Workflow Authoring Check have separate names, state, and responsibilities. -- Dependents of a reporting checkpoint must be gated on its output; authoring rejects ungated shapes at start/validate AND at replan/extend fragment actions, and the runtime replan/extend mutation seam re-checks the merged graph (exempting checkpoints already terminal — their verdict was delivered; runtime create remains deliberately unchanged). A gated checkpoint must declare `output_schema` (authoring obligation). +- Dependents of a reporting checkpoint must be gated on its output; authoring rejects ungated shapes at start/validate AND at replan/extend fragment actions, and the runtime replan/extend mutation seam re-checks the merged graph (exempting checkpoints already terminal in the durable graph — they are settled and immutable, the spawn-before-verdict race is past; runtime create remains deliberately unchanged). A gated checkpoint must declare `output_schema` (authoring obligation). ## Boundaries diff --git a/packages/opencode/src/dag/docs/adr/0003-reporting-checkpoint-gating.md b/packages/opencode/src/dag/docs/adr/0003-reporting-checkpoint-gating.md index 41b1e50b6..e00687348 100644 --- a/packages/opencode/src/dag/docs/adr/0003-reporting-checkpoint-gating.md +++ b/packages/opencode/src/dag/docs/adr/0003-reporting-checkpoint-gating.md @@ -27,24 +27,44 @@ checkpoint must be a reporting leaf, or the node must drop `report_to_parent`. `node_defaults.report_to_parent` is honored: a node inheriting the default reports the same way. -Enforcement lives in `checkpointGateDiagnostics`, wired only into -`validatePostCompile`'s structural branch — the authoring start/validate path. -Every ungated dependent emits one error-severity `dag.invalid` diagnostic in -both `portable` and `environment` profiles, so `start` and `validate` reject -the shape before any durable graph exists. - -Enforcement is authoring-only by design. `Dag.create` and the replan/extend -fragment paths stay untouched: the verdict vocabulary is open, the ACCEPT path -must not wait for the parent, and runtime enforcement would change the -semantics of every existing graph, including issue #294's wake-chain and -reopen-extend behavior. +Enforcement lives in `checkpointGateDiagnostics`. It is wired into +`validatePostCompile` for every action — `start`/`validate` AND the +replan/extend fragment authoring paths (DAG-02, 2026-08-18: pre-fix it hid +behind the `structural === start-only` branch, so a fragment could attach an +ungated dependent to a reporting checkpoint and the engine spawned it before +the parent read the verdict) — and into `replanStructuralDiagnostics`, which +re-checks the MERGED graph at the runtime replan/extend mutation seam, so a +fragment dependent on an EXISTING checkpoint cannot escape either. Every +ungated dependent emits one error-severity `dag.invalid` diagnostic in both +`portable` and `environment` profiles. + +Two deliberate carve-outs: + +- Checkpoints already terminal in the durable graph are exempt at the runtime + merged-graph check: they are settled and immutable, the + spawn-before-verdict race is past, and the sanctioned additive/reopen waves + (`node("repair", ["checkpoint"])` after a completed reporting leaf) keep + working. +- A gated checkpoint must additionally declare `output_schema` (DAG-01, + authoring obligation): without a schema the child may complete with prose + that resolves no fields, leaving the gate permanently false and silently + skipping the gated subtree. The runtime path does not impose the schema + obligation (`requireOutputSchema: false`) because runtime-created graphs + deliberately bypass authoring validation. + +`Dag.create` itself stays untouched by design: the verdict vocabulary is +open, the ACCEPT path must not wait for the parent, and runtime create-level +enforcement would change the semantics of every existing graph, including +issue #294's wake-chain and reopen-extend behavior. ## Consequences -- Ungated reporting checkpoints fail fast at start/validate with a diagnostic - naming the checkpoint, the dependent, and the three legal fixes. +- Ungated reporting checkpoints fail fast at start/validate — and since + DAG-02 also at replan/extend fragment authoring and at the runtime + replan/extend mutation seam — with a diagnostic naming the checkpoint, the + dependent, and the legal fixes. - Runtime create, wake chains, and reopen-extend semantics are unchanged; - trusted internal callers retain full runtime flexibility. + trusted internal callers retain full runtime flexibility at create time. - Saved and curated workflows were audited: 14 curated block workflows are unaffected; only `ultra-flow-route.yaml` and `release-route.yaml` trip the new check and are tracked in opencode-dag-config#14. @@ -62,9 +82,8 @@ reopen-extend behavior. ## Deferred -- Replan/extend fragments are not checkpoint-gate-checked (coverage gap; no - date). Runtime flexibility was prioritized; fragment authoring remains - advisory. +- ~~Replan/extend fragments are not checkpoint-gate-checked~~ — resolved + 2026-08-18 (DAG-02): fragment authoring and the runtime merged-graph seam + both enforce the gate, with the terminal-checkpoint carve-out above. - Deprecation of advisory wake chains (no date): `report_to_parent` without - gated dependents stays legal but is a smell worth revisiting once fragment - coverage exists. + gated dependents stays legal but is a smell worth revisiting. diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index d6d984e23..8f9cf7a82 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -57,6 +57,12 @@ interface WorkflowEntry { config: WorkflowConfig | undefined fibers: Map> watchers: Map> + /** DAG-03/F2: a replan-verdict veto whose durable pause could not be + * persisted. While set, the in-memory paused flag survives the durable-row + * re-syncs performed by node terminal events and refreshControlFlags, so + * no stimulus spawns the vetoed direction before the parent acts. Cleared + * by the parent's explicit control events (replan/resume/step). */ + vetoHold: boolean } const serviceLayer = Layer.effect( @@ -150,8 +156,9 @@ const serviceLayer = Layer.effect( // equality gate was permanently false, every gated dependent // was skipped as condition_false, and checkCompletion still // reported the workflow COMPLETED (silent half-graph loss). - // Mirrors the replan-verdict gate normalization above - // (issue #322). Non-JSON strings fall back to the raw value: + // Mirrors the replan-verdict gate's parseJsonOption + // normalization in the NodeCompleted handler below (issue + // #322). Non-JSON strings fall back to the raw value: // whole-output equality still works, field paths stay // undefined (documented condition_false), and numeric // comparisons keep their loud failure. @@ -456,7 +463,7 @@ const serviceLayer = Layer.effect( const isStepping = wf.status === "stepping" if (isPaused) runtime.setPaused(true) if (isStepping) runtime.setStepMode(true) - const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map() } + const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map(), vetoHold: false } // P2-E deletion-race re-check: the SessionV1.Event.Deleted sweep // only removes entries already published into `runtimes`. If the // FK cascade deleted this workflow's row while reconciliation ran, @@ -585,7 +592,7 @@ const serviceLayer = Layer.effect( const maxConcurrency = Math.max(1, config?.max_concurrency ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxConcurrency) const runtime = new WorkflowRuntime(toSchedulingNodes(nodes), maxConcurrency) const semaphore = Semaphore.makeUnsafe(maxConcurrency) - const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map() } + const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map(), vetoHold: false } // P2-E deletion-race re-check (same window as recoverWorkflow): // the Deleted sweep only removes entries already in `runtimes`, // and getNodes above is an awaited yield a deletion can slip @@ -668,7 +675,9 @@ const serviceLayer = Layer.effect( yield* Effect.logDebug("DagLoop dropped stale node terminal event", { dagID, nodeID, expected, dbStatus: node?.status ?? "missing" }) } const workflow = yield* store.getWorkflow(dagID) - entry.runtime.setPaused(workflow?.status === "paused") + // F2: honor the veto hold — a durable "running" row must + // not lift the fail-closed pause a verdict gate set. + entry.runtime.setPaused(workflow?.status === "paused" || entry.vetoHold) entry.runtime.setStepMode(workflow?.status === "stepping") // Guard against stale events: a node already cancelled // (markUnsatisfied) or already satisfied must not be flipped @@ -721,11 +730,16 @@ const serviceLayer = Layer.effect( if (yield* attemptPause) return true if (yield* attemptPause) return true const wf = yield* store.getWorkflow(dagID).pipe(Effect.orDie) - if (wf?.status !== "paused") + if (wf?.status !== "paused") { + // F2: record the hold so the durable-row re-syncs + // below (node terminal prologues, refreshControlFlags) + // cannot lift it until the parent acts. + entry.vetoHold = true yield* Effect.logError( "DagLoop pause on replan verdict failed — holding in-memory pause (fail-closed)", { dagID, nodeID, durableStatus: wf?.status ?? "missing" }, ) + } return true }) entry.runtime.setPaused(paused) @@ -854,7 +868,8 @@ const serviceLayer = Layer.effect( const refreshControlFlags = Effect.fnUntraced(function* (dagID: string, entry: WorkflowEntry) { const workflow = yield* store.getWorkflow(dagID) if (!workflow || isWorkflowTerminalStatus(workflow.status as never)) return undefined - entry.runtime.setPaused(workflow.status === "paused") + // F2: honor the veto hold — see WorkflowEntry.vetoHold. + entry.runtime.setPaused(workflow.status === "paused" || entry.vetoHold) entry.runtime.setStepMode(workflow.status === "stepping") return workflow }) @@ -881,6 +896,9 @@ const serviceLayer = Layer.effect( if (!entry) return yield* entry.evalLock.withPermits(1)( Effect.gen(function* () { + // F2 (DAG-03): resume/step is the parent's explicit control — + // release the fail-closed veto hold before the flag re-sync. + entry.vetoHold = false const workflow = yield* refreshControlFlags(dagID, entry) if (workflow?.status !== "stepping") return // Dag.step validated "no in-flight node" on a DB snapshot @@ -907,6 +925,9 @@ const serviceLayer = Layer.effect( if (!entry) return yield* entry.evalLock.withPermits(1)( Effect.gen(function* () { + // F2 (DAG-03): resume/step is the parent's explicit control — + // release the fail-closed veto hold before the flag re-sync. + entry.vetoHold = false const workflow = yield* refreshControlFlags(dagID, entry) if (workflow?.status === "running") yield* spawnReady(dagID) // A workflow can be resumed with every node already settled @@ -934,6 +955,15 @@ const serviceLayer = Layer.effect( yield* entry.evalLock.withPermits(1)( Effect.gen(function* () { const wf = yield* store.getWorkflow(dagID).pipe(Effect.orDie) + // F2 (DAG-03): a replan is the parent's explicit disposition + // of the verdict — release the fail-closed veto hold and + // re-sync the control flags from the durable row (this + // handler never refreshed them, so a hold set by the verdict + // gate would otherwise silence the trailing spawnReady + // forever and the corrective nodes would never run). + entry.vetoHold = false + entry.runtime.setPaused(wf?.status === "paused") + entry.runtime.setStepMode(wf?.status === "stepping") const oldConfig = entry.config if (wf) entry.config = parseWorkflowConfig(wf.config) // Rev-view (v1.0.15 Train A): THE aggregation filter point. diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index 6436c91a7..95761c5fe 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -584,10 +584,10 @@ function conditionDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { * * Options: * - `exemptCheckpointIds` (DAG-02 runtime path): a checkpoint already - * terminal in the durable graph has delivered its verdict — the ordering - * race the gate protects is in the past, so additive waves / reopens may - * attach dependents without a condition. Authoring never exempts: nothing - * is terminal there yet. + * terminal in the durable graph is settled and immutable (terminal nodes + * never re-run) — the ordering race the gate protects is in the past, so + * additive waves / reopens may attach dependents without a condition. + * Authoring never exempts: nothing is terminal there yet. * - `requireOutputSchema` (default true; the runtime replan path passes * false): DAG-01's "gated checkpoints must declare output_schema" is an * AUTHORING obligation — runtime-created graphs deliberately bypass @@ -857,7 +857,7 @@ export function replanStructuralDiagnostics(input: ReplanStructuralInput): Diagn // Pre-fix replan/extend skipped this gate entirely, so the dependent // was spawned the moment the checkpoint completed, before the parent // could read the verdict. Checkpoints already terminal in the durable - // graph are exempt: their verdict was delivered, the race is past. The + // graph are exempt: they are settled and immutable, the race is past. The // output_schema obligation is authoring-only (requireOutputSchema:false) // — runtime-created graphs deliberately bypass authoring validation. ...checkpointGateDiagnostics(input.merged.nodes, input.merged.node_defaults, { diff --git a/packages/opencode/test/dag/dag-loop-guards.test.ts b/packages/opencode/test/dag/dag-loop-guards.test.ts index a8b875adb..ae98e42f2 100644 --- a/packages/opencode/test/dag/dag-loop-guards.test.ts +++ b/packages/opencode/test/dag/dag-loop-guards.test.ts @@ -734,4 +734,64 @@ describe("DagLoop replan verdict gate fail-closed (DAG-03)", () => { ), ) }) + + // Review F2 (R1): the hold must SURVIVE the durable-row re-sync that the + // next node terminal event performs, and it must be RELEASED by an explicit + // parent control action (replan/resume/step) — otherwise the fail-closed + // hold was lifted by any subsequent NodeCompleted/NodeSkipped/stepped + // stimulus and spawnReady ran on the vetoed direction before the parent + // ever adjudicated the verdict. + it("the veto hold survives a terminal-event flag re-sync (DAG-03 / F2)", async () => { + await Effect.runPromise( + runGuardTest( + { instanceProject: "project-1", failPause: { remaining: 99 } }, + ({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create(vetoedGateGraph("Resync hold", "resync-hold")) + const { gate } = yield* takeBootPrompts(childPrompts) + expect(gate.title).toBe("gate") + yield* dag.nodeCompleted(dagID, "gate", { verdict: "replan", findings: "vetoed" }) + expect((yield* store.getWorkflow(dagID))?.status).toBe("running") + // Completing the unrelated probe lands in the NodeCompleted + // handler, whose prologue re-syncs paused from the DURABLE row + // ("running") and then calls spawnReady — the re-sync must not + // lift the veto hold. + yield* dag.nodeCompleted(dagID, "probe", "probe done") + yield* Effect.sleep("300 millis") + expect((yield* store.getNode(dagID, "downstream"))?.status).toBe("pending") + }), + ), + ) + }) + + it("a parent replan releases the hold and the corrective path spawns (DAG-03 / F2)", async () => { + await Effect.runPromise( + runGuardTest( + { instanceProject: "project-1", failPause: { remaining: 99 } }, + ({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create(vetoedGateGraph("Replan release", "replan-release")) + const { gate } = yield* takeBootPrompts(childPrompts) + expect(gate.title).toBe("gate") + yield* dag.nodeCompleted(dagID, "gate", { verdict: "replan", findings: "vetoed" }) + expect((yield* store.getWorkflow(dagID))?.status).toBe("running") + // Parent disposition: replan adds a corrective node off the + // (terminal) checkpoint — exempt from the merged checkpoint gate. + yield* dag.replan(dagID, { nodes: [node({ id: "corrective", name: "corrective", depends_on: ["gate"] })] }) + // The WorkflowReplanned handler releases the hold and re-syncs + // flags from the durable row, so the corrective node spawns. The + // wake prompt may interleave; drain until the corrective prompt. + const corrective = yield* Effect.gen(function* () { + for (let i = 0; i < 4; i++) { + const next = yield* takeWithin(childPrompts, `prompt ${i} after replan never arrived`) + if (next.title === "corrective") return next + } + return yield* Effect.fail(new Error("corrective node did not spawn after the releasing replan")) + }) + expect(corrective.title).toBe("corrective") + yield* Deferred.succeed(corrective.release, "fixed") + }), + ), + ) + }) }) diff --git a/packages/opencode/test/server/global-lifecycle.test.ts b/packages/opencode/test/server/global-lifecycle.test.ts index 7f1b2db1f..415c494fa 100644 --- a/packages/opencode/test/server/global-lifecycle.test.ts +++ b/packages/opencode/test/server/global-lifecycle.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Layer, Option } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import * as TestClock from "effect/testing/TestClock" import { logLines } from "effect/testing/TestConsole" import { InstanceStore } from "@/project/instance-store" From ff1f8dab5832f659134d84638f5070dad5f9e481 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 18:22:08 +0800 Subject: [PATCH 6/9] docs(dag): sharpen vetoHold release-scope and bounded-dispose comments (review R2) --- docs/findings/dag-batch-findings.md | 8 ++++++++ packages/opencode/src/dag/runtime/loop.ts | 10 ++++++++-- packages/opencode/src/server/global-lifecycle.ts | 4 ++++ packages/opencode/test/dag/dag-loop-guards.test.ts | 1 - 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/findings/dag-batch-findings.md b/docs/findings/dag-batch-findings.md index c1f8f3730..eb15f3f25 100644 --- a/docs/findings/dag-batch-findings.md +++ b/docs/findings/dag-batch-findings.md @@ -45,4 +45,12 @@ - 结论:非干净轮。修复后进入 Round 2。 ### Round 2 +- Spec 镜:**PASS**,3 条 INFO;Standards 镜:**PASS**,1 条 INFO。处置: + - R2-1(INFO):vetoHold 注释宣称「replan/resume/step 均可释放」不精确——hold 态持久行是 running,resume 对 running 是非法迁移(InvalidTransitionError),resume 释放仅在 stepping/pending 态可达。→ 已改写字段注释:replan/step 任何 hold 态可达;resume 仅 stepping/pending;control(replan) 正是 verdict 所求的处置路径。 + - R2-2(INFO):hold 为进程内状态,重启后从持久行重建(审计 DAG-03 的范围就是进程内 fail-open)。→ 已在字段注释记录该边界。 + - R2-3(INFO):timeoutOption 的切断发生在第一个可中断点——uninterruptible finalizer 区域内的 wedge 可越过上限(硬切断需 Effect.disconnect,刻意不取,与 exerciser 的 Promise.race 同残差)。→ 已在 global-lifecycle 注释精确化。 + - Standards INFO-1:dag-loop-guards.test.ts 未用的 `probe` 变量。→ 已删。 +- 结论:非干净轮(4 INFO)。修复后进入 Round 3。 + +### Round 3 - 未开始 diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 8f9cf7a82..992726e0c 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -60,8 +60,14 @@ interface WorkflowEntry { /** DAG-03/F2: a replan-verdict veto whose durable pause could not be * persisted. While set, the in-memory paused flag survives the durable-row * re-syncs performed by node terminal events and refreshControlFlags, so - * no stimulus spawns the vetoed direction before the parent acts. Cleared - * by the parent's explicit control events (replan/resume/step). */ + * no stimulus spawns the vetoed direction before the parent acts. Released + * by the parent's explicit control events — replan and step are reachable + * from every hold state; resume only when the durable row is + * stepping/pending (a held row reads "running" and resume would be an + * invalid transition there — control(replan) is the disposition the + * verdict asked for). Process-local: a restart while the durable pause + * never landed rebuilds the flags from the durable row (the audit's + * DAG-03 scope was the in-process fail-open, which this closes). */ vetoHold: boolean } diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts index aa442a924..c6c47ba9e 100644 --- a/packages/opencode/src/server/global-lifecycle.ts +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -21,6 +21,10 @@ export const emitGlobalDisposed = Effect.sync(() => // timeoutOption represents the timeout as Option.none (never as an error), so // a genuine disposal failure still propagates for callers that do not swallow, // while a hang is always cut off and the Disposed event below always lands. +// The cut lands at the disposal's first interruptible point: a wedge inside +// an uninterruptible finalizer region can outlast the cap (a hard sever would +// need Effect.disconnect, deliberately not taken — same residual the +// exerciser's Promise.race guard carries). const DISPOSE_ALL_TIMEOUT = "10 seconds" export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")( diff --git a/packages/opencode/test/dag/dag-loop-guards.test.ts b/packages/opencode/test/dag/dag-loop-guards.test.ts index ae98e42f2..ab44d6f3b 100644 --- a/packages/opencode/test/dag/dag-loop-guards.test.ts +++ b/packages/opencode/test/dag/dag-loop-guards.test.ts @@ -705,7 +705,6 @@ describe("DagLoop replan verdict gate fail-closed (DAG-03)", () => { // The durable pause never landed expect((yield* store.getWorkflow(dagID))?.status).toBe("running") // Post-veto stimulus on an unrelated node. - const probe = (yield* store.getNode(dagID, "probe"))! yield* dag.nodeFailed(dagID, "probe", "probe exploded", "exec_failed") yield* Effect.sleep("300 millis") // Fail-closed: the vetoed dependent was NOT spawned by the From 85df04311bbed238ba85b9531fdacd254f94d5a0 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 18:31:25 +0800 Subject: [PATCH 7/9] docs(dag): correct the vetoHold resume-reachability enumeration (review R3) --- docs/findings/dag-batch-findings.md | 5 +++++ packages/opencode/src/dag/runtime/loop.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/findings/dag-batch-findings.md b/docs/findings/dag-batch-findings.md index eb15f3f25..cce290441 100644 --- a/docs/findings/dag-batch-findings.md +++ b/docs/findings/dag-batch-findings.md @@ -53,4 +53,9 @@ - 结论:非干净轮(4 INFO)。修复后进入 Round 3。 ### Round 3 +- Spec 镜:**PASS,no findings**(干净轮候选)。 +- Standards 镜:**PASS**,1 条 INFO:R3-1——vetoHold 注释的 resume 枚举「stepping/pending」不完整:hold 之后父层仍可先持久 pause 再 resume(该路径释放有效),且 pending 对已启动工作流不可达。→ 已改为「resume only when the durable row is not running (paused/stepping)」并说明直至持久 pause 落地。 +- 结论:非干净轮。修复后进入 Round 4。 + +### Round 4 - 未开始 diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 992726e0c..e2c3f316b 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -62,12 +62,12 @@ interface WorkflowEntry { * re-syncs performed by node terminal events and refreshControlFlags, so * no stimulus spawns the vetoed direction before the parent acts. Released * by the parent's explicit control events — replan and step are reachable - * from every hold state; resume only when the durable row is - * stepping/pending (a held row reads "running" and resume would be an - * invalid transition there — control(replan) is the disposition the - * verdict asked for). Process-local: a restart while the durable pause - * never landed rebuilds the flags from the durable row (the audit's - * DAG-03 scope was the in-process fail-open, which this closes). */ + * from every hold state; resume only when the durable row is not running + * (paused/stepping — a held row reads "running" and resume is an invalid + * transition there until a durable pause lands; control(replan) is the + * disposition the verdict asked for). Process-local: a restart while the + * durable pause never landed rebuilds the flags from the durable row (the + * audit's DAG-03 scope was the in-process fail-open, which this closes). */ vetoHold: boolean } From 5128f0958173c5f63a11b783d5f0d0d74cb62a90 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 18:39:42 +0800 Subject: [PATCH 8/9] docs(dag): attach the output_schema clause to the gating fix in the ungated hint (review R4) --- docs/findings/dag-batch-findings.md | 5 +++++ packages/opencode/src/dag/validation.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/findings/dag-batch-findings.md b/docs/findings/dag-batch-findings.md index cce290441..193b42540 100644 --- a/docs/findings/dag-batch-findings.md +++ b/docs/findings/dag-batch-findings.md @@ -58,4 +58,9 @@ - 结论:非干净轮。修复后进入 Round 4。 ### Round 4 +- Spec 镜:**PASS,no findings**。 +- Standards 镜:**PASS**,1 条 INFO:R4-1——ungated 诊断的 hint 把「declare output_schema」列为独立替代项,但单独声明 schema 不能解除 ungated 错误(须与 gating 条件组合)。→ 已改写为「Gate … with condition … and declare output_schema …」组合句式。 +- 结论:非干净轮。修复后进入 Round 5。 + +### Round 5 - 未开始 diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index 95761c5fe..d61882afb 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -618,8 +618,7 @@ export function checkpointGateDiagnostics( `reporting checkpoint "${checkpoint.id}" has dependent "${dependent.id}" that is not gated on its output` + ` — the engine spawns "${dependent.id}" as soon as "${checkpoint.id}" completes, so the checkpoint verdict cannot be acted on first`, hint: - `Gate "${dependent.id}" with condition: "${checkpoint.id}.output. == ..." (e.g. on its verdict),` - + ` declare output_schema on "${checkpoint.id}" so the gate reads a schema-validated verdict,` + `Gate "${dependent.id}" with condition: "${checkpoint.id}.output. == ..." (e.g. on its verdict) and declare output_schema on "${checkpoint.id}" so the gate reads a schema-validated verdict,` + ` keep "${checkpoint.id}" a reporting leaf, or set report_to_parent: false on "${checkpoint.id}" if downstream must run unconditionally`, }), ) From 230fd2c62acbe5172227bc8c47b93214b97c14bb Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 18:52:49 +0800 Subject: [PATCH 9/9] docs(dag): declare module convergence after two consecutive clean review rounds --- docs/findings/dag-batch-findings.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/findings/dag-batch-findings.md b/docs/findings/dag-batch-findings.md index 193b42540..5321aa242 100644 --- a/docs/findings/dag-batch-findings.md +++ b/docs/findings/dag-batch-findings.md @@ -63,4 +63,25 @@ - 结论:非干净轮。修复后进入 Round 5。 ### Round 5 -- 未开始 +- Spec 镜:**PASS,no findings**;Standards 镜:**PASS,no findings**(R4 hint 修复逐条复核为真)。 +- 结论:**干净轮 1/2**。 + +### Round 6 +- Spec 镜:**PASS,no findings**(四缺陷 + F2 + 文档同步独立复核;附 process note:PR 前回填 R5 结果——本条即回填)。 +- Standards 镜:**PASS,no findings**(Effect v4 API 对照 effect-smol 源码逐一验证;注释真实性对照 transition table/replan.ts/spawn.ts 复核)。 +- 结论:**干净轮 2/2**。连续两轮零 findings → **DAG 模块收敛**。 + +## 收敛结论 + +R1(Spec 3 Low + Standards 6)→ R2(Spec 3 + Standards 1)→ R3(Spec 0 + Standards 1)→ R4(Spec 0 + Standards 1)→ **R5+R6 连续两轮双镜零 findings**。全部 findings 关闭,findings 衰减轨迹清晰(Medium 实装 → Low 措辞 → 零)。 + +## 模块门禁(终态) + +- 切片级:dag 测试簇绿(579/579 含新增 19 条回归)+ 包内 `bun typecheck` 绿 + 每切片变异翻红验证。 +- 全量套件:4157 tests / 342 files,仅 2 失败均为 GOAL run 期间已在干净基线 detached 复跑证实的 darwin 环境既有失败(help-snapshots、project-copy;pty 本轮通过),与本批无因果;DAG/GOAL 相关零失败。 + +## 交付 + +- 分支:`fix/dag-batch`(基于 origin/dev 1d087ffe9) +- 提交链:43fd72bbd(audit 文档)→ 71ab1bdf6(DAG-01/02)→ 1c4f1ad7a(DAG-03)→ db626d4ba(DAG-04)→ 337814648 / ff1f8dab5 / 85df04311 / 5128f0958(审阅轮修复) +- PR → dev(Typecheck 门禁),合入由用户授权执行