From 04492b479a95418199da6a3bc8160f9d05fb9f55 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 16 Aug 2026 03:15:33 +0800 Subject: [PATCH] fix(goal): retry ESC pause persistence before giving up the goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transient DB failure during pauseForUserCancel would silently lose the pause: the turnDriven mark cleared, the pause never persisted, and the next idle event resurrected the goal against the user's explicit ESC (shouldPreempt cannot catch ESC — it adds no user message). Retry the pause up to twice with 50ms backoff (Effect.exit captures defects, unlike typed retry); on exhausted retries log loudly instead of warning, so a resurrecting goal is never invisible. No clean fault-injection seam for the retry branch (test DB is :memory:, pauseAndPublish is a layer closure over drizzle orDie) — happy path covered by test/goal/turn-scope.test.ts; seam absence recorded per diagnosis policy. --- packages/opencode/src/goal/goal.ts | 43 +++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index 106d0be3cc..dc3fbe8fb4 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -1,6 +1,6 @@ export * as Goal from "./goal" -import { Effect, Layer, Context, Schema, Fiber } from "effect" +import { Effect, Layer, Context, Schema, Fiber, Cause, Exit } from "effect" import { desc, eq, sql } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -227,22 +227,39 @@ const serviceLayer = Layer.effect( return GoalPrompts.GOAL_TURN_MAX_STEPS }) - // ESC-on-goal-turn: durable pause + lease release + mark clear, failure- - // absorbed. Called from SessionPrompt.cancel so a user ESC on a goal turn - // pauses the goal instead of letting the post-cancel idle event resurrect - // it with an unwanted continuation. + // ESC-on-goal-turn: durable pause + lease release + mark clear. Called + // from SessionPrompt.cancel so a user ESC on a goal turn pauses the goal + // instead of letting the post-cancel idle event resurrect it with an + // unwanted continuation. + // + // GOAL-FP-01-19: a transient DB failure must not silently lose the pause + // — the mark would clear, the pause never persist, and the next idle + // would resurrect the goal against the user's explicit intent + // (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. const pauseForUserCancel = Effect.fnUntraced(function* (sessionID: SessionID, reason: string) { - const paused = yield* pauseAndPublish(sessionID, reason).pipe( - Effect.catchCause((cause) => - Effect.logWarning("goal pause on cancel failed", { sessionID, cause: String(cause) }).pipe( - Effect.as(undefined), - ), - ), - ) - if (paused) + let paused: GoalState.Info | undefined + let lastCause: Cause.Cause | undefined + for (let attempt = 0; attempt < 3; attempt++) { + const exit = yield* pauseAndPublish(sessionID, reason).pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + paused = exit.value + break + } + lastCause = exit.cause + if (attempt < 2) yield* Effect.sleep("50 millis") + } + 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 })