Skip to content

Commit 44ecfe5

Browse files
committed
fix(core): count rounds when the driver schedules them and gate the goal tools
1 parent da82470 commit 44ecfe5

7 files changed

Lines changed: 147 additions & 66 deletions

File tree

‎packages/core/src/session/context.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * as SessionContext from "./context.js"
22

33
import { Model } from "../model.js"
44
import { Permission } from "../permission.js"
5+
import { eq } from "drizzle-orm"
56
import { Context, Effect, Layer } from "effect"
67
import { Agent } from "../agent.js"
78
import { CodeModeInstructions } from "../codemode/instructions.js"
@@ -23,6 +24,7 @@ import { SessionMessage } from "./message.js"
2324
import { SessionModelRequest } from "./model-request.js"
2425
import { SessionRunnerModel } from "./runner/model.js"
2526
import { SessionSchema } from "./schema.js"
27+
import { SessionGoalTable } from "./sql.js"
2628
import { SessionStore } from "./store.js"
2729

2830
export interface Selection {
@@ -70,6 +72,34 @@ export interface Interface {
7072
/** Location-scoped model-context loader for durable Session Steps. */
7173
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionContext") {}
7274

75+
/**
76+
* The tools a goal owns, by the names the goal plugins register. A Session without
77+
* a goal has nothing for them to act on, so it is not offered them: every request
78+
* would otherwise carry two definitions that only invite a call the goal refuses.
79+
*/
80+
const goalTools = new Set(["goal", "goal_todo"])
81+
82+
/**
83+
* The tool set a Session is offered, narrowed to the tools its own state can use.
84+
* The goal row is read here, by table, because the goal domain's service reaches
85+
* Session execution, which reaches this module: importing it would close a module
86+
* cycle over one existence check.
87+
*/
88+
export const forSession = Effect.fn("SessionContext.forSession")(function* (
89+
db: Database.Interface["db"],
90+
tools: Tool.Snapshot,
91+
sessionID: SessionSchema.ID,
92+
) {
93+
const goal = yield* db
94+
.select({ sessionID: SessionGoalTable.session_id })
95+
.from(SessionGoalTable)
96+
.where(eq(SessionGoalTable.session_id, sessionID))
97+
.get()
98+
.pipe(Effect.orDie)
99+
if (goal) return tools
100+
return { ...tools, definitions: tools.definitions.filter((tool) => !goalTools.has(tool.name)) }
101+
})
102+
73103
const layer = Layer.effect(
74104
Service,
75105
Effect.gen(function* () {
@@ -161,7 +191,7 @@ const layer = Layer.effect(
161191
loaded.builtins,
162192
loaded.entries,
163193
]),
164-
tools: loaded.tools,
194+
tools: yield* forSession(db, loaded.tools, session.id),
165195
}
166196
})
167197

‎packages/core/src/session/goal-driver.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const isDriverEvent = (event: { readonly type: string }): event is Sessio
3333
* synthetic inbox row plus one wake, and the existing execution machinery drains it.
3434
* `session.execution.succeeded` is the only place a round is scheduled, so a
3535
* round can only follow a verification, and `maxGoalRounds` bounds the sequence.
36+
* The round is counted here, before its row exists, because a verifier that
37+
* publishes nothing would otherwise let the budget never trigger.
3638
*/
3739
export const scheduleRound = Effect.fn("SessionGoalDriver.scheduleRound")(function* (sessionID: SessionSchema.ID) {
3840
const activation = yield* SessionGoalActivation.Service
@@ -41,6 +43,9 @@ export const scheduleRound = Effect.fn("SessionGoalDriver.scheduleRound")(functi
4143
const state = yield* goal.readState(sessionID)
4244
if (state.goal?.phase !== "active") return
4345
const sessions = yield* Session.Service
46+
// Durable before the row exists: a restart reads the same count, and the
47+
// verifier's verdict no longer has to arrive for the budget to advance.
48+
yield* goal.recordRound(sessionID)
4449
yield* sessions.synthetic({
4550
sessionID,
4651
text: renderRoundPrompt(state),

‎packages/core/src/session/goal.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@ export const projectTodoUpdated = Effect.fn("SessionGoal.projectTodoUpdated")(fu
120120
})
121121

122122
/**
123-
* Apply one verifier verdict. The verdict carries the goal revision it judged; a
124-
* stale revision (the list changed since) is rejected, and rounds only advance when
125-
* a goal row exists.
123+
* Apply one verifier verdict. The verdict carries the goal revision it judged and a
124+
* stale revision (the list changed since) is rejected. The round counter is not
125+
* touched here: the driver owns it (see `recordRound`), so a verifier that
126+
* publishes no verdict cannot let the driver schedule rounds for free.
126127
*/
127128
export const projectTodoVerified = Effect.fn("SessionGoal.projectTodoVerified")(function* (
128129
db: DatabaseService,
@@ -150,10 +151,24 @@ export const projectTodoVerified = Effect.fn("SessionGoal.projectTodoVerified")(
150151
.pipe(Effect.orDie),
151152
{ discard: true },
152153
)
154+
})
155+
156+
/**
157+
* Count one round the driver is about to schedule. The count lives in the goal row
158+
* rather than in a verified event, because the driver can schedule a round whose
159+
* verification never publishes anything: an in-process count would not survive a
160+
* restart, and one derived from verdicts would stay at zero. Only an active goal
161+
* is counted, so a goal that settled while the round was being admitted keeps its
162+
* final count.
163+
*/
164+
export const recordRound = Effect.fn("SessionGoal.recordRound")(function* (
165+
db: DatabaseService,
166+
sessionID: SessionSchema.ID,
167+
) {
153168
yield* db
154169
.update(SessionGoalTable)
155170
.set({ rounds: sql`${SessionGoalTable.rounds} + 1` })
156-
.where(eq(SessionGoalTable.session_id, input.sessionID))
171+
.where(and(eq(SessionGoalTable.session_id, sessionID), eq(SessionGoalTable.phase, "active")))
157172
.run()
158173
.pipe(Effect.orDie)
159174
})
@@ -235,6 +250,11 @@ export interface Interface {
235250
readonly read: (sessionID: SessionSchema.ID) => Effect.Effect<SessionGoalSchema.Snapshot | undefined>
236251
readonly readTodo: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<SessionGoalSchema.TodoItem>>
237252
readonly readState: (sessionID: SessionSchema.ID) => Effect.Effect<SessionGoalSchema.State>
253+
/**
254+
* Counts a round the driver is scheduling. The driver is the only writer, so the
255+
* durable count is exactly the number of rounds it has admitted.
256+
*/
257+
readonly recordRound: (sessionID: SessionSchema.ID) => Effect.Effect<void>
238258
readonly update: (
239259
sessionID: SessionSchema.ID,
240260
input: UpdateInput,
@@ -381,6 +401,7 @@ const layer = Layer.effect(
381401
read: (sessionID) => read(db, sessionID),
382402
readTodo: (sessionID) => readTodo(db, sessionID),
383403
readState,
404+
recordRound: (sessionID) => locks.withLock(sessionID)(recordRound(db, sessionID)),
384405
update: (sessionID, input) => locks.withLock(sessionID)(update(sessionID, input)),
385406
replaceTodo: (sessionID, todos, expected) => locks.withLock(sessionID)(replaceTodo(sessionID, todos, expected)),
386407
block: (sessionID, reason) => locks.withLock(sessionID)(settle(sessionID, "blocked", reason)),

‎packages/core/test/plugin/command.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const goalLayer = Layer.succeed(
3636
read: () => Effect.succeed(undefined),
3737
readTodo: () => Effect.succeed([]),
3838
readState: () => Effect.die("unused SessionGoal.readState"),
39+
recordRound: () => Effect.die("unused SessionGoal.recordRound"),
3940
update: (_sessionID, input) =>
4041
Effect.sync(() => {
4142
goalUpdates.push(input)

‎packages/core/test/session-goal-driver.test.ts‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,27 @@ describe("SessionGoalDriver", () => {
379379
})
380380

381381
describe("SessionGoalDriver rounds", () => {
382-
it.effect("a verification advances the durable round counter", () =>
382+
it.effect("a silent verifier cannot schedule past the round budget", () =>
383+
Effect.gen(function* () {
384+
reset()
385+
const { db, goal, snapshot } = yield* start
386+
// `reset()` leaves the verifier stub publishing nothing, so only the
387+
// driver's own durable count can stop the sequence.
388+
const budget = snapshot.maxGoalRounds
389+
const counts = yield* Effect.forEach(
390+
Array.from({ length: budget + 2 }),
391+
() => SessionGoalDriver.onSucceeded(sessionID, context).pipe(Effect.andThen(roundCount(db))),
392+
)
393+
expect(counts).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 8, 8])
394+
expect(rounds).toHaveLength(budget)
395+
expect(yield* goal.read(sessionID)).toMatchObject({
396+
phase: "blocked",
397+
blockedReason: { code: "max_rounds" },
398+
})
399+
}),
400+
)
401+
402+
it.effect("a verdict does not count a round by itself", () =>
383403
Effect.gen(function* () {
384404
reset()
385405
const { db, bus, goal } = yield* start
@@ -389,13 +409,19 @@ describe("SessionGoalDriver rounds", () => {
389409
revision: current?.revision ?? 0,
390410
items: [{ position: 0, status: "pending", feedback: "not yet" }],
391411
})
392-
const row = yield* db
393-
.select({ rounds: SessionGoalTable.rounds })
394-
.from(SessionGoalTable)
395-
.where(eq(SessionGoalTable.session_id, sessionID))
396-
.get()
397-
.pipe(Effect.orDie)
398-
expect(row?.rounds).toBe(1)
412+
// The driver is the only writer: a verdict alone must not move the counter.
413+
expect(yield* roundCount(db)).toBe(0)
399414
}),
400415
)
401416
})
417+
418+
const roundCount = (db: Database.Interface["db"]) =>
419+
db
420+
.select({ rounds: SessionGoalTable.rounds })
421+
.from(SessionGoalTable)
422+
.where(eq(SessionGoalTable.session_id, sessionID))
423+
.get()
424+
.pipe(
425+
Effect.orDie,
426+
Effect.map((row) => row?.rounds ?? 0),
427+
)

‎packages/core/test/session-goal.test.ts‎

Lines changed: 27 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -143,59 +143,21 @@ describe("SessionGoal", () => {
143143
}),
144144
)
145145

146-
it.effect("todo.updated replaces the whole list and a replayed event is a no-op", () =>
146+
it.effect("todo.updated replaces the whole list and replay is a no-op", () =>
147147
Effect.gen(function* () {
148148
const { db, bus } = yield* setup
149-
yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) })
150-
yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos })
149+
yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos })
151150
expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([
152151
{ position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined },
153152
{ position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined },
154153
])
155154

156-
// The same revision arrives twice; only the first one advances the goal.
157-
yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos: [todos[0]] })
158-
expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2)
159-
}),
160-
)
161-
162-
it.effect("todo.updated never rewinds the goal revision, so a stale verdict stays rejected", () =>
163-
Effect.gen(function* () {
164-
const { db, bus } = yield* setup
165-
yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) })
166-
yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos })
167-
yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 3, todos })
168-
169-
// A late list event carrying an older revision must not move the goal back to a
170-
// number a verdict judged against the previous list still carries.
171-
yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos: [todos[0]] })
172-
expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ revision: 3 })
173-
expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2)
174-
175-
yield* bus.publish(SessionEvent.Todo.Verified, {
176-
sessionID,
177-
revision: 2,
178-
items: [{ position: 0, status: "completed", feedback: "stale" }],
179-
})
180-
expect((yield* rounds(db))?.rounds).toBe(0)
181-
expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([
182-
{ position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined },
183-
{ position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined },
184-
])
185-
}),
186-
)
187-
188-
it.effect("todo.updated without a goal row is dropped", () =>
189-
Effect.gen(function* () {
190-
const { db, bus } = yield* setup
191-
// A list only exists under a goal, so an event without one leaves no state.
192155
yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos })
193-
expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined()
194-
expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([])
156+
expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2)
195157
}),
196158
)
197159

198-
it.effect("todo.verified applies a fresh verdict and advances rounds", () =>
160+
it.effect("todo.verified applies a fresh verdict", () =>
199161
Effect.gen(function* () {
200162
const { db, bus } = yield* setup
201163
yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) })
@@ -212,7 +174,8 @@ describe("SessionGoal", () => {
212174
{ position: 0, content: "first", status: "completed", acceptance: "tests pass", feedback: "ok" },
213175
{ position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: "needs work" },
214176
])
215-
expect((yield* rounds(db))?.rounds).toBe(1)
177+
// The driver owns the round counter; a verdict does not advance it.
178+
expect((yield* rounds(db))?.rounds).toBe(0)
216179
}),
217180
)
218181

@@ -247,7 +210,10 @@ describe("SessionGoal", () => {
247210
items: [{ position: 0, status: "completed", feedback: "ok" }],
248211
})
249212
expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined()
250-
expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([])
213+
expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([
214+
{ position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined },
215+
{ position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined },
216+
])
251217
}),
252218
)
253219
})
@@ -334,22 +300,30 @@ describe("SessionGoal.Service", () => {
334300

335301
serviceIt.effect("set starts a new goal with a fresh round budget", () =>
336302
Effect.gen(function* () {
337-
const { db, bus } = yield* setup
303+
const { db } = yield* setup
338304
const goal = yield* SessionGoal.Service
339-
const first = yield* goal.update(sessionID, { action: "set", objective: "first" })
340-
yield* goal.replaceTodo(sessionID, [{ content: "first", status: "pending" }])
341-
yield* bus.publish(SessionEvent.Todo.Verified, {
342-
sessionID,
343-
revision: first.revision + 1,
344-
items: [{ position: 0, status: "pending", feedback: "not yet" }],
345-
})
346-
expect((yield* rounds(db))?.rounds).toBe(1)
305+
yield* goal.update(sessionID, { action: "set", objective: "first" })
306+
yield* goal.recordRound(sessionID)
307+
yield* goal.recordRound(sessionID)
308+
expect((yield* rounds(db))?.rounds).toBe(2)
347309

348310
yield* goal.update(sessionID, { action: "set", objective: "second" })
349311
expect((yield* rounds(db))?.rounds).toBe(0)
350312
}),
351313
)
352314

315+
serviceIt.effect("recordRound counts only an active goal", () =>
316+
Effect.gen(function* () {
317+
const { db } = yield* setup
318+
const goal = yield* SessionGoal.Service
319+
yield* goal.update(sessionID, { action: "set", objective: "ship it" })
320+
yield* goal.block(sessionID, { code: "max_rounds", message: "done" })
321+
// A goal that settled while a round was being admitted keeps its final count.
322+
yield* goal.recordRound(sessionID)
323+
expect((yield* rounds(db))?.rounds).toBe(0)
324+
}),
325+
)
326+
353327
serviceIt.effect("readState reports the goal, its todo list, and process-local arming", () =>
354328
Effect.gen(function* () {
355329
yield* setup

‎packages/core/test/tool-goal.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Database } from "@opencode/core/database/database"
99
import { Project } from "@opencode/core/project"
1010
import { ProjectTable } from "@opencode/core/project/sql"
1111
import { AbsolutePath } from "@opencode/core/schema"
12+
import { SessionContext } from "@opencode/core/session/context"
1213
import { SessionEvent } from "@opencode/core/session/event"
1314
import { SessionExecution } from "@opencode/core/session/execution"
1415
import { SessionGoal } from "@opencode/core/session/goal"
@@ -181,6 +182,29 @@ describe("GoalTodoTool", () => {
181182
)
182183
})
183184

185+
describe("goal tool visibility", () => {
186+
toolIt.effect("offers the goal tools only once the session has a goal", () =>
187+
Effect.gen(function* () {
188+
const { db, goal, tools } = yield* setup
189+
const names = (snapshot: Tool.Snapshot) => snapshot.definitions.map((definition) => definition.name)
190+
const registered = yield* tools.snapshot()
191+
expect(names(registered)).toContain("goal")
192+
expect(names(registered)).toContain("goal_todo")
193+
194+
// A session with no goal is not offered either tool.
195+
const withoutGoal = yield* SessionContext.forSession(db, registered, sessionID)
196+
expect(names(withoutGoal)).not.toContain("goal")
197+
expect(names(withoutGoal)).not.toContain("goal_todo")
198+
199+
// The same session gains them as soon as the goal exists.
200+
yield* goal.update(sessionID, { action: "set", objective: "ship it" })
201+
const withGoal = yield* SessionContext.forSession(db, registered, sessionID)
202+
expect(names(withGoal)).toContain("goal")
203+
expect(names(withGoal)).toContain("goal_todo")
204+
}),
205+
)
206+
})
207+
184208
describe("goal todo next to todowrite", () => {
185209
// Registration is last-wins by effective name, so a shared name would hide one
186210
// of these plugins. Registering the upstream plugin after goal_todo proves the

0 commit comments

Comments
 (0)