Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
537 changes: 537 additions & 0 deletions docs/audit-dag-memory-goal-2026-08-18.md

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions docs/findings/goal-batch-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 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) | 完成(红-绿-变异通过) | 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 均通过
- 全量测试套件(`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
- 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
- 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
- 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
- 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 资格。

## 交付

- **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 复跑证实非本批引入)。
- 已知本地环境既有失败(与本批无关,已在干净基线 detached 复跑证实):全量测试中 project-copy / help-snapshots / pty 三项(darwin 环境/计时性)。根 turbo typecheck 曾一次命中 `@opencode-ai/app` 的瞬时缓存失败,随后(pre-push 钩子)29/29 全绿自愈。

## 下一 run

DAG 批次(DAG-01..04):事件触发 = 本 PR 合入 dev 后从新基线切 `fix/dag-batch`。
70 changes: 60 additions & 10 deletions packages/opencode/src/goal/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
| {
Expand Down Expand Up @@ -161,6 +163,13 @@ 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 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<GoalState.Info | undefined>
/** True when the session's current turn is goal-driven. */
Expand Down Expand Up @@ -203,9 +212,13 @@ 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 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<SessionID>()

const markTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) {
Expand Down Expand Up @@ -243,13 +256,21 @@ 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<never> | undefined
for (let attempt = 0; attempt < 3; attempt++) {
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
Expand All @@ -259,13 +280,30 @@ const serviceLayer = Layer.effect(
yield* automation.unregister(sessionID, { kind: "goal", id: paused.goal_id ?? "legacy" }).pipe(
Effect.ignore,
)
} else {
turnDriven.delete(sessionID)
} 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 may resurrect on next idle",
{ sessionID, cause: lastCause ? Cause.pretty(lastCause) : "unknown" },
"goal pause on cancel failed after retries — goal stays active and turn-driven; a repeat ESC retries the pause",
{ 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)
}
turnDriven.delete(sessionID)
return paused
})

Expand Down Expand Up @@ -730,7 +768,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。"
Expand All @@ -746,7 +794,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",
Expand Down
Loading
Loading