Skip to content

Commit cfb1914

Browse files
authored
Merge pull request #283 from LeXwDeX/fix/goal-visibility
fix(goal): tolerate vanished-session message windows, surface loop failures
2 parents 3da7554 + 53a2ad3 commit cfb1914

15 files changed

Lines changed: 348 additions & 28 deletions

packages/opencode/src/goal/judge.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,18 @@ export const run = Effect.fn("Goal.Judge.run")(function* (
7575
// "judge is unreliable" uniformly regardless of failure mode. The verdict
7676
// stays "continue" so a single transient blip does not stall the loop;
7777
// it only pauses after MAX_CONSECUTIVE_PARSE_FAILURES in a row.
78-
Effect.orElseSucceed((): JudgeResult => ({
79-
verdict: "continue",
80-
reason: "judge transport error (timeout or network) — counting toward pause budget",
81-
parseFailed: true,
82-
})),
78+
//
79+
// catchCause (not orElseSucceed): the production callLLM chain can
80+
// DEFECT — config first-use orDie, payload decode throws — and a defect
81+
// escaping here kills afterIdle invisibly (the loop stalls at 0 turns
82+
// with zero logs and no pause budget). catchCause folds defects into
83+
// the same parseFailed budget.
84+
Effect.catchCause(() =>
85+
Effect.succeed({
86+
verdict: "continue",
87+
reason: "judge transport error (timeout or network) — counting toward pause budget",
88+
parseFailed: true,
89+
} satisfies JudgeResult),
90+
),
8391
)
8492
})

packages/opencode/src/goal/loop.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * as GoalLoop from "./loop"
22

33
import { Effect, Layer, Context, Option, Stream, Scope, Fiber, Cause } from "effect"
4+
import { SessionV1 } from "@opencode-ai/core/v1/session"
45
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
56
import { InstanceState } from "@/effect/instance-state"
67
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -14,6 +15,7 @@ import { GoalPrompts } from "./prompts"
1415
import { generateText } from "ai"
1516
import { SessionID } from "@/session/schema"
1617
import { SessionAutomationLease } from "@/session/automation-lease"
18+
import { NotFoundError } from "@/storage/storage"
1719

1820
export 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)),

packages/opencode/test/cli/github-action.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { test, expect, describe } from "bun:test"
22
import { SessionV1 } from "@opencode-ai/core/v1/session"
33
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
4-
import type { MessageV2 } from "../../src/session/message-v2"
54
import { SessionID, MessageID, PartID } from "../../src/session/schema"
65

76
// Helper to create minimal valid parts

packages/opencode/test/dag/dag-loop-integration.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { describe, expect, it } from "bun:test"
22
import {
3-
buildGraph,
43
type SchedulingNode,
54
WorkflowRuntime,
65
} from "@opencode-ai/core/dag/core/scheduling"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect } from "bun:test"
2+
import { Effect, Layer } from "effect"
3+
import { NodeFileSystem } from "@effect/platform-node"
4+
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
5+
import { Goal } from "@/goal/goal"
6+
import { SessionStatus } from "@/session/status"
7+
import { SessionID } from "@/session/schema"
8+
import { InstanceStore } from "@/project/instance-store"
9+
import { provideTmpdirInstance } from "../fixture/fixture"
10+
import { pollWithTimeout, testEffect } from "../lib/effect"
11+
12+
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
13+
14+
// GOAL-BOOT-WIRING probe: boots the instance through the PRODUCTION path
15+
// (AppRuntime → InstanceStore.provide → InstanceBootstrap.run, which is the
16+
// only place that serviceOption-resolves and inits GoalLoop). Every other
17+
// test/goal suite builds GoalLoop.layer directly and therefore never
18+
// exercises this wiring. Pipeline under test, no judge involved:
19+
// boot instance → set goal → publish session idle →
20+
// afterIdle must reach the no-lastAssistant branch and PAUSE the goal.
21+
// If the serviceOption wiring, subscription, ownership gate, lease claim,
22+
// or event delivery is broken, the goal stays "active" and this test goes red.
23+
describe("GoalLoop production wiring — idle must drive afterIdle", () => {
24+
it.live(
25+
"an idle session with an active goal leaves the active state",
26+
() =>
27+
provideTmpdirInstance((path) =>
28+
Effect.promise(async () => {
29+
const { AppRuntime } = await import("@/effect/app-runtime")
30+
await AppRuntime.runPromise(
31+
Effect.gen(function* () {
32+
const store = yield* InstanceStore.Service
33+
yield* store.provide(
34+
{ directory: path },
35+
Effect.gen(function* () {
36+
// Instance booted via production bootstrap — GoalLoop.init
37+
// must already have run here; do NOT call it again.
38+
const goal = yield* Goal.Service
39+
const status = yield* SessionStatus.Service
40+
41+
const sid = SessionID.descending()
42+
yield* goal.set(sid, "wiring probe", 5)
43+
44+
// Session starts busy; flip to idle — this is the exact event
45+
// the production Runner emits after a turn ends.
46+
yield* status.set(sid, { type: "busy" })
47+
yield* status.set(sid, { type: "idle" })
48+
49+
// No assistant message exists → the healthy pipeline pauses
50+
// the goal with the "近期消息中无 assistant 回复" reason. A
51+
// stalled pipeline leaves it active.
52+
const final = yield* pollWithTimeout(
53+
Effect.gen(function* () {
54+
const state = yield* goal.load(sid)
55+
if (state && state.status !== "active") return state
56+
return undefined
57+
}),
58+
"goal never left active after idle — GoalLoop pipeline not armed on the production wiring",
59+
"8 seconds",
60+
)
61+
expect(final.status).toBe("paused")
62+
}),
63+
)
64+
}),
65+
)
66+
}),
67+
),
68+
20_000,
69+
)
70+
})

0 commit comments

Comments
 (0)