From cfb42e89ce2436498a7d9c998beb74d5a23129ba Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 10:37:23 +0800 Subject: [PATCH 01/39] fix(memory): extend background-maintenance lock discipline to the periodic prepare path (issue #324) --- packages/opencode/src/memory/CONTEXT.md | 2 + packages/opencode/src/memory/memory.ts | 177 +++++++++---------- packages/opencode/test/memory/memory.test.ts | 64 ++++++- 3 files changed, 147 insertions(+), 96 deletions(-) diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index cc95f18b7b..450cf6a30c 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -51,6 +51,8 @@ The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` re - Worktree `remove`/`reset` reconcile legacy memory fail-closed against the **complete** directory snapshot (primary + every registered sandbox) and always invalidate the admission cache before rescanning; they never trust a cached clean result. - Legacy files are re-read and compared immediately before deletion; content that changed after the scan is preserved and surfaced as a conflict. - Every writer of a MEMORY config file serializes on the file's cross-process lock; byte-atomicity is not undermined by whole-document last-writer-wins. +- Maintenance model calls never run under the identity fence or the project lock: prepare and checkpoint render the pre-maintenance snapshot, then kick maintenance in the background, gated on identity liveness so a retired identity never starts a job (one job in flight per project, the commit-only write back under the fence). +- Bounded matcher calls deliberately hold the fence across one model call: the search matcher to coalesce concurrent identical queries, the prepare and checkpoint matchers because their match result feeds an atomic read-match-write under the project lock. Only unbounded-class work (maintenance) is excluded from the fence; a bounded matcher is at most one call per fence acquisition. ## Boundaries diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 4a33e01c77..564a5de770 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -328,27 +328,6 @@ export const layer: Layer.Layer< return decoded.value.actions }) - const maintain = Effect.fn("Memory.maintain")(function* (input: { - model: Provider.Model - config: MemorySchema.Config - topics: MemorySchema.Topic[] - messages: SessionV1.WithParts[] - projectID: Project.Info["id"] - }) { - const actions = yield* proposeMaintenance(input) - if (!actions) return input.topics - return yield* store - .updateTopics(input.projectID, (topics) => ({ - applied: MemoryStore.applyActions({ - topics, - actions, - topicLimit: input.config.topic_limit, - }), - result: undefined, - })) - .pipe(Effect.map((updated) => updated.topics)) - }) - const select = Effect.fn("Memory.select")(function* (input: { model: Provider.Model config: MemorySchema.Config @@ -373,12 +352,13 @@ export const layer: Layer.Layer< // matcher and maintenance model run OUTSIDE the fence/lock, and only the // topic commit acquires them (applyUpdate), so a long reasoning call // cannot wedge the lock, leak it on interruption, or block the caller. - const backgroundMaintain = Effect.fn("Memory.backgroundMaintain")(function* (input: { + type MaintenanceInput = { model: Provider.Model config: MemorySchema.Config messages: SessionV1.WithParts[] projectID: ProjectV2.ID - }) { + } + const backgroundMaintain = Effect.fn("Memory.backgroundMaintain")(function* (input: MaintenanceInput) { const topics = yield* store.readTopics(input.projectID) const actions = yield* proposeMaintenance({ model: input.model, @@ -405,37 +385,40 @@ export const layer: Layer.Layer< return next }) - const kickMaintenance = Effect.fn("Memory.kickMaintenance")(function* (input: { - model: Provider.Model - config: MemorySchema.Config - messages: SessionV1.WithParts[] - projectID: ProjectV2.ID - }) { + const kickMaintenance = Effect.fn("Memory.kickMaintenance")(function* (input: MaintenanceInput) { const job = backgroundMaintain(input).pipe( Effect.catchCause((cause) => Effect.logWarning("background MEMORY maintenance failed", { cause })), Effect.ensuring(releaseMaintenanceSlot(input.projectID)), ) - // Reserve and fork atomically: an interruption between the two would - // leak the in-flight slot and silently skip every later maintenance for - // this process; a fork into a closing scope must hand the slot back. - yield* Effect.uninterruptible( - Effect.gen(function* () { - const reserved = yield* Ref.modify(maintenanceInFlight, (set) => - set.has(input.projectID) - ? ([false, set] as const) - : ([true, new Set(set).add(input.projectID)] as const), - ) - if (!reserved) return - yield* job.pipe( - Effect.forkIn(scope), - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* releaseMaintenanceSlot(input.projectID) - yield* Effect.logWarning("background MEMORY maintenance fork failed", { cause }) - }), - ), - ) - }), + // The single definition of the kickoff rule: the identity fence gates + // the fork, so a retired identity never burns a maintenance model call. + // Callers must NOT already hold the fence (it is not reentrant) and must + // treat None as "identity retired" — dropping their cached session state + // is the whole cost, because the commit inside applyUpdate is fenced too. + return yield* fence.withLiveIdentity( + input.projectID, + // Reserve and fork atomically: an interruption between the two would + // leak the in-flight slot and silently skip every later maintenance for + // this process; a fork into a closing scope must hand the slot back. + Effect.uninterruptible( + Effect.gen(function* () { + const reserved = yield* Ref.modify(maintenanceInFlight, (set) => + set.has(input.projectID) + ? ([false, set] as const) + : ([true, new Set(set).add(input.projectID)] as const), + ) + if (!reserved) return + yield* job.pipe( + Effect.forkIn(scope), + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* releaseMaintenanceSlot(input.projectID) + yield* Effect.logWarning("background MEMORY maintenance fork failed", { cause }) + }), + ), + ) + }), + ), ) }) @@ -470,50 +453,52 @@ export const layer: Layer.Layer< session.firstTurnAttempted = true if (!due && !shouldMatch) return - // Cross-process identity guard (see checkpointUnsafe): MemoryIdentityFence - // re-checks identity liveness under the identity lock before writing. + const maintenance = { + model: current.model, + config: current.loaded.config, + messages: input.messages, + projectID: current.project.id, + } + + if (!shouldMatch) { + // Due-only turns reuse the cached injection: no project lock, no store + // read, and the cadence bookkeeping is a process-local map write. The + // kick carries the identity gate (see kickMaintenance). + const entry = data.sessions.get(input.sessionID) + if (entry?.turn.messageID === user.info.id) entry.turn = { ...entry.turn, completedTurns: turns } + if (Option.isNone(yield* kickMaintenance(maintenance))) yield* clearSession(input.sessionID) + return + } + + // The fence and the project lock cover the topic read plus the bounded + // first-turn matcher (declared tradeoff, see CONTEXT.md). Due maintenance + // is kicked AFTER the fence releases, so a long reasoning call never + // holds it: this turn renders the pre-maintenance topics and the + // committed update surfaces on a later prepare. 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({ - model: current.model, - config: current.loaded.config, - topics, - messages: input.messages, - projectID: current.project.id, - }).pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("periodic MEMORY maintenance failed", { cause }) - return topics - }), - ), - ) - : topics - const rendered = shouldMatch - ? (yield* select({ - model: current.model, - config: current.loaded.config, - topics: maintained, - text: user.text, - projectID: current.project.id, - })).rendered - : (data.sessions.get(input.sessionID)?.turn.rendered ?? []) - const entry = data.sessions.get(input.sessionID) - if (entry?.turn.messageID !== user.info.id) return - entry.turn = { ...entry.turn, completedTurns: turns, rendered } - }), - ) - }), + lock.withProject(current.project.id)( + Effect.gen(function* () { + const topics = yield* store.readTopics(current.project.id) + const rendered = (yield* select({ + model: current.model, + config: current.loaded.config, + topics, + text: user.text, + projectID: current.project.id, + })).rendered + const entry = data.sessions.get(input.sessionID) + if (entry?.turn.messageID !== user.info.id) return + entry.turn = { ...entry.turn, completedTurns: turns, rendered } + }), + ), ) if (Option.isNone(live)) { yield* clearSession(input.sessionID) return } + if (!due) return + if (Option.isNone(yield* kickMaintenance(maintenance))) yield* clearSession(input.sessionID) }) const prepare: Interface["prepare"] = Effect.fn("Memory.prepare")((input) => @@ -574,8 +559,11 @@ export const layer: Layer.Layer< } const origin = user.info.id - // Cross-process identity guard (see checkpointUnsafe): MemoryIdentityFence - // re-checks identity liveness under the identity lock before matching/writing. + // Declared tradeoff (issue #324, see CONTEXT.md): unlike maintenance, the + // matcher model call runs INSIDE the fence/lock. That serialization is + // what coalesces concurrent identical queries — the second caller blocks, + // re-reads `queries` under the lock, and reuses the first result instead + // of spending another model call. The lock also covers markMatched. const live = yield* fence.withLiveIdentity( current.project.id, Effect.gen(function* () { @@ -658,16 +646,17 @@ export const layer: Layer.Layer< yield* clearSession(input.sessionID) return [] } - // Maintenance runs in the background AFTER the identity fence: compaction - // must not wait on a long reasoning call, a retired identity never burns - // model calls, and the injection above rendered the pre-maintenance - // topics. At most one job per project is in flight. - yield* kickMaintenance({ + // Maintenance is kicked AFTER the identity fence releases: compaction must + // not wait on a long reasoning call, and the injection above rendered the + // pre-maintenance topics. The kick carries the identity gate and the + // one-job-per-project reservation (see kickMaintenance). + const kicked = yield* kickMaintenance({ model: current.model, config: current.loaded.config, messages: input.messages, projectID: current.project.id, }) + if (Option.isNone(kicked)) yield* clearSession(input.sessionID) return live.value }) diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 2f0a9e4b3c..182503e44a 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -29,7 +29,7 @@ import { MCP } from "@/mcp" import { Skill } from "@/skill" import { SystemPrompt } from "@/session/system" import { tmpdirScoped } from "../fixture/fixture" -import { pollWithTimeout, testEffect } from "../lib/effect" +import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { ProviderTest } from "../fake/provider" const config = { @@ -317,6 +317,7 @@ function recallFixture() { config: MemorySchema.Config projectInitialized: number matcher?: (query: string) => Effect.Effect + maintenanceHook?: () => Effect.Effect } = { queries: [], reads: 0, @@ -367,6 +368,7 @@ function recallFixture() { return { topic_ids: query.includes("架构") ? [state.topics[0]?.id] : [] } } state.maintenance++ + if (state.maintenanceHook) return yield* state.maintenanceHook() return { actions: [{ type: "no_change" }] } }), }), @@ -410,6 +412,7 @@ function recallFixture() { state.config = config state.projectInitialized = 1 state.matcher = undefined + state.maintenanceHook = undefined }, it: testEffect(layer), systemIt: testEffect(systemLayer), @@ -1268,13 +1271,70 @@ describe("memory turn-scoped retrieval", () => { ] yield* memory.prepare({ sessionID, messages }) - expect(recall.state.maintenance).toBe(1) + // Maintenance runs in the background after the render fence (issue + // #324): polling stands in for the old synchronous completion. + yield* pollWithTimeout( + Effect.sync(() => (recall.state.maintenance === 1 ? true : undefined)), + "due maintenance never ran in the background", + ) expect(recall.state.queries).not.toContain("再次讨论架构边界") expect(yield* memory.context(sessionID)).toEqual([]) }), { git: true }, ) + recall.it.instance( + "keeps the fence and project lock free while background maintenance streams", + () => + Effect.gen(function* () { + recall.reset() + recall.state.config = { ...config, turn_interval: 1 } + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_maintenance_lock_free") + const firstID = MessageID.ascending() + const first = user(firstID, sessionID, "继续之前确认的架构边界") + + yield* memory.prepare({ sessionID, messages: [first] }) + const messages = [ + first, + { + info: assistant(firstID, sessionID, ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"), "end_turn"), + parts: [], + }, + user(MessageID.ascending(), sessionID, "第二次架构讨论"), + ] + + const release = yield* Deferred.make() + recall.state.maintenanceHook = () => + Effect.gen(function* () { + yield* Deferred.await(release) + return { actions: [{ type: "no_change" }] } + }) + + const pending = yield* memory.prepare({ sessionID, messages }).pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.sync(() => (recall.state.maintenance >= 1 ? true : undefined)), + "due maintenance never reached the model call", + ) + + // The maintenance model call is in flight. Because prepare kicked it + // outside the fence (issue #324), a concurrent checkpoint — whose + // render select needs the same identity fence and project lock — is + // not starved; under the old inline shape it would wait on the fence + // until the streaming call finished. + const rendered = yield* awaitWithTimeout( + memory.checkpoint({ sessionID, messages }), + "checkpoint starved by background maintenance", + ) + expect(rendered.length).toBeGreaterThan(0) + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(pending) + expect(recall.state.maintenance).toBe(1) + }), + { git: true }, + ) + recall.it.instance( "discards a query that completes after a new real user turn starts", () => From bcad76ebf5d9f3fed263abc14e4b80990d269d9e Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 13:31:37 +0800 Subject: [PATCH 02/39] 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 0000000000..2762d3a453 --- /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 ed7185a0f1555b82efa0b4e4b8669b9b0d0ea20f Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 13:37:03 +0800 Subject: [PATCH 03/39] fix(goal): boundary gate suppresses re-judgment, not the drive (GOAL-01) --- docs/findings/goal-batch-findings.md | 22 ++ packages/opencode/src/goal/loop.ts | 264 ++++++++++--------- packages/opencode/test/goal/e2e-loop.test.ts | 38 ++- workflows/audit-fix-loop.md | 82 ++++++ 4 files changed, 273 insertions(+), 133 deletions(-) create mode 100644 docs/findings/goal-batch-findings.md create mode 100644 workflows/audit-fix-loop.md diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md new file mode 100644 index 0000000000..9e0e9c6ea7 --- /dev/null +++ b/docs/findings/goal-batch-findings.md @@ -0,0 +1,22 @@ +# GOAL 批次 Findings Register + +- 验收 primary source:`docs/audit-dag-memory-goal-2026-08-18.md`(GOAL 章节) +- 分支:`fix/goal-batch` → PR `dev` +- 收敛判据:连续两轮独立审阅(Spec 镜 + Standards 镜)零 findings + 模块门禁全绿 +- 规格:`workflows/audit-fix-loop.md` + +## 审计缺陷切片(输入项,非审阅 finding) + +| ID | 严重性 | 切片顺序 | 状态 | 提交 | +|---|---|---|---|---| +| GOAL-01 | High | 1 (P0) | 进行中 | — | +| GOAL-02 | Medium | 2 (P2) | 待办 | — | +| GOAL-03 | Low | 3 | 待办 | — | +| GOAL-04 | Low | 4 | 待办 | — | + +## 审阅轮次 + +(每轮审阅结果记账于此;全部关闭后才具备发 PR 资格) + +### Round 1 +- 未开始 diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index f6a55ecd70..65b85e4b26 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -247,10 +247,24 @@ const serviceLayer = Layer.effect( // process; the goal row's last_judged_msg is the crash-surviving record // of which boundary was already judged and committed. 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. Live idle events are never gated here: every dispatched - // continuation produces a fresh assistant message, so the live path - // always judges a new boundary. + // re-judging would inflate turns_used. Live idle events are never gated + // here: every dispatched continuation produces a fresh assistant + // message, so the live path always judges a new boundary. + // + // GOAL-01: the gate suppresses RE-JUDGMENT, never the drive. The old + // behavior `return`ed here, which permanently stranded goals whose + // committed continue evaluation lost its continuation to a crash + // (process died after the commit, before the next assistant message): + // every boot scan re-hit this gate, nothing ever dispatched another + // turn, and last_judged_msg (only written by judge commits) never + // advanced. Now the gate sets suppressJudge and falls through — the + // judge call and its updateAfterJudge commit below are skipped (the + // boundary is already judged; re-judging is what would inflate + // turns_used), but the shared continuation dispatch still runs and + // restores the driver. A second crash repeats this safely: a fresh + // process starts with an empty evaluatedRevisions map and an unchanged + // last_judged_msg, so the gate fires and re-dispatches again. + let suppressJudge = false if (scanResume && goalState.last_judged_msg) { const win = yield* sessions .messages({ sessionID, limit: 20 }) @@ -260,7 +274,7 @@ const serviceLayer = Layer.effect( ), ) const lastSeen = [...win].reverse().find((m) => m.info.role === "assistant") - if (lastSeen && lastSeen.info.id === goalState.last_judged_msg) return + if (lastSeen && lastSeen.info.id === goalState.last_judged_msg) suppressJudge = true } const goalOwner = { kind: "goal" as const, id: goalState.goal_id ?? "legacy" } yield* automation.register(sessionID, goalOwner) @@ -324,123 +338,133 @@ const serviceLayer = Layer.effect( yield* promptSvc.prompt({ sessionID, noReply: true, parts: [{ type: "text", text: `⏸ 目标已暂停 — ${pauseMsg}` }] }).pipe(Effect.ignore) return } - const responseText = lastAssistant.parts - .filter((p): p is Extract<(typeof lastAssistant.parts)[number], { type: "text" }> => p.type === "text") - .map((p) => p.text) - .join("\n") - .slice(-4000) - // When the last assistant turn produced no text (pure tool calls, - // reasoning-only, or a submit_result with no prose), the goal should - // NOT silently stall — the agent is making progress via tools. Skip - // the judge (there is nothing to classify) and continue directly, - // using a synthetic "continue" verdict so the loop dispatches the - // next turn. Previously this was a bare `return` that left the goal - // permanently "active" with no continuation — the agent appeared to - // stop working on its own. - const callLLM = Option.getOrUndefined(yield* Effect.serviceOption(GoalLoopJudgeLLM)) - const verdict = responseText - ? yield* GoalJudge.run( - goalState.goal, - responseText, - goalState.subgoals ?? [], - // Judge LLM call: prefer the test-injected callable so e2e tests - // can script verdicts without Provider/network; otherwise build the - // production Provider → generateText path. - callLLM?.call ?? - ((opts) => - Effect.gen(function* () { - const defaultM = yield* provider.defaultModel() - const small = yield* provider.getSmallModel(defaultM.providerID) - const model = small ?? (yield* provider.getModel(defaultM.providerID, defaultM.modelID)) - const language = yield* provider.getLanguage(model) - const result = yield* Effect.tryPromise({ - try: (signal) => - generateText({ - model: language, - system: opts.system, - prompt: opts.user, - temperature: opts.temperature, - maxOutputTokens: opts.maxTokens, - abortSignal: signal, - }), - catch: (e) => new Error(`judge LLM call failed: ${String(e)}`), - }).pipe(Effect.timeout(`${opts.timeout} seconds`)) - if (!result) return "" - return result.text - })), - ) - : { verdict: "continue" as const, reason: "上一轮无文本输出(纯工具调用),跳过判定直接继续", parseFailed: false } - - const updateResult = Option.getOrUndefined( - yield* automation.use( - observedLease, - goal.updateAfterJudge( - sessionID, - verdict.verdict, - verdict.reason, - verdict.parseFailed, - { - goalID: goalState.goal_id ?? "legacy", - revision: goalState.revision ?? 0, - }, - lastAssistant.info.id, - ), - ), - ) - if (!updateResult) return - - // D-4: record the committed revision as evaluated-by-this-process - // (every verdict — continue, done, blocked — is a completed - // evaluation of the pre-commit state). - evaluatedRevisions.set(sessionID, updateResult.state.revision ?? 0) - - if (!updateResult.shouldContinue) { - yield* automation.unregister(sessionID, goalOwner) - evaluatedRevisions.delete(sessionID) - if (verdict.verdict === "done") { - // GOAL-FP-01-15: the done transition has already committed when this - // prompt runs (durable state leads presentation — the row is gone - // and goal.updated(done)/goal.cleared are published), so a failure - // here loses only the transcript line, never the state. Never - // swallow it silently — log it so a lost confirmation is - // diagnosable. No retry: a retried prompt could re-inject a "done" - // line after the goal was re-created. - yield* promptSvc.prompt({ - sessionID, - noReply: true, - parts: [{ type: "text", text: updateResult.message }], - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("goal done message delivery failed", { - sessionID, - cause: Cause.pretty(cause), - }), - ), - ) - } else { - // Auto-pause branch: updateAfterJudge paused the goal due to - // judge-parse-failure or budget exhaustion (verdict.verdict is - // still "continue"). Without surfacing the message here, these - // automatic pauses would be invisible to the user — updateAfterJudge - // already saved the paused state and published goal.updated, but - // nothing rendered the "⏸ 目标已暂停 — …" line into the transcript. - // Emit it as a noReply part so it shows up without spawning a new - // agent turn; the fiber then naturally terminates (no clearFiber - // needed, see updateAfterJudge). - yield* promptSvc.prompt({ - sessionID, - noReply: true, - parts: [{ type: "text", text: updateResult.message }], - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("goal pause message delivery failed", { - sessionID, - cause: Cause.pretty(cause), - }), + // GOAL-01: on a boundary-gate hit the judge call and its commit are + // skipped wholesale (see suppressJudge above) — execution falls through + // to the shared continuation dispatch below. + if (!suppressJudge) { + const responseText = lastAssistant.parts + .filter((p): p is Extract<(typeof lastAssistant.parts)[number], { type: "text" }> => p.type === "text") + .map((p) => p.text) + .join("\n") + .slice(-4000) + // When the last assistant turn produced no text (pure tool calls, + // reasoning-only, or a submit_result with no prose), the goal should + // NOT silently stall — the agent is making progress via tools. Skip + // the judge (there is nothing to classify) and continue directly, + // using a synthetic "continue" verdict so the loop dispatches the + // next turn. Previously this was a bare `return` that left the goal + // permanently "active" with no continuation — the agent appeared to + // stop working on its own. + const callLLM = Option.getOrUndefined(yield* Effect.serviceOption(GoalLoopJudgeLLM)) + const verdict = responseText + ? yield* GoalJudge.run( + goalState.goal, + responseText, + goalState.subgoals ?? [], + // Judge LLM call: prefer the test-injected callable so e2e tests + // can script verdicts without Provider/network; otherwise build the + // production Provider → generateText path. + callLLM?.call ?? + ((opts) => + Effect.gen(function* () { + const defaultM = yield* provider.defaultModel() + const small = yield* provider.getSmallModel(defaultM.providerID) + const model = small ?? (yield* provider.getModel(defaultM.providerID, defaultM.modelID)) + const language = yield* provider.getLanguage(model) + const result = yield* Effect.tryPromise({ + try: (signal) => + generateText({ + model: language, + system: opts.system, + prompt: opts.user, + temperature: opts.temperature, + maxOutputTokens: opts.maxTokens, + abortSignal: signal, + }), + catch: (e) => new Error(`judge LLM call failed: ${String(e)}`), + }).pipe(Effect.timeout(`${opts.timeout} seconds`)) + if (!result) return "" + return result.text + })), + ) + : { verdict: "continue" as const, reason: "上一轮无文本输出(纯工具调用),跳过判定直接继续", parseFailed: false } + + const updateResult = Option.getOrUndefined( + yield* automation.use( + observedLease, + goal.updateAfterJudge( + sessionID, + verdict.verdict, + verdict.reason, + verdict.parseFailed, + { + goalID: goalState.goal_id ?? "legacy", + revision: goalState.revision ?? 0, + }, + lastAssistant.info.id, ), - ) + ), + ) + if (!updateResult) return + + // D-4: record the committed revision as evaluated-by-this-process + // (every verdict — continue, done, blocked — is a completed + // evaluation of the pre-commit state). + evaluatedRevisions.set(sessionID, updateResult.state.revision ?? 0) + + if (!updateResult.shouldContinue) { + yield* automation.unregister(sessionID, goalOwner) + evaluatedRevisions.delete(sessionID) + if (verdict.verdict === "done") { + // GOAL-FP-01-15: the done transition has already committed when this + // prompt runs (durable state leads presentation — the row is gone + // and goal.updated(done)/goal.cleared are published), so a failure + // here loses only the transcript line, never the state. Never + // swallow it silently — log it so a lost confirmation is + // diagnosable. No retry: a retried prompt could re-inject a "done" + // line after the goal was re-created. + yield* promptSvc.prompt({ + sessionID, + noReply: true, + parts: [{ type: "text", text: updateResult.message }], + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("goal done message delivery failed", { + sessionID, + cause: Cause.pretty(cause), + }), + ), + ) + } else { + // Auto-pause branch: updateAfterJudge paused the goal due to + // judge-parse-failure or budget exhaustion (verdict.verdict is + // still "continue"). Without surfacing the message here, these + // automatic pauses would be invisible to the user — updateAfterJudge + // already saved the paused state and published goal.updated, but + // nothing rendered the "⏸ 目标已暂停 — …" line into the transcript. + // Emit it as a noReply part so it shows up without spawning a new + // agent turn; the fiber then naturally terminates (no clearFiber + // needed, see updateAfterJudge). + yield* promptSvc.prompt({ + sessionID, + noReply: true, + parts: [{ type: "text", text: updateResult.message }], + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("goal pause message delivery failed", { + sessionID, + cause: Cause.pretty(cause), + }), + ), + ) + } + return } - return + } else { + // GOAL-01 gate hit: mark the current revision as drive-restored so a + // duplicate scan trigger on the SAME revision is skipped by the D-4 + // gate above (at most one continuation per revision per process). + evaluatedRevisions.set(sessionID, goalState.revision ?? 0) } const currentStatus = yield* status.get(sessionID) diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index 0ba2cf7eb9..290d260186 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -1794,14 +1794,18 @@ describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-F ) }) -// issue #285 / GOAL-FP-01-21: the boot scan must not re-judge a boundary the -// crashed process already judged and committed. The process-local -// evaluatedRevisions map dies with the process, so the DURABLE gate is the -// goal row's last_judged_msg: updateAfterJudge records the judged assistant -// message ID on every continue commit, and the scan path skips evaluation -// while the session window still ends on that same message. Live idle events -// are never gated (each dispatched continuation produces a fresh assistant -// message, so the live path always sees a new boundary). +// issue #285 / GOAL-FP-01-21 / GOAL-01: the boot scan must not re-judge a +// boundary the crashed process already judged and committed, but it MUST +// still restore the drive. The process-local evaluatedRevisions map dies +// with the process, so the DURABLE gate is the goal row's last_judged_msg: +// updateAfterJudge records the judged assistant message ID on every continue +// commit, and while the session window still ends on that same message the +// scan path suppresses RE-JUDGMENT only — a plain skip stranded goals whose +// committed continuation was lost to the crash (nothing left to drive them, +// GOAL-01). On a gate hit the judge and its commit are skipped, but the +// continuation dispatch still runs. Live idle events are never gated (each +// dispatched continuation produces a fresh assistant message, so the live +// path always sees a new boundary). describe("GoalLoop — boot scan must not re-evaluate an already-judged boundary (issue #285)", () => { let judgeCalls = 0 let continuationCalls = 0 @@ -1887,7 +1891,11 @@ describe("GoalLoop — boot scan must not re-evaluate an already-judged boundary return result?.state }) - it.instance("scan with an unchanged boundary skips re-evaluation (no inflation)", () => + // GOAL-01: the gate suppresses RE-JUDGMENT, never the drive. The crashed + // continuation must be re-dispatched (the goal would otherwise sit + // permanently active with nothing driving it), while the judge call and + // the turns_used increment stay suppressed (no inflation). + it.instance("scan with an unchanged boundary skips re-judgment but still dispatches the continuation", () => Effect.gen(function* () { reset() const loop = yield* GoalLoop.Service @@ -1899,11 +1907,15 @@ describe("GoalLoop — boot scan must not re-evaluate an already-judged boundary yield* commitPriorBoundary(sid) yield* loop.init() - // Negative assertion: the scan runs in a forked fiber with no readiness - // signal on the skip path, so a bounded wait stands in for polling. - yield* Effect.sleep("300 millis") + // The gate-hit path dispatches the continuation synchronously enough to + // poll on its admission signal instead of a bounded sleep. + yield* pollWithTimeout( + Effect.sync(() => (continuationCalls >= 1 ? true : undefined)), + "gate-hit scan never dispatched the crashed continuation", + "5 seconds", + ) expect(judgeCalls).toBe(0) - expect(continuationCalls).toBe(0) + expect(continuationCalls).toBe(1) const g = yield* goal.load(sid) expect(g?.turns_used).toBe(1) }), diff --git a/workflows/audit-fix-loop.md b/workflows/audit-fix-loop.md new file mode 100644 index 0000000000..f52e11e88d --- /dev/null +++ b/workflows/audit-fix-loop.md @@ -0,0 +1,82 @@ +# Workflow: audit-fix-loop(审计缺陷修复固定点循环) + +Primary source:`docs/audit-dag-memory-goal-2026-08-18.md`(验收依据的唯一 source of truth)。 +本规格取代此前 /private/tmp 下的全部 loop 文档与 findings register(已灭失)。 + +## 目的 + +以「开发切片 → 独立审阅 → 发现问题 → 修复 → loop 回审阅」的固定点循环,把审计文档中的缺陷按模块收敛到**零 findings**,每个模块分别以 PR → dev 落地。 + +## Runs + +| Run | 模块 | 分支 | 缺陷集 | 触发 | +|---|---|---|---|---| +| 1 | GOAL | `fix/goal-batch`(已建,基于 origin/dev) | GOAL-01..04 | 立即 | +| 2 | DAG | `fix/dag-batch`(Run 1 合入后从新 dev 切出) | DAG-01..04 | Run 1 PR 合入 dev(事件触发) | + +MEMORY(MEM-01..03)已在多轮循环中偿付(PR #333 合入 dev),不再重跑。 + +## 硬边界 + +- PR 只发 `dev`;禁止发 dev→main PR、禁止 release、禁止直推 `main`/`dev`。 +- 验收依据 = 审计文档缺陷条目(位置证据 + 建议修法 + 测试覆盖缺口)及其「建议处置顺序」;不扩大审计面(审计未读区段的缺陷不在本轮范围)。 +- 所有审阅发现入账 findings register,全部关闭后才具备发 PR 资格。 +- 测试不从仓库根运行;typecheck = 在 `packages/opencode` 内 `bun typecheck`。 + +## 单次 run 流程 + +### 0. 准备 +- GOAL run:先以独立 `docs(audit)` 提交把审计文档入库(两个 run 共同的验收依据必须先进 dev)。创建 `docs/findings/goal-batch-findings.md`。 +- DAG run:确认 dev 基线已含 GOAL 修复 + 审计文档,切 `fix/dag-batch`。创建 `docs/findings/dag-batch-findings.md`。 + +### 1. 切片开发(按审计「建议处置顺序」) +- GOAL run:GOAL-01(P0)→ GOAL-02(P2)→ GOAL-03 → GOAL-04 +- DAG run:DAG-01 + DAG-02(P0,审计明确要求一并修)→ DAG-03(P1)→ DAG-04(P1) + +每个切片: +1. **红**:按审计「测试覆盖」缺口先写/改回归测试,测试必须先在当前代码上失败。 +2. **绿**:按审计「建议修法」最小实现;每个缺陷一个独立提交(提交信息引用缺陷 ID)。 +3. **变异**:临时回退实现 → 第 1 步测试必须翻红 → 恢复(证明测试真的钉住了该缺陷)。 +4. **门禁**:目标测试簇 + `bun typecheck` 绿。 + +### 2. 审阅轮(固定点循环主体) +每轮并行派遣**两个互相独立、只读**的审阅子代理(不得复用开发者推理上下文,只看 diff + 审计文档 + 仓库规约): +- **Spec 镜**:diff 逐条对照审计文档对应缺陷条目的验收要求; +- **Standards 镜**:diff 对照仓库 AGENTS.md、Effect 规则、`src/goal|dag` 的 CONTEXT.md 与测试 fixture 规约。 + +每个 finding 必须含:ID、严重度、file:line、证据引文、要求动作;写入 findings register。 +- 有 findings → 逐条修复(修复同样走红-绿门禁)→ 回到审阅。 +- **连续两轮全部审阅零 findings = 模块收敛**。 + +### 3. 模块门禁 +- `bun typecheck`(packages/opencode 内)绿; +- 全量测试套件绿(packages/opencode 内运行); +- diff 自检:改动仅落在对应模块源码 + 测试 + docs。 + +### 4. Checkpoint(唯一人工介入点,push right) +PR 发起前交付一份决策 brief: +- diff 概览(按缺陷分列文件/行数); +- findings register 全部条目的关闭证据; +- 全量测试 + typecheck 结果; +- PR 标题与正文草稿(conventional 格式)。 + +用户批准 → `gh pr create --base dev` → 附 PR 链接与 CI run 链接收口。 + +**条件 checkpoint(仅当发生时)**:某切片建不出红测试——审计验收失去可验证依据,暂停等待用户裁决降级或停。DAG-04 触发源不属于此类:审计文档已给出无复现时的交付边界(见下),无需人工裁决。 + +## Run-specific 设计要点(探索阶段已定案,实施者直接遵循) + +### GOAL run +- **GOAL-01**:`src/goal/loop.ts` 边界门从「抑制驱动」改为「只抑制重判」——引入 `boundaryGateHit` 标志,命中时跳过 judge + `updateAfterJudge`(不膨胀 turns_used),fall-through 到共享 continuation 派发段恢复驱动;gate-hit 分支写 `evaluatedRevisions` 做同 revision 去重。改写 `test/goal/e2e-loop.test.ts:1890-1910` 钉错的断言:judgeCalls=0、continuationCalls=1(pollWithTimeout 信号)、turns_used=1。 +- **GOAL-02**:`src/goal/goal.ts` `pauseForUserCancel` 把无条件 `turnDriven.delete` 移入成功分支;失败分支保留 turnDriven,使持久态(active)/lease(已注册)/进程内标记三者一致,再次 ESC 仍走 pause 快路径。`test/goal/turn-scope.test.ts` 用写坏 goal_state payload 注入确定性 defect 覆盖。 +- **GOAL-03**:`src/goal/goal.ts` `updateAfterJudge` continue 分支对失败 judge 预算中性:parseFailed 时不递增 turns_used、不盖 last_judged_msg;consecutive_parse_failures 计数与 MAX=3 自动暂停不变。改 GOAL-FP-01-18b e2e 测试:poll 信号换 consecutive_parse_failures>=1,turns_used 断言 0。 +- **GOAL-04**:`src/goal/loop.ts` `scanForActiveGoals` busy skip 记 logInfo + deferred 列表;主循环后同一 scan fiber 内单次有界重试(2s),仍 busy 则 logWarning 收口(会话自身 idle 事件仍是驱动者)。扩展现有 busy-session 测试经 `logLines` 断言跳过日志。 + +### DAG run +- **DAG-01+02(一并修)**:`loop.ts:141-156` 条件求值前对字符串输出做与 `loop.ts:667` 相同的 `parseJsonOption` 归一化;`checkpointGateDiagnostics` 追加「被门控引用的 checkpoint 必须声明 output_schema」;把 checkpoint 门接入 replan/extend 路径(`authoring.ts:136` 的 structural 不再对非 start 动作整体关闭结构检查)。回归用例覆盖 `action: "replan"` 与运行时字符串输出两条。 +- **DAG-03**:`loop.ts:681-703` pause 终态失败改 fail-closed `entry.runtime.setPaused(true)`;`dag.pause` 的 defect 纳入同一处理(不只 error channel)。 +- **DAG-04**:`summary-publisher.ts:151-170` 外层 catchCause 依 `spawn.ts:255-257` 既有模式补 `Cause.hasInterrupts` 再抛;`global-lifecycle.ts:16-25` 生产 disposeAll 加有界超时(参照 exerciser 的 bounded 形状)。回归测试以程序注入事件覆盖 dispose 期间 interrupt 语义。**触发源不追查**,按审计文档原样记录为已知缺口(#316 验收项 3 已由审计回答)。 + +## 完成定义 + +两个 run 各自满足:连续两轮零 findings + 模块门禁全绿 + findings register 全部关闭 + PR → dev 创建成功且 CI 运行链接已附。至此循环终止。 From 551b8f78a9d646104044189e39c3aa04884ae7aa Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 13:42:21 +0800 Subject: [PATCH 04/39] fix(goal): keep turn-driven mark when ESC pause exhausts retries (GOAL-02) --- packages/opencode/src/goal/goal.ts | 22 +++++++- .../opencode/test/goal/turn-scope.test.ts | 50 +++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index 1fd6c07f96..2171cf24f4 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -161,6 +161,11 @@ export interface Interface { * seam so SessionPrompt.cancel stays free of lease plumbing. No-op (returns * undefined) when the goal is not active. Never fails: pause failures are * logged and swallowed so a cancel path can always proceed. + * + * GOAL-02: when the pause exhausts its retries, durable row and lease + * both still say "active" — the turn mark is RETAINED so the three + * authorities agree and a repeat ESC retries the pause. The mark is + * cleared only on a successfully persisted pause. */ readonly pauseForUserCancel: (sessionID: SessionID, reason: string) => Effect.Effect /** True when the session's current turn is goal-driven. */ @@ -243,6 +248,9 @@ const serviceLayer = Layer.effect( // (shouldPreempt cannot catch it: ESC adds no user message). Retry the // pause twice with a short backoff; if it still fails, log LOUDLY — the // goal may resurrect, but it will never do so invisibly. + // GOAL-02: an exhausted failure path keeps durable row, lease, and the + // turn mark consistent (all still "active/owned/driven") — see the + // failure branch below. const pauseForUserCancel = Effect.fnUntraced(function* (sessionID: SessionID, reason: string) { let paused: GoalState.Info | undefined let lastCause: Cause.Cause | undefined @@ -259,13 +267,23 @@ const serviceLayer = Layer.effect( yield* automation.unregister(sessionID, { kind: "goal", id: paused.goal_id ?? "legacy" }).pipe( Effect.ignore, ) + turnDriven.delete(sessionID) } else { + // GOAL-02: the pause could not be persisted — the durable row is + // still "active" and the lease registration is still in place, so the + // process-local mark must AGREE with both: keep it. Pre-fix it was + // deleted unconditionally, which disagreed with the durable + // authorities (goal still owns the session as active) and lost the + // ESC provenance on the resurrected turn — the user's second ESC + // would no longer route through this goal-pause fast path, because + // SessionPrompt.cancel maps ESC to a goal pause only for marked + // turns. With the mark retained, every repeat ESC retries the pause + // until the store recovers. yield* Effect.logError( - "goal pause on cancel failed after retries — goal may resurrect on next idle", + "goal pause on cancel failed after retries — goal stays active and turn-driven; a repeat ESC retries the pause", { sessionID, cause: lastCause ? Cause.pretty(lastCause) : "unknown" }, ) } - turnDriven.delete(sessionID) return paused }) diff --git a/packages/opencode/test/goal/turn-scope.test.ts b/packages/opencode/test/goal/turn-scope.test.ts index 99dde397d5..5608ff2931 100644 --- a/packages/opencode/test/goal/turn-scope.test.ts +++ b/packages/opencode/test/goal/turn-scope.test.ts @@ -1,10 +1,13 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" +import { eq } from "drizzle-orm" import { Goal } from "@/goal/goal" +import { GoalState } from "@/goal/state" import { GoalPrompts } from "@/goal/prompts" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionStatus } from "@/session/status" import { Database } from "@opencode-ai/core/database/database" +import { GoalStateTable } from "@opencode-ai/core/goal/sql" import { SessionID } from "@/session/schema" import { testEffect } from "../lib/effect" @@ -18,10 +21,11 @@ import { testEffect } from "../lib/effect" const testLayer = Goal.layer.pipe( // provideMerge (not provide): the statusLine test body yields // SessionStatus.Service to set busy/idle — it must see the SAME instance the - // Goal service reads. + // Goal service reads. Database is merged for the GOAL-02 fault injection + // (the test body corrupts/restores the goal_state payload directly). Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provideMerge(SessionStatus.defaultLayer), - Layer.provide(Database.defaultLayer), + Layer.provideMerge(Database.defaultLayer), ) const it = testEffect(testLayer) @@ -102,6 +106,46 @@ describe("Goal turn-scope — pauseForUserCancel (ESC semantics)", () => { }), ) + // GOAL-02: when the pause cannot be persisted after all retries, the durable + // row is still "active" and the lease registration is still in place — the + // process-local turnDriven mark must AGREE with both (kept, not deleted). + // Pre-fix the mark was deleted unconditionally, which lost the ESC + // provenance: the resurrected turn's second ESC no longer routed through the + // goal pause fast path. + it.live("pause failure after retries keeps the turn mark (durable row, lease, mark agree on active)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const { db } = yield* Database.Service + const sid = SessionID.descending() + const seeded = yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + + // Deterministic pause failure: corrupt the durable row's payload so the + // transition's decode defects on every one of the three retry attempts. + yield* db + .update(GoalStateTable) + .set({ payload: "{corrupt" }) + .where(eq(GoalStateTable.session_id, sid)) + .run() + + const paused = yield* goal.pauseForUserCancel(sid, "用户中断(ESC)") + expect(paused).toBeUndefined() + expect(yield* goal.isTurnDriven(sid)).toBe(true) + + // Restore a valid active row: the pause seam works again, and the + // successful pause clears the mark exactly like the healthy path. + yield* db + .update(GoalStateTable) + .set({ payload: JSON.stringify(Schema.encodeSync(GoalState.Info)(seeded)) }) + .where(eq(GoalStateTable.session_id, sid)) + .run() + + const retried = yield* goal.pauseForUserCancel(sid, "用户中断(ESC)重试") + expect(retried?.status).toBe("paused") + expect(yield* goal.isTurnDriven(sid)).toBe(false) + }), + ) + it.live("terminal transitions clear the mark (markDone)", () => Effect.gen(function* () { const goal = yield* Goal.Service From 9fc67e8e787ab8473804af3f2cc20d76bcd0387a Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 13:46:37 +0800 Subject: [PATCH 05/39] fix(goal): failed judge is budget-neutral, does not stamp judged boundary (GOAL-03) --- packages/opencode/src/goal/goal.ts | 20 +++++-- packages/opencode/test/goal/e2e-loop.test.ts | 17 ++++-- packages/opencode/test/goal/goal.test.ts | 56 +++++++++++++++++++- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index 2171cf24f4..add33dc175 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -91,7 +91,9 @@ export interface Interface { /** issue #285: the assistant message ID judged for this evaluation. * Persisted on continue commits as the DURABLE crash-recovery gate — the * boot scan skips a window still ending on this boundary (the - * process-local evaluatedRevisions map cannot survive a crash). */ + * process-local evaluatedRevisions map cannot survive a crash). + * GOAL-03: never persisted when `parseFailed` — a judge that produced no + * verdict judged no boundary. */ judged?: string, ) => Effect.Effect< | { @@ -748,7 +750,17 @@ const serviceLayer = Layer.effect( } } - const turnsUsed = GoalState.nni(state.turns_used + 1) + // GOAL-03: a judge that never produced a verdict (transport error or + // unparseable output) evaluated no turn — budget-neutral: it must not + // consume one of the user's max_turns, and it must not stamp + // last_judged_msg (the boundary was never judged; a crash after this + // commit must re-judge the same boundary, and that re-judgment — not + // this failed attempt — may consume the budget slot). Pre-fix a flaky + // judge burned budget on unevaluated turns while intermittent + // successes kept the parse-failure counter resetting. The counter + // itself still climbs here, so MAX_CONSECUTIVE_PARSE_FAILURES + // auto-pause is unaffected. + const turnsUsed = parseFailed ? state.turns_used : GoalState.nni(state.turns_used + 1) const pauseReason = newParseFailures >= GoalPrompts.MAX_CONSECUTIVE_PARSE_FAILURES ? "judge 模型未返回有效 JSON 判定。请检查模型配置或换用更可靠的模型,然后 /goal resume。" @@ -764,7 +776,9 @@ const serviceLayer = Layer.effect( paused_reason: pauseReason, consecutive_parse_failures: GoalState.nni(newParseFailures), // issue #285: record the judged boundary for the durable scan gate. - ...(judged !== undefined ? { last_judged_msg: judged } : {}), + // GOAL-03: only a judge that actually returned a verdict judged the + // boundary (see turnsUsed above). + ...(judged !== undefined && !parseFailed ? { last_judged_msg: judged } : {}), }) return { tag: "save", diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index 290d260186..1a83bbfd11 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -1731,7 +1731,9 @@ describe("GoalLoop — NotFoundError on the post-judge reload must not stall (GO // resolution → getLanguage → generateText) can defect (config orDie, payload // decode throws); a defect escaping into the fork was the invisible 0-turn // stall class. catchCause folds it into the parseFailed budget so the loop -// commits the turn and auto-pauses after MAX_CONSECUTIVE_PARSE_FAILURES. +// commits the parse failure and auto-pauses after +// MAX_CONSECUTIVE_PARSE_FAILURES (GOAL-03: budget-neutrally — a failed judge +// consumed no turn, so turns_used and the boundary stamp are untouched). describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-FP-01-18b)", () => { let judgeCalls = 0 const sessionMock = Layer.mock(Session.Service, { @@ -1763,7 +1765,11 @@ describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-F ) const it = testEffect(defectLayer) - it.instance("a defecting judge commits the turn and counts a parse failure", () => + // GOAL-03: the commit still lands (the parse-failure counter advances so + // the auto-pause safety valve keeps working), but the failed judge is + // budget-neutral — it evaluated no turn, so turns_used stays 0 and the + // boundary is not stamped as judged. + it.instance("a defecting judge commits a parse failure without consuming budget", () => Effect.gen(function* () { judgeCalls = 0 const loop = yield* GoalLoop.Service @@ -1780,14 +1786,15 @@ describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-F const committed = yield* pollWithTimeout( Effect.gen(function* () { const g = yield* goal.load(sid) - return g && g.turns_used >= 1 ? g : undefined + return g && g.consecutive_parse_failures >= 1 ? g : undefined }), - "turn never committed — the judge defect escaped the fork", + "parse failure never committed — the judge defect escaped the fork", "5 seconds", ) expect(judgeCalls).toBe(1) - expect(committed.turns_used).toBe(1) + expect(committed.turns_used).toBe(0) expect(committed.consecutive_parse_failures).toBe(1) + expect(committed.last_judged_msg).toBeUndefined() // First defect is a blip: verdict stays continue, goal keeps running. expect(committed.status).toBe("active") }), diff --git a/packages/opencode/test/goal/goal.test.ts b/packages/opencode/test/goal/goal.test.ts index ef65f9babc..876070ee07 100644 --- a/packages/opencode/test/goal/goal.test.ts +++ b/packages/opencode/test/goal/goal.test.ts @@ -460,11 +460,17 @@ describe("Goal.resume — preserves turns_used (no fresh budget), resets parse f const sessionID = SessionID.descending() const state = yield* goal.set(sessionID, "build feature X", 10) - // One continuation dispatch with a parse failure → turns_used=1, cpf=1 - yield* goal.updateAfterJudge(sessionID, "continue", "more steps", true, { + // One SUCCESSFUL continue judgment → turns_used=1 (real budget spent). + const s1 = yield* goal.updateAfterJudge(sessionID, "continue", "real verdict", false, { goalID: state.goal_id ?? "legacy", revision: state.revision ?? 0, }) + // Then one parse-failure judgment → cpf=1 while turns_used stays 1 + // (GOAL-03 budget-neutrality: a failed judge spends no turn). + yield* goal.updateAfterJudge(sessionID, "continue", "more steps", true, { + goalID: s1?.state.goal_id ?? "legacy", + revision: s1?.state.revision ?? 0, + }) const beforePause = yield* goal.load(sessionID) expect(Number(beforePause?.turns_used)).toBe(1) expect(Number(beforePause?.consecutive_parse_failures)).toBe(1) @@ -727,6 +733,52 @@ describe("Goal.updateAfterJudge — transport failures trigger auto-pause (D5)", expect(state?.status).toBe("paused") }), ) + + // GOAL-03: a judge that never produced a verdict (transport error or + // unparseable output) evaluated no turn — it must not consume one of the + // user's max_turns and must not stamp last_judged_msg (the boundary was + // never judged; a crash after this commit must re-judge the same boundary, + // and that re-judgment — not this failed attempt — may consume the budget + // slot). The parse-failure counter still climbs, so an unreliable judge + // still auto-pauses after MAX_CONSECUTIVE_PARSE_FAILURES. + it.live("a failed judge is budget-neutral: no turns_used increment, no last_judged_msg stamp", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + const seeded = yield* goal.set(sessionID, "build feature X", 10) + + const failed = yield* goal.updateAfterJudge( + sessionID, + "continue", + "judge transport error (timeout or network) — counting toward pause budget", + true, + { goalID: seeded.goal_id ?? "legacy", revision: seeded.revision ?? 0 }, + "msg_boundary_failed", + ) + expect(failed?.shouldContinue).toBe(true) + + const state = yield* goal.load(sessionID) + expect(Number(state?.turns_used)).toBe(0) + expect(state?.last_judged_msg).toBeUndefined() + expect(Number(state?.consecutive_parse_failures)).toBe(1) + + // A successful judge afterwards consumes exactly one budget slot and + // stamps the boundary it actually judged. + const ok = yield* goal.updateAfterJudge( + sessionID, + "continue", + "real verdict", + false, + { goalID: state?.goal_id ?? "legacy", revision: state?.revision ?? 0 }, + "msg_boundary_real", + ) + expect(ok?.shouldContinue).toBe(true) + const after = yield* goal.load(sessionID) + expect(Number(after?.turns_used)).toBe(1) + expect(after?.last_judged_msg).toBe("msg_boundary_real") + expect(Number(after?.consecutive_parse_failures)).toBe(0) + }), + ) }) // --------------------------------------------------------------------------- From f0e7278655938c27b8a0a072806a11f19100028a Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 13:51:04 +0800 Subject: [PATCH 06/39] fix(goal): startup scan busy skip is logged and retried once, never silent (GOAL-04) --- packages/opencode/src/goal/loop.ts | 50 +++++++++++++++++++- packages/opencode/test/goal/e2e-loop.test.ts | 13 +++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index 65b85e4b26..f02d948669 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -100,6 +100,13 @@ export function isStaleZombie( ) } +// GOAL-04: the startup scan's single retry window for sessions busy at scan +// time. Short by design: a genuinely running turn ends with its own idle +// event, which the (already armed) idle subscription drives — the retry only +// covers the bootstrap→scan status race and gives the skip a visible, +// bounded end instead of a silent drop. +const SCAN_BUSY_RETRY_DELAY = "2 seconds" + const serviceLayer = Layer.effect( Service, Effect.gen(function* () { @@ -681,6 +688,9 @@ const serviceLayer = Layer.effect( // will be driven by its own turn-end idle event. 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. + // GOAL-04: the skip is never silent — deferred sessions are logged and + // retried once after SCAN_BUSY_RETRY_DELAY, and a final busy state is + // abandoned with an explicit warning. // - Crash window (query → trigger): terminal changes are absorbed by the // active-status re-check; non-terminal changes (already evaluated in // this process) by the D-4 record gate in triggerEvaluation/afterIdle. @@ -688,9 +698,17 @@ const serviceLayer = Layer.effect( // session never kills the rest of the scan; the whole scan is forked, // so a failure can never kill init. const scanForActiveGoals = Effect.fnUntraced(function* (snapshot: ReadonlyArray) { + const deferred: SessionID[] = [] for (const sessionID of snapshot) { const current = yield* status.get(sessionID) - if (current.type !== "idle") continue + if (current.type !== "idle") { + // GOAL-04: never skip silently. Pre-fix this was a bare `continue` + // — no log, no retry obligation — leaving the goal persistently + // active but dormant with nothing to diagnose. Record the session + // for the bounded retry below. + deferred.push(sessionID) + continue + } yield* triggerEvaluation(sessionID, true).pipe( Effect.catchCause((cause) => Effect.logWarning("goal startup scan failed for session", { @@ -700,6 +718,36 @@ const serviceLayer = Layer.effect( ), ) } + if (deferred.length === 0) return + yield* Effect.logInfo("goal startup scan deferred busy sessions", { + sessions: deferred.join(","), + }) + // GOAL-04 retry obligation: one bounded re-check in this same scan + // fiber (already forked + supervised, so the sleep can never kill + // init). Sessions still busy after the window are left to their own + // turn-end idle event — the idle subscription is armed and drives them + // then; a session whose turn never emits idle is a runner defect + // outside the goal module, but the warning makes the abandonment + // visible instead of silent. + yield* Effect.sleep(SCAN_BUSY_RETRY_DELAY) + for (const sessionID of deferred) { + const current = yield* status.get(sessionID) + if (current.type !== "idle") { + yield* Effect.logWarning( + "goal startup scan gave up on busy session — its own idle event remains the driver", + { sessionID, status: current.type }, + ) + continue + } + yield* triggerEvaluation(sessionID, true).pipe( + Effect.catchCause((cause) => + Effect.logWarning("goal startup scan retry failed for session", { + sessionID, + cause: Cause.pretty(cause), + }), + ), + ) + } }) const init = Effect.fn("GoalLoop.init")(function* () { diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index 1a83bbfd11..ccd93aaddc 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -1377,6 +1377,19 @@ describe("GoalLoop — startup scan resumes pre-boot active goals (GOAL-FP-01-04 expect(a?.status).toBe("active") expect(Number(a?.turns_used)).toBe(0) + // GOAL-04: the busy skip must be VISIBLE — the scan logs the deferral + // with the session id (previously a bare `continue`: no log, no retry + // obligation, a silently dormant goal). + yield* pollWithTimeout( + Effect.gen(function* () { + const logs = JSON.stringify(yield* logLines) + return logs.includes("goal startup scan deferred busy sessions") ? (true as const) : undefined + }), + "busy session skip was never logged (GOAL-04)", + "5 seconds", + ) + expect(JSON.stringify(yield* logLines)).toContain(String(sidA)) + // When the busy session finishes, its own idle event drives the goal. yield* status.set(sidA, { type: "idle" }) yield* pollWithTimeout( From 429e58815c34a29ab03384d8064d655277e09dcf Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 14:07:03 +0800 Subject: [PATCH 07/39] chore(goal): align comments with GOAL-02/04 semantics (review round 1) --- docs/findings/goal-batch-findings.md | 14 ++++++++++---- packages/opencode/src/goal/goal.ts | 9 ++++++--- packages/opencode/test/goal/e2e-loop.test.ts | 5 ++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index 9e0e9c6ea7..0ec838d233 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -9,10 +9,16 @@ | ID | 严重性 | 切片顺序 | 状态 | 提交 | |---|---|---|---|---| -| GOAL-01 | High | 1 (P0) | 进行中 | — | -| GOAL-02 | Medium | 2 (P2) | 待办 | — | -| GOAL-03 | Low | 3 | 待办 | — | -| GOAL-04 | Low | 4 | 待办 | — | +| GOAL-01 | High | 1 (P0) | 完成(红-绿-变异通过) | ed7185a0f | +| GOAL-02 | Medium | 2 (P2) | 完成(红-绿-变异通过) | 551b8f78a | +| GOAL-03 | Low | 3 | 完成(红-绿-变异通过) | 9fc67e8e7 | +| GOAL-04 | Low | 4 | 完成(红-绿-变异通过) | f0e727865 | + +## 模块门禁 +- `bun typecheck`(tsgo --noEmit):✅ 绿 +- goal 目标测试簇(test/goal/,107 tests):✅ 绿 +- 全量测试套件:进行中 +- 每切片变异验证(revert 翻红 → 恢复):✅ GOAL-01/02/03/04 均通过 ## 审阅轮次 diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index add33dc175..f0590641a8 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -210,9 +210,12 @@ const serviceLayer = Layer.effect( // GOAL-TURN-SCOPE: process-local provenance of the CURRENT goal-driven // turn. Keyed by session; set at every goal dispatch (kick in prompt.ts, // continuation in loop.ts), cleared at turn end (afterIdle entry) and at - // every terminal transition (pause/clear/markDone) plus ESC-cancel. A stale - // mark is harmless: goalTurnMaxSteps re-validates against the durable goal - // row before reporting a ceiling. + // every terminal transition (pause/clear/markDone). On ESC-cancel the + // clear happens ONLY when the pause persisted — if the pause exhausts its + // retries the mark is RETAINED so it agrees with the still-active + // durable row and lease (GOAL-02). A stale mark is harmless: + // goalTurnMaxSteps re-validates against the durable goal row before + // reporting a ceiling. const turnDriven = new Set() const markTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) { diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index ccd93aaddc..5635ac8dad 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -1348,7 +1348,7 @@ describe("GoalLoop — startup scan resumes pre-boot active goals (GOAL-FP-01-04 }), ) - it.instance("a busy session is not force-evaluated by the scan; its own idle event drives it", () => + it.instance("a busy session is not force-evaluated by the scan; its own idle event (or the bounded scan retry) drives it", () => Effect.gen(function* () { reset() const loop = yield* GoalLoop.Service @@ -1391,6 +1391,9 @@ describe("GoalLoop — startup scan resumes pre-boot active goals (GOAL-FP-01-04 expect(JSON.stringify(yield* logLines)).toContain(String(sidA)) // When the busy session finishes, its own idle event drives the goal. + // (The GOAL-04 bounded scan retry may also re-trigger it if the flip to + // idle happens within the retry window — the revision fence keeps the + // commit exactly-once either way.) yield* status.set(sidA, { type: "idle" }) yield* pollWithTimeout( Effect.sync(() => (judgeCalls >= 2 ? true : undefined)), From 5dd5a3037063d4563b4b7be8cb230e8a3e3e447e Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 14:37:08 +0800 Subject: [PATCH 08/39] fix(goal): distinguish ESC-pause no-op from retry exhaustion; silence scan disposal interrupts (review R3) --- docs/findings/goal-batch-findings.md | 17 +++++++++- packages/opencode/src/goal/goal.ts | 31 ++++++++++++------- packages/opencode/src/goal/loop.ts | 7 ++++- .../opencode/test/goal/turn-scope.test.ts | 21 +++++++++++++ 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index 0ec838d233..b16282edce 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -17,12 +17,27 @@ ## 模块门禁 - `bun typecheck`(tsgo --noEmit):✅ 绿 - goal 目标测试簇(test/goal/,107 tests):✅ 绿 -- 全量测试套件:进行中 - 每切片变异验证(revert 翻红 → 恢复):✅ GOAL-01/02/03/04 均通过 +- 全量测试套件(`bun test`,4141 tests / 341 files):goal 相关全绿;另 3 处失败经基线复跑判定为**非本批引入**(见下)。 + - 基线 CI(`1d087ffe9`,GitHub linux)全量 **success** → 基线干净。 + - 本机(darwin)基线 detached 复跑:`project-copy`、`help-snapshots` 同样失败,`httpapi-v2-pty` 计时性 flake(隔离复跑即过)。三者均不 import `src/goal`,diff 亦不触及其依赖闭包 → 环境/时序性既有缺陷,与本批改动无因果。 ## 审阅轮次 (每轮审阅结果记账于此;全部关闭后才具备发 PR 资格) ### Round 1 +- 派遣:Spec 镜(对照审计 GOAL 章节逐条验收)+ Standards 镜(仓库规约/Effect/CONTEXT/测试纪律),只读、并行、互不复用上下文。 +- Standards 镜:**PASS,no findings**。 +- Spec 镜:**PASS**,2 项 Low findings(均已关闭): + - F-1(Low)`src/goal/goal.ts`:GOAL-02 后 turnDriven 汇总注释仍写"ESC-cancel 即清除",与"仅 pause 持久化成功才清除"不符。→ 已改写注释(commit 429e58815)。 + - F-2(Low)`test/goal/e2e-loop.test.ts`:GOAL-04 断言所在用例名/注释未提"有界 scan 重试也可驱动 deferred 会话"。→ 已改名 + 补注释(commit 429e58815)。 +- 结论:非干净轮。修复 F-1/F-2 后进入 Round 2。 + +### Round 2 +- Spec 镜:**PASS,no findings**(F-1/F-2 修复逐行复核通过;四缺陷验收保持满足;429e58815 仅注释/命名变更,无行为影响)。 +- Standards 镜:**PASS,no findings**。 +- 结论:第 1 个干净轮。按收敛判据需连续两轮零 findings → 进入 Round 3。 + +### Round 3 - 未开始 diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index f0590641a8..60483419b4 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -273,21 +273,28 @@ const serviceLayer = Layer.effect( Effect.ignore, ) turnDriven.delete(sessionID) - } else { - // GOAL-02: the pause could not be persisted — the durable row is - // still "active" and the lease registration is still in place, so the - // process-local mark must AGREE with both: keep it. Pre-fix it was - // deleted unconditionally, which disagreed with the durable - // authorities (goal still owns the session as active) and lost the - // ESC provenance on the resurrected turn — the user's second ESC - // would no longer route through this goal-pause fast path, because - // SessionPrompt.cancel maps ESC to a goal pause only for marked - // turns. With the mark retained, every repeat ESC retries the pause - // until the store recovers. + } else if (lastCause) { + // GOAL-02: genuine retry exhaustion — the pause could not be + // persisted, the durable row is still "active" and the lease + // registration is still in place, so the process-local mark must + // AGREE with both: keep it. Pre-fix it was deleted unconditionally, + // which disagreed with the durable authorities (goal still owns the + // session as active) and lost the ESC provenance on the resurrected + // turn — the user's second ESC would no longer route through this + // goal-pause fast path, because SessionPrompt.cancel maps ESC to a + // goal pause only for marked turns. With the mark retained, every + // repeat ESC retries the pause until the store recovers. yield* Effect.logError( "goal pause on cancel failed after retries — goal stays active and turn-driven; a repeat ESC retries the pause", - { sessionID, cause: lastCause ? Cause.pretty(lastCause) : "unknown" }, + { sessionID, cause: Cause.pretty(lastCause) }, ) + } else { + // Successful NO-OP: pauseAndPublish found no active goal (row absent + // or already paused/cleared — e.g. an auto-pause committed between + // the mark and this ESC). No durable authority claims the goal as + // active, so there is nothing to retain the mark for and no failure + // to report — retire the stale mark silently. + turnDriven.delete(sessionID) } return paused }) diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index f02d948669..06a11b36c4 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -215,7 +215,12 @@ const serviceLayer = Layer.effect( // init. yield* scanForActiveGoals(snapshot).pipe( Effect.catchCause((cause) => - Effect.logWarning("goal startup scan failed", { cause: Cause.pretty(cause) }), + // GOAL-04: instance disposal interrupts this forked scan fiber — + // the bounded retry sleep widened that window. Same F1 discipline + // as triggerEvaluation: interrupts stay silent, real failures log. + Cause.hasInterrupts(cause) + ? Effect.void + : Effect.logWarning("goal startup scan failed", { cause: Cause.pretty(cause) }), ), Effect.forkScoped, ) diff --git a/packages/opencode/test/goal/turn-scope.test.ts b/packages/opencode/test/goal/turn-scope.test.ts index 5608ff2931..9cafded3b3 100644 --- a/packages/opencode/test/goal/turn-scope.test.ts +++ b/packages/opencode/test/goal/turn-scope.test.ts @@ -146,6 +146,27 @@ describe("Goal turn-scope — pauseForUserCancel (ESC semantics)", () => { }), ) + // Review R3-INFO-1: a successful NO-OP (goal already paused/cleared when ESC + // lands) must not be reported as a retry-exhaustion failure — no durable + // authority claims the goal as active, so the stale mark is retired + // silently. + it.live("cancel on an already-paused goal is a silent no-op that retires a stale mark", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.pause(sid, "auto-paused") + + const paused = yield* goal.pauseForUserCancel(sid, "用户中断(ESC)") + expect(paused).toBeUndefined() + expect(yield* goal.isTurnDriven(sid)).toBe(false) + const state = yield* goal.load(sid) + expect(state?.status).toBe("paused") + expect(state?.paused_reason).toBe("auto-paused") + }), + ) + it.live("terminal transitions clear the mark (markDone)", () => Effect.gen(function* () { const goal = yield* Goal.Service From ce87f84bdd9c2ed7261e4ec746076a14cf93c7b4 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 14:38:02 +0800 Subject: [PATCH 09/39] docs(goal): record review round-3 findings and closures --- docs/findings/goal-batch-findings.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index b16282edce..c23910fb4c 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -40,4 +40,12 @@ - 结论:第 1 个干净轮。按收敛判据需连续两轮零 findings → 进入 Round 3。 ### Round 3 +- Spec 镜:**PASS,no findings**。 +- Standards 镜:**PASS**(verdict),3 条 INFO(非阻塞),处置如下: + - R3-INFO-1(goal.ts pauseForUserCancel):成功 no-op(ESC 落在已暂停/已清除目标上,如 auto-pause 提交与 mark 之间的窗口)被误报为 retry-exhaustion ERROR。→ **已修复**(commit 5dd5a3037):以 `lastCause` 区分三态——成功 pause(清 mark+unregister)、真实耗尽(保留 mark+ERROR)、成功 no-op(静默清除陈旧 mark);新增回归测试 `cancel on an already-paused goal is a silent no-op that retires a stale mark`。 + - R3-INFO-2(loop.ts scan 级 catchCause):GOAL-04 新增 2s 重试放大了 dispose 中断窗口,正常关停会被记成 "goal startup scan failed"。→ **已修复**(commit 5dd5a3037):与同文件 triggerEvaluation 相同的 F1 纪律——`Cause.hasInterrupts` 静默,真实失败才告警。无独立红测试:dispose-期间中断无法在当前 harness 内确定性触发而不耦合 instance 内部;以同文件既有 F1 模式一致性为准。 + - R3-INFO-3(分支含 3 个非 goal 文件):审计文档/findings register/workflow 规格随 GOAL PR 落地是 workflow 规格的设计决定(audit-fix-loop.md §0:审计文档必须先于两个 run 进 dev),**非缺陷,按设计关闭**。 +- 结论:非干净轮(Round 2 的连续干净计数重置)。修复后进入 Round 4。 + +### Round 4 - 未开始 From db44487c77f88a1ec8801ee26468d4cefe1f8f58 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 15:01:16 +0800 Subject: [PATCH 10/39] refactor(goal): address review round-4 findings (no-op classification, neutral pause msg, interrupt suppression, stronger no-op test) --- docs/findings/goal-batch-findings.md | 9 ++++++ packages/opencode/src/goal/goal.ts | 20 +++++++++---- packages/opencode/src/goal/loop.ts | 28 +++++++++++++------ .../opencode/test/goal/turn-scope.test.ts | 18 ++++++++---- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index c23910fb4c..0a0ea85ecf 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -48,4 +48,13 @@ - 结论:非干净轮(Round 2 的连续干净计数重置)。修复后进入 Round 4。 ### Round 4 +- Spec 镜:**PASS**,2 条 INFO;Standards 镜:**PASS**,4 条 INFO(其中 lastCause 混合结果一条与 Spec 镜重合)。处置: + - lastCause 分类按最终尝试结果(R4 共同项):成功退出重试循环时 `lastCause = undefined`,杜绝「早期瞬态失败 + 后续成功 no-op」被误判为耗尽。→ 已修复。 + - pause 文案「judge 期间会话状态变化」在 GOAL-01 gate-hit 路径失准:改为中性「会话状态变化(X),目标已暂停」(既有测试只断言 contains「状态变化」,不受影响)。→ 已修复。 + - noop 回归测试未真正钉住(Goal.pause 本身清 mark,前置 mark 到不了 pauseForUserCancel):重写为 pause 之后重新 markTurnDriven 造真实陈旧 mark,并断言 logLines 不含 "failed after retries"(旧代码必触发该日志 → 测试真正翻红可验证)。→ 已修复。 + - 两处注释(Interface doc + GOAL-TURN-SCOPE 块)与第三分支(no-op 静默清 mark)矛盾:已改写一致。 + - GOAL-04 重试环的 per-session catchCause 缺 interrupt 抑制(与外层 scan handler 不一致):两处 per-session catchCause(首轮 + 重试环)均加 `Cause.hasInterrupts` F1 抑制。→ 已修复。 +- 结论:非干净轮。修复后进入 Round 5。 + +### Round 5 - 未开始 diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index 60483419b4..ea46ddf37a 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -167,7 +167,9 @@ export interface Interface { * GOAL-02: when the pause exhausts its retries, durable row and lease * both still say "active" — the turn mark is RETAINED so the three * authorities agree and a repeat ESC retries the pause. The mark is - * cleared only on a successfully persisted pause. + * cleared on a successfully persisted pause; a successful NO-OP (goal + * already paused/cleared when ESC lands) silently retires the stale mark + * — no durable authority claims the goal as active. */ readonly pauseForUserCancel: (sessionID: SessionID, reason: string) => Effect.Effect /** True when the session's current turn is goal-driven. */ @@ -211,11 +213,12 @@ const serviceLayer = Layer.effect( // turn. Keyed by session; set at every goal dispatch (kick in prompt.ts, // continuation in loop.ts), cleared at turn end (afterIdle entry) and at // every terminal transition (pause/clear/markDone). On ESC-cancel the - // clear happens ONLY when the pause persisted — if the pause exhausts its - // retries the mark is RETAINED so it agrees with the still-active - // durable row and lease (GOAL-02). A stale mark is harmless: - // goalTurnMaxSteps re-validates against the durable goal row before - // reporting a ceiling. + // clear happens when the pause persisted OR the cancel is a successful + // no-op (goal already inactive — nothing claims it as active); only if + // the pause exhausts its retries is the mark RETAINED so it agrees with + // the still-active durable row and lease (GOAL-02). A stale mark is + // harmless: goalTurnMaxSteps re-validates against the durable goal row + // before reporting a ceiling. const turnDriven = new Set() const markTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) { @@ -263,6 +266,11 @@ const serviceLayer = Layer.effect( const exit = yield* pauseAndPublish(sessionID, reason).pipe(Effect.exit) if (Exit.isSuccess(exit)) { paused = exit.value + // Classify by the FINAL attempt: an early transient failure followed + // by a successful outcome (e.g. a concurrent pauser lands between + // retries) is a success/no-op, not retry exhaustion — drop the + // stale cause so the branches below read the real outcome. + lastCause = undefined break } lastCause = exit.cause diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index 06a11b36c4..0f685486a0 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -485,7 +485,11 @@ const serviceLayer = Layer.effect( // Previously this was a bare `return` that left the goal silently // "active" with no continuation. Pause with a visible reason so the // user knows the loop was interrupted by a status change. - const pauseMsg = `judge 期间会话状态变化(${currentStatus.type}),目标已暂停` + // Neutral wording on purpose: this pause is reachable both after a + // real judge call AND via the GOAL-01 gate-hit fall-through, where + // the judge was suppressed — the user-visible reason must not claim + // a judge was running. + const pauseMsg = `会话状态变化(${currentStatus.type}),目标已暂停` yield* pauseGoal(sessionID, pauseMsg).pipe(Effect.ignore) yield* promptSvc.prompt({ sessionID, noReply: true, parts: [{ type: "text", text: `⏸ 目标已暂停 — ${pauseMsg}` }] }).pipe(Effect.ignore) return @@ -716,10 +720,14 @@ const serviceLayer = Layer.effect( } yield* triggerEvaluation(sessionID, true).pipe( Effect.catchCause((cause) => - Effect.logWarning("goal startup scan failed for session", { - sessionID, - cause: Cause.pretty(cause), - }), + // F1 discipline (same as the outer scan handler): instance + // disposal interrupts these per-session effects silently. + Cause.hasInterrupts(cause) + ? Effect.void + : Effect.logWarning("goal startup scan failed for session", { + sessionID, + cause: Cause.pretty(cause), + }), ), ) } @@ -746,10 +754,12 @@ const serviceLayer = Layer.effect( } yield* triggerEvaluation(sessionID, true).pipe( Effect.catchCause((cause) => - Effect.logWarning("goal startup scan retry failed for session", { - sessionID, - cause: Cause.pretty(cause), - }), + Cause.hasInterrupts(cause) + ? Effect.void + : Effect.logWarning("goal startup scan retry failed for session", { + sessionID, + cause: Cause.pretty(cause), + }), ), ) } diff --git a/packages/opencode/test/goal/turn-scope.test.ts b/packages/opencode/test/goal/turn-scope.test.ts index 9cafded3b3..184b0dd53c 100644 --- a/packages/opencode/test/goal/turn-scope.test.ts +++ b/packages/opencode/test/goal/turn-scope.test.ts @@ -9,6 +9,7 @@ import { SessionStatus } from "@/session/status" import { Database } from "@opencode-ai/core/database/database" import { GoalStateTable } from "@opencode-ai/core/goal/sql" import { SessionID } from "@/session/schema" +import { logLines } from "effect/testing/TestConsole" import { testEffect } from "../lib/effect" // GOAL-TURN-SCOPE regression tests: the turn-provenance mark (kick / @@ -146,17 +147,21 @@ describe("Goal turn-scope — pauseForUserCancel (ESC semantics)", () => { }), ) - // Review R3-INFO-1: a successful NO-OP (goal already paused/cleared when ESC - // lands) must not be reported as a retry-exhaustion failure — no durable - // authority claims the goal as active, so the stale mark is retired - // silently. - it.live("cancel on an already-paused goal is a silent no-op that retires a stale mark", () => + // Review R3-INFO-1: a successful NO-OP (goal already paused/cleared when + // ESC lands) must not be reported as a retry-exhaustion failure — no + // durable authority claims the goal as active. Recreates the genuinely + // stale mark the way an auto-pause leaves it behind (updateAfterJudge + // pauses the row WITHOUT clearing the turn mark; loop.ts clears it at the + // next afterIdle entry), then asserts pauseForUserCancel retires it + // silently. Pre-fix this window logged a false "failed after retries" + // ERROR. + it.instance("cancel on an already-paused goal is a silent no-op that retires a stale mark", () => Effect.gen(function* () { const goal = yield* Goal.Service const sid = SessionID.descending() yield* goal.set(sid, "test goal", 5) - yield* goal.markTurnDriven(sid) yield* goal.pause(sid, "auto-paused") + yield* goal.markTurnDriven(sid) const paused = yield* goal.pauseForUserCancel(sid, "用户中断(ESC)") expect(paused).toBeUndefined() @@ -164,6 +169,7 @@ describe("Goal turn-scope — pauseForUserCancel (ESC semantics)", () => { const state = yield* goal.load(sid) expect(state?.status).toBe("paused") expect(state?.paused_reason).toBe("auto-paused") + expect(JSON.stringify(yield* logLines)).not.toContain("failed after retries") }), ) From 58b56b490ff6eac175068a8c92c034265dcff923 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 15:25:12 +0800 Subject: [PATCH 11/39] docs(goal): sync D-4/status-branch/freshMsgs comments with the GOAL-01 judge-less path --- docs/findings/goal-batch-findings.md | 7 +++++++ packages/opencode/src/goal/loop.ts | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index 0a0ea85ecf..3c0d7dd2ed 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -57,4 +57,11 @@ - 结论:非干净轮。修复后进入 Round 5。 ### Round 5 +- Spec 镜:**PASS,no findings**(干净轮 1/2 候补——但 Standards 非干净,计数重置)。 +- Standards 镜:**PASS**,2 条 INFO(GOAL-01 judge-less 路径后遗留的陈旧注释): + - R5-INFO-1:D-4 evaluatedRevisions 头注释仍称「仅由成功 updateAfterJudge commit 写入」,未含 gate-hit drive-restored 写入点。→ 已改写(并自查发现同根第 3 处:freshMsgs 的 "Reload messages after judge LLM call" 一并改为两可措辞)。 + - R5-INFO-2:branch-3 首行 "Session is no longer idle after the judge call" 对 gate-hit 路径失准。→ 已改写。 +- 结论:非干净轮。进入 Round 6。 + +### Round 6 - 未开始 diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index 0f685486a0..fc8b8c704f 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -229,11 +229,14 @@ const serviceLayer = Layer.effect( ) // D-4 (GOAL-FP-01-04 follow-up): per-process record of which goal - // revision this process already evaluated. Written by afterIdle on every - // successful updateAfterJudge commit; consulted ONLY by the startup-scan + // revision this process already evaluated. Written by afterIdle at TWO + // sites: every successful updateAfterJudge commit, and the GOAL-01 + // boundary-gate hit (where the drive is restored WITHOUT a commit — the + // map then marks the revision as drive-restored, so a duplicate scan + // trigger on the same revision skips). Consulted ONLY by the startup-scan // path (scanResume) — the idle path must keep re-evaluating the same // revision across new turn boundaries, so the gate never applies to it. - // Lifecycle mirrors the fibers map: overwritten by every commit, deleted + // Lifecycle mirrors the fibers map: overwritten by every write, deleted // at the same terminal points where afterIdle unregisters the goal // automation. const evaluatedRevisions = new Map() @@ -481,7 +484,9 @@ const serviceLayer = Layer.effect( const currentStatus = yield* status.get(sessionID) if (currentStatus.type !== "idle") { - // Session is no longer idle after the judge call (5-30s latency). + // Session is no longer idle by the time dispatch resumes — it flipped + // during the judge call (5-30s latency), or between the gate and here + // on the GOAL-01 judge-less fall-through. // Previously this was a bare `return` that left the goal silently // "active" with no continuation. Pause with a visible reason so the // user knows the loop was interrupted by a status change. @@ -495,8 +500,9 @@ const serviceLayer = Layer.effect( return } - // Reload messages after judge LLM call — the snapshot from before judge - // may be stale if user sent messages during the 5-30s judge latency. + // Reload messages before dispatch — the pre-judge snapshot may be stale + // (the user can send messages during the 5-30s judge latency, or during + // the GOAL-01 judge-less fall-through). // Same vanished-session tolerance as the pre-judge window: NotFoundError // becomes an empty window (shouldPreempt is defensively false for it), // never a typed failure escaping the fork. From 5e6ab11fe1b6450bb532738e7585fe5be8c77e37 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 15:47:16 +0800 Subject: [PATCH 12/39] docs(goal): declare module convergence after two consecutive clean review rounds --- docs/findings/goal-batch-findings.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index 3c0d7dd2ed..3626ca6a3b 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -64,4 +64,15 @@ - 结论:非干净轮。进入 Round 6。 ### Round 6 -- 未开始 +- Spec 镜:**PASS,no findings**。 +- Standards 镜:**PASS,no findings**(含注释真实性、Effect 习语、测试纪律、CONTEXT.md 不变量的全量复核)。 +- 结论:**干净轮 1/2**。进入 Round 7;若再干净 → 连续两轮零 findings,模块收敛。 + +### Round 7 +- Spec 镜:**PASS,no findings**(独立复核 GOAL-01..04 修复 + 测试义务 + 验证为正确部分)。 +- Standards 镜:**PASS,no findings**(Effect 习语/风格/CONTEXT.md 不变量/测试纪律/注释真实性全量复核)。 +- 结论:**干净轮 2/2**。连续两轮零 findings → **GOAL 模块收敛**。 + +## 收敛结论 + +R1 有 2 Low → 修复;R2 干净(因 R3 有 findings 计数重置);R3 有 3 INFO → 修复;R4 有 5 INFO → 修复;R5 有 2 INFO → 修复;**R6+R7 连续两轮双镜零 findings**。全部 findings 已关闭,模块具备发 PR 资格。 From a56ed05214165c8dee800586cc1ed6f0a660200e Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 15:58:12 +0800 Subject: [PATCH 13/39] docs(goal): record PR #334 delivery and closure of the goal run --- docs/findings/goal-batch-findings.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index 3626ca6a3b..ab4d2fcc2d 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -76,3 +76,14 @@ ## 收敛结论 R1 有 2 Low → 修复;R2 干净(因 R3 有 findings 计数重置);R3 有 3 INFO → 修复;R4 有 5 INFO → 修复;R5 有 2 INFO → 修复;**R6+R7 连续两轮双镜零 findings**。全部 findings 已关闭,模块具备发 PR 资格。 + +## 交付 + +- **PR**:https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/334 → `dev`(门禁 Typecheck;CI run 32113882464 进行中) +- 提交链:bcad76ebf(audit 文档)→ ed7185a0f(GOAL-01)→ 551b8f78a(GOAL-02)→ 9fc67e8e7(GOAL-03)→ f0e727865(GOAL-04)→ 429e58815 / 5dd5a3037 / ce87f84bd / db44487c7 / 58b56b490 / 5e6ab11fe(审阅轮修复与记账) +- 终态门禁:goal 测试簇 108/108 绿;`bun typecheck`(packages/opencode)绿;全量 4142 tests 除 3 项基线既有 darwin 环境性失败外全绿(已在干净基线 detached 复跑证实非本批引入)。 +- 已知本地环境既有问题(与本批无关,已证实):根 turbo typecheck 的 `@opencode-ai/app` 子路径解析、project-copy / help-snapshots / pty 三个测试。 + +## 下一 run + +DAG 批次(DAG-01..04):事件触发 = 本 PR 合入 dev 后从新基线切 `fix/dag-batch`。 From 799ea0529cc13def21d2ce088e2928738f4427dc Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 15:59:48 +0800 Subject: [PATCH 14/39] docs(goal): correct turbo typecheck status (pre-push hook green) --- docs/findings/goal-batch-findings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/findings/goal-batch-findings.md b/docs/findings/goal-batch-findings.md index ab4d2fcc2d..3863b86a4e 100644 --- a/docs/findings/goal-batch-findings.md +++ b/docs/findings/goal-batch-findings.md @@ -82,7 +82,7 @@ R1 有 2 Low → 修复;R2 干净(因 R3 有 findings 计数重置);R3 - **PR**:https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/334 → `dev`(门禁 Typecheck;CI run 32113882464 进行中) - 提交链:bcad76ebf(audit 文档)→ ed7185a0f(GOAL-01)→ 551b8f78a(GOAL-02)→ 9fc67e8e7(GOAL-03)→ f0e727865(GOAL-04)→ 429e58815 / 5dd5a3037 / ce87f84bd / db44487c7 / 58b56b490 / 5e6ab11fe(审阅轮修复与记账) - 终态门禁:goal 测试簇 108/108 绿;`bun typecheck`(packages/opencode)绿;全量 4142 tests 除 3 项基线既有 darwin 环境性失败外全绿(已在干净基线 detached 复跑证实非本批引入)。 -- 已知本地环境既有问题(与本批无关,已证实):根 turbo typecheck 的 `@opencode-ai/app` 子路径解析、project-copy / help-snapshots / pty 三个测试。 +- 已知本地环境既有失败(与本批无关,已在干净基线 detached 复跑证实):全量测试中 project-copy / help-snapshots / pty 三项(darwin 环境/计时性)。根 turbo typecheck 曾一次命中 `@opencode-ai/app` 的瞬时缓存失败,随后(pre-push 钩子)29/29 全绿自愈。 ## 下一 run From 43fd72bbd7db99ae94eb8c278ddf44ca7f4a175c Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 16:01:19 +0800 Subject: [PATCH 15/39] 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 0000000000..2762d3a453 --- /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 16/39] 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 0000000000..ca9c577b55 --- /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 38ac3de76a..c6b09297ad 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 28185c5c3c..75263d79b7 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 26d1a7ae8b..3090c73542 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 c92fae8847..6436c91a73 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 18fcebecf8..80a8291be7 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 fce8c31d39..5ad837c376 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 6c9de51b77..c952761549 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 34867d29ff..97be991771 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 17/39] 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 3090c73542..d6d984e23f 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 5ad837c376..a8b875adbc 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 18/39] 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 618d375762..7afd0fa1db 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 12b7687bfe..aa442a924c 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 15da4955fa..1353cb87ff 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 0000000000..7f1b2db1f5 --- /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 19/39] 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 ca9c577b55..c1f8f3730e 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 c6b09297ad..87f180fa95 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 41b1e50b6d..e006873489 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 d6d984e23f..8f9cf7a828 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 6436c91a73..95761c5fe1 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 a8b875adbc..ae98e42f2f 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 7f1b2db1f5..415c494fa0 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 20/39] 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 c1f8f3730e..eb15f3f25c 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 8f9cf7a828..992726e0c0 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 aa442a924c..c6c47ba9e5 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 ae98e42f2f..ab44d6f3b7 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 21/39] 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 eb15f3f25c..cce290441b 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 992726e0c0..e2c3f316bc 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 22/39] 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 cce290441b..193b42540a 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 95761c5fe1..d61882afb9 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 23/39] 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 193b42540a..5321aa242f 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 门禁),合入由用户授权执行 From 70291fbe4b6c953dc2adf6cfbc0ca932ca9ca7e0 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:20:23 +0800 Subject: [PATCH 24/39] fix(memory): matcher calls run outside the fence, coalescing via in-flight deferreds (MEM-01/MEM-02 acceptance) --- docs/findings/memory-batch-findings.md | 31 +++ packages/opencode/src/memory/CONTEXT.md | 2 +- packages/opencode/src/memory/memory.ts | 223 +++++++++++-------- packages/opencode/test/memory/memory.test.ts | 89 ++++++++ 4 files changed, 256 insertions(+), 89 deletions(-) create mode 100644 docs/findings/memory-batch-findings.md diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md new file mode 100644 index 0000000000..218564baed --- /dev/null +++ b/docs/findings/memory-batch-findings.md @@ -0,0 +1,31 @@ +# MEMORY 批次(验收遗留)Findings Register + +- 验收 primary source:`docs/audit-dag-memory-goal-2026-08-18.md`(MEMORY 章节)+ 产品→代码验收(f1c2c8c33→11cfafe9c)Spec 轴三项遗留 +- 分支:`fix/memory-fence-scope` → PR `dev` +- 收敛判据:连续两轮独立审阅(Spec 镜 + Standards 镜)零 findings + 模块门禁全绿 +- 规格:`workflows/audit-fix-loop.md` + +## 处置项 + +| ID | 来源 | 处置 | 状态 | 提交 | +|---|---|---|---|---| +| MEM-01 后半(P1) | 审计 + 验收 (a)2 | prepareUnsafe `shouldMatch` 分支的 select matcher 移出 fence/lock:matcher 无锁跑,markMatched 经 `applyUpdate`(fence+lock 只包提交) | 完成(红-绿-变异通过) | 待提交 | +| MEM-02(P2) | 审计 + 验收 (a)1 | `search` 的 identity fence 缩到 markMatched 提交;同查询合并从「持锁阻塞后来者」改为 per-key in-flight coalescing(进程内 Deferred),语义等价(后来者 reused:true、不耗 slot)且不再跨模型调用持 fence/lock | 完成(红-绿-变异通过;旧 coalescing 回归保持绿) | 待提交 | +| MEM-03(P3) | 验收 (a)3 | 处置记录:已被 PR #333 的 MEM-01 重构结构性抵消——维护恒为后台(kickMaintenance),失败折入其 catchCause,维护前渲染是已声明设计(CONTEXT.md「render the pre-maintenance snapshot」)。旧的「维护前快照渲染注入」窗口随 inline maintain 一起消失。本行即处置记录。 | 已记录 | — | + +## 设计要点 + +- `applyUpdate` 泛型化:`Update` 透传结果,select 的 markMatched 提交经它返回 matched topics(供 render)。 +- `select` 拆两半:`match()`(无 fence/lock)+ markMatched 提交(`applyUpdate`)。 +- `search`:短临界区(进程内 lock)只做缓存 re-read/limit/queryCount++ 与 in-flight 登记;matcher 在任何 fence/lock 之外;同 key 后来者 await in-flight Deferred(进程内合并,语义与旧「锁内阻塞」等价);提交走 `applyUpdate`。 +- `prepareUnsafe` shouldMatch 分支:matcher 出 fence/lock;identity retired(applyUpdate 返回 None)时仍 clearSession(fail-closed 不变)。 +- CONTEXT.md 最后一条 invariant 改写:fence 只包提交;同查询合并显式声明为进程内 in-flight coalescing。 +- 锁序不变量:全程不出现 lock 内嵌 fence(KeyedMutex 不可重入 + 与 checkpoint 的 fence>lock 序相反会死锁)。 + +## 模块门禁 +- 未开始 + +## 审阅轮次 + +### Round 1 +- 未开始 diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 450cf6a30c..7a628344b5 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -52,7 +52,7 @@ The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` re - Legacy files are re-read and compared immediately before deletion; content that changed after the scan is preserved and surfaced as a conflict. - Every writer of a MEMORY config file serializes on the file's cross-process lock; byte-atomicity is not undermined by whole-document last-writer-wins. - Maintenance model calls never run under the identity fence or the project lock: prepare and checkpoint render the pre-maintenance snapshot, then kick maintenance in the background, gated on identity liveness so a retired identity never starts a job (one job in flight per project, the commit-only write back under the fence). -- Bounded matcher calls deliberately hold the fence across one model call: the search matcher to coalesce concurrent identical queries, the prepare and checkpoint matchers because their match result feeds an atomic read-match-write under the project lock. Only unbounded-class work (maintenance) is excluded from the fence; a bounded matcher is at most one call per fence acquisition. +- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence/lock, and only the markMatched commit acquires them (`applyUpdate`). Concurrent identical search queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's result (reused, no extra query slot) instead of blocking a lock across the model call. ## Boundaries diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 564a5de770..691adfd4e2 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -3,7 +3,7 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProjectV2 } from "@opencode-ai/core/project" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Context, Effect, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" +import { Context, Deferred, Effect, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" import { stringify } from "yaml" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" @@ -93,6 +93,16 @@ export const layer: Layer.Layer< const state = yield* InstanceState.make(() => Effect.succeed({ sessions: new Map() })) const scope = yield* Scope.Scope const maintenanceInFlight = yield* Ref.make(new Set()) + // MEM-02: per-(session,key) in-flight matcher registrations. Replaces the + // old "hold the fence/lock across the model call so the second caller + // blocks and re-reads the cache" coalescing: the second identical query + // now awaits the first one's Deferred instead — same observable semantics + // (reused: true, no extra query slot) without a model call under the + // fence/lock. Process-local by design (the fence is the cross-process + // seam, and it now covers only the markMatched commit). + const matchInFlight = yield* Ref.make( + new Map>(), + ) const availableModels = Effect.fn("Memory.availableModels")(function* () { const providers = yield* provider.list() @@ -283,7 +293,12 @@ export const layer: Layer.Layer< // 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. - const applyUpdate = (projectID: ProjectV2.ID, update: (topics: MemorySchema.Topic[]) => MemoryStore.Update) => + // The update callback's result is passed through, so a commit like + // markMatched can hand the caller the post-commit topics to render. + const applyUpdate = ( + projectID: ProjectV2.ID, + update: (topics: MemorySchema.Topic[]) => MemoryStore.Update, + ) => fence.withLiveIdentity( projectID, lock.withProject(projectID)(store.updateTopics(projectID, update)), @@ -328,6 +343,9 @@ export const layer: Layer.Layer< return decoded.value.actions }) + // MEM-01/02: the matcher model call runs OUTSIDE the fence/lock; only the + // markMatched commit acquires them (applyUpdate). The matched topics come + // back from the commit for rendering. const select = Effect.fn("Memory.select")(function* (input: { model: Provider.Model config: MemorySchema.Config @@ -336,11 +354,20 @@ export const layer: Layer.Layer< projectID: Project.Info["id"] }) { const topicIDs = yield* match(input) - const matched = yield* store.updateTopics(input.projectID, (topics) => ({ - applied: MemoryStore.markMatched(topics, topicIDs), - result: undefined, - })) - const byID = new Map(matched.topics.map((topic) => [topic.id, topic])) + const committed = yield* applyUpdate(input.projectID, (topics) => { + // Re-filter against the post-read topics: the matcher filtered on the + // snapshot it saw; a topic deleted since then must not resurrect. + const live = new Set(topics.map((topic) => topic.id)) + return { + applied: MemoryStore.markMatched(topics, topicIDs.filter((id) => live.has(id))), + result: undefined as void, + } + }) + // Identity retired between the model call and the commit: nothing was + // written; there is no matched set to render. + if (Option.isNone(committed)) return undefined + const matched = committed.value.topics + const byID = new Map(matched.map((topic) => [topic.id, topic])) const selected = topicIDs.flatMap((id) => { const topic = byID.get(id) return topic ? [topic] : [] @@ -470,33 +497,30 @@ export const layer: Layer.Layer< return } - // The fence and the project lock cover the topic read plus the bounded - // first-turn matcher (declared tradeoff, see CONTEXT.md). Due maintenance - // is kicked AFTER the fence releases, so a long reasoning call never - // holds it: this turn renders the pre-maintenance topics and the - // committed update surfaces on a later prepare. - const live = yield* fence.withLiveIdentity( - current.project.id, - lock.withProject(current.project.id)( - Effect.gen(function* () { - const topics = yield* store.readTopics(current.project.id) - const rendered = (yield* select({ - model: current.model, - config: current.loaded.config, - topics, - text: user.text, - projectID: current.project.id, - })).rendered - const entry = data.sessions.get(input.sessionID) - if (entry?.turn.messageID !== user.info.id) return - entry.turn = { ...entry.turn, completedTurns: turns, rendered } - }), - ), - ) - if (Option.isNone(live)) { + // MEM-01: the first-turn matcher runs OUTSIDE the fence/lock; only its + // markMatched commit acquires them (inside select → applyUpdate). An + // identity retired mid-call surfaces as select === undefined — fail + // closed by dropping the cached session state. Due maintenance is + // kicked AFTERwards, so a long reasoning call never holds the fence: + // this turn renders the pre-maintenance topics and the committed update + // surfaces on a later prepare. + const rendered = yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: user.text, + projectID: current.project.id, + }) + if (!rendered) { yield* clearSession(input.sessionID) return } + { + const entry = data.sessions.get(input.sessionID) + if (entry?.turn.messageID === user.info.id) { + entry.turn = { ...entry.turn, completedTurns: turns, rendered: rendered.rendered } + } + } if (!due) return if (Option.isNone(yield* kickMaintenance(maintenance))) yield* clearSession(input.sessionID) }) @@ -559,51 +583,78 @@ export const layer: Layer.Layer< } const origin = user.info.id - // Declared tradeoff (issue #324, see CONTEXT.md): unlike maintenance, the - // matcher model call runs INSIDE the fence/lock. That serialization is - // what coalesces concurrent identical queries — the second caller blocks, - // re-reads `queries` under the lock, and reuses the first result instead - // of spending another model call. The lock also covers markMatched. - const live = yield* fence.withLiveIdentity( - current.project.id, + // MEM-02 (issue #324 acceptance): the matcher model call runs OUTSIDE + // the fence/lock. Concurrent identical queries coalesce through the + // per-(session,key) in-flight Deferred instead of lock-blocking: the + // second caller re-checks the cache under a SHORT project-lock + // critical section, awaits the first caller's result, and reports + // reused without spending another model call or query slot. Only the + // markMatched commit (inside select → applyUpdate) acquires the + // fence/lock. + const inFlightKey = `${input.sessionID}\0${key}` + const deferred = yield* Deferred.make<{ count: number; rendered: string[] }>() + + const outcome = yield* lock.withProject(current.project.id)( Effect.gen(function* () { - return yield* lock.withProject(current.project.id)( - Effect.gen(function* () { - const activeTurn = data.sessions.get(input.sessionID)?.turn - if (activeTurn?.messageID !== origin) return { status: "stale" as const } - const repeated = activeTurn.queries.get(key) - if (repeated) { - activeTurn.rendered = repeated.rendered - return repeated.count > 0 - ? { status: "attached" as const, count: repeated.count, reused: true } - : { status: "empty" as const, reused: true } - } - if (activeTurn.queryCount >= 2) return { status: "limit" as const } - activeTurn.queryCount++ - const topics = yield* store.readTopics(current.project.id) - const selected = yield* select({ - model: current.model, - config: current.loaded.config, - topics, - text: query, - projectID: current.project.id, - }) - const latest = data.sessions.get(input.sessionID)?.turn - if (latest?.messageID !== origin) return { status: "stale" as const } - latest.queries.set(key, selected) - latest.rendered = selected.rendered - return selected.count > 0 - ? { status: "attached" as const, count: selected.count, reused: false } - : { status: "empty" as const, reused: false } - }), + const activeTurn = data.sessions.get(input.sessionID)?.turn + if (activeTurn?.messageID !== origin) return { status: "stale" as const } + const repeated = activeTurn.queries.get(key) + if (repeated) { + activeTurn.rendered = repeated.rendered + return repeated.count > 0 + ? { status: "attached" as const, count: repeated.count, reused: true } + : { status: "empty" as const, reused: true } + } + if (activeTurn.queryCount >= 2) return { status: "limit" as const } + const running = yield* Ref.modify(matchInFlight, (map) => + map.has(inFlightKey) + ? ([map.get(inFlightKey)!, map] as const) + : ([deferred, new Map(map).set(inFlightKey, deferred)] as const), ) + if (running !== deferred) return { kind: "await-first" as const, first: running } + activeTurn.queryCount++ + return { kind: "run" as const } }), ) - if (Option.isNone(live)) { - yield* clearSession(input.sessionID) - return { status: "unavailable" as const } + if ("kind" in outcome) { + if (outcome.kind === "await-first") { + const first = yield* Deferred.await(outcome.first) + const latest = data.sessions.get(input.sessionID)?.turn + if (latest?.messageID !== origin) return { status: "stale" as const } + return first.count > 0 + ? { status: "attached" as const, count: first.count, reused: true } + : { status: "empty" as const, reused: true } + } + // This caller owns the matcher run: model call outside every + // fence/lock, then the fenced markMatched commit, then publish. + const selected = yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: query, + projectID: current.project.id, + }) + yield* Ref.update(matchInFlight, (map) => { + if (map.get(inFlightKey) !== deferred) return map + const next = new Map(map) + next.delete(inFlightKey) + return next + }) + // Identity retired between model call and commit — fail closed. + if (!selected) { + yield* clearSession(input.sessionID) + return { status: "unavailable" as const } + } + yield* Deferred.succeed(deferred, selected) + const latest = data.sessions.get(input.sessionID)?.turn + if (latest?.messageID !== origin) return { status: "stale" as const } + latest.queries.set(key, selected) + latest.rendered = selected.rendered + return selected.count > 0 + ? { status: "attached" as const, count: selected.count, reused: false } + : { status: "empty" as const, reused: false } } - return live.value + return outcome }) const search: Interface["search"] = Effect.fn("Memory.search")((input) => @@ -627,22 +678,18 @@ export const layer: Layer.Layer< return [] } const user = latestRealUser(input.messages) - const live = yield* fence.withLiveIdentity( - current.project.id, - lock.withProject(current.project.id)( - Effect.gen(function* () { - const topics = yield* store.readTopics(current.project.id) - return (yield* select({ - model: current.model, - config: current.loaded.config, - topics, - text: user?.text ?? "", - projectID: current.project.id, - })).rendered - }), - ), - ) - if (Option.isNone(live)) { + // MEM-01: the render matcher runs OUTSIDE the fence/lock; its + // markMatched commit is fenced inside select → applyUpdate. An identity + // retired mid-call (select === undefined) fails closed to an empty + // render, same as the retired-fence outcome before. + const selected = yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: user?.text ?? "", + projectID: current.project.id, + }) + if (!selected) { yield* clearSession(input.sessionID) return [] } @@ -657,7 +704,7 @@ export const layer: Layer.Layer< projectID: current.project.id, }) if (Option.isNone(kicked)) yield* clearSession(input.sessionID) - return live.value + return selected.rendered }) const checkpoint: Interface["checkpoint"] = Effect.fn("Memory.checkpoint")((input) => diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 182503e44a..3689dd1920 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1382,6 +1382,95 @@ describe("memory turn-scoped retrieval", () => { }), { git: true }, ) + + // MEM-02 follow-up (acceptance): the cross-process identity fence must not + // be held across the SEARCH matcher model call — only the markMatched + // commit is fenced. While the matcher is parked mid-call, a concurrent + // checkpoint (whose render select needs the same identity fence) must not + // starve. Under the old shape the fence wrapped the whole lock block, so + // the checkpoint waited on the streaming matcher. + recall.it.instance( + "keeps the identity fence free while the search matcher streams", + () => + Effect.gen(function* () { + recall.reset() + const started = yield* Deferred.make() + const release = yield* Deferred.make() + // Only the SLOW search query parks; the checkpoint's own render match + // must sail through — that is the assertion. + recall.state.matcher = (query) => + query === "慢架构查询" + ? Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + }) + : Effect.succeed({ topic_ids: [recall.state.topics[0]?.id ?? ""] }) + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_search_fence_free") + const messages = [ + user(MessageID.ascending(), sessionID, "先处理当前问题"), + user(MessageID.ascending(), sessionID, "召回相关历史"), + ] + + const pending = yield* memory.search({ sessionID, messages, query: "慢架构查询" }).pipe(Effect.forkChild) + yield* Deferred.await(started) + + const rendered = yield* awaitWithTimeout( + memory.checkpoint({ sessionID, messages }), + "checkpoint starved by the search matcher — fence held across the model call (MEM-02)", + ) + expect(rendered.length).toBeGreaterThan(0) + + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(pending)).toEqual({ status: "attached", count: 1, reused: false }) + }), + { git: true }, + ) + + // MEM-01 follow-up (acceptance): same discipline for the prepare + // first-turn (shouldMatch) branch — the bounded matcher moved out of the + // fence/lock, so a parked first-turn matcher cannot starve the fence. + recall.it.instance( + "keeps the identity fence free while the first-turn prepare matcher streams", + () => + Effect.gen(function* () { + recall.reset() + const started = yield* Deferred.make() + const release = yield* Deferred.make() + // Only the FIRST matcher call (the first-turn prepare) parks; the + // concurrent checkpoint's render match — same text, so query-based + // discrimination is impossible — must sail through on its own call. + let matcherCalls = 0 + recall.state.matcher = () => + Effect.gen(function* () { + matcherCalls++ + if (matcherCalls > 1) return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + }) + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_prepare_fence_free") + const messages = [user(MessageID.ascending(), sessionID, "首次真实用户输入关于架构")] + + const pending = yield* memory.prepare({ sessionID, messages }).pipe(Effect.forkChild) + yield* Deferred.await(started) + + const rendered = yield* awaitWithTimeout( + memory.checkpoint({ sessionID, messages }), + "checkpoint starved by the first-turn prepare matcher — fence held across the model call (MEM-01)", + ) + expect(rendered.length).toBeGreaterThan(0) + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(pending) + // Both the first-turn prepare and the concurrent checkpoint render + // matched the same text; each recorded exactly its own call. + expect(recall.state.queries).toEqual(["首次真实用户输入关于架构", "首次真实用户输入关于架构"]) + }), + { git: true }, + ) }) describe("memory project config Git exclusions", () => { From f08548d6d169bb634628d4649918ceb6e83a0fb5 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:35:24 +0800 Subject: [PATCH 25/39] fix(memory): in-flight matcher registration is exit-safe and publishes before release (review R1) --- docs/findings/memory-batch-findings.md | 9 ++ packages/opencode/src/memory/CONTEXT.md | 2 +- packages/opencode/src/memory/memory.ts | 111 +++++++++++++------ packages/opencode/test/memory/memory.test.ts | 48 ++++++++ 4 files changed, 133 insertions(+), 37 deletions(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index 218564baed..d23174f326 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -28,4 +28,13 @@ ## 审阅轮次 ### Round 1 +- Spec 镜:**BLOCKING**(R-1 P1 + R-2/R-3 INFO);Standards 镜:**BLOCKING**(F1 P1 与 R-1 同源 + F2-F5 INFO)。处置: + - R-1/F1(P1):in-flight 泄漏——失败/中断/retired 路径 deferred 永不完成 → coalesced awaiter 永久挂起、(session,key) 进程级 wedge。→ **已修**:runner 分支 `Effect.onExit`(对齐 kickMaintenance 槽位纪律)——每个退出路径先 `releaseIfOwner` 再把真实 Exit 打包进 deferred(deferred 永不失败,Exit 载荷即全部消息);awaiter 按 Exit 分支:失败→`failed`、interrupt→failCause 传播、retired→`unavailable`。新增红绿测试「a failed first query never wedges the session key or its coalesced awaiter」(有界等待断言无挂起、无 wedge)+ 变异验证(去掉 onExit → 新测试与旧 coalescing 测试双红)。 + - R-2(INFO):dereg→缓存写入窗口内第三个同查询会多耗一次调用。→ 已修:缓存写入提前到 `Effect.tap`(releaseIfOwner 之前),窗口闭合。 + - R-3(INFO):等价声明只覆盖 happy path。→ CONTEXT.md 措辞已含失败路径降级语义。 + - F2(INFO):CONTEXT.md「outside every fence/lock」对短注册临界区不真。→ 已改为「outside every fence + SHORT project-lock critical section (registration)」。 + - F3/F4/F5(INFO):裸块、`!` 断言、命名不一致。→ 已修(裸块展开、get-then-check、统一 `selected`)。 +- 结论:修复后进入 Round 2。 + +### Round 2 - 未开始 diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 7a628344b5..4625e4ebb4 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -52,7 +52,7 @@ The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` re - Legacy files are re-read and compared immediately before deletion; content that changed after the scan is preserved and surfaced as a conflict. - Every writer of a MEMORY config file serializes on the file's cross-process lock; byte-atomicity is not undermined by whole-document last-writer-wins. - Maintenance model calls never run under the identity fence or the project lock: prepare and checkpoint render the pre-maintenance snapshot, then kick maintenance in the background, gated on identity liveness so a retired identity never starts a job (one job in flight per project, the commit-only write back under the fence). -- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence/lock, and only the markMatched commit acquires them (`applyUpdate`). Concurrent identical search queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's result (reused, no extra query slot) instead of blocking a lock across the model call. +- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence, and only the markMatched commit acquires the fence (`applyUpdate`). Search keeps a SHORT project-lock critical section (stale/cache/limit check + in-flight registration); concurrent identical queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's exit (reused, no extra query slot) and degrades to `failed`/`unavailable` when the first call fails or its identity retires, instead of blocking a lock across the model call. ## Boundaries diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 691adfd4e2..09693f643b 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -3,7 +3,7 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProjectV2 } from "@opencode-ai/core/project" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Context, Deferred, Effect, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" +import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" import { stringify } from "yaml" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" @@ -98,12 +98,15 @@ export const layer: Layer.Layer< // blocks and re-reads the cache" coalescing: the second identical query // now awaits the first one's Deferred instead — same observable semantics // (reused: true, no extra query slot) without a model call under the - // fence/lock. Process-local by design (the fence is the cross-process - // seam, and it now covers only the markMatched commit). + // fence/lock. The runner completes the deferred on EVERY exit (onExit), + // so a failed or interrupted first call wakes its coalesced awaiter + // (degraded to "failed") instead of parking it forever. Process-local by + // design (the fence is the cross-process seam, and it now covers only + // the markMatched commit). + type MatchRun = { count: number; rendered: string[] } const matchInFlight = yield* Ref.make( - new Map>(), + new Map>>(), ) - const availableModels = Effect.fn("Memory.availableModels")(function* () { const providers = yield* provider.list() return new Set( @@ -504,22 +507,20 @@ export const layer: Layer.Layer< // kicked AFTERwards, so a long reasoning call never holds the fence: // this turn renders the pre-maintenance topics and the committed update // surfaces on a later prepare. - const rendered = yield* select({ + const selected = yield* select({ model: current.model, config: current.loaded.config, topics: yield* store.readTopics(current.project.id), text: user.text, projectID: current.project.id, }) - if (!rendered) { + if (!selected) { yield* clearSession(input.sessionID) return } - { - const entry = data.sessions.get(input.sessionID) - if (entry?.turn.messageID === user.info.id) { - entry.turn = { ...entry.turn, completedTurns: turns, rendered: rendered.rendered } - } + const entry = data.sessions.get(input.sessionID) + if (entry?.turn.messageID === user.info.id) { + entry.turn = { ...entry.turn, completedTurns: turns, rendered: selected.rendered } } if (!due) return if (Option.isNone(yield* kickMaintenance(maintenance))) yield* clearSession(input.sessionID) @@ -587,12 +588,21 @@ export const layer: Layer.Layer< // the fence/lock. Concurrent identical queries coalesce through the // per-(session,key) in-flight Deferred instead of lock-blocking: the // second caller re-checks the cache under a SHORT project-lock - // critical section, awaits the first caller's result, and reports - // reused without spending another model call or query slot. Only the - // markMatched commit (inside select → applyUpdate) acquires the - // fence/lock. + // critical section (stale/cache/limit check + registration), awaits + // the first caller's result, and reports reused without spending + // another model call or query slot. Only the markMatched commit + // (inside select → applyUpdate) acquires the fence. const inFlightKey = `${input.sessionID}\0${key}` - const deferred = yield* Deferred.make<{ count: number; rendered: string[] }>() + const deferred = yield* Deferred.make>() + + const releaseIfOwner = Effect.fnUntraced(function* () { + yield* Ref.update(matchInFlight, (map) => { + if (map.get(inFlightKey) !== deferred) return map + const next = new Map(map) + next.delete(inFlightKey) + return next + }) + }) const outcome = yield* lock.withProject(current.project.id)( Effect.gen(function* () { @@ -606,11 +616,11 @@ export const layer: Layer.Layer< : { status: "empty" as const, reused: true } } if (activeTurn.queryCount >= 2) return { status: "limit" as const } - const running = yield* Ref.modify(matchInFlight, (map) => - map.has(inFlightKey) - ? ([map.get(inFlightKey)!, map] as const) - : ([deferred, new Map(map).set(inFlightKey, deferred)] as const), - ) + const running = yield* Ref.modify(matchInFlight, (map) => { + const existing = map.get(inFlightKey) + if (existing) return [existing, map] as const + return [deferred, new Map(map).set(inFlightKey, deferred)] as const + }) if (running !== deferred) return { kind: "await-first" as const, first: running } activeTurn.queryCount++ return { kind: "run" as const } @@ -618,38 +628,67 @@ export const layer: Layer.Layer< ) if ("kind" in outcome) { if (outcome.kind === "await-first") { + // The awaiter rides the runner's exit: the runner completes the + // deferred on EVERY exit (onExit below packs the exit — success, + // failure, interrupt, retired — into the payload), so the await + // always wakes. Failure/interrupt degrades to "failed", never a + // permanent park on a doomed deferred. const first = yield* Deferred.await(outcome.first) + if (Exit.isFailure(first)) { + if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) + return { status: "failed" as const } + } + const selected = first.value + if (!selected) return { status: "unavailable" as const } const latest = data.sessions.get(input.sessionID)?.turn if (latest?.messageID !== origin) return { status: "stale" as const } - return first.count > 0 - ? { status: "attached" as const, count: first.count, reused: true } + return selected.count > 0 + ? { status: "attached" as const, count: selected.count, reused: true } : { status: "empty" as const, reused: true } } // This caller owns the matcher run: model call outside every // fence/lock, then the fenced markMatched commit, then publish. - const selected = yield* select({ + // onExit mirrors kickMaintenance's slot discipline (its comment: "an + // interruption between the two would leak the in-flight slot"): + // every exit deregisters the map entry AND packs the real outcome + // into the deferred, so a coalesced awaiter wakes instead of parking + // forever. The deferred itself never fails — the Exit payload is the + // whole message. + const runExit = yield* select({ model: current.model, config: current.loaded.config, topics: yield* store.readTopics(current.project.id), text: query, projectID: current.project.id, - }) - yield* Ref.update(matchInFlight, (map) => { - if (map.get(inFlightKey) !== deferred) return map - const next = new Map(map) - next.delete(inFlightKey) - return next - }) - // Identity retired between model call and commit — fail closed. + }).pipe( + Effect.tap((selected) => { + // Publish the cache entry BEFORE the in-flight deregistration in + // onExit: a third identical caller entering between the two + // would otherwise miss both the cache and the in-flight entry + // and burn a second model call + query slot where the old + // lock-blocking design guaranteed reuse. Stale-origin runs skip + // the write; the stale check below still governs the response. + if (!selected) return Effect.void + const latest = data.sessions.get(input.sessionID)?.turn + if (latest?.messageID !== origin) return Effect.void + latest.queries.set(key, selected) + latest.rendered = selected.rendered + return Effect.void + }), + Effect.onExit((exit) => releaseIfOwner().pipe(Effect.andThen(Deferred.succeed(deferred, exit)))), + Effect.exit, + ) + if (Exit.isFailure(runExit)) return yield* Effect.failCause(runExit.cause) + const selected = runExit.value + // Identity retired between model call and commit — fail closed. The + // deferred already carries the same (succeeded-undefined) exit, so a + // coalesced awaiter degrades to "unavailable" rather than hanging. if (!selected) { yield* clearSession(input.sessionID) return { status: "unavailable" as const } } - yield* Deferred.succeed(deferred, selected) const latest = data.sessions.get(input.sessionID)?.turn if (latest?.messageID !== origin) return { status: "stale" as const } - latest.queries.set(key, selected) - latest.rendered = selected.rendered return selected.count > 0 ? { status: "attached" as const, count: selected.count, reused: false } : { status: "empty" as const, reused: false } diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 3689dd1920..9ae80b4825 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1471,6 +1471,54 @@ describe("memory turn-scoped retrieval", () => { }), { git: true }, ) + + // Review R-1: the runner must complete the in-flight deferred on EVERY + // exit. A failed first matcher call must not wedge the (session,key): a + // coalesced awaiter wakes (degraded) and a later identical query re-runs + // the matcher instead of parking on a leaked in-flight entry. + recall.it.instance( + "a failed first query never wedges the session key or its coalesced awaiter", + () => + Effect.gen(function* () { + recall.reset() + const started = yield* Deferred.make() + let matcherCalls = 0 + recall.state.matcher = () => + Effect.gen(function* () { + matcherCalls++ + if (matcherCalls === 1) { + yield* Deferred.succeed(started, undefined) + throw new Error("matcher exploded") + } + return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + }) + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_failed_first") + const messages = [ + user(MessageID.ascending(), sessionID, "先处理当前问题"), + user(MessageID.ascending(), sessionID, "召回相关历史"), + ] + + const failing = yield* memory.search({ sessionID, messages, query: "易碎查询" }).pipe(Effect.forkChild) + yield* Deferred.await(started) + // Concurrent identical query — either it coalesced onto the failing + // run (wakes degraded to "failed") or it raced past the in-flight + // window and re-runs (attached). Both are non-hang outcomes; a + // parked-forever awaiter or a wedged key fails the bounded waits. + const coalesced = yield* memory.search({ sessionID, messages, query: "易碎查询" }).pipe(Effect.forkChild) + const failingResult = yield* awaitWithTimeout(Fiber.join(failing), "failing query hung") + const coalescedResult = yield* awaitWithTimeout(Fiber.join(coalesced), "coalesced awaiter hung on the failing run") + expect(failingResult).toEqual({ status: "failed" }) + expect(["failed", "attached", "empty"]).toContain(coalescedResult.status) + // The (session,key) is NOT wedged: the next identical query resolves + // within the bounded window (fresh run or cached reuse from the + // coalesced fiber's successful re-run — either proves liveness). + expect(yield* awaitWithTimeout(memory.search({ sessionID, messages, query: "易碎查询" }), "later identical query wedged on the leaked in-flight entry")).toMatchObject({ + status: "attached", + }) + }), + { git: true }, + ) }) describe("memory project config Git exclusions", () => { From ef8d6b8d1745ec7114087e8084c723b38a78fe30 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:45:54 +0800 Subject: [PATCH 26/39] fix(memory): bracket the whole runner tail from registration, turn-scoped in-flight key (review R2) --- packages/opencode/src/memory/memory.ts | 72 +++++++++++--------- packages/opencode/test/memory/memory.test.ts | 45 +++++++++++- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 09693f643b..e4d9fdd2e7 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -93,16 +93,19 @@ export const layer: Layer.Layer< const state = yield* InstanceState.make(() => Effect.succeed({ sessions: new Map() })) const scope = yield* Scope.Scope const maintenanceInFlight = yield* Ref.make(new Set()) - // MEM-02: per-(session,key) in-flight matcher registrations. Replaces the - // old "hold the fence/lock across the model call so the second caller - // blocks and re-reads the cache" coalescing: the second identical query - // now awaits the first one's Deferred instead — same observable semantics - // (reused: true, no extra query slot) without a model call under the - // fence/lock. The runner completes the deferred on EVERY exit (onExit), - // so a failed or interrupted first call wakes its coalesced awaiter - // (degraded to "failed") instead of parking it forever. Process-local by - // design (the fence is the cross-process seam, and it now covers only - // the markMatched commit). + // MEM-02: per-(session,turn,key) in-flight matcher registrations — the + // turn origin (messageID) in the key keeps coalescing turn-scoped, so a + // new turn's identical query re-runs instead of riding a previous turn's + // result that could never populate its cache. Replaces the old "hold the + // fence/lock across the model call so the second caller blocks and + // re-reads the cache" coalescing: the second identical query now awaits + // the first one's Deferred instead — same observable semantics (reused: + // true, no extra query slot) without a model call under the fence/lock. + // The runner brackets EVERYTHING after registration in an exit guard + // (onExit), so a failed or interrupted first call wakes its coalesced + // awaiter (degraded to "failed") instead of parking it forever. + // Process-local by design (the fence is the cross-process seam, and it + // now covers only the markMatched commit). type MatchRun = { count: number; rendered: string[] } const matchInFlight = yield* Ref.make( new Map>>(), @@ -363,7 +366,7 @@ export const layer: Layer.Layer< const live = new Set(topics.map((topic) => topic.id)) return { applied: MemoryStore.markMatched(topics, topicIDs.filter((id) => live.has(id))), - result: undefined as void, + result: undefined, } }) // Identity retired between the model call and the commit: nothing was @@ -592,7 +595,7 @@ export const layer: Layer.Layer< // the first caller's result, and reports reused without spending // another model call or query slot. Only the markMatched commit // (inside select → applyUpdate) acquires the fence. - const inFlightKey = `${input.sessionID}\0${key}` + const inFlightKey = `${input.sessionID}\0${origin}\0${key}` const deferred = yield* Deferred.make>() const releaseIfOwner = Effect.fnUntraced(function* () { @@ -628,11 +631,13 @@ export const layer: Layer.Layer< ) if ("kind" in outcome) { if (outcome.kind === "await-first") { - // The awaiter rides the runner's exit: the runner completes the - // deferred on EVERY exit (onExit below packs the exit — success, - // failure, interrupt, retired — into the payload), so the await - // always wakes. Failure/interrupt degrades to "failed", never a - // permanent park on a doomed deferred. + // The awaiter rides the runner's exit: the runner's exit bracket + // packs every outcome — success, failure, interrupt, retired — + // into the deferred payload, so the await always wakes. Failures + // surface as this caller's "failed" (mapped by the search + // wrapper's catchCause); the runner's interrupt is re-raised here + // via failCause (the awaiter shares the cancellation); a retired + // identity degrades to "unavailable". Never a permanent park. const first = yield* Deferred.await(outcome.first) if (Exit.isFailure(first)) { if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) @@ -646,20 +651,25 @@ export const layer: Layer.Layer< ? { status: "attached" as const, count: selected.count, reused: true } : { status: "empty" as const, reused: true } } - // This caller owns the matcher run: model call outside every - // fence/lock, then the fenced markMatched commit, then publish. - // onExit mirrors kickMaintenance's slot discipline (its comment: "an - // interruption between the two would leak the in-flight slot"): - // every exit deregisters the map entry AND packs the real outcome - // into the deferred, so a coalesced awaiter wakes instead of parking - // forever. The deferred itself never fails — the Exit payload is the - // whole message. - const runExit = yield* select({ - model: current.model, - config: current.loaded.config, - topics: yield* store.readTopics(current.project.id), - text: query, - projectID: current.project.id, + // This caller owns the matcher run. EVERYTHING after registration — + // the topics read and the select pipeline (model call, fenced + // markMatched commit) — runs inside one exit bracket: onExit fires + // on success, failure, interrupt, and identity-retired alike, + // deregistering the map entry and packing the real exit into the + // deferred so a coalesced awaiter wakes instead of parking forever + // (kickMaintenance's slot discipline, applied from the moment the + // entry exists — an interrupt during the topics read would otherwise + // unwind before the bracket attaches and wedge the (turn,key) + // forever). The deferred itself never fails — the Exit payload is + // the whole message. + const runExit = yield* Effect.gen(function* () { + return yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: query, + projectID: current.project.id, + }) }).pipe( Effect.tap((selected) => { // Publish the cache entry BEFORE the in-flight deregistration in diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 9ae80b4825..a1adfed294 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -318,6 +318,8 @@ function recallFixture() { projectInitialized: number matcher?: (query: string) => Effect.Effect maintenanceHook?: () => Effect.Effect + /** Parks the runner's pre-select topics read (interrupt-window probe). */ + parkReads?: { started: Deferred.Deferred; release: Deferred.Deferred } } = { queries: [], reads: 0, @@ -376,8 +378,12 @@ function recallFixture() { MemoryLock.defaultLayer, Layer.mock(MemoryStore.Service, { readTopics: () => - Effect.sync(() => { + Effect.gen(function* () { state.reads++ + if (state.parkReads) { + yield* Deferred.succeed(state.parkReads.started, undefined) + yield* Deferred.await(state.parkReads.release) + } return state.topics }), updateTopics: (_projectID, update) => @@ -413,6 +419,7 @@ function recallFixture() { state.projectInitialized = 1 state.matcher = undefined state.maintenanceHook = undefined + state.parkReads = undefined }, it: testEffect(layer), systemIt: testEffect(systemLayer), @@ -1519,6 +1526,42 @@ describe("memory turn-scoped retrieval", () => { }), { git: true }, ) + + // Review R2 issue 1: the exit bracket must start at REGISTRATION, not at + // the select pipeline — an interrupt during the runner's pre-select topics + // read (a real suspension in production, flock'd disk I/O) previously + // unwound before onExit attached, leaking the in-flight entry and wedging + // the (turn,key) forever. + recall.it.instance( + "an interrupted topics read releases the in-flight entry and never wedges the key", + () => + Effect.gen(function* () { + recall.reset() + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_interrupted_read") + const messages = [ + user(MessageID.ascending(), sessionID, "先处理当前问题"), + user(MessageID.ascending(), sessionID, "召回相关历史"), + ] + const started = yield* Deferred.make() + const release = yield* Deferred.make() + recall.state.parkReads = { started, release } + + const runner = yield* memory.search({ sessionID, messages, query: "可中断查询" }).pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* Fiber.interrupt(runner).pipe(Effect.ignore) + + // The entry must be released despite the interrupt landing before + // the select pipeline attached its bracket: a later identical query + // resolves within the bounded window instead of parking forever + // (empty: the query text matches no topic — liveness is the point). + yield* Deferred.succeed(release, undefined) + expect(yield* awaitWithTimeout(memory.search({ sessionID, messages, query: "可中断查询" }), "later identical query wedged on the entry leaked by the interrupted read")).toMatchObject({ + status: "empty", + }) + }), + { git: true }, + ) }) describe("memory project config Git exclusions", () => { From 9094abcaa8e030a7fc6a602ee0d953bed0cf60dc Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:47:09 +0800 Subject: [PATCH 27/39] docs(memory): record review round-2 closures --- docs/findings/memory-batch-findings.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index d23174f326..d9beaa1388 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -37,4 +37,13 @@ - 结论:修复后进入 Round 2。 ### Round 2 +- Spec 镜:**BLOCKING**(Issue 1 P1 + Issue 2 INFO);Standards 镜:**BLOCKING**(Issue 1 P1 同源 + 2/3/4 INFO)。处置: + - Issue 1(P1):onExit 括号只覆盖 select 管道——注册后的 `store.readTopics` 挂起(生产为 flock 磁盘 IO)期间被中断/失败会在括号附着前 unwind → 泄漏条目 + wedge。→ **已修**:整个尾部(readTopics + select 管道)包进同一 `Effect.gen(...).pipe(tap, onExit, exit)` 括号;mock 的 `readTopics` 加 `parkReads` 挂起钩子;新增红绿测试「an interrupted topics read releases the in-flight entry and never wedges the key」+ 变异验证(readTopics 挪出括号 → 翻红)。 + - Spec Issue 2(INFO):in-flight key 无 turn 分量 → 新轮次的同文查询可能骑上一轮的 deferred,attached 但自身缓存不填充。→ **已修**:key 加入 turn origin(messageID)——合并严格 turn 内,跨轮重跑。 + - Standards 2(INFO):awaiter 注释的 interrupt 机制描述不准(failCause 重抛在 awaiter,failed 映射在 search wrapper)。→ 已改写。 + - Standards 3(INFO):测试注释宣称 interrupted 覆盖但原先无此测试。→ R2 新增的中断测试已补齐该覆盖。 + - Standards 4(INFO):`undefined as void` 多余 cast。→ 已删。 +- 结论:修复后进入 Round 3。 + +### Round 3 - 未开始 From 04385af798d7c7bcd7dca0b139dafdd26a2a0399 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:55:58 +0800 Subject: [PATCH 28/39] docs(memory): sync in-flight key wording and awaiter interrupt comment (review R3) --- docs/findings/memory-batch-findings.md | 11 +++++++++-- packages/opencode/src/memory/CONTEXT.md | 2 +- packages/opencode/src/memory/memory.ts | 10 +++++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index d9beaa1388..b7195b9a2b 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -9,8 +9,8 @@ | ID | 来源 | 处置 | 状态 | 提交 | |---|---|---|---|---| -| MEM-01 后半(P1) | 审计 + 验收 (a)2 | prepareUnsafe `shouldMatch` 分支的 select matcher 移出 fence/lock:matcher 无锁跑,markMatched 经 `applyUpdate`(fence+lock 只包提交) | 完成(红-绿-变异通过) | 待提交 | -| MEM-02(P2) | 审计 + 验收 (a)1 | `search` 的 identity fence 缩到 markMatched 提交;同查询合并从「持锁阻塞后来者」改为 per-key in-flight coalescing(进程内 Deferred),语义等价(后来者 reused:true、不耗 slot)且不再跨模型调用持 fence/lock | 完成(红-绿-变异通过;旧 coalescing 回归保持绿) | 待提交 | +| MEM-01 后半(P1) | 审计 + 验收 (a)2 | prepareUnsafe `shouldMatch` 分支的 select matcher 移出 fence/lock:matcher 无锁跑,markMatched 经 `applyUpdate`(fence+lock 只包提交) | 完成(红-绿-变异通过) | 70291fbe4 | +| MEM-02(P2) | 审计 + 验收 (a)1 | `search` 的 identity fence 缩到 markMatched 提交;同查询合并从「持锁阻塞后来者」改为 turn 内 per-(session,turn,key) in-flight coalescing(进程内 Deferred),语义等价(后来者 reused:true、不耗 slot;失败/中断/retired 时唤醒降级而非挂起)且不再跨模型调用持 fence/lock | 完成(红-绿-变异通过;旧 coalescing 回归保持绿) | 70291fbe4 + f08548d6d + ef8d6b8d1 | | MEM-03(P3) | 验收 (a)3 | 处置记录:已被 PR #333 的 MEM-01 重构结构性抵消——维护恒为后台(kickMaintenance),失败折入其 catchCause,维护前渲染是已声明设计(CONTEXT.md「render the pre-maintenance snapshot」)。旧的「维护前快照渲染注入」窗口随 inline maintain 一起消失。本行即处置记录。 | 已记录 | — | ## 设计要点 @@ -46,4 +46,11 @@ - 结论:修复后进入 Round 3。 ### Round 3 +- Spec 镜:**PASS**,1 条 INFO;Standards 镜:**PASS**,2 条 INFO。处置: + - R3-1(INFO):CONTEXT.md 与 searchUnsafe 注释仍写 per-(session,key),key 已 turn-scoped。→ 两处已改为 per-(session,turn,key)。 + - R3-2(INFO):awaiter 注释「shares the cancellation」不符合 v4 语义(failCause 重抛由 wrapper catchCause 吸收,awaiter 仍以 failed 完成)。→ 已改写为准确机制。 + - register「待提交」歧义。→ 已改为实际提交哈希。 +- 结论:非干净轮(INFO)。修复后进入 Round 4。 + +### Round 4 - 未开始 diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 4625e4ebb4..140467d01f 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -52,7 +52,7 @@ The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` re - Legacy files are re-read and compared immediately before deletion; content that changed after the scan is preserved and surfaced as a conflict. - Every writer of a MEMORY config file serializes on the file's cross-process lock; byte-atomicity is not undermined by whole-document last-writer-wins. - Maintenance model calls never run under the identity fence or the project lock: prepare and checkpoint render the pre-maintenance snapshot, then kick maintenance in the background, gated on identity liveness so a retired identity never starts a job (one job in flight per project, the commit-only write back under the fence). -- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence, and only the markMatched commit acquires the fence (`applyUpdate`). Search keeps a SHORT project-lock critical section (stale/cache/limit check + in-flight registration); concurrent identical queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's exit (reused, no extra query slot) and degrades to `failed`/`unavailable` when the first call fails or its identity retires, instead of blocking a lock across the model call. +- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence, and only the markMatched commit acquires the fence (`applyUpdate`). Search keeps a SHORT project-lock critical section (stale/cache/limit check + in-flight registration); concurrent identical queries within one turn coalesce through a process-local per-(session,turn,key) in-flight Deferred — the second caller awaits the first caller's exit (reused, no extra query slot) and degrades to `failed`/`unavailable` when the first call fails or its identity retires, instead of blocking a lock across the model call. ## Boundaries diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index e4d9fdd2e7..d15e8e5ed1 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -589,8 +589,8 @@ export const layer: Layer.Layer< // MEM-02 (issue #324 acceptance): the matcher model call runs OUTSIDE // the fence/lock. Concurrent identical queries coalesce through the - // per-(session,key) in-flight Deferred instead of lock-blocking: the - // second caller re-checks the cache under a SHORT project-lock + // per-(session,turn,key) in-flight Deferred instead of lock-blocking: + // the second caller re-checks the cache under a SHORT project-lock // critical section (stale/cache/limit check + registration), awaits // the first caller's result, and reports reused without spending // another model call or query slot. Only the markMatched commit @@ -635,9 +635,9 @@ export const layer: Layer.Layer< // packs every outcome — success, failure, interrupt, retired — // into the deferred payload, so the await always wakes. Failures // surface as this caller's "failed" (mapped by the search - // wrapper's catchCause); the runner's interrupt is re-raised here - // via failCause (the awaiter shares the cancellation); a retired - // identity degrades to "unavailable". Never a permanent park. + // wrapper's catchCause); the runner's interrupt cause is re-raised + // via failCause so the wrapper's log carries it — the awaiter + // itself still completes with "failed". Never a permanent park. const first = yield* Deferred.await(outcome.first) if (Exit.isFailure(first)) { if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) From 0c19194659c6ff82c157d43911404dde8b33d5f6 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 21:04:56 +0800 Subject: [PATCH 29/39] docs(memory): precise awaiter failure mapping and topics-read suspension wording (review R4) --- docs/findings/memory-batch-findings.md | 5 +++++ packages/opencode/src/memory/memory.ts | 8 ++++---- packages/opencode/test/memory/memory.test.ts | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index b7195b9a2b..fd5d73f190 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -53,4 +53,9 @@ - 结论:非干净轮(INFO)。修复后进入 Round 4。 ### Round 4 +- Spec 镜:**PASS,no findings**(干净轮候选)。 +- Standards 镜:**PASS**,2 条 INFO:R4-1 awaiter 注释把非中断失败的映射错归 wrapper catchCause(实际直接返回 failed);R4-2 测试注释「flock'd disk I/O」不准(readTopics 无 flock,是 async fs)。→ 均已改写。 +- 结论:非干净轮。修复后进入 Round 5。 + +### Round 5 - 未开始 diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index d15e8e5ed1..89b2f99da2 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -634,10 +634,10 @@ export const layer: Layer.Layer< // The awaiter rides the runner's exit: the runner's exit bracket // packs every outcome — success, failure, interrupt, retired — // into the deferred payload, so the await always wakes. Failures - // surface as this caller's "failed" (mapped by the search - // wrapper's catchCause); the runner's interrupt cause is re-raised - // via failCause so the wrapper's log carries it — the awaiter - // itself still completes with "failed". Never a permanent park. + // surface as this caller's "failed" (returned directly here); + // the runner's interrupt cause is re-raised via failCause so the + // wrapper's log carries it — the awaiter itself still completes + // with "failed". Never a permanent park. const first = yield* Deferred.await(outcome.first) if (Exit.isFailure(first)) { if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index a1adfed294..9b711d3897 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1529,9 +1529,9 @@ describe("memory turn-scoped retrieval", () => { // Review R2 issue 1: the exit bracket must start at REGISTRATION, not at // the select pipeline — an interrupt during the runner's pre-select topics - // read (a real suspension in production, flock'd disk I/O) previously - // unwound before onExit attached, leaking the in-flight entry and wedging - // the (turn,key) forever. + // read (a real async fs suspension in production) previously unwound + // before onExit attached, leaking the in-flight entry and wedging the + // (turn,key) forever. recall.it.instance( "an interrupted topics read releases the in-flight entry and never wedges the key", () => From 57a1a81f30c299bc075242d0ecffbf4da4783888 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 21:10:11 +0800 Subject: [PATCH 30/39] docs(memory): declare acceptance-leftover batch convergence --- docs/findings/memory-batch-findings.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index fd5d73f190..426b1beb01 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -58,4 +58,22 @@ - 结论:非干净轮。修复后进入 Round 5。 ### Round 5 -- 未开始 +- Spec 镜:**PASS,no findings**;Standards 镜:**PASS,no findings**(R4 两处措辞独立复核为准确)。 +- 结论:**干净轮 2/2**。连续两轮零 findings → **MEMORY 验收遗留批次收敛**。 + +## 收敛结论 + +R1(双镜 BLOCKING:in-flight 泄漏 P1)→ R2(双镜 BLOCKING:括号窗口 P1 + turn 作用域)→ R3(双 PASS,3 INFO 措辞)→ R4(Spec 干净 + Standards 2 INFO 措辞)→ **R4+R5 连续两轮零 findings**。findings 轨迹:两轮 P1 并发缺陷(实装修复+回归测试)→ 纯措辞 → 零。 + +## 模块门禁(终态) + +- memory 测试簇 95/95 绿(新增 5 条回归:fence-free ×2、failed-wedge、interrupted-read-wedge、原 coalescing 保持) +- `bun typecheck` + pre-push turbo 29/29 绿 +- 全量套件 4164 tests:仅 2 失败为历次批次已在干净基线证实的 darwin 环境既有失败(help-snapshots、project-copy),与本批无因果 +- 变异验证:fence 回置翻红 ×2(search/prepare)、onExit 移除翻红 ×2(wedge + coalescing)、readTopics 出括号翻红 ×1 + +## 交付 + +- 分支:`fix/memory-fence-scope`(基于 origin/dev 11cfafe9c) +- 提交链:70291fbe4(MEM-01/02 主体)→ f08548d6d(R1 exit-safe)→ ef8d6b8d1(R2 整尾括号 + turn key)→ 9094abcaa/04385af79/0c1919465(记账与措辞) +- PR → dev(Typecheck 门禁) From 833f0b86c9f181de5050a280fdc25808c6bddce6 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 22:25:07 +0800 Subject: [PATCH 31/39] fix(dag): reporting checkpoints carry an adversarial verification clause (issue #323) --- packages/opencode/src/dag/blocks.ts | 9 +++++ packages/opencode/test/dag/blocks.test.ts | 48 +++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index c63cd33f7b..3ceb8adbd3 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -327,6 +327,14 @@ function node(input: { outputSchema?: Record }): NodeConfig { const instruction = input.instruction?.trim() ? "Block-specific instruction:\n{{instruction}}" : "" + // issue #323: a reporting checkpoint adjudicates a direction, so its + // prompt must demand adversarial independent verification. The production + // incident: a gate confirmed parent-supplied "defect evidence" that was a + // production no-op because it re-read the parent's narrative instead of + // the source. Upstream claims are hypotheses, not facts. + const adversarial = input.reportToParent + ? "As a reporting checkpoint you adjudicate this direction: treat upstream and parent-supplied claims as hypotheses to verify, never facts to confirm. independently read the relevant source before endorsing the most load-bearing claims, cite what you actually inspected, and replan or reject the direction when a load-bearing claim does not survive inspection." + : "" return { id: input.id, name: input.name, @@ -339,6 +347,7 @@ function node(input: { "Workflow objective:\n{{objective}}", instruction, input.contract, + adversarial, "Use dependency outputs as evidence and return a concise artifact that downstream blocks can consume. Do not ask the user questions from this child session.", ] .filter(Boolean) diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index ba51154e58..237f959b8b 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -44,6 +44,54 @@ describe("workflow blocks", () => { ]) }) + // issue #323: a reporting checkpoint adjudicates a direction — its prompt + // must demand adversarial independent verification, not self-confirmation + // of upstream claims. The production incident: cp-after-exploration + // confirmed a parent-supplied "defect" that was a production no-op because + // the gate re-read the parent's narrative instead of reading the source. + it("compiles reporting checkpoints with an adversarial verification clause", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Ship the feature", + blocks: [ + { id: "cp-after-exploration", kind: "explore", report_to_parent: true }, + { id: "stage", kind: "coding", depends_on: ["cp-after-exploration"] }, + ], + }) + const checkpoint = nodes.find((node) => node.id === "cp-after-exploration") + expect(checkpoint?.report_to_parent).toBe(true) + // Upstream claims are hypotheses to verify, not facts to confirm. + expect(checkpoint?.prompt_template.inline).toContain("hypotheses to verify") + // Independent source inspection is mandatory for load-bearing claims. + expect(checkpoint?.prompt_template.inline).toContain("independently read the relevant source") + // A claim that fails inspection must fail the direction, not pass it. + expect(checkpoint?.prompt_template.inline).toContain("replan or reject") + }) + + it("keeps the adversarial clause off non-reporting nodes", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Ship the feature", + blocks: [ + { id: "map", kind: "explore" }, + { id: "stage", kind: "coding", depends_on: ["map"] }, + ], + }) + // Only adjudicating checkpoints pay the adversarial cost; evidence + // producers (a plain explore) and executors (coding) do not. + for (const node of nodes) { + expect(node.report_to_parent).toBe(false) + expect(node.prompt_template.inline).not.toContain("hypotheses to verify") + } + }) + + it("gives default-reporting synthesize nodes the adversarial clause", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Ship the feature", + blocks: [{ id: "closing", kind: "synthesize" }], + }) + expect(nodes[0]?.report_to_parent).toBe(true) + expect(nodes[0]?.prompt_template.inline).toContain("hypotheses to verify") + }) + it("composes configured design delivery capabilities without new lifecycle kinds", () => { const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Design, implement, and review project-owned memory", From 4019ddf0be1ede663abb422fedc9dfda644b95c5 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 22:28:38 +0800 Subject: [PATCH 32/39] fix(dag): add a quantified spot-check floor to the adversarial clause (review R1) --- packages/opencode/src/dag/blocks.ts | 2 +- packages/opencode/test/dag/blocks.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index 3ceb8adbd3..42be90b106 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -333,7 +333,7 @@ function node(input: { // production no-op because it re-read the parent's narrative instead of // the source. Upstream claims are hypotheses, not facts. const adversarial = input.reportToParent - ? "As a reporting checkpoint you adjudicate this direction: treat upstream and parent-supplied claims as hypotheses to verify, never facts to confirm. independently read the relevant source before endorsing the most load-bearing claims, cite what you actually inspected, and replan or reject the direction when a load-bearing claim does not survive inspection." + ? "As a reporting checkpoint you adjudicate this direction: treat upstream and parent-supplied claims as hypotheses to verify, never facts to confirm. Independently read the relevant source before endorsing, spot-check at least three of the most load-bearing claims (name the count you actually inspected), cite what you actually inspected, and replan or reject the direction when a load-bearing claim does not survive inspection." : "" return { id: input.id, diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index 237f959b8b..a36184d272 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -62,7 +62,9 @@ describe("workflow blocks", () => { // Upstream claims are hypotheses to verify, not facts to confirm. expect(checkpoint?.prompt_template.inline).toContain("hypotheses to verify") // Independent source inspection is mandatory for load-bearing claims. - expect(checkpoint?.prompt_template.inline).toContain("independently read the relevant source") + expect(checkpoint?.prompt_template.inline).toContain("Independently read the relevant source") + // A quantified spot-check floor on the most load-bearing claims. + expect(checkpoint?.prompt_template.inline).toContain("at least three") // A claim that fails inspection must fail the direction, not pass it. expect(checkpoint?.prompt_template.inline).toContain("replan or reject") }) From 37ee09c774c83c353888d7bd879f0e3b3a8c28c9 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 23:58:34 +0800 Subject: [PATCH 33/39] fix(dag): host-level supervision sweep settles nodes orphaned by instance teardown (production incident 2026-08-18) --- .../src/dag/runtime/supervision-sweep.ts | 164 +++++++++ packages/opencode/src/effect/app-runtime.ts | 6 + .../test/dag/dag-node-supervision.test.ts | 329 ++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 packages/opencode/src/dag/runtime/supervision-sweep.ts create mode 100644 packages/opencode/test/dag/dag-node-supervision.test.ts diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts new file mode 100644 index 0000000000..77442be9d0 --- /dev/null +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +export * as DagSupervisionSweep from "./supervision-sweep" + +import { Context, Effect, Fiber, Layer, Scope } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { WorkflowNodeTable } from "@opencode-ai/core/dag/sql" +import { and, eq, sql } from "drizzle-orm" +import { Dag } from "@/dag/dag" +import { DagLocation } from "@/dag/location" +import { InstanceState } from "@/effect/instance-state" +import { SessionPrompt } from "@/session/prompt" + +/** + * Host-level deadline-supervision sweep — the fallback retry for the + * production incident (2026-08-18, dag_fe5feabfcae607fqVdRh47lN1B): + * + * A per-directory instance teardown (lifecycle cleanup, directory switch, + * config change) silently reaps every fiber forked into its scope — the + * DagLoop subscriptions, the spawn execution fiber, AND the deadline + * watcher — while the durable node row stays `running`. Because the host + * process keeps running, nothing ever re-arms supervision: the node rots in + * `running` past its deadline with `timeout_extensions` frozen for hours + * (7.5h observed). Re-init/crash recovery CAN settle such rows, but only + * when something re-triggers the instance — and in a live host nothing + * does. + * + * This sweep is deliberately NOT forked into any per-directory + * InstanceState scope: its repeating fiber is forked into the LAYER scope at + * construction (per AGENTS.md's background-loop convention) and lives for + * the process lifetime. Each tick looks for the frozen signature — a + * `running` node whose deadline has passed and whose `timeout_extensions` + * did not move between two consecutive ticks (a live watcher escalates on + * its interval, so a frozen counter across a full tick window means + * supervision is gone). On detection it cancels the child session and fails + * the node durably ("timeout") — the same terminal semantics the watcher's + * cap enforcement would have applied. + * + * False-positive safety: a node whose watcher is alive always shows counter + * movement across two ticks (escalateIntervalMs == max(1s, timeoutMs), far + * below the sweep interval); a node that terminalized races safely — the + * nodeFailed guard rejects the stale write. + */ + +export interface Interface { + /** Re-arm the periodic sweep fiber (idempotent). Production layers fork it at construction; init exists for entry points that prefer explicit control. */ + readonly init: () => Effect.Effect + /** One scan pass. Exported for deterministic tests: call twice with the freeze window in between to simulate a dead watcher. */ + readonly sweepOnce: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/DagSupervisionSweep") {} + +export const SWEEP_INTERVAL = "60 seconds" + +const serviceLayer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const dag = yield* Dag.Service + const promptSvc = yield* SessionPrompt.Service + const scope = yield* Scope.Scope + + // nodeKey -> timeout_extensions observed at the previous tick. A running, + // deadline-overdue node whose counter did not advance across a tick is + // frozen (supervision dead). + const lastSeen = new Map() + let sweepFiber: Fiber.Fiber | undefined + + const sweepOnce = Effect.fn("DagSupervisionSweep.sweepOnce")(function* () { + const rows = yield* db + .select({ + workflowId: WorkflowNodeTable.workflow_id, + nodeId: WorkflowNodeTable.id, + childSessionId: WorkflowNodeTable.child_session_id, + deadlineMs: WorkflowNodeTable.deadline_ms, + extensions: WorkflowNodeTable.timeout_extensions, + }) + .from(WorkflowNodeTable) + .where( + and( + eq(WorkflowNodeTable.status, "running"), + sql`${WorkflowNodeTable.deadline_ms} IS NOT NULL AND ${WorkflowNodeTable.deadline_ms} <= ${Date.now()}`, + ), + ) + .all() + .pipe(Effect.orDie) + + const observed = new Map() + for (const row of rows) { + const key = `${row.workflowId}\0${row.nodeId}` + observed.set(key, row.extensions) + const previous = lastSeen.get(key) + if (previous === undefined) continue + if (previous !== row.extensions) continue // counter moved — watcher alive + // Frozen across a full tick: confirm this instance still owns the + // workflow before writing (a repainted or migrated identity belongs + // to whichever instance now owns it). + if (!(yield* DagLocation.ownsWorkflow(row.workflowId, yield* InstanceState.directory))) continue + if (row.childSessionId) { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the durable column is typed string|null; the cancel seam brands SessionID. + yield* promptSvc.cancel(row.childSessionId as never).pipe(Effect.ignore) + } + yield* dag + .nodeFailed( + row.workflowId, + row.nodeId, + `deadline supervision lost (no escalation progress across sweep window) — swept, extensions ${row.extensions}`, + "timeout", + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("DagSupervisionSweep nodeFailed failed", { + dagID: row.workflowId, + nodeID: row.nodeId, + cause, + }), + ), + ) + yield* Effect.logWarning("DagSupervisionSweep settled a node with dead deadline supervision", { + dagID: row.workflowId, + nodeID: row.nodeId, + extensions: row.extensions, + }) + observed.delete(key) + } + // Retain only what is still overdue-running so settled/restarted nodes + // do not accumulate. + lastSeen.clear() + for (const [key, extensions] of observed) lastSeen.set(key, extensions) + }) + + const init = Effect.fn("DagSupervisionSweep.init")(function* () { + if (sweepFiber) return + sweepFiber = yield* Effect.gen(function* () { + for (;;) { + yield* Effect.sleep(SWEEP_INTERVAL) + yield* sweepOnce() + } + }).pipe(Effect.forkIn(scope)) + }) + + // AGENTS.md background-loop convention: fork at construction so the sweep + // survives without any caller remembering to init it. + yield* init() + + return Service.of({ init, sweepOnce }) + }), +) + +/** The bare effect layer — bring your own Database/Dag/SessionPrompt. Tests compose this against their mocks; production uses `defaultLayer`. */ +export const layerWithoutDeps = serviceLayer + +export const layer = serviceLayer.pipe( + Layer.provide(Database.defaultLayer), + Layer.provide(Dag.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), +) + +export const defaultLayer = layer + +export const node = LayerNode.make(serviceLayer, [Database.node, Dag.node, SessionPrompt.node]) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index d9edca954c..435b173794 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -60,6 +60,7 @@ import { Dag } from "@/dag/dag" import { DagStore } from "@opencode-ai/core/dag/store" import { DagLoop } from "@/dag/runtime/loop" import { DagSummaryPublisher } from "@/dag/runtime/summary-publisher" +import { DagSupervisionSweep } from "@/dag/runtime/supervision-sweep" import { Memory } from "@/memory/memory" export const AppLayer = Layer.mergeAll( @@ -132,6 +133,11 @@ export const AppLayer = Layer.mergeAll( Layer.provideMerge(GoalLoop.defaultLayer), Layer.provideMerge(DagLoop.defaultLayer), Layer.provideMerge(DagSummaryPublisher.defaultLayer), + // Host-level deadline-supervision sweep (production incident 2026-08-18): + // forks its repeating fiber at construction into the LAYER scope — unlike + // DagLoop it must NOT die with a per-directory instance teardown, or a + // `running` node with dead supervision would rot forever. + Layer.provideMerge(DagSupervisionSweep.defaultLayer), Layer.provideMerge(SettingsHook.defaultLayer), ) diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts new file mode 100644 index 0000000000..d899320f6c --- /dev/null +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -0,0 +1,329 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- The incident +// harness deliberately mirrors dag-loop-guards.test.ts: mocked service layers +// and seeded row fixtures use `as never` type shims (mock objects implement +// only the interface slice the scenario exercises). The shims are type-only; +// converting them would fork the template's shape without changing behavior. +// oxlint-disable eslint/no-unused-vars -- gate objects are taken for their +// readiness side effect (takeWithin), not their value. +import { describe, expect, it } from "bun:test" +import { Deferred, Effect, Layer, Option, Queue } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Agent } from "@/agent/agent" +import { Dag, type NodeConfig } from "@/dag/dag" +import { DagLoop } from "@/dag/runtime/loop" +import { InstanceRef } from "@/effect/instance-ref" +import { disposeInstance } from "@/effect/instance-registry" +import { DagSupervisionSweep } from "@/dag/runtime/supervision-sweep" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionPrompt } from "@/session/prompt" +import { MessageID } from "@/session/schema" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" + +// Production-incident harness (2026-08-18, dag_fe5feabfcae607fqVdRh47lN1B): +// a coding node's child session LLM stream died silently mid-turn; the node +// stayed `running` past its deadline for 7.5+ hours with escalation_pending=0 +// and timeout_extensions=0 — the deadline watcher never fired while the host +// process stayed alive. This harness reproduces the supervision shape at +// 2-second deadlines and asserts the invariant the incident violated: +// +// A running node past its deadline must leave `running` (escalate or fail) +// within a bounded window — no matter HOW the surrounding fibers die. +// +// Modes cover the candidate death paths: +// stream-hang — the child prompt never resolves (incident shape) +// dispose-instance — the per-directory instance scope closes mid-run +// healthy — control: the watcher fires normally + +interface PromptGate { + readonly title: string + readonly release: Deferred.Deferred +} + +function node(overrides: Partial = {}): NodeConfig { + return { + id: "n1", + name: "Node 1", + worker_type: "build", + depends_on: [], + required: true, + prompt_template: { inline: "work" }, + ...overrides, + } +} + +function takeWithin(queue: Queue.Queue, message: string) { + return Queue.take(queue).pipe( + Effect.timeoutOption("2 seconds"), + Effect.flatMap(Option.match({ onNone: () => Effect.fail(new Error(message)), onSome: Effect.succeed })), + ) +} + +function reply(sessionID: string, text: string): SessionV1.WithParts { + return { + info: { + id: MessageID.ascending(), + sessionID, + role: "assistant", + time: { created: Date.now() }, + }, + parts: [{ type: "text", text }], + } as never +} + +function supervisionLayer(input: { + readonly childPrompts: Queue.Queue + readonly cancels: string[] +}) { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const status = SessionStatus.layer.pipe(Layer.provide(bridge)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) + const childTitles = new Map() + const created: string[] = [] + const session = Layer.mock(Session.Service, { + get: () => Effect.succeed({ id: "ses_parent", permission: [], agent: "build" } as never), + create: (value) => + Effect.sync(() => { + const id = `ses_child_${created.length + 1}` + created.push(id) + childTitles.set(id, (value?.title ?? id).replace(" (DAG node)", "")) + return { id } as never + }), + messages: () => Effect.succeed([]), + }) + const deliver = Effect.fn("test.SessionPrompt.deliver")(function* (value: SessionPrompt.PromptInput) { + const sessionID = value.sessionID as string + const release = yield* Deferred.make() + yield* Queue.offer(input.childPrompts, { + title: childTitles.get(sessionID) ?? sessionID, + release, + }) + return reply(sessionID, yield* Deferred.await(release)) + }) + const prompt = Layer.mock( + SessionPrompt.Service, + withIdleAdmission({ + cancel: (sessionID: string) => + Effect.sync(() => { + input.cancels.push(sessionID) + }), + prompt: (value: SessionPrompt.PromptInput) => deliver(value), + promptIfIdle: (value: SessionPrompt.PromptInput) => deliver(value).pipe(Effect.map(Option.some)), + }), + ) + const agent = Layer.mock(Agent.Service, { + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), + }) + const loop = DagLoop.layer.pipe(Layer.provide(base), Layer.provide(session), Layer.provide(prompt), Layer.provide(agent)) + const sweep = DagSupervisionSweep.layerWithoutDeps.pipe(Layer.provide(base), Layer.provide(prompt)) + return Layer.merge(Layer.merge(base, loop), sweep) +} + +interface SupervisionServices { + readonly dag: Dag.Interface + readonly loop: DagLoop.Interface + readonly store: DagStore.Interface + readonly sweep: import("@/dag/runtime/supervision-sweep").Interface + readonly childPrompts: Queue.Queue + readonly cancels: string[] + readonly database: Database.Interface +} +function runSupervisionTest(options: { readonly instanceProject: string }, test: (services: SupervisionServices) => Effect.Effect) { + return Effect.gen(function* () { + const childPrompts = yield* Queue.unbounded() + const cancels: string[] = [] + return yield* Effect.gen(function* () { + const dag = yield* Dag.Service + const loop = yield* DagLoop.Service + const store = yield* DagStore.Service + const sweep = yield* DagSupervisionSweep.Service + const database = yield* Database.Service + for (const project of ["project-1", "project-2"]) { + yield* database.db + .insert(ProjectTable) + .values({ id: project as never, worktree: process.cwd() as never, sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(SessionTable) + .values({ + id: `ses_${project}` as never, + project_id: project as never, + slug: project, + directory: process.cwd() as never, + title: `Parent of ${project}`, + version: "test", + }) + .run() + .pipe(Effect.orDie) + } + yield* loop.init() + return yield* test({ dag, loop, store, sweep, childPrompts, cancels, database }) + }).pipe( + Effect.provide(supervisionLayer({ childPrompts, cancels })), + Effect.provideService(InstanceRef, { + directory: process.cwd(), + worktree: process.cwd(), + project: { id: options.instanceProject }, + } as never), + Effect.scoped, + ) + }) +} + +// Shared graph: one coding node with a 2-second worker timeout. nodeTimeoutMs +// default would make the deadline 10 minutes — unusable for a test. The cap +// is pinned to 1 escalation so cap enforcement lands inside the test window. +const incidentGraph = { + projectID: "project-1", + sessionID: "ses_project-1", + title: "incident", + config: { + name: "incident", + max_timeout_extensions: 1, + nodes: [node({ id: "worker", name: "worker", worker_config: { timeout_ms: 2_000 } })], + }, +} + +// The incident invariant, as a poll predicate: the node must leave `running` +// (any terminal status, or escalated-but-running counts as progress only if +// extensions climb — the incident had BOTH frozen at zero, so we assert on +// status change OR timeout_extensions > 0). +const supervisionProgress = (store: DagStore.Interface, dagID: string, nodeID: string) => + Effect.gen(function* () { + const row = yield* store.getNode(dagID, nodeID) + if (!row) return undefined + if (row.status !== "running") return row + if (row.timeoutExtensions > 0) return row + return undefined + }) + +// bun's default per-test timeout is 5s; the healthy cap-enforcement path +// needs ~8s at a 2s deadline — run this file with --timeout 30000 (the CI +// suite default) or keep each body under the limit. +describe("DAG node supervision — deadline enforcement (production incident)", () => { + it("healthy: a node past its deadline gets escalated by the watcher", async () => { + await Effect.runPromise( + runSupervisionTest({ instanceProject: "project-1" }, ({ dag, store, childPrompts, cancels }) => + Effect.gen(function* () { + const dagID = yield* dag.create(incidentGraph) + const child = yield* takeWithin(childPrompts, "worker did not start") + // Leave the prompt unresolved past the 2s deadline: the watcher + // must escalate (timeout_extensions climbs), then exhaust the cap + // and force-cancel the child. + yield* pollWithTimeout( + supervisionProgress(store, dagID, "worker"), + "watcher never escalated a node past its deadline (healthy control)", + "8 seconds", + ) + // Cap enforcement: max extensions default is 3 — after enough + // escalations the watcher cancels the child and fails the node. + yield* pollWithTimeout( + Effect.gen(function* () { + const row = yield* store.getNode(dagID, "worker") + return row?.status === "failed" ? row : undefined + }), + "watcher never cap-enforced (cancel + nodeFailed(timeout))", + "30 seconds", + ) + expect(cancels.length).toBeGreaterThan(0) + }), + ), + ) + }) + + it("stream-hang: the incident shape — prompt never resolves, node still must not rot in running", async () => { + await Effect.runPromise( + runSupervisionTest({ instanceProject: "project-1" }, ({ dag, store, childPrompts }: SupervisionServices ) => + Effect.gen(function* () { + const dagID = yield* dag.create(incidentGraph) + const child = yield* takeWithin(childPrompts, "worker did not start") + // Incident shape: the LLM stream died — the prompt gate is never + // released and never errors. Supervision must still progress. + void child + yield* pollWithTimeout( + supervisionProgress(store, dagID, "worker"), + "node rotted in running past its deadline with zero supervision progress (incident)", + "8 seconds", + ) + }), + ), + ) + }) + + // H5 (instance-scope harvest): closing the per-directory instance state + // mid-run interrupts every fiber forked into its scope — the DagLoop + // subscriptions, the spawn execution fiber, AND the deadline watcher — + // without touching the durable row. The production signature (a node stuck + // in running with escalation frozen at zero for hours while the host kept + // logging) is only reachable if supervision dies silently this way. + it("dispose-instance: instance teardown mid-run freezes durable supervision (incident mechanism)", async () => { + await Effect.runPromise( + runSupervisionTest({ instanceProject: "project-1" }, ({ dag, store, sweep, childPrompts }: SupervisionServices) => + Effect.gen(function* () { + const dagID = yield* dag.create(incidentGraph) + const child = yield* takeWithin(childPrompts, "worker did not start") + void child + // Wait for the first escalation so we know supervision was live. + yield* pollWithTimeout( + supervisionProgress(store, dagID, "worker"), + "watcher never escalated before dispose", + "8 seconds", + ) + const extensionsAtDispose = (yield* store.getNode(dagID, "worker"))?.timeoutExtensions ?? 0 + // Dispose the instance (the production candidate: lifecycle/ + // directory cleanup) — silently reaps every in-scope fiber. + yield* Effect.promise(() => disposeInstance(process.cwd())) + // Give any surviving supervision ample time to escalate again. + yield* Effect.sleep("4 seconds") + const row = yield* store.getNode(dagID, "worker") + // The frozen-supervision signature: still running, extensions + // frozen at the dispose-time value, no cap enforcement. + expect(row?.status).toBe("running") + expect(row?.timeoutExtensions).toBe(extensionsAtDispose) + + // The fallback-retry contract (production fix): the HOST-LEVEL + // supervision sweep — whose fiber lives in the layer scope and + // survives the instance teardown — settles the frozen node on its + // second tick (frozen counter across ticks = dead supervision). + yield* sweep.sweepOnce() + yield* Effect.sleep("200 millis") + yield* sweep.sweepOnce() + const swept = yield* pollWithTimeout( + Effect.gen(function* () { + const settled = yield* store.getNode(dagID, "worker") + return settled && settled.status !== "running" ? settled : undefined + }), + "host-level sweep never settled the node with dead supervision (fallback retry)", + "5 seconds", + ) + expect(swept?.errorClass).toBe("timeout") + }), + ), + ) + }) +}) From 9b76a87f65d529526a735619eb80a3913808db3e Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 00:13:27 +0800 Subject: [PATCH 34/39] fix(dag): sweep survives layer context (no ambient InstanceRef), cadence-aware freeze window (review R1) --- .../src/dag/runtime/supervision-sweep.ts | 103 +++++++++++------- .../test/dag/dag-node-supervision.test.ts | 63 +++++++++-- 2 files changed, 121 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index 77442be9d0..56be2f05c4 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -9,8 +9,6 @@ import { Database } from "@opencode-ai/core/database/database" import { WorkflowNodeTable } from "@opencode-ai/core/dag/sql" import { and, eq, sql } from "drizzle-orm" import { Dag } from "@/dag/dag" -import { DagLocation } from "@/dag/location" -import { InstanceState } from "@/effect/instance-state" import { SessionPrompt } from "@/session/prompt" /** @@ -28,26 +26,31 @@ import { SessionPrompt } from "@/session/prompt" * does. * * This sweep is deliberately NOT forked into any per-directory - * InstanceState scope: its repeating fiber is forked into the LAYER scope at - * construction (per AGENTS.md's background-loop convention) and lives for - * the process lifetime. Each tick looks for the frozen signature — a - * `running` node whose deadline has passed and whose `timeout_extensions` - * did not move between two consecutive ticks (a live watcher escalates on - * its interval, so a frozen counter across a full tick window means - * supervision is gone). On detection it cancels the child session and fails - * the node durably ("timeout") — the same terminal semantics the watcher's - * cap enforcement would have applied. + * InstanceState scope: its repeating fiber is forked into the LAYER scope + * at construction and lives for the process lifetime. It must therefore + * never depend on ambient per-instance context (InstanceRef) — its fiber's + * context is the layer-build context, which has none. Ownership is decided + * from the durable rows alone: this process's Database owns every workflow + * row it can read, and the nodeFailed guard under the workflow lock + * serializes any race with another writer (including a second host sharing + * the DB — double settles collapse to one). * - * False-positive safety: a node whose watcher is alive always shows counter - * movement across two ticks (escalateIntervalMs == max(1s, timeoutMs), far - * below the sweep interval); a node that terminalized races safely — the - * nodeFailed guard rejects the stale write. + * False-positive safety: a LIVE watcher escalates on + * escalateIntervalMs == max(1s, timeout_ms ?? 10min) and nodeTimeoutEscalated + * does NOT move deadline_ms — so a live overdue node legitimately shows a + * flat timeout_extensions for up to one full escalate interval (10 minutes + * on the default config). The freeze window is therefore expressed in + * ticks: a node is only declared dead once its counter has stayed flat for + * frozenTicksNeeded(escalateIntervalMs) consecutive sweep ticks — the + * default 10-minute cadence needs 11 ticks (≈11 minutes), so a live watcher + * always moves the counter well inside the window, while a dead one (the + * incident shape: 7.5h frozen) is settled in bounded time. */ export interface Interface { - /** Re-arm the periodic sweep fiber (idempotent). Production layers fork it at construction; init exists for entry points that prefer explicit control. */ + /** Re-arm the periodic sweep fiber (idempotent; production layers fork it at construction). */ readonly init: () => Effect.Effect - /** One scan pass. Exported for deterministic tests: call twice with the freeze window in between to simulate a dead watcher. */ + /** One scan pass. Exported for deterministic tests: loop it frozenTicksNeeded times to simulate a dead watcher. */ readonly sweepOnce: () => Effect.Effect } @@ -55,6 +58,16 @@ export class Service extends Context.Service()("@opencode/Da export const SWEEP_INTERVAL = "60 seconds" +/** + * Ticks a flat timeout_extensions counter must persist across before the + * sweep declares supervision dead: ceil(escalateInterval / sweepInterval) + 1, + * evaluated against the DEFAULT node timeout (10 min) — the widest cadence a + * live watcher can legitimately sleep. Nodes configured with shorter + * timeouts escalate faster, so they are only ever settled later than + * strictly necessary, never sooner. + */ +export const FROZEN_TICKS_NEEDED = 11 + const serviceLayer = Layer.effect( Service, Effect.gen(function* () { @@ -63,11 +76,11 @@ const serviceLayer = Layer.effect( const promptSvc = yield* SessionPrompt.Service const scope = yield* Scope.Scope - // nodeKey -> timeout_extensions observed at the previous tick. A running, - // deadline-overdue node whose counter did not advance across a tick is - // frozen (supervision dead). - const lastSeen = new Map() - let sweepFiber: Fiber.Fiber | undefined + // nodeKey -> {extensions, flatTicks}: the counter value last observed and + // how many consecutive sweep ticks it has stayed flat while the node was + // running and overdue. Reset on any counter movement, terminal status, or + // disappearance from the query. + const flatStreak = new Map() const sweepOnce = Effect.fn("DagSupervisionSweep.sweepOnce")(function* () { const rows = yield* db @@ -75,7 +88,6 @@ const serviceLayer = Layer.effect( workflowId: WorkflowNodeTable.workflow_id, nodeId: WorkflowNodeTable.id, childSessionId: WorkflowNodeTable.child_session_id, - deadlineMs: WorkflowNodeTable.deadline_ms, extensions: WorkflowNodeTable.timeout_extensions, }) .from(WorkflowNodeTable) @@ -86,19 +98,28 @@ const serviceLayer = Layer.effect( ), ) .all() - .pipe(Effect.orDie) + .pipe( + // A store defect must not kill the sweep — the same silent-death + // class this service exists to eliminate. Degrade to an empty + // pass and retry next tick (mirror of spawn.ts's R13 hardening). + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("DagSupervisionSweep store query failed — skipping tick", { cause }) + return [] + }), + ), + ) - const observed = new Map() + const observed = new Map() for (const row of rows) { const key = `${row.workflowId}\0${row.nodeId}` - observed.set(key, row.extensions) - const previous = lastSeen.get(key) - if (previous === undefined) continue - if (previous !== row.extensions) continue // counter moved — watcher alive - // Frozen across a full tick: confirm this instance still owns the - // workflow before writing (a repainted or migrated identity belongs - // to whichever instance now owns it). - if (!(yield* DagLocation.ownsWorkflow(row.workflowId, yield* InstanceState.directory))) continue + const prior = flatStreak.get(key) + const flatTicks = prior && prior.extensions === row.extensions ? prior.flatTicks + 1 : 0 + observed.set(key, { extensions: row.extensions, flatTicks }) + if (flatTicks < FROZEN_TICKS_NEEDED) continue + // Frozen across the full window: cancel the (possibly dead) child and + // settle the node. The nodeFailed guard under the workflow lock + // serializes any race with a live watcher or another host's sweep. if (row.childSessionId) { // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the durable column is typed string|null; the cancel seam brands SessionID. yield* promptSvc.cancel(row.childSessionId as never).pipe(Effect.ignore) @@ -107,7 +128,7 @@ const serviceLayer = Layer.effect( .nodeFailed( row.workflowId, row.nodeId, - `deadline supervision lost (no escalation progress across sweep window) — swept, extensions ${row.extensions}`, + `deadline supervision lost (no escalation progress across ${FROZEN_TICKS_NEEDED} sweep ticks) — swept, extensions ${row.extensions}`, "timeout", ) .pipe( @@ -128,16 +149,24 @@ const serviceLayer = Layer.effect( } // Retain only what is still overdue-running so settled/restarted nodes // do not accumulate. - lastSeen.clear() - for (const [key, extensions] of observed) lastSeen.set(key, extensions) + flatStreak.clear() + for (const [key, streak] of observed) flatStreak.set(key, streak) }) + let sweepFiber: Fiber.Fiber | undefined + const init = Effect.fn("DagSupervisionSweep.init")(function* () { if (sweepFiber) return sweepFiber = yield* Effect.gen(function* () { for (;;) { yield* Effect.sleep(SWEEP_INTERVAL) - yield* sweepOnce() + yield* sweepOnce().pipe( + // Per-tick guard: any residual defect inside a tick degrades to + // a logged skip — the loop itself must outlive every failure. + Effect.catchCause((cause) => + Effect.logWarning("DagSupervisionSweep tick failed — retrying next interval", { cause }), + ), + ) } }).pipe(Effect.forkIn(scope)) }) diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index d899320f6c..a01d61616f 100644 --- a/packages/opencode/test/dag/dag-node-supervision.test.ts +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -222,9 +222,8 @@ const supervisionProgress = (store: DagStore.Interface, dagID: string, nodeID: s return undefined }) -// bun's default per-test timeout is 5s; the healthy cap-enforcement path -// needs ~8s at a 2s deadline — run this file with --timeout 30000 (the CI -// suite default) or keep each body under the limit. +// bun's default per-test timeout is 5s; several bodies here need 8-40s at a +// 2s deadline — run this file with --timeout 30000 (the CI suite default). describe("DAG node supervision — deadline enforcement (production incident)", () => { it("healthy: a node past its deadline gets escalated by the watcher", async () => { await Effect.runPromise( @@ -308,11 +307,15 @@ describe("DAG node supervision — deadline enforcement (production incident)", // The fallback-retry contract (production fix): the HOST-LEVEL // supervision sweep — whose fiber lives in the layer scope and - // survives the instance teardown — settles the frozen node on its - // second tick (frozen counter across ticks = dead supervision). - yield* sweep.sweepOnce() - yield* Effect.sleep("200 millis") - yield* sweep.sweepOnce() + // survives the instance teardown — settles the frozen node once + // its counter has stayed flat for FROZEN_TICKS_NEEDED ticks + // (dead supervision; a live watcher always moves the counter + // inside the window). + const { FROZEN_TICKS_NEEDED } = DagSupervisionSweep + for (let tick = 0; tick <= FROZEN_TICKS_NEEDED; tick++) { + yield* sweep.sweepOnce() + yield* Effect.sleep("50 millis") + } const swept = yield* pollWithTimeout( Effect.gen(function* () { const settled = yield* store.getNode(dagID, "worker") @@ -326,4 +329,48 @@ describe("DAG node supervision — deadline enforcement (production incident)", ), ) }) + + // Review R1 issue 2 (false-positive kill): a LIVE watcher on a node whose + // escalation cadence spans multiple sweep intervals must never be swept — + // the counter legitimately stays flat between escalations. The graph keeps + // the default cap (20) so the watcher's ladder is the intended path; the + // sweep passes run alongside a live watcher for well over the freeze + // window, and the settle that eventually lands must carry the WATCHER's + // own cap reason, never the sweep's. + it("freeze window: a live watcher is never swept — only its own cap enforcement ends the node", async () => { + await Effect.runPromise( + runSupervisionTest({ instanceProject: "project-1" }, ({ dag, store, sweep, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + ...incidentGraph, + config: { ...incidentGraph.config, max_timeout_extensions: 3 }, + }) + const child = yield* takeWithin(childPrompts, "worker did not start") + void child + // Supervision alive: the watcher escalates on its 2s cadence. + yield* pollWithTimeout( + supervisionProgress(store, dagID, "worker"), + "watcher never escalated before the streak test", + "8 seconds", + ) + const { FROZEN_TICKS_NEEDED } = DagSupervisionSweep + // Run more sweep passes than the freeze window alongside the live + // watcher: its 2s escalation cadence resets the streak every time, + // so the sweep must never fire. + for (let tick = 0; tick < FROZEN_TICKS_NEEDED + 2; tick++) { + yield* Effect.sleep("300 millis") + yield* sweep.sweepOnce() + } + const row = yield* store.getNode(dagID, "worker") + // Either still running (ladder ongoing) or terminalized by the + // watcher's OWN cap — never by the sweep. + if (row?.status === "failed") { + expect(row?.errorReason).toContain("timeout extensions exhausted") + } else { + expect(row?.status).toBe("running") + } + }), + ), + ) + }) }) From 0d11e05263376f5c6ea4b6b37081272056a332d8 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 00:25:46 +0800 Subject: [PATCH 35/39] fix(dag): per-node cadence-aware freeze window from workflow config (review R2) --- .../src/dag/runtime/supervision-sweep.ts | 46 +++++++++++++++---- .../test/dag/dag-node-supervision.test.ts | 26 ++++++----- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index 56be2f05c4..bd49d85649 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -6,6 +6,7 @@ export * as DagSupervisionSweep from "./supervision-sweep" import { Context, Effect, Fiber, Layer, Scope } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" +import { DagStore } from "@opencode-ai/core/dag/store" import { WorkflowNodeTable } from "@opencode-ai/core/dag/sql" import { and, eq, sql } from "drizzle-orm" import { Dag } from "@/dag/dag" @@ -57,21 +58,28 @@ export interface Interface { export class Service extends Context.Service()("@opencode/DagSupervisionSweep") {} export const SWEEP_INTERVAL = "60 seconds" +const SWEEP_INTERVAL_MS = 60_000 /** - * Ticks a flat timeout_extensions counter must persist across before the - * sweep declares supervision dead: ceil(escalateInterval / sweepInterval) + 1, - * evaluated against the DEFAULT node timeout (10 min) — the widest cadence a - * live watcher can legitimately sleep. Nodes configured with shorter - * timeouts escalate faster, so they are only ever settled later than - * strictly necessary, never sooner. + * Flat ticks before declaring supervision dead, derived from the node's own + * escalation cadence: a LIVE watcher escalates every + * escalateIntervalMs == max(1s, timeout_ms ?? 10min) and never moves + * deadline_ms, so its counter can legitimately stay flat for up to one full + * interval. Requiring ceil(interval / sweep interval) + 1 consecutive flat + * ticks means a live watcher — at ANY configured timeout, including the + * doc-recommended 30-minute verifier timeouts — always moves the counter + * inside the window, while a dead one (the incident shape: hours frozen) is + * settled in bounded time. The config lookup happens once a node has been + * overdue-flat for at least one tick, so healthy graphs pay nothing. */ -export const FROZEN_TICKS_NEEDED = 11 +export const frozenTicksNeeded = (escalateIntervalMs: number) => + Math.ceil(Math.max(escalateIntervalMs, 1_000) / SWEEP_INTERVAL_MS) + 1 const serviceLayer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service + const store = yield* DagStore.Service const dag = yield* Dag.Service const promptSvc = yield* SessionPrompt.Service const scope = yield* Scope.Scope @@ -82,6 +90,20 @@ const serviceLayer = Layer.effect( // disappearance from the query. const flatStreak = new Map() + // The node's escalation cadence, from the workflow's persisted config — + // the same source spawn.ts derived the watcher's escalateIntervalMs from. + // Cached per workflow id for the tick; a config read failure degrades to + // the DEFAULT cadence (the widest guaranteed-safe window). + const escalateIntervalFor = Effect.fnUntraced(function* (workflowId: string, nodeId: string) { + const wf = yield* store.getWorkflow(workflowId).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ) + if (!wf) return Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs + const node = JSON.parse(wf.config).nodes?.find?.((n: { id: string }) => n.id === nodeId) + const timeoutMs = node?.worker_config?.timeout_ms + return Math.max(1_000, typeof timeoutMs === "number" ? timeoutMs : Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs) + }) + const sweepOnce = Effect.fn("DagSupervisionSweep.sweepOnce")(function* () { const rows = yield* db .select({ @@ -116,7 +138,10 @@ const serviceLayer = Layer.effect( const prior = flatStreak.get(key) const flatTicks = prior && prior.extensions === row.extensions ? prior.flatTicks + 1 : 0 observed.set(key, { extensions: row.extensions, flatTicks }) - if (flatTicks < FROZEN_TICKS_NEEDED) continue + // Only nodes already flat for a tick pay the config lookup. + if (flatTicks < 1) continue + const escalateIntervalMs = yield* escalateIntervalFor(row.workflowId, row.nodeId) + if (flatTicks < frozenTicksNeeded(escalateIntervalMs)) continue // Frozen across the full window: cancel the (possibly dead) child and // settle the node. The nodeFailed guard under the workflow lock // serializes any race with a live watcher or another host's sweep. @@ -128,7 +153,7 @@ const serviceLayer = Layer.effect( .nodeFailed( row.workflowId, row.nodeId, - `deadline supervision lost (no escalation progress across ${FROZEN_TICKS_NEEDED} sweep ticks) — swept, extensions ${row.extensions}`, + `deadline supervision lost (no escalation progress across ${flatTicks} sweep ticks, escalate cadence ${escalateIntervalMs}ms) — swept, extensions ${row.extensions}`, "timeout", ) .pipe( @@ -184,10 +209,11 @@ export const layerWithoutDeps = serviceLayer export const layer = serviceLayer.pipe( Layer.provide(Database.defaultLayer), + Layer.provide(DagStore.defaultLayer), Layer.provide(Dag.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), ) export const defaultLayer = layer -export const node = LayerNode.make(serviceLayer, [Database.node, Dag.node, SessionPrompt.node]) +export const node = LayerNode.make(serviceLayer, [Database.node, DagStore.node, Dag.node, SessionPrompt.node]) diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index a01d61616f..94c791f5c7 100644 --- a/packages/opencode/test/dag/dag-node-supervision.test.ts +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -308,11 +308,11 @@ describe("DAG node supervision — deadline enforcement (production incident)", // The fallback-retry contract (production fix): the HOST-LEVEL // supervision sweep — whose fiber lives in the layer scope and // survives the instance teardown — settles the frozen node once - // its counter has stayed flat for FROZEN_TICKS_NEEDED ticks - // (dead supervision; a live watcher always moves the counter - // inside the window). - const { FROZEN_TICKS_NEEDED } = DagSupervisionSweep - for (let tick = 0; tick <= FROZEN_TICKS_NEEDED; tick++) { + // its counter has stayed flat for frozenTicksNeeded(2s) = 2 ticks + // (dead supervision; a live 2s-cadence watcher always moves the + // counter inside the window). + const needed = DagSupervisionSweep.frozenTicksNeeded(2_000) + for (let tick = 0; tick <= needed; tick++) { yield* sweep.sweepOnce() yield* Effect.sleep("50 millis") } @@ -353,12 +353,16 @@ describe("DAG node supervision — deadline enforcement (production incident)", "watcher never escalated before the streak test", "8 seconds", ) - const { FROZEN_TICKS_NEEDED } = DagSupervisionSweep - // Run more sweep passes than the freeze window alongside the live - // watcher: its 2s escalation cadence resets the streak every time, - // so the sweep must never fire. - for (let tick = 0; tick < FROZEN_TICKS_NEEDED + 2; tick++) { - yield* Effect.sleep("300 millis") + const needed = DagSupervisionSweep.frozenTicksNeeded(2_000) + // Sweep passes at the PRODUCTION cadence relationship: each pass is + // spaced just past the node's 2s escalate interval, so the live + // watcher moves the counter between every pass and the streak can + // never reach `needed`. (Spacing the passes closer than the + // escalate interval would defeat the window's math — a live + // watcher's counter is legitimately flat for up to one full + // interval.) + for (let tick = 0; tick < needed + 2; tick++) { + yield* Effect.sleep("2.3 seconds") yield* sweep.sweepOnce() } const row = yield* store.getNode(dagID, "worker") From 5033463d2eb33f700052b67f3de19c4a4fc40233 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 00:37:07 +0800 Subject: [PATCH 36/39] fix(dag): defensive config parsing and pure cadence lookup in sweep (review R3) --- .../src/dag/runtime/supervision-sweep.ts | 24 +++++++--- .../test/dag/dag-node-supervision.test.ts | 45 ++++++++++++++++++- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index bd49d85649..7a3070d009 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -10,6 +10,7 @@ import { DagStore } from "@opencode-ai/core/dag/store" import { WorkflowNodeTable } from "@opencode-ai/core/dag/sql" import { and, eq, sql } from "drizzle-orm" import { Dag } from "@/dag/dag" +import { parseWorkflowConfig } from "@/dag/dag" import { SessionPrompt } from "@/session/prompt" /** @@ -75,6 +76,19 @@ const SWEEP_INTERVAL_MS = 60_000 export const frozenTicksNeeded = (escalateIntervalMs: number) => Math.ceil(Math.max(escalateIntervalMs, 1_000) / SWEEP_INTERVAL_MS) + 1 +/** + * The node's escalation cadence derived from a persisted config row. Pure — + * exported for unit tests. parseWorkflowConfig is the repo's defensive + * parser: malformed JSON or shape-divergent rows return undefined instead of + * throwing, and every degrade path lands on the DEFAULT cadence (the widest + * guaranteed-safe window) — a single corrupt row must never defect the sweep. + */ +export const escalateIntervalFromConfig = (raw: string | undefined, nodeId: string) => { + const node = raw === undefined ? undefined : parseWorkflowConfig(raw)?.nodes.find((n) => n.id === nodeId) + const timeoutMs = node?.worker_config?.timeout_ms + return Math.max(1_000, typeof timeoutMs === "number" ? timeoutMs : Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs) +} + const serviceLayer = Layer.effect( Service, Effect.gen(function* () { @@ -92,16 +106,14 @@ const serviceLayer = Layer.effect( // The node's escalation cadence, from the workflow's persisted config — // the same source spawn.ts derived the watcher's escalateIntervalMs from. - // Cached per workflow id for the tick; a config read failure degrades to - // the DEFAULT cadence (the widest guaranteed-safe window). + // Only nodes already flat for a tick pay this lookup; a store read + // failure degrades to the DEFAULT cadence (the widest guaranteed-safe + // window). const escalateIntervalFor = Effect.fnUntraced(function* (workflowId: string, nodeId: string) { const wf = yield* store.getWorkflow(workflowId).pipe( Effect.catchCause(() => Effect.succeed(undefined)), ) - if (!wf) return Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs - const node = JSON.parse(wf.config).nodes?.find?.((n: { id: string }) => n.id === nodeId) - const timeoutMs = node?.worker_config?.timeout_ms - return Math.max(1_000, typeof timeoutMs === "number" ? timeoutMs : Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs) + return escalateIntervalFromConfig(wf?.config, nodeId) }) const sweepOnce = Effect.fn("DagSupervisionSweep.sweepOnce")(function* () { diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index 94c791f5c7..aae2947cea 100644 --- a/packages/opencode/test/dag/dag-node-supervision.test.ts +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -239,8 +239,9 @@ describe("DAG node supervision — deadline enforcement (production incident)", "watcher never escalated a node past its deadline (healthy control)", "8 seconds", ) - // Cap enforcement: max extensions default is 3 — after enough - // escalations the watcher cancels the child and fails the node. + // Cap enforcement: this graph pins max_timeout_extensions to 1 — + // after one escalation the watcher cancels the child and fails the + // node. yield* pollWithTimeout( Effect.gen(function* () { const row = yield* store.getNode(dagID, "worker") @@ -378,3 +379,43 @@ describe("DAG node supervision — deadline enforcement (production incident)", ) }) }) + +describe("DagSupervisionSweep cadence derivation (pure)", () => { + it("derives the cadence from the node's persisted timeout_ms", () => { + const config = JSON.stringify({ nodes: [{ id: "worker", depends_on: [], worker_config: { timeout_ms: 30_000 } }] }) + expect(DagSupervisionSweep.escalateIntervalFromConfig(config, "worker")).toBe(30_000) + }) + + it("floors sub-second timeouts to the watcher's 1s minimum", () => { + const config = JSON.stringify({ nodes: [{ id: "worker", depends_on: [], worker_config: { timeout_ms: 10 } }] }) + expect(DagSupervisionSweep.escalateIntervalFromConfig(config, "worker")).toBe(1_000) + }) + + it("degrades to the DEFAULT cadence on absent row, malformed JSON, shape-divergent rows, or missing node", () => { + const expected = Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs + // No workflow row at all. + expect(DagSupervisionSweep.escalateIntervalFromConfig(undefined, "worker")).toBe(expected) + // Corrupt JSON string, and JSON whose root is not a record. + expect(DagSupervisionSweep.escalateIntervalFromConfig("{not json", "worker")).toBe(expected) + expect(DagSupervisionSweep.escalateIntervalFromConfig("null", "worker")).toBe(expected) + // Shape-divergent rows: nodes not an array / null entries. + expect(DagSupervisionSweep.escalateIntervalFromConfig(JSON.stringify({ nodes: null }), "worker")).toBe(expected) + expect(DagSupervisionSweep.escalateIntervalFromConfig(JSON.stringify({ nodes: [null] }), "worker")).toBe(expected) + // Node absent from the config, or present without worker_config.timeout_ms. + const other = JSON.stringify({ nodes: [{ id: "other", depends_on: [], worker_config: { timeout_ms: 30_000 } }] }) + expect(DagSupervisionSweep.escalateIntervalFromConfig(other, "worker")).toBe(expected) + const bare = JSON.stringify({ nodes: [{ id: "worker", depends_on: [] }] }) + expect(DagSupervisionSweep.escalateIntervalFromConfig(bare, "worker")).toBe(expected) + }) + + it("freeze window boundaries: one interval of flat plus one tick, at every configured timeout", () => { + expect(DagSupervisionSweep.frozenTicksNeeded(1)).toBe(2) + expect(DagSupervisionSweep.frozenTicksNeeded(60_000)).toBe(2) + expect(DagSupervisionSweep.frozenTicksNeeded(60_001)).toBe(3) + // The default 10-minute cadence and the doc-recommended 30-minute + // verifier timeout — a live watcher at each cadence always moves the + // counter inside the window. + expect(DagSupervisionSweep.frozenTicksNeeded(Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs)).toBe(11) + expect(DagSupervisionSweep.frozenTicksNeeded(1_800_000)).toBe(31) + }) +}) From f185065ccda5050b8f2cb27dd5e155d37544a368 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 00:49:42 +0800 Subject: [PATCH 37/39] fix(dag): cause-recover the sweep cancel seam, gate settle success (review R4) --- .../src/dag/runtime/supervision-sweep.ts | 26 ++++++-- .../test/dag/dag-node-supervision.test.ts | 64 +++++++++++++++++-- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index 7a3070d009..26b7d722d3 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -158,10 +158,17 @@ const serviceLayer = Layer.effect( // settle the node. The nodeFailed guard under the workflow lock // serializes any race with a live watcher or another host's sweep. if (row.childSessionId) { + // Best-effort cancel, recovered at CAUSE level: the sweep's layer + // context has no ambient InstanceRef, so a real SessionPrompt.cancel + // dies at InstanceState.context ("InstanceRef not provided") — and + // cancel's channel is E=never, where Effect.ignore recovers nothing. + // In the incident shape (instance disposed) the child fiber died + // with the scope, so a skipped cancel is also the correct outcome; + // the durable settle below is the source of truth either way. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the durable column is typed string|null; the cancel seam brands SessionID. - yield* promptSvc.cancel(row.childSessionId as never).pipe(Effect.ignore) + yield* promptSvc.cancel(row.childSessionId as never).pipe(Effect.catchCause(() => Effect.void)) } - yield* dag + const settled = yield* dag .nodeFailed( row.workflowId, row.nodeId, @@ -169,14 +176,21 @@ const serviceLayer = Layer.effect( "timeout", ) .pipe( + Effect.as(true), Effect.catchCause((cause) => - Effect.logWarning("DagSupervisionSweep nodeFailed failed", { - dagID: row.workflowId, - nodeID: row.nodeId, - cause, + Effect.gen(function* () { + yield* Effect.logWarning("DagSupervisionSweep nodeFailed failed — retrying next tick", { + dagID: row.workflowId, + nodeID: row.nodeId, + cause, + }) + return false }), ), ) + // On a failed settle keep the streak so the next tick retries + // immediately instead of deferring by a full freeze window. + if (!settled) continue yield* Effect.logWarning("DagSupervisionSweep settled a node with dead deadline supervision", { dagID: row.workflowId, nodeID: row.nodeId, diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index aae2947cea..a12681bd0a 100644 --- a/packages/opencode/test/dag/dag-node-supervision.test.ts +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -82,6 +82,7 @@ function reply(sessionID: string, text: string): SessionV1.WithParts { function supervisionLayer(input: { readonly childPrompts: Queue.Queue readonly cancels: string[] + readonly cancelDefect?: boolean }) { const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) @@ -116,10 +117,15 @@ function supervisionLayer(input: { const prompt = Layer.mock( SessionPrompt.Service, withIdleAdmission({ - cancel: (sessionID: string) => - Effect.sync(() => { - input.cancels.push(sessionID) - }), + // cancelDefect simulates the production sweep context: the layer-scoped + // fiber has no ambient InstanceRef, so a REAL SessionPrompt.cancel dies + // at InstanceState.context with exactly this defect. + cancel: input.cancelDefect + ? () => Effect.die(new Error("InstanceRef not provided")) + : (sessionID: string) => + Effect.sync(() => { + input.cancels.push(sessionID) + }), prompt: (value: SessionPrompt.PromptInput) => deliver(value), promptIfIdle: (value: SessionPrompt.PromptInput) => deliver(value).pipe(Effect.map(Option.some)), }), @@ -152,7 +158,10 @@ interface SupervisionServices { readonly cancels: string[] readonly database: Database.Interface } -function runSupervisionTest(options: { readonly instanceProject: string }, test: (services: SupervisionServices) => Effect.Effect) { +function runSupervisionTest( + options: { readonly instanceProject: string; readonly cancelDefect?: boolean }, + test: (services: SupervisionServices) => Effect.Effect, +) { return Effect.gen(function* () { const childPrompts = yield* Queue.unbounded() const cancels: string[] = [] @@ -184,7 +193,7 @@ function runSupervisionTest(options: { readonly instanceProject: string }, te yield* loop.init() return yield* test({ dag, loop, store, sweep, childPrompts, cancels, database }) }).pipe( - Effect.provide(supervisionLayer({ childPrompts, cancels })), + Effect.provide(supervisionLayer({ childPrompts, cancels, cancelDefect: options.cancelDefect })), Effect.provideService(InstanceRef, { directory: process.cwd(), worktree: process.cwd(), @@ -331,6 +340,49 @@ describe("DAG node supervision — deadline enforcement (production incident)", ) }) + // Review R4 issue 1 (P0): the sweep's layer-scoped fiber has no ambient + // InstanceRef, so a real SessionPrompt.cancel DIES at + // InstanceState.context — and cancel's channel is E=never, where + // Effect.ignore recovers nothing. A cause-level recovery on that seam is + // what keeps the durable settle reachable in production. Simulated here by + // mocking cancel to the exact production defect. + it("cancel-defect: a dying cancel seam (production: no ambient InstanceRef) never blocks the settle", async () => { + await Effect.runPromise( + runSupervisionTest({ instanceProject: "project-1", cancelDefect: true }, ({ dag, store, sweep, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create(incidentGraph) + const child = yield* takeWithin(childPrompts, "worker did not start") + void child + yield* pollWithTimeout( + supervisionProgress(store, dagID, "worker"), + "watcher never escalated before dispose", + "8 seconds", + ) + yield* Effect.promise(() => disposeInstance(process.cwd())) + yield* Effect.sleep("4 seconds") + // Deadline passed, watcher dead: every settle-attempt pass hits the + // dying cancel seam first. Pre-fix, the defect aborts sweepOnce + // before nodeFailed; post-fix the settle still lands. + const needed = DagSupervisionSweep.frozenTicksNeeded(2_000) + for (let tick = 0; tick <= needed; tick++) { + yield* sweep.sweepOnce() + yield* Effect.sleep("50 millis") + } + const swept = yield* pollWithTimeout( + Effect.gen(function* () { + const settled = yield* store.getNode(dagID, "worker") + return settled && settled.status !== "running" ? settled : undefined + }), + "sweep never settled past a dying cancel seam", + "5 seconds", + ) + expect(swept?.errorClass).toBe("timeout") + expect(swept?.errorReason).toContain("swept") + }), + ), + ) + }) + // Review R1 issue 2 (false-positive kill): a LIVE watcher on a node whose // escalation cadence spans multiple sweep intervals must never be swept — // the counter legitimately stays flat between escalations. The graph keeps From 235d031964fd4ef38d5da1f2ed01968a1cbae773 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 00:58:54 +0800 Subject: [PATCH 38/39] fix(dag): propagate interrupts in sweep recovery sites, correct cross-host comment (review R5) --- .../src/dag/runtime/supervision-sweep.ts | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index 26b7d722d3..f43170b34b 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -3,7 +3,7 @@ export * as DagSupervisionSweep from "./supervision-sweep" -import { Context, Effect, Fiber, Layer, Scope } from "effect" +import { Cause, Context, Effect, Fiber, Layer, Scope } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { DagStore } from "@opencode-ai/core/dag/store" @@ -33,9 +33,11 @@ import { SessionPrompt } from "@/session/prompt" * never depend on ambient per-instance context (InstanceRef) — its fiber's * context is the layer-build context, which has none. Ownership is decided * from the durable rows alone: this process's Database owns every workflow - * row it can read, and the nodeFailed guard under the workflow lock - * serializes any race with another writer (including a second host sharing - * the DB — double settles collapse to one). + * row it can read. Same-host races (a live watcher, the DagLoop) are + * serialized by the workflow's in-process lock; a second host sharing the + * DB is handled by the durable guardNode status read plus the projector's + * conditional UPDATE (only the first NodeFailed folds a non-terminal row) — + * double settles collapse to one. * * False-positive safety: a LIVE watcher escalates on * escalateIntervalMs == max(1s, timeout_ms ?? 10min) and nodeTimeoutEscalated @@ -111,7 +113,7 @@ const serviceLayer = Layer.effect( // window). const escalateIntervalFor = Effect.fnUntraced(function* (workflowId: string, nodeId: string) { const wf = yield* store.getWorkflow(workflowId).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), + Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined))), ) return escalateIntervalFromConfig(wf?.config, nodeId) }) @@ -136,11 +138,15 @@ const serviceLayer = Layer.effect( // A store defect must not kill the sweep — the same silent-death // class this service exists to eliminate. Degrade to an empty // pass and retry next tick (mirror of spawn.ts's R13 hardening). + // Interrupts (scope disposal) still propagate — the repo's + // background-loop discipline. Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("DagSupervisionSweep store query failed — skipping tick", { cause }) - return [] - }), + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.gen(function* () { + yield* Effect.logWarning("DagSupervisionSweep store query failed — skipping tick", { cause }) + return [] + }), ), ) @@ -155,8 +161,10 @@ const serviceLayer = Layer.effect( const escalateIntervalMs = yield* escalateIntervalFor(row.workflowId, row.nodeId) if (flatTicks < frozenTicksNeeded(escalateIntervalMs)) continue // Frozen across the full window: cancel the (possibly dead) child and - // settle the node. The nodeFailed guard under the workflow lock - // serializes any race with a live watcher or another host's sweep. + // settle the node. Same-host races (a live watcher) are serialized by + // the workflow's in-process lock; another host's sweep is collapsed + // by the durable terminal-status guard — either way at most one + // settle lands. if (row.childSessionId) { // Best-effort cancel, recovered at CAUSE level: the sweep's layer // context has no ambient InstanceRef, so a real SessionPrompt.cancel @@ -166,7 +174,9 @@ const serviceLayer = Layer.effect( // with the scope, so a skipped cancel is also the correct outcome; // the durable settle below is the source of truth either way. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the durable column is typed string|null; the cancel seam brands SessionID. - yield* promptSvc.cancel(row.childSessionId as never).pipe(Effect.catchCause(() => Effect.void)) + yield* promptSvc.cancel(row.childSessionId as never).pipe( + Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.void)), + ) } const settled = yield* dag .nodeFailed( @@ -178,14 +188,16 @@ const serviceLayer = Layer.effect( .pipe( Effect.as(true), Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("DagSupervisionSweep nodeFailed failed — retrying next tick", { - dagID: row.workflowId, - nodeID: row.nodeId, - cause, - }) - return false - }), + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.gen(function* () { + yield* Effect.logWarning("DagSupervisionSweep nodeFailed failed — retrying next tick", { + dagID: row.workflowId, + nodeID: row.nodeId, + cause, + }) + return false + }), ), ) // On a failed settle keep the streak so the next tick retries @@ -214,8 +226,11 @@ const serviceLayer = Layer.effect( yield* sweepOnce().pipe( // Per-tick guard: any residual defect inside a tick degrades to // a logged skip — the loop itself must outlive every failure. + // Interrupts (scope disposal) still exit the loop. Effect.catchCause((cause) => - Effect.logWarning("DagSupervisionSweep tick failed — retrying next interval", { cause }), + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("DagSupervisionSweep tick failed — retrying next interval", { cause }), ), ) } From a957cb7678be4bd1be1afc2c994a12b7c7a0eb67 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 01:03:19 +0800 Subject: [PATCH 39/39] chore(dag): merge duplicate dag import --- packages/opencode/src/dag/runtime/supervision-sweep.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index f43170b34b..fbfbb0ad60 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -9,8 +9,7 @@ import { Database } from "@opencode-ai/core/database/database" import { DagStore } from "@opencode-ai/core/dag/store" import { WorkflowNodeTable } from "@opencode-ai/core/dag/sql" import { and, eq, sql } from "drizzle-orm" -import { Dag } from "@/dag/dag" -import { parseWorkflowConfig } from "@/dag/dag" +import { Dag, parseWorkflowConfig } from "@/dag/dag" import { SessionPrompt } from "@/session/prompt" /**