|
1 | 1 | export * as Goal from "./goal" |
2 | 2 |
|
3 | | -import { Effect, Layer, Context, Schema, Fiber } from "effect" |
| 3 | +import { Effect, Layer, Context, Schema, Fiber, Cause, Exit } from "effect" |
4 | 4 | import { desc, eq, sql } from "drizzle-orm" |
5 | 5 | import { SessionTable } from "@opencode-ai/core/session/sql" |
6 | 6 | import { LayerNode } from "@opencode-ai/core/effect/layer-node" |
@@ -227,22 +227,39 @@ const serviceLayer = Layer.effect( |
227 | 227 | return GoalPrompts.GOAL_TURN_MAX_STEPS |
228 | 228 | }) |
229 | 229 |
|
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. |
234 | 241 | 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) { |
243 | 254 | yield* automation.unregister(sessionID, { kind: "goal", id: paused.goal_id ?? "legacy" }).pipe( |
244 | 255 | Effect.ignore, |
245 | 256 | ) |
| 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 | + } |
246 | 263 | turnDriven.delete(sessionID) |
247 | 264 | return paused |
248 | 265 | }) |
|
0 commit comments