Skip to content

Commit 070babc

Browse files
authored
Merge pull request #289 from LeXwDeX/fix/goal-scan-boundary
fix(goal): durable boundary gate stops crash-recovery turn inflation
2 parents 64e60eb + 11597a0 commit 070babc

4 files changed

Lines changed: 178 additions & 2 deletions

File tree

packages/opencode/src/goal/goal.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export interface Interface {
8888
* goalID+revision pair. Required (not optional): an omitted expected would
8989
* let a stale loop result mutate whatever goal replaced the judged one. */
9090
expected: { readonly goalID: string; readonly revision: number },
91+
/** issue #285: the assistant message ID judged for this evaluation.
92+
* Persisted on continue commits as the DURABLE crash-recovery gate — the
93+
* boot scan skips a window still ending on this boundary (the
94+
* process-local evaluatedRevisions map cannot survive a crash). */
95+
judged?: string,
9196
) => Effect.Effect<
9297
| {
9398
state: GoalState.Info
@@ -678,6 +683,7 @@ const serviceLayer = Layer.effect(
678683
reason: string,
679684
parseFailed: boolean,
680685
expected: { readonly goalID: string; readonly revision: number },
686+
judged?: string,
681687
) {
682688
return yield* transition(sessionID, (state) => {
683689
if (!state || state.status !== "active" || !matchesExpected(state, expected))
@@ -739,6 +745,8 @@ const serviceLayer = Layer.effect(
739745
last_reason: reason,
740746
paused_reason: pauseReason,
741747
consecutive_parse_failures: GoalState.nni(newParseFailures),
748+
// issue #285: record the judged boundary for the durable scan gate.
749+
...(judged !== undefined ? { last_judged_msg: judged } : {}),
742750
})
743751
return {
744752
tag: "save",

packages/opencode/src/goal/loop.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,26 @@ const serviceLayer = Layer.effect(
242242
// newer revision). If this process already evaluated the CURRENT
243243
// revision, the scan trigger is stale — skip.
244244
if (scanResume && evaluatedRevisions.get(sessionID) === (goalState.revision ?? 0)) return
245+
// issue #285 — durable boundary gate (scan path only). The
246+
// evaluatedRevisions map above is process-local and dies with the
247+
// process; the goal row's last_judged_msg is the crash-surviving record
248+
// of which boundary was already judged and committed. While the session
249+
// window still ends on that same message, no new progress has landed —
250+
// re-judging would inflate turns_used and dispatch a duplicate
251+
// continuation. Live idle events are never gated here: every dispatched
252+
// continuation produces a fresh assistant message, so the live path
253+
// always judges a new boundary.
254+
if (scanResume && goalState.last_judged_msg) {
255+
const win = yield* sessions
256+
.messages({ sessionID, limit: 20 })
257+
.pipe(
258+
Effect.catchIf((e) => NotFoundError.isInstance(e), () =>
259+
Effect.succeed([] as SessionV1.WithParts[]),
260+
),
261+
)
262+
const lastSeen = [...win].reverse().find((m) => m.info.role === "assistant")
263+
if (lastSeen && lastSeen.info.id === goalState.last_judged_msg) return
264+
}
245265
const goalOwner = { kind: "goal" as const, id: goalState.goal_id ?? "legacy" }
246266
yield* automation.register(sessionID, goalOwner)
247267
const observedLease = Option.getOrUndefined(yield* automation.claim(sessionID, goalOwner))
@@ -363,6 +383,7 @@ const serviceLayer = Layer.effect(
363383
goalID: goalState.goal_id ?? "legacy",
364384
revision: goalState.revision ?? 0,
365385
},
386+
lastAssistant.info.id,
366387
),
367388
),
368389
)

packages/opencode/src/goal/state.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export class Info extends Schema.Class<Info>("GoalState")({
2828
last_verdict: Schema.optional(Verdict),
2929
last_reason: Schema.optional(Schema.String),
3030
paused_reason: Schema.optional(Schema.String),
31+
// issue #285 — the assistant message ID of the boundary judged by the last
32+
// committed continue evaluation. Durable crash-recovery gate: the boot scan
33+
// must not re-judge a window that still ends on this message (the
34+
// process-local evaluatedRevisions map dies with the process).
35+
last_judged_msg: Schema.optional(Schema.String),
3136
consecutive_parse_failures: NonNegativeInt,
3237
subgoals: Schema.Array(Schema.String).pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed([] as ReadonlyArray<string>))),
3338
}) {}
@@ -54,6 +59,7 @@ export function advance(state: Info, patch: Partial<Omit<Info, "revision">>) {
5459
last_verdict: state.last_verdict,
5560
last_reason: state.last_reason,
5661
paused_reason: state.paused_reason,
62+
last_judged_msg: state.last_judged_msg,
5763
consecutive_parse_failures: state.consecutive_parse_failures,
5864
subgoals: state.subgoals,
5965
...patch,

packages/opencode/test/goal/e2e-loop.test.ts

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ const assistantText = "I have made progress on the feature."
5151
// Each scenario yields one scheduler turn before its first idle publish so that
5252
// fiber can acquire the PubSub subscription. No business event exists yet, so
5353
// outcome completion remains separately observed through public state/events.
54-
const mkAssistant = () =>
54+
const mkAssistant = (id?: string) =>
5555
({
56-
info: { role: "assistant", time: { created: Date.now() } },
56+
info: { id, role: "assistant", time: { created: Date.now() } },
5757
parts: [{ type: "text", text: assistantText }],
5858
}) as never
5959

@@ -1793,3 +1793,144 @@ describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-F
17931793
}),
17941794
)
17951795
})
1796+
1797+
// issue #285 / GOAL-FP-01-21: the boot scan must not re-judge a boundary the
1798+
// crashed process already judged and committed. The process-local
1799+
// evaluatedRevisions map dies with the process, so the DURABLE gate is the
1800+
// goal row's last_judged_msg: updateAfterJudge records the judged assistant
1801+
// message ID on every continue commit, and the scan path skips evaluation
1802+
// while the session window still ends on that same message. Live idle events
1803+
// are never gated (each dispatched continuation produces a fresh assistant
1804+
// message, so the live path always sees a new boundary).
1805+
describe("GoalLoop — boot scan must not re-evaluate an already-judged boundary (issue #285)", () => {
1806+
let judgeCalls = 0
1807+
let continuationCalls = 0
1808+
let boundaryID = "msg_boundary_a"
1809+
const reset = () => {
1810+
judgeCalls = 0
1811+
continuationCalls = 0
1812+
boundaryID = "msg_boundary_a"
1813+
}
1814+
1815+
const sessionMock = Layer.mock(Session.Service, {
1816+
messages: () => Effect.succeed([mkAssistant(boundaryID)]),
1817+
})
1818+
const promptMock = Layer.mock(SessionPrompt.Service, withIdleAdmission({
1819+
prompt: () => Effect.die("the direct prompt path is not exercised in this scenario"),
1820+
promptIfIdle: () =>
1821+
Effect.sync(() => {
1822+
continuationCalls += 1
1823+
return Option.none()
1824+
}),
1825+
}))
1826+
const judgeMock = Layer.succeed(
1827+
GoalLoopJudgeLLM,
1828+
GoalLoopJudgeLLM.of({
1829+
call: () =>
1830+
Effect.sync(() => {
1831+
judgeCalls += 1
1832+
return JSON.stringify({ verdict: "continue", reason: "more work" })
1833+
}),
1834+
}),
1835+
)
1836+
1837+
const boundaryLayer = GoalLoop.layer.pipe(
1838+
Layer.provide(sessionMock),
1839+
Layer.provide(promptMock),
1840+
Layer.provide(Layer.mock(Provider.Service, {})),
1841+
Layer.provide(judgeMock),
1842+
Layer.provideMerge(Goal.defaultLayer),
1843+
Layer.provideMerge(SessionStatus.defaultLayer),
1844+
Layer.provideMerge(EventV2Bridge.defaultLayer),
1845+
Layer.provideMerge(SessionAutomationLease.defaultLayer),
1846+
Layer.provideMerge(Database.defaultLayer),
1847+
)
1848+
const it = testEffect(boundaryLayer)
1849+
1850+
const seedSessionRow = (sessionID: SessionID, directory: string) =>
1851+
Effect.gen(function* () {
1852+
const { db } = yield* Database.Service
1853+
const projectID = ProjectSchema.ID.make(Bun.randomUUIDv7())
1854+
yield* db.insert(ProjectTable).values({
1855+
id: projectID,
1856+
worktree: AbsolutePath.make(directory),
1857+
sandboxes: [AbsolutePath.make(directory)],
1858+
})
1859+
yield* db.insert(SessionTable).values({
1860+
id: sessionID,
1861+
project_id: projectID,
1862+
slug: "test-session",
1863+
directory,
1864+
title: "test session",
1865+
version: "1",
1866+
time_created: Date.now(),
1867+
time_updated: Date.now(),
1868+
})
1869+
})
1870+
1871+
// Commits one continue evaluation ahead of the (re)boot — models a process
1872+
// that crashed right after the commit, before the continuation produced an
1873+
// assistant message.
1874+
const commitPriorBoundary = (sid: SessionID) =>
1875+
Effect.gen(function* () {
1876+
const goal = yield* Goal.Service
1877+
const before = yield* goal.load(sid)
1878+
const result = yield* goal.updateAfterJudge(
1879+
sid,
1880+
"continue",
1881+
"pre-crash commit",
1882+
false,
1883+
{ goalID: before!.goal_id ?? "legacy", revision: before!.revision ?? 0 },
1884+
boundaryID,
1885+
)
1886+
expect(result?.state.turns_used).toBe(1)
1887+
return result?.state
1888+
})
1889+
1890+
it.instance("scan with an unchanged boundary skips re-evaluation (no inflation)", () =>
1891+
Effect.gen(function* () {
1892+
reset()
1893+
const loop = yield* GoalLoop.Service
1894+
const goal = yield* Goal.Service
1895+
const directory = (yield* TestInstance).directory
1896+
const sid = SessionID.descending()
1897+
yield* seedSessionRow(sid, directory)
1898+
yield* goal.set(sid, "boundary guard", 5)
1899+
yield* commitPriorBoundary(sid)
1900+
1901+
yield* loop.init()
1902+
// Negative assertion: the scan runs in a forked fiber with no readiness
1903+
// signal on the skip path, so a bounded wait stands in for polling.
1904+
yield* Effect.sleep("300 millis")
1905+
expect(judgeCalls).toBe(0)
1906+
expect(continuationCalls).toBe(0)
1907+
const g = yield* goal.load(sid)
1908+
expect(g?.turns_used).toBe(1)
1909+
}),
1910+
)
1911+
1912+
it.instance("scan proceeds once the window advanced past the boundary", () =>
1913+
Effect.gen(function* () {
1914+
reset()
1915+
const loop = yield* GoalLoop.Service
1916+
const goal = yield* Goal.Service
1917+
const directory = (yield* TestInstance).directory
1918+
const sid = SessionID.descending()
1919+
yield* seedSessionRow(sid, directory)
1920+
yield* goal.set(sid, "boundary guard", 5)
1921+
yield* commitPriorBoundary(sid)
1922+
// The continuation finished and produced a NEW assistant message.
1923+
boundaryID = "msg_boundary_b"
1924+
1925+
yield* loop.init()
1926+
yield* pollWithTimeout(
1927+
Effect.sync(() => (judgeCalls >= 1 ? true : undefined)),
1928+
"scan never re-evaluated the advanced boundary",
1929+
"5 seconds",
1930+
)
1931+
const g = yield* goal.load(sid)
1932+
expect(g?.turns_used).toBe(2)
1933+
expect(continuationCalls).toBe(1)
1934+
}),
1935+
)
1936+
})

0 commit comments

Comments
 (0)