11export * as GoalLoop from "./loop"
22
33import { Effect , Layer , Context , Option , Stream , Scope , Fiber , Cause } from "effect"
4+ import { SessionV1 } from "@opencode-ai/core/v1/session"
45import { LayerNode } from "@opencode-ai/core/effect/layer-node"
56import { InstanceState } from "@/effect/instance-state"
67import { EventV2Bridge } from "@/event-v2-bridge"
@@ -14,6 +15,7 @@ import { GoalPrompts } from "./prompts"
1415import { generateText } from "ai"
1516import { SessionID } from "@/session/schema"
1617import { SessionAutomationLease } from "@/session/automation-lease"
18+ import { NotFoundError } from "@/storage/storage"
1719
1820export interface Interface {
1921 readonly init : ( ) => Effect . Effect < void >
@@ -148,12 +150,12 @@ const serviceLayer = Layer.effect(
148150 yield * triggerEvaluation ( sid )
149151 // P2-B subscription survival: this handler now contains the
150152 // first defect-capable durable reads in the goal idle path
151- // (Goal.ownsSession / goal.load both orDie). Effect.ignore does
152- // NOT absorb defects — a transient store failure would
153- // permanently kill the runForEach subscription and the loop
154- // would never evaluate another idle event . catchCause absorbs
155- // failures AND defects at the boundary, so a store defect
156- // degrades to a logged, skipped evaluation — never a dead loop .
153+ // (Goal.ownsSession / goal.load both orDie). In effect v4,
154+ // Effect.ignore absorbs failures, defects AND interruptions —
155+ // an error here would vanish without a trace, leaving skipped
156+ // evaluations permanently invisible . catchCause keeps the same
157+ // absorption but LOGS at the boundary, so a store defect
158+ // degrades to a logged, skipped evaluation — never a silent one .
157159 } ) . pipe (
158160 Effect . catchCause ( ( cause ) =>
159161 Effect . logWarning ( "GoalLoop idle handler failed" , { sessionID : evt . data . sessionID , cause } ) ,
@@ -263,7 +265,13 @@ const serviceLayer = Layer.effect(
263265 goalState . turns_used === 0 &&
264266 Date . now ( ) - goalState . created_at > GoalPrompts . FRESHNESS_THRESHOLD
265267 ) {
266- const probeMsgs = yield * sessions . messages ( { sessionID, limit : 1 } )
268+ const probeMsgs = yield * sessions
269+ . messages ( { sessionID, limit : 1 } )
270+ . pipe (
271+ Effect . catchIf ( ( e ) => NotFoundError . isInstance ( e ) , ( ) =>
272+ Effect . succeed ( [ ] as SessionV1 . WithParts [ ] ) ,
273+ ) ,
274+ )
267275 const hasAssistant = probeMsgs . some ( ( m ) => m . info . role === "assistant" )
268276 if ( isStaleZombie ( goalState , hasAssistant ) ) {
269277 yield * pauseGoal (
@@ -274,7 +282,18 @@ const serviceLayer = Layer.effect(
274282 }
275283 }
276284
277- const msgs = yield * sessions . messages ( { sessionID, limit : 20 } )
285+ // A session whose row is gone (deleted mid-goal, or a synthetic
286+ // session) fails page() with NotFoundError. Treat it as an empty window
287+ // (same pattern as MessageV2.stream) so the no-lastAssistant branch
288+ // below pauses visibly instead of this typed failure escaping and
289+ // leaving the goal permanently "active".
290+ const msgs = yield * sessions
291+ . messages ( { sessionID, limit : 20 } )
292+ . pipe (
293+ Effect . catchIf ( ( e ) => NotFoundError . isInstance ( e ) , ( ) =>
294+ Effect . succeed ( [ ] as SessionV1 . WithParts [ ] ) ,
295+ ) ,
296+ )
278297 const lastAssistant = [ ...msgs ] . reverse ( ) . find ( ( m ) => m . info . role === "assistant" )
279298 if ( ! lastAssistant ) {
280299 // No assistant message in the last 20 — the conversation may have
@@ -391,7 +410,14 @@ const serviceLayer = Layer.effect(
391410 sessionID,
392411 noReply : true ,
393412 parts : [ { type : "text" , text : updateResult . message } ] ,
394- } ) . pipe ( Effect . ignore )
413+ } ) . pipe (
414+ Effect . catchCause ( ( cause ) =>
415+ Effect . logWarning ( "goal pause message delivery failed" , {
416+ sessionID,
417+ cause : Cause . pretty ( cause ) ,
418+ } ) ,
419+ ) ,
420+ )
395421 }
396422 return
397423 }
@@ -409,8 +435,17 @@ const serviceLayer = Layer.effect(
409435 }
410436
411437 // Reload messages after judge LLM call — the snapshot from before judge
412- // may be stale if user sent messages during the 5-30s judge latency
413- const freshMsgs = yield * sessions . messages ( { sessionID, limit : 20 } )
438+ // may be stale if user sent messages during the 5-30s judge latency.
439+ // Same vanished-session tolerance as the pre-judge window: NotFoundError
440+ // becomes an empty window (shouldPreempt is defensively false for it),
441+ // never a typed failure escaping the fork.
442+ const freshMsgs = yield * sessions
443+ . messages ( { sessionID, limit : 20 } )
444+ . pipe (
445+ Effect . catchIf ( ( e ) => NotFoundError . isInstance ( e ) , ( ) =>
446+ Effect . succeed ( [ ] as SessionV1 . WithParts [ ] ) ,
447+ ) ,
448+ )
414449
415450 if ( shouldPreempt ( freshMsgs ) ) {
416451 // Same self-interrupt hazard as the done branch above: we ARE the
@@ -561,7 +596,20 @@ const serviceLayer = Layer.effect(
561596 // afterIdle re-checks at its own entry load (see there) to close the
562597 // window between this load and the fork.
563598 if ( scanResume && evaluatedRevisions . get ( sessionID ) === ( goalState . revision ?? 0 ) ) return
564- const fiber = yield * afterIdle ( sessionID , scanResume ) . pipe ( Effect . ignore , Effect . forkIn ( scope ) )
599+ // GOAL-FP-01-17: never Effect.ignore here. A typed failure escaping
600+ // afterIdle (e.g. a messages read against a vanished session row) used
601+ // to vanish into ignore and left the goal permanently "active" with
602+ // zero logs — an invisible stall. Interrupts (fiber replacement by a
603+ // newer idle, scope disposal) stay silent: they are the normal
604+ // overwrite path, same F1 discipline as the continuation catch below.
605+ const fiber = yield * afterIdle ( sessionID , scanResume ) . pipe (
606+ Effect . catchCause ( ( cause ) =>
607+ Cause . hasInterrupts ( cause )
608+ ? Effect . void
609+ : Effect . logWarning ( "goal afterIdle failed" , { sessionID, cause : Cause . pretty ( cause ) } ) ,
610+ ) ,
611+ Effect . forkIn ( scope ) ,
612+ )
565613 yield * goal . registerLoopFiber ( sessionID , fiber )
566614 yield * Fiber . await ( fiber ) . pipe (
567615 Effect . flatMap ( ( ) => goal . clearLoopFiberIf ( sessionID , fiber ) ) ,
0 commit comments