Skip to content

Commit b02c727

Browse files
authored
Merge pull request #284 from LeXwDeX/fix/goal-esc-retry
fix(goal): retry ESC pause persistence before giving up the goal
2 parents cfb1914 + 04492b4 commit b02c727

1 file changed

Lines changed: 30 additions & 13 deletions

File tree

packages/opencode/src/goal/goal.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * as Goal from "./goal"
22

3-
import { Effect, Layer, Context, Schema, Fiber } from "effect"
3+
import { Effect, Layer, Context, Schema, Fiber, Cause, Exit } from "effect"
44
import { desc, eq, sql } from "drizzle-orm"
55
import { SessionTable } from "@opencode-ai/core/session/sql"
66
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -227,22 +227,39 @@ const serviceLayer = Layer.effect(
227227
return GoalPrompts.GOAL_TURN_MAX_STEPS
228228
})
229229

230-
// ESC-on-goal-turn: durable pause + lease release + mark clear, failure-
231-
// absorbed. Called from SessionPrompt.cancel so a user ESC on a goal turn
232-
// pauses the goal instead of letting the post-cancel idle event resurrect
233-
// it with an unwanted continuation.
230+
// ESC-on-goal-turn: durable pause + lease release + mark clear. Called
231+
// from SessionPrompt.cancel so a user ESC on a goal turn pauses the goal
232+
// instead of letting the post-cancel idle event resurrect it with an
233+
// unwanted continuation.
234+
//
235+
// GOAL-FP-01-19: a transient DB failure must not silently lose the pause
236+
// — the mark would clear, the pause never persist, and the next idle
237+
// would resurrect the goal against the user's explicit intent
238+
// (shouldPreempt cannot catch it: ESC adds no user message). Retry the
239+
// pause twice with a short backoff; if it still fails, log LOUDLY — the
240+
// goal may resurrect, but it will never do so invisibly.
234241
const pauseForUserCancel = Effect.fnUntraced(function* (sessionID: SessionID, reason: string) {
235-
const paused = yield* pauseAndPublish(sessionID, reason).pipe(
236-
Effect.catchCause((cause) =>
237-
Effect.logWarning("goal pause on cancel failed", { sessionID, cause: String(cause) }).pipe(
238-
Effect.as(undefined),
239-
),
240-
),
241-
)
242-
if (paused)
242+
let paused: GoalState.Info | undefined
243+
let lastCause: Cause.Cause<never> | undefined
244+
for (let attempt = 0; attempt < 3; attempt++) {
245+
const exit = yield* pauseAndPublish(sessionID, reason).pipe(Effect.exit)
246+
if (Exit.isSuccess(exit)) {
247+
paused = exit.value
248+
break
249+
}
250+
lastCause = exit.cause
251+
if (attempt < 2) yield* Effect.sleep("50 millis")
252+
}
253+
if (paused) {
243254
yield* automation.unregister(sessionID, { kind: "goal", id: paused.goal_id ?? "legacy" }).pipe(
244255
Effect.ignore,
245256
)
257+
} else {
258+
yield* Effect.logError(
259+
"goal pause on cancel failed after retries — goal may resurrect on next idle",
260+
{ sessionID, cause: lastCause ? Cause.pretty(lastCause) : "unknown" },
261+
)
262+
}
246263
turnDriven.delete(sessionID)
247264
return paused
248265
})

0 commit comments

Comments
 (0)