From d308d251e28e665ee0eeeefef0a053b90821151c Mon Sep 17 00:00:00 2001 From: Te_River <158043744+Te-River@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:39:34 +0800 Subject: [PATCH 1/6] feat(core): add session goal and todo durable contracts The durable half of a session goal: a schema contract, session_goal / session_todo tables, four durable events, their projections and the read model. No endpoints, no UI, no round driver. The projections trust the event log's order: an update upserts the row wholesale, and a different goal id clears the previous todo list instead of being skipped. A clear deletes by (session_id, goal_id). The todo list is replaced wholesale in one transaction, and a verification applies verdicts by position, rejecting a verdict whose revision moved since it was taken. Round counting and the revision compare-and-set belong to the endpoints and the driver that use them. --- packages/client/script/generate.ts | 2 + packages/client/src/effect/api/api.ts | 53 ++++ .../client/src/promise/generated/types.ts | 65 +++++ packages/core/schema.json | 228 +++++++++++++++++- packages/core/src/database/migration.gen.ts | 10 +- .../20261012112921_modern_red_wolf.ts | 41 ++++ packages/core/src/database/schema.gen.ts | 29 +++ packages/core/src/session/goal.ts | 175 ++++++++++++++ packages/core/src/session/message-updater.ts | 4 + packages/core/src/session/projector.ts | 24 ++ packages/core/src/session/sql.ts | 36 +++ packages/core/test/session-goal.test.ts | 216 +++++++++++++++++ packages/httpapi-codegen/src/index.ts | 29 ++- packages/schema/src/index.ts | 1 + packages/schema/src/session-event.ts | 54 +++++ packages/schema/src/session-goal.ts | 63 +++++ packages/schema/test/event-manifest.test.ts | 4 + 17 files changed, 1021 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/database/migration/20261012112921_modern_red_wolf.ts create mode 100644 packages/core/src/session/goal.ts create mode 100644 packages/core/test/session-goal.test.ts create mode 100644 packages/schema/src/session-goal.ts diff --git a/packages/client/script/generate.ts b/packages/client/script/generate.ts index 1b3395cc7e23..f736cb241dbd 100644 --- a/packages/client/script/generate.ts +++ b/packages/client/script/generate.ts @@ -32,6 +32,7 @@ import { Question } from "@opencode/schema/question" import { Reference } from "@opencode/schema/reference" import { AbsolutePath, RelativePath } from "@opencode/schema/schema" import { Session } from "@opencode/schema/session" +import { SessionGoal } from "@opencode/schema/session-goal" import { SessionMessage } from "@opencode/schema/session-message" import { SessionInbox } from "@opencode/schema/session-inbox" import { Shell } from "@opencode/schema/shell" @@ -77,6 +78,7 @@ const effectTypeReferences = [ ...namespaceTypes("Question", "@opencode/schema/question", Question), ...namespaceTypes("Reference", "@opencode/schema/reference", Reference), ...namespaceTypes("Session", "@opencode/schema/session", Session), + ...namespaceTypes("SessionGoal", "@opencode/schema/session-goal", SessionGoal), ...namespaceTypes("SessionMessage", "@opencode/schema/session-message", SessionMessage), ...namespaceTypes("SessionInbox", "@opencode/schema/session-inbox", SessionInbox), ...namespaceTypes("Shell", "@opencode/schema/shell", Shell), diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3c80f44505d3..0ab2ee79c0c3 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -23,6 +23,7 @@ import type { Event } from "@opencode/schema/event" import type { EventLog } from "@opencode/schema/event-log" import type { Workspace } from "@opencode/schema/workspace" import type { Instruction } from "@opencode/schema/instruction" +import type { SessionGoal } from "@opencode/schema/session-goal" import type { Snapshot } from "@opencode/schema/snapshot" import type { Shell } from "@opencode/schema/shell" import type { Form } from "@opencode/schema/form" @@ -729,6 +730,58 @@ export type SessionLogOutput = readonly text?: string | undefined } } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.goal.updated" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { readonly sessionID: Session.ID; readonly snapshot: SessionGoal.Snapshot } + } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.goal.cleared" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { readonly sessionID: Session.ID; readonly goalID: SessionGoal.ID; readonly revision: number } + } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.todo.updated" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { + readonly sessionID: Session.ID + readonly revision: number + readonly todos: ReadonlyArray + } + } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.todo.verified" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { + readonly sessionID: Session.ID + readonly revision: number + readonly items: ReadonlyArray + } + } | { readonly id: Event.ID readonly created: number diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 9d4db667f963..189552bac501 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4491,3 +4491,68 @@ export type ConfigShellsOutput = Array export type ConfigUpdateInput = { readonly shell: string | null } export type ConfigUpdateOutput = void + +export type SessionGoalID = string + +export type SessionGoalReason = { code: string; message: string } + +export type SessionGoalTodoInput = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string +} + +export type SessionGoalTodoVerification = { + position: number + status: "pending" | "in_progress" | "completed" | "cancelled" + feedback: string +} + +export type SessionGoalSnapshot = { + id: SessionGoalID + revision: number + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number +} + +export type SessionGoalCleared = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.cleared" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } +} + +export type SessionTodoUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; todos: Array } +} + +export type SessionTodoVerified = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.verified" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; items: Array } +} + +export type SessionGoalUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } +} diff --git a/packages/core/schema.json b/packages/core/schema.json index 711154c879ad..c4f6677f249d 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "44bd8ca5-689a-410e-a46a-958eb5accd97", - "prevIds": ["f1f14616-b6a1-483d-87b0-d5b7a9132580"], + "id": "ada3c5df-aa2e-4ad7-bc13-2f6f5b166cdf", + "prevIds": ["44bd8ca5-689a-410e-a46a-958eb5accd97"], "ddl": [ { "name": "account_state", @@ -64,6 +64,10 @@ "name": "instruction_state", "entityType": "tables" }, + { + "name": "session_goal", + "entityType": "tables" + }, { "name": "session_inbox", "entityType": "tables" @@ -76,6 +80,10 @@ "name": "session_v2", "entityType": "tables" }, + { + "name": "session_todo", + "entityType": "tables" + }, { "name": "workspace", "entityType": "tables" @@ -984,6 +992,106 @@ "entityType": "columns", "table": "instruction_state" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "goal_id", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "objective", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "phase", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "blocked_reason", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "8", + "generated": null, + "name": "max_goal_rounds", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "rounds", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_goal" + }, { "type": "text", "notNull": false, @@ -1484,6 +1592,86 @@ "entityType": "columns", "table": "session_v2" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "acceptance", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "feedback", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_todo" + }, { "type": "text", "notNull": false, @@ -1662,6 +1850,17 @@ "entityType": "fks", "table": "instruction_state" }, + { + "columns": ["session_id"], + "tableTo": "session_v2", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_goal_session_id_session_v2_id_fk", + "entityType": "fks", + "table": "session_goal" + }, { "columns": ["session_id"], "tableTo": "session_v2", @@ -1695,6 +1894,17 @@ "entityType": "fks", "table": "session_v2" }, + { + "columns": ["session_id"], + "tableTo": "session_v2", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_todo_session_id_session_v2_id_fk", + "entityType": "fks", + "table": "session_todo" + }, { "columns": ["project_id"], "tableTo": "project", @@ -1734,6 +1944,13 @@ "entityType": "pks", "table": "instruction_entry" }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "session_todo_pk", + "entityType": "pks", + "table": "session_todo" + }, { "columns": ["project_id", "directory"], "nameExplicit": false, @@ -1818,6 +2035,13 @@ "table": "instruction_state", "entityType": "pks" }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_goal_pk", + "table": "session_goal", + "entityType": "pks" + }, { "columns": ["id"], "nameExplicit": false, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 712c88bad4a1..0d6831370da4 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -49,10 +49,11 @@ import m46 from "./migration/20260910120000_clear_v1_session_permission.js" import m47 from "./migration/20260923013825_project_time_active.js" import m48 from "./migration/20261007190000_azure_cli_external_credential.js" import m49 from "./migration/20261010180456_project_time_archived.js" -import m50 from "./migration/20261024002554_session_allowance.js" -import m51 from "./migration/20261024175301_drop_session_allowance.js" -import m52 from "./migration/20261025123711_blob.js" -import m53 from "./migration/20261025154125_drop_session_pending.js" +import m50 from "./migration/20261012112921_modern_red_wolf.js" +import m51 from "./migration/20261024002554_session_allowance.js" +import m52 from "./migration/20261024175301_drop_session_allowance.js" +import m53 from "./migration/20261025123711_blob.js" +import m54 from "./migration/20261025154125_drop_session_pending.js" export const migrations = [ m00, @@ -109,4 +110,5 @@ export const migrations = [ m51, m52, m53, + m54, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20261012112921_modern_red_wolf.ts b/packages/core/src/database/migration/20261012112921_modern_red_wolf.ts new file mode 100644 index 000000000000..f3caec2f2931 --- /dev/null +++ b/packages/core/src/database/migration/20261012112921_modern_red_wolf.ts @@ -0,0 +1,41 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20261012112921_modern_red_wolf", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_goal\` ( + \`session_id\` text PRIMARY KEY, + \`goal_id\` text NOT NULL, + \`revision\` integer NOT NULL, + \`objective\` text NOT NULL, + \`phase\` text NOT NULL, + \`blocked_reason\` text, + \`max_goal_rounds\` integer DEFAULT 8 NOT NULL, + \`rounds\` integer DEFAULT 0 NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_session_goal_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_todo\` ( + \`session_id\` text NOT NULL, + \`position\` integer NOT NULL, + \`content\` text NOT NULL, + \`status\` text NOT NULL, + \`acceptance\` text, + \`feedback\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`session_todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`), + CONSTRAINT \`fk_session_todo_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} + +export default migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 9de9d86990c3..b4ed2b30da55 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -166,6 +166,21 @@ const schema: Omit = { CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_goal\` ( + \`session_id\` text PRIMARY KEY, + \`goal_id\` text NOT NULL, + \`revision\` integer NOT NULL, + \`objective\` text NOT NULL, + \`phase\` text NOT NULL, + \`blocked_reason\` text, + \`max_goal_rounds\` integer DEFAULT 8 NOT NULL, + \`rounds\` integer DEFAULT 0 NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_session_goal_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_inbox\` ( \`id\` text PRIMARY KEY, @@ -231,6 +246,20 @@ const schema: Omit = { CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_todo\` ( + \`session_id\` text NOT NULL, + \`position\` integer NOT NULL, + \`content\` text NOT NULL, + \`status\` text NOT NULL, + \`acceptance\` text, + \`feedback\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`session_todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`), + CONSTRAINT \`fk_session_todo_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`workspace\` ( \`id\` text PRIMARY KEY, diff --git a/packages/core/src/session/goal.ts b/packages/core/src/session/goal.ts new file mode 100644 index 000000000000..be9d5787caba --- /dev/null +++ b/packages/core/src/session/goal.ts @@ -0,0 +1,175 @@ +export * as SessionGoal from "./goal.js" + +import { and, asc, eq, sql } from "drizzle-orm" +import { Effect } from "effect" +import type { Database } from "../database/database.js" +import { SessionGoal as SessionGoalSchema } from "@opencode/schema/session-goal" +import { SessionGoalTable, SessionTodoTable } from "./sql.js" +import type { SessionSchema } from "./schema.js" + +type DatabaseService = Database.Interface["db"] + +/** + * Upsert the current goal. The bus projects each committed event exactly once and in + * order, so the latest event always wins and no revision guard is needed. A new goal + * identity starts a fresh todo list, so the previous goal's list is dropped. + */ +export const projectUpdated = Effect.fn("SessionGoal.projectUpdated")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly snapshot: SessionGoalSchema.Snapshot + }, +) { + const values = { + session_id: input.sessionID, + goal_id: input.snapshot.id, + revision: input.snapshot.revision, + objective: input.snapshot.objective, + phase: input.snapshot.phase, + blocked_reason: input.snapshot.blockedReason ?? null, + max_goal_rounds: input.snapshot.maxGoalRounds, + } + const current = yield* db + .select({ goal_id: SessionGoalTable.goal_id }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (current && current.goal_id !== input.snapshot.id) yield* clearTodos(db, input.sessionID) + yield* db + .insert(SessionGoalTable) + .values(values) + .onConflictDoUpdate({ target: SessionGoalTable.session_id, set: values }) + .run() + .pipe(Effect.orDie) +}) + +/** Delete the goal row and its todo list. The tombstone stays in the event log for audit. */ +export const projectCleared = Effect.fn("SessionGoal.projectCleared")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly goalID: SessionGoalSchema.ID + }, +) { + yield* clearTodos(db, input.sessionID) + yield* db + .delete(SessionGoalTable) + .where(and(eq(SessionGoalTable.session_id, input.sessionID), eq(SessionGoalTable.goal_id, input.goalID))) + .run() + .pipe(Effect.orDie) +}) + +/** Replace the whole list and advance the goal revision, so a stale verification is rejected. */ +export const projectTodoUpdated = Effect.fn("SessionGoal.projectTodoUpdated")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly revision: number + readonly todos: ReadonlyArray + }, +) { + yield* clearTodos(db, input.sessionID) + if (input.todos.length > 0) + yield* db + .insert(SessionTodoTable) + .values( + input.todos.map((todo, position) => ({ + session_id: input.sessionID, + position, + content: todo.content, + status: todo.status, + acceptance: todo.acceptance ?? null, + })), + ) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionGoalTable) + .set({ revision: input.revision }) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .run() + .pipe(Effect.orDie) +}) + +/** + * Apply one verifier verdict. The verdict carries the goal revision it judged; a + * stale revision (the list changed since) is rejected, and rounds only advance when + * a goal row exists. + */ +export const projectTodoVerified = Effect.fn("SessionGoal.projectTodoVerified")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly revision: number + readonly items: ReadonlyArray + }, +) { + const goal = yield* db + .select({ revision: SessionGoalTable.revision }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!goal || goal.revision !== input.revision) return + yield* Effect.forEach( + input.items, + (item) => + db + .update(SessionTodoTable) + .set({ status: item.status, feedback: item.feedback }) + .where(and(eq(SessionTodoTable.session_id, input.sessionID), eq(SessionTodoTable.position, item.position))) + .run() + .pipe(Effect.orDie), + { discard: true }, + ) + yield* db + .update(SessionGoalTable) + .set({ rounds: sql`${SessionGoalTable.rounds} + 1` }) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .run() + .pipe(Effect.orDie) +}) + +function clearTodos(db: DatabaseService, sessionID: SessionSchema.ID) { + return db.delete(SessionTodoTable).where(eq(SessionTodoTable.session_id, sessionID)).run().pipe(Effect.orDie) +} + +export const read = Effect.fn("SessionGoal.read")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const row = yield* db + .select() + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + return row ? fromRow(row) : undefined +}) + +export const readTodo = Effect.fn("SessionGoal.readTodo")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const rows = yield* db + .select() + .from(SessionTodoTable) + .where(eq(SessionTodoTable.session_id, sessionID)) + .orderBy(asc(SessionTodoTable.position)) + .all() + .pipe(Effect.orDie) + return rows.map((row) => ({ + position: row.position, + content: row.content, + status: row.status, + acceptance: row.acceptance ?? undefined, + feedback: row.feedback ?? undefined, + })) +}) + +function fromRow(row: typeof SessionGoalTable.$inferSelect): SessionGoalSchema.Snapshot { + return { + id: row.goal_id, + revision: row.revision, + objective: row.objective, + phase: row.phase, + blockedReason: row.blocked_reason ?? undefined, + maxGoalRounds: row.max_goal_rounds, + } +} diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 24d9ce352809..b53e21b9f742 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -161,6 +161,10 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { }), ) }, + "session.goal.updated": () => Effect.void, + "session.goal.cleared": () => Effect.void, + "session.todo.updated": () => Effect.void, + "session.todo.verified": () => Effect.void, "session.synthetic": (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 7cce5b808a9b..86af7f1b825c 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -13,6 +13,7 @@ import { SessionMessageUpdater } from "./message-updater.js" import { SessionInbox } from "./inbox.js" import { InstructionState } from "./instruction-state.js" import { SessionBlob } from "./blob.js" +import { SessionGoal } from "./goal.js" import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" import { SessionStored } from "./stored.js" import { InstructionEntry } from "./instruction-entry.js" @@ -734,6 +735,29 @@ const layer = Layer.effectDiscard( yield* InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta) }), ) + yield* bus.project(SessionEvent.Goal.Updated, (event) => + SessionGoal.projectUpdated(db, { sessionID: event.data.sessionID, snapshot: event.data.snapshot }), + ) + yield* bus.project(SessionEvent.Goal.Cleared, (event) => + SessionGoal.projectCleared(db, { + sessionID: event.data.sessionID, + goalID: event.data.goalID, + }), + ) + yield* bus.project(SessionEvent.Todo.Updated, (event) => + SessionGoal.projectTodoUpdated(db, { + sessionID: event.data.sessionID, + revision: event.data.revision, + todos: event.data.todos, + }), + ) + yield* bus.project(SessionEvent.Todo.Verified, (event) => + SessionGoal.projectTodoVerified(db, { + sessionID: event.data.sessionID, + revision: event.data.revision, + items: event.data.items, + }), + ) yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event)) yield* bus.project(SessionEvent.Shell.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index a4d0c3b351fa..61d41d04787f 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -14,6 +14,7 @@ import type { Workspace } from "@opencode/schema/workspace" import { Timestamps } from "../database/schema.sql.js" import type { Instruction } from "@opencode/schema/instruction" import type { Session } from "@opencode/schema/session" +import type { SessionGoal } from "@opencode/schema/session-goal" import type { CompactionPayload, MovePayload, SyntheticPayload } from "@opencode/schema/session-inbox" import type { RevertV1 } from "@opencode/schema/session-revert" import type { Schema } from "effect" @@ -188,3 +189,38 @@ export const BlobRefTable = sqliteTable( index("blob_ref_session_seq_idx").on(table.session_id, table.seq), ], ) + +/** One row per session: the current goal. Cleared goals leave no row; the event log is the audit trail. */ +export const SessionGoalTable = sqliteTable("session_goal", { + session_id: text() + .$type() + .primaryKey() + .references(() => SessionTable.id, { onDelete: "cascade" }), + goal_id: text().$type().notNull(), + revision: integer().notNull(), + objective: text().notNull(), + phase: text().$type().notNull(), + blocked_reason: text({ mode: "json" }).$type(), + max_goal_rounds: integer().notNull().default(8), + rounds: integer().notNull().default(0), + ...Timestamps, +}) + +/** Whole-list replacement keyed by array index; there is no per-item identity. */ +export const SessionTodoTable = sqliteTable( + "session_todo", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + position: integer().notNull(), + content: text().notNull(), + status: text().$type().notNull(), + acceptance: text(), + feedback: text(), + ...Timestamps, + }, + (table) => [primaryKey({ columns: [table.session_id, table.position] })], +) + diff --git a/packages/core/test/session-goal.test.ts b/packages/core/test/session-goal.test.ts new file mode 100644 index 000000000000..67ce4ac3c369 --- /dev/null +++ b/packages/core/test/session-goal.test.ts @@ -0,0 +1,216 @@ +import { describe, expect } from "bun:test" +import { eq, sql } from "drizzle-orm" +import { Effect } from "effect" +import { Database } from "@opencode/core/database/database" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Bus } from "@opencode/core/bus" +import { Project } from "@opencode/core/project" +import { ProjectTable } from "@opencode/core/project/sql" +import { AbsolutePath } from "@opencode/core/schema" +import { SessionEvent } from "@opencode/core/session/event" +import { SessionGoal } from "@opencode/core/session/goal" +import { SessionProjector } from "@opencode/core/session/projector" +import { SessionSchema } from "@opencode/core/session/schema" +import { SessionGoalTable, SessionTable } from "@opencode/core/session/sql" +import { SessionGoal as GoalSchema } from "@opencode/schema/session-goal" +import { testEffect } from "./lib/effect" + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ + Bus.node.replace(Bus.configured({ persist: true })), + ]), +) + +const sessionID = SessionSchema.ID.make("ses_goal_test") + +const setup = Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "goal-test", + directory: "/project", + title: "Goal test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + return { db, bus: yield* Bus.Service } +}) + +const snapshot = (revision: number, overrides?: Partial): GoalSchema.Snapshot => ({ + id: GoalSchema.ID.make("goal_test"), + revision, + objective: "ship it", + phase: "active", + maxGoalRounds: 8, + ...overrides, +}) + +const todos = [ + { content: "first", status: "pending" as const, acceptance: "tests pass" }, + { content: "second", status: "in_progress" as const }, +] + +const rounds = (db: Effect.Success["db"]) => + db + .select({ rounds: SessionGoalTable.rounds }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + +describe("SessionGoal", () => { + it.effect("migration creates the session_goal and session_todo tables", () => + Effect.gen(function* () { + const { db } = yield* setup + const tables = yield* db + .all<{ name: string }>( + sql`select name from sqlite_master where type = 'table' and name in ('session_goal', 'session_todo')`, + ) + .pipe(Effect.orDie) + expect(tables.map((row) => row.name).toSorted()).toEqual(["session_goal", "session_todo"]) + }), + ) + + it.effect("goal.updated upserts and the latest event wins", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ revision: 1, objective: "ship it" }) + + // A newer revision wins. + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(2, { phase: "paused" }) }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ revision: 2, phase: "paused" }) + }), + ) + + it.effect("goal.updated replaces the goal when the id differs, even at a lower revision", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(5) }) + yield* bus.publish(SessionEvent.Goal.Updated, { + sessionID, + snapshot: snapshot(1, { id: GoalSchema.ID.make("goal_other"), objective: "new goal" }), + }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ + id: "goal_other", + revision: 1, + objective: "new goal", + }) + }), + ) + + it.effect("goal.cleared removes the row and its todo list", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2) + + yield* bus.publish(SessionEvent.Goal.Cleared, { + sessionID, + goalID: GoalSchema.ID.make("goal_test"), + revision: 3, + }) + expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined() + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) + }), + ) + + it.effect("a new goal identity clears the previous todo list", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + + yield* bus.publish(SessionEvent.Goal.Updated, { + sessionID, + snapshot: snapshot(3, { id: GoalSchema.ID.make("goal_other") }), + }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) + }), + ) + + it.effect("todo.updated replaces the whole list and replay is a no-op", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, + ]) + + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2) + }), + ) + + it.effect("todo.verified applies a fresh verdict and advances rounds", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 2, + items: [ + { position: 0, status: "completed", feedback: "ok" }, + { position: 1, status: "in_progress", feedback: "needs work" }, + ], + }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "completed", acceptance: "tests pass", feedback: "ok" }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: "needs work" }, + ]) + expect((yield* rounds(db))?.rounds).toBe(1) + }), + ) + + it.effect("todo.verified rejects a verdict judged against a stale list", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + // The list is replaced after the verifier read it. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 3, todos }) + + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 2, + items: [{ position: 0, status: "completed", feedback: "stale" }], + }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, + ]) + expect((yield* rounds(db))?.rounds).toBe(0) + }), + ) + + it.effect("todo.verified with no goal row is a no-op", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 1, + items: [{ position: 0, status: "completed", feedback: "ok" }], + }) + expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined() + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, + ]) + }), + ) +}) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 3df97570fa3c..e1ef956ec981 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -512,7 +512,7 @@ function effectType(schema: Schema.Top, references: ReturnType, mutable: boolean, r promiseTypeAsts(schemas) as [SchemaAST.AST, ...Array], ) const document = SchemaRepresentation.toCodeDocument(representations) - if (document.artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { + // Import artifacts (e.g. a `Brand` import from a branded scalar) are runtime-only; the + // emitted types never reference them, so they do not make the types "referenced". + const artifacts = document.artifacts.filter((artifact) => artifact._tag !== "Import") + if (artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) } const anonymous = new Set( @@ -1096,7 +1099,7 @@ function structuralTypes(schemas: ReadonlyArray, mutable: boolean, r const output = type .replaceAll("Schema.Json", "JsonValue") .replaceAll(/(?() for (const reference of references) { @@ -1132,7 +1135,10 @@ function uniqueTypeName(seed: string, used: ReadonlySet, suffix = 1): st function structuralType(schema: Schema.Top) { const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.toRepresentations([promiseTypeAst(schema)])) - if (document.artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { + // Import artifacts (e.g. a `Brand` import from a branded scalar) are runtime-only; the + // emitted type never references them, so they do not make the type "referenced". + const artifacts = document.artifacts.filter((artifact) => artifact._tag !== "Import") + if (artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) } const references = new Map( @@ -1149,9 +1155,11 @@ function structuralType(schema: Schema.Top) { } return type } - return preserveStringSuggestions( - expand(document.codes[0].Type) - .replaceAll("Schema.Json", "JsonValue"), + return stripBrand( + preserveStringSuggestions( + expand(document.codes[0].Type) + .replaceAll("Schema.Json", "JsonValue"), + ), ) } @@ -1221,6 +1229,13 @@ function preserveStringSuggestions(type: string) { return type.replaceAll(/((?:"(?:\\.|[^"\\])*"\s*\|\s*)+)string\b/g, "$1(string & {})") } +// The committed generated surface renders branded scalars as their base type. Newer +// effect versions emit `string & Brand.Brand<"...">`; strip the brand so the output +// stays stable across effect versions. +function stripBrand(type: string) { + return type.replaceAll(/ & Brand\.Brand<"[^"]*">/g, "") +} + function normalizePromiseClientContent(content: string, groups: ReadonlyArray) { const endpoints = groups.flatMap((group) => group.endpoints) const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0])) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index c7718c9f5689..97d8ae70c3a1 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -21,6 +21,7 @@ export { Reference } from "./reference.js" export { Rpc } from "./rpc.js" export { WebSearch } from "./websearch.js" export { Session } from "./session.js" +export { SessionGoal } from "./session-goal.js" export { Vcs } from "./vcs.js" export { SessionInbox } from "./session-inbox.js" export { SessionError } from "./session-error.js" diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 0b6af19e6e42..e4f27e4bedd6 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -25,6 +25,7 @@ import { SessionInbox } from "./session-inbox.js" import { Project } from "./project.js" import { SessionFork } from "./session-fork.js" import { Permission } from "./permission.js" +import { SessionGoal } from "./session-goal.js" const Base = { sessionID: SessionID, @@ -281,6 +282,55 @@ export const InstructionsUpdated = Event.durable({ }) export type InstructionsUpdated = typeof InstructionsUpdated.Type +export namespace Goal { + export const Updated = Event.durable({ + type: "session.goal.updated", + ...options, + schema: { + ...Base, + snapshot: SessionGoal.Snapshot, + }, + }) + export type Updated = typeof Updated.Type + + export const Cleared = Event.durable({ + type: "session.goal.cleared", + ...options, + schema: { + ...Base, + goalID: SessionGoal.ID, + revision: NonNegativeInt, + }, + }) + export type Cleared = typeof Cleared.Type +} + +export namespace Todo { + export const Updated = Event.durable({ + type: "session.todo.updated", + ...options, + schema: { + ...Base, + /** The goal revision this list belongs to; a later list advances it. */ + revision: NonNegativeInt, + todos: Schema.Array(SessionGoal.TodoInput), + }, + }) + export type Updated = typeof Updated.Type + + export const Verified = Event.durable({ + type: "session.todo.verified", + ...options, + schema: { + ...Base, + /** The goal revision the verifier judged; a stale revision is rejected. */ + revision: NonNegativeInt, + items: Schema.Array(SessionGoal.TodoVerification), + }, + }) + export type Verified = typeof Verified.Type +} + export const Synthetic = Event.durable({ type: "session.synthetic", ...options, @@ -674,6 +724,10 @@ export const Definitions = Event.inventory( Execution.Failed, Execution.Interrupted, InstructionsUpdated, + Goal.Updated, + Goal.Cleared, + Todo.Updated, + Todo.Verified, Synthetic, Skill.Activated, Shell.Started, diff --git a/packages/schema/src/session-goal.ts b/packages/schema/src/session-goal.ts new file mode 100644 index 000000000000..c8f1d0d1db55 --- /dev/null +++ b/packages/schema/src/session-goal.ts @@ -0,0 +1,63 @@ +export * as SessionGoal from "./session-goal.js" + +import { Schema } from "effect" +import { descending } from "./identifier.js" +import { optional, statics } from "./schema.js" + +/** Server-generated goal identity. Clients never supply it, so an id is never reused. */ +export const ID = Schema.String.check(Schema.isStartingWith("goal_")).pipe( + Schema.brand("SessionGoal.ID"), + Schema.annotate({ identifier: "SessionGoal.ID" }), + statics((schema) => ({ create: () => schema.make("goal_" + descending()) })), +) +export type ID = typeof ID.Type + +export const Phase = Schema.Literals(["active", "paused", "blocked", "complete"]) +export type Phase = typeof Phase.Type + +export interface Reason extends Schema.Schema.Type {} +export const Reason = Schema.Struct({ + code: Schema.String, + message: Schema.String, +}).annotate({ identifier: "SessionGoal.Reason" }) + +export interface Snapshot extends Schema.Schema.Type {} +export const Snapshot = Schema.Struct({ + id: ID, + /** CAS reference; every durable mutation increments it. */ + revision: Schema.Number, + objective: Schema.String, + phase: Phase, + blockedReason: optional(Reason), + maxGoalRounds: Schema.Number, +}).annotate({ identifier: "SessionGoal.Snapshot" }) + +export const TodoStatus = Schema.Literals(["pending", "in_progress", "completed", "cancelled"]) +export type TodoStatus = typeof TodoStatus.Type + +export interface TodoItem extends Schema.Schema.Type {} +export const TodoItem = Schema.Struct({ + position: Schema.Number, + content: Schema.String, + status: TodoStatus, + /** Verifier-owned: how this item is judged done. */ + acceptance: optional(Schema.String), + /** Verifier-owned: why the last verification did not pass this item. */ + feedback: optional(Schema.String), +}).annotate({ identifier: "SessionGoal.TodoItem" }) + +/** The model-authored subset of a todo item; position is the array index. */ +export interface TodoInput extends Schema.Schema.Type {} +export const TodoInput = Schema.Struct({ + content: Schema.String, + status: TodoStatus, + acceptance: optional(Schema.String), +}).annotate({ identifier: "SessionGoal.TodoInput" }) + +/** One verifier verdict per todo position; feedback is verifier-owned. */ +export interface TodoVerification extends Schema.Schema.Type {} +export const TodoVerification = Schema.Struct({ + position: Schema.Number, + status: TodoStatus, + feedback: Schema.String, +}).annotate({ identifier: "SessionGoal.TodoVerification" }) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index d7c07244eda2..e2ef6e7e1640 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -125,6 +125,10 @@ describe("public event manifest", () => { "session.execution.failed.1", "session.execution.interrupted.1", "session.instructions.updated.2", + "session.goal.updated.1", + "session.goal.cleared.1", + "session.todo.updated.1", + "session.todo.verified.1", "session.synthetic.1", "session.skill.activated.1", "session.shell.started.1", From 63a747bcbac50406d99b7c6817a19ef47e06415b Mon Sep 17 00:00:00 2001 From: Te_River <158043744+Te-River@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:41:21 +0800 Subject: [PATCH 2/6] fix(core): reject a todo revision that does not advance the goal A session.todo.updated event carries the goal revision a verification is judged against, so an event that does not advance it could rewind the goal onto a number an already-stale verdict carries; an event with no goal row wrote a list no verdict could ever match. Both are dropped now. Also drops the unrelated httpapi-codegen change (stripBrand and the Import artifact filter). The generated client stays as committed; regenerating from a clean install is left to the maintainer. --- packages/core/src/session/goal.ts | 15 +++++++- packages/core/test/session-goal.test.ts | 49 +++++++++++++++++++++---- packages/httpapi-codegen/src/index.ts | 29 ++++----------- packages/schema/src/session-event.ts | 2 +- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/packages/core/src/session/goal.ts b/packages/core/src/session/goal.ts index be9d5787caba..4e16681ee8d6 100644 --- a/packages/core/src/session/goal.ts +++ b/packages/core/src/session/goal.ts @@ -61,7 +61,13 @@ export const projectCleared = Effect.fn("SessionGoal.projectCleared")(function* .pipe(Effect.orDie) }) -/** Replace the whole list and advance the goal revision, so a stale verification is rejected. */ +/** + * Replace the whole list and advance the goal revision, so a stale verification is + * rejected. The revision is the same counter a verification is judged against, so an + * event that does not advance it is dropped: letting it through would rewind the goal + * to a number an already-stale verdict carries. A list only exists under a goal, so an + * event with no goal row is dropped as well instead of writing a list no verdict can match. + */ export const projectTodoUpdated = Effect.fn("SessionGoal.projectTodoUpdated")(function* ( db: DatabaseService, input: { @@ -70,6 +76,13 @@ export const projectTodoUpdated = Effect.fn("SessionGoal.projectTodoUpdated")(fu readonly todos: ReadonlyArray }, ) { + const goal = yield* db + .select({ revision: SessionGoalTable.revision }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!goal || input.revision <= goal.revision) return yield* clearTodos(db, input.sessionID) if (input.todos.length > 0) yield* db diff --git a/packages/core/test/session-goal.test.ts b/packages/core/test/session-goal.test.ts index 67ce4ac3c369..52719bc0eb34 100644 --- a/packages/core/test/session-goal.test.ts +++ b/packages/core/test/session-goal.test.ts @@ -141,20 +141,58 @@ describe("SessionGoal", () => { }), ) - it.effect("todo.updated replaces the whole list and replay is a no-op", () => + it.effect("todo.updated replaces the whole list and a replayed event is a no-op", () => Effect.gen(function* () { const { db, bus } = yield* setup - yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, ]) - yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + // The same revision arrives twice; only the first one advances the goal. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos: [todos[0]] }) expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2) }), ) + it.effect("todo.updated never rewinds the goal revision, so a stale verdict stays rejected", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 3, todos }) + + // A late list event carrying an older revision must not move the goal back to a + // number a verdict judged against the previous list still carries. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos: [todos[0]] }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ revision: 3 }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2) + + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 2, + items: [{ position: 0, status: "completed", feedback: "stale" }], + }) + expect((yield* rounds(db))?.rounds).toBe(0) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, + ]) + }), + ) + + it.effect("todo.updated without a goal row is dropped", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + // A list only exists under a goal, so an event without one leaves no state. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined() + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) + }), + ) + it.effect("todo.verified applies a fresh verdict and advances rounds", () => Effect.gen(function* () { const { db, bus } = yield* setup @@ -207,10 +245,7 @@ describe("SessionGoal", () => { items: [{ position: 0, status: "completed", feedback: "ok" }], }) expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined() - expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ - { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, - { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, - ]) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) }), ) }) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index e1ef956ec981..3df97570fa3c 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -512,7 +512,7 @@ function effectType(schema: Schema.Top, references: ReturnType, mutable: boolean, r promiseTypeAsts(schemas) as [SchemaAST.AST, ...Array], ) const document = SchemaRepresentation.toCodeDocument(representations) - // Import artifacts (e.g. a `Brand` import from a branded scalar) are runtime-only; the - // emitted types never reference them, so they do not make the types "referenced". - const artifacts = document.artifacts.filter((artifact) => artifact._tag !== "Import") - if (artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { + if (document.artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) } const anonymous = new Set( @@ -1099,7 +1096,7 @@ function structuralTypes(schemas: ReadonlyArray, mutable: boolean, r const output = type .replaceAll("Schema.Json", "JsonValue") .replaceAll(/(?() for (const reference of references) { @@ -1135,10 +1132,7 @@ function uniqueTypeName(seed: string, used: ReadonlySet, suffix = 1): st function structuralType(schema: Schema.Top) { const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.toRepresentations([promiseTypeAst(schema)])) - // Import artifacts (e.g. a `Brand` import from a branded scalar) are runtime-only; the - // emitted type never references them, so they do not make the type "referenced". - const artifacts = document.artifacts.filter((artifact) => artifact._tag !== "Import") - if (artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { + if (document.artifacts.length > 0 || Object.keys(document.references.recursives).length > 0) { throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) } const references = new Map( @@ -1155,11 +1149,9 @@ function structuralType(schema: Schema.Top) { } return type } - return stripBrand( - preserveStringSuggestions( - expand(document.codes[0].Type) - .replaceAll("Schema.Json", "JsonValue"), - ), + return preserveStringSuggestions( + expand(document.codes[0].Type) + .replaceAll("Schema.Json", "JsonValue"), ) } @@ -1229,13 +1221,6 @@ function preserveStringSuggestions(type: string) { return type.replaceAll(/((?:"(?:\\.|[^"\\])*"\s*\|\s*)+)string\b/g, "$1(string & {})") } -// The committed generated surface renders branded scalars as their base type. Newer -// effect versions emit `string & Brand.Brand<"...">`; strip the brand so the output -// stays stable across effect versions. -function stripBrand(type: string) { - return type.replaceAll(/ & Brand\.Brand<"[^"]*">/g, "") -} - function normalizePromiseClientContent(content: string, groups: ReadonlyArray) { const endpoints = groups.flatMap((group) => group.endpoints) const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0])) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index e4f27e4bedd6..0983e8dfb447 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -311,7 +311,7 @@ export namespace Todo { ...options, schema: { ...Base, - /** The goal revision this list belongs to; a later list advances it. */ + /** The goal revision this list advances to; a list that does not advance it is dropped. */ revision: NonNegativeInt, todos: Schema.Array(SessionGoal.TodoInput), }, From 6355ec6887343a65c1594083fb90b9e8cdb800de Mon Sep 17 00:00:00 2001 From: Te_River <158043744+Te-River@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:02:19 +0800 Subject: [PATCH 3/6] chore(client): regenerate for the goal contracts --- packages/client/src/effect/api/api.ts | 4 + .../client/src/promise/generated/types.ts | 142 ++++++++++-------- 2 files changed, 81 insertions(+), 65 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 0ab2ee79c0c3..8d2670cda557 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -2012,6 +2012,10 @@ export type EventSubscribeInput = { | "session.execution.failed" | "session.execution.interrupted" | "session.instructions.updated" + | "session.goal.updated" + | "session.goal.cleared" + | "session.todo.updated" + | "session.todo.verified" | "session.synthetic" | "session.skill.activated" | "session.shell.started" diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 189552bac501..472d9dc93c39 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -148,6 +148,22 @@ export type InstructionHash = string export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } } +export type SessionGoalID = string + +export type SessionGoalReason = { code: string; message: string } + +export type SessionGoalTodoInput = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string +} + +export type SessionGoalTodoVerification = { + position: number + status: "pending" | "in_progress" | "completed" | "cancelled" + feedback: string +} + export type SessionMessageNative1 = { [x: string]: any } export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined } @@ -564,6 +580,15 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: Ev export type LocationRef = { directory: AbsolutePath; workspaceID?: WorkspaceID } +export type SessionGoalSnapshot = { + id: SessionGoalID + revision: number + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number +} + export type SessionMessageAssistantText1 = { type: "text"; text: string; native?: SessionMessageNative1 } export type SessionMessageAssistantReasoning1 = { @@ -1236,6 +1261,36 @@ export type SessionInstructionsUpdated = { data: { sessionID: SessionID; delta: { [x: string]: InstructionHash | "removed" }; text?: string } } +export type SessionGoalCleared = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.cleared" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } +} + +export type SessionTodoUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; todos: Array } +} + +export type SessionTodoVerified = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.verified" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; items: Array } +} + export type SessionSynthetic = { id: EventID created: number @@ -1977,6 +2032,16 @@ export type McpResourcesChanged = { data: { server: string } } +export type SessionGoalUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } +} + export type SessionToolSuccess = { id: EventID created: number @@ -2642,6 +2707,10 @@ export type SessionEventDurable = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated + | SessionGoalUpdated + | SessionGoalCleared + | SessionTodoUpdated + | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -2708,6 +2777,10 @@ export type V2Event = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated + | SessionGoalUpdated + | SessionGoalCleared + | SessionTodoUpdated + | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -4169,6 +4242,10 @@ export type EventSubscribeInput = { | "session.execution.failed" | "session.execution.interrupted" | "session.instructions.updated" + | "session.goal.updated" + | "session.goal.cleared" + | "session.todo.updated" + | "session.todo.verified" | "session.synthetic" | "session.skill.activated" | "session.shell.started" @@ -4491,68 +4568,3 @@ export type ConfigShellsOutput = Array export type ConfigUpdateInput = { readonly shell: string | null } export type ConfigUpdateOutput = void - -export type SessionGoalID = string - -export type SessionGoalReason = { code: string; message: string } - -export type SessionGoalTodoInput = { - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - acceptance?: string -} - -export type SessionGoalTodoVerification = { - position: number - status: "pending" | "in_progress" | "completed" | "cancelled" - feedback: string -} - -export type SessionGoalSnapshot = { - id: SessionGoalID - revision: number - objective: string - phase: "active" | "paused" | "blocked" | "complete" - blockedReason?: SessionGoalReason - maxGoalRounds: number -} - -export type SessionGoalCleared = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.goal.cleared" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } -} - -export type SessionTodoUpdated = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.todo.updated" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; revision: number; todos: Array } -} - -export type SessionTodoVerified = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.todo.verified" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; revision: number; items: Array } -} - -export type SessionGoalUpdated = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.goal.updated" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } -} From 1b787de57c187b336c5b371cacf5c28b2317ab49 Mon Sep 17 00:00:00 2001 From: Te_River <158043744+Te-River@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:10:20 +0800 Subject: [PATCH 4/6] feat(protocol): expose session goal and todo endpoints Add the session goal and todo endpoints, the /goal command, and the user's cancel authority. pause commits the durable phase and the process-local disarm separately, then interrupts the running turn with reason user. The round driver, verifier and model tools land in a follow-up. --- packages/client/src/effect/api/api.ts | 26 ++ .../client/src/effect/generated/client.ts | 34 +++ .../client/src/promise/generated/client.ts | 49 ++++ .../client/src/promise/generated/types.ts | 229 ++++++++++++------ packages/core/src/plugin/command.ts | 17 ++ packages/core/src/plugin/internal.ts | 6 + packages/core/src/session/goal-activation.ts | 33 +++ packages/core/src/session/goal.ts | 150 +++++++++++- packages/core/test/plugin/command.test.ts | 16 +- packages/core/test/session-goal.test.ts | 67 +++++ packages/protocol/src/groups/session.ts | 54 +++++ packages/schema/src/session-goal.ts | 15 ++ packages/server/src/handlers/session.ts | 39 +++ packages/server/src/routes.ts | 5 + packages/server/test/session-goal.test.ts | 83 +++++++ 15 files changed, 747 insertions(+), 76 deletions(-) create mode 100644 packages/core/src/session/goal-activation.ts create mode 100644 packages/server/test/session-goal.test.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 8d2670cda557..7f41a82ccff2 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1315,6 +1315,30 @@ export type SessionBackgroundOperation = ( input: SessionBackgroundInput, ) => Effect.Effect +export type SessionGoalGetInput = { readonly sessionID: Session.ID } +export type SessionGoalGetOutput = SessionGoal.State +export type SessionGoalGetOperation = (input: SessionGoalGetInput) => Effect.Effect + +export type SessionGoalUpdateInput = { + readonly sessionID: Session.ID + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string | undefined + readonly expectedRevision?: number | undefined +} +export type SessionGoalUpdateOutput = SessionGoal.Snapshot +export type SessionGoalUpdateOperation = ( + input: SessionGoalUpdateInput, +) => Effect.Effect + +export type SessionTodoReplaceInput = { + readonly sessionID: Session.ID + readonly todos: ReadonlyArray +} +export type SessionTodoReplaceOutput = void +export type SessionTodoReplaceOperation = ( + input: SessionTodoReplaceInput, +) => Effect.Effect + export type SessionMessageGetInput = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } export type SessionMessageGetOutput = SessionMessage.Info export type SessionMessageGetOperation = ( @@ -1420,6 +1444,8 @@ export interface SessionApi { readonly log: SessionLogOperation readonly interrupt: SessionInterruptOperation readonly background: SessionBackgroundOperation + readonly goal: { readonly get: SessionGoalGetOperation; readonly update: SessionGoalUpdateOperation } + readonly todo: { readonly replace: SessionTodoReplaceOperation } readonly message: { readonly get: SessionMessageGetOperation } readonly form: { readonly list: SessionFormListOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a7d53fd2b025..d4f6aaf5b3ec 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -93,6 +93,12 @@ import type { SessionInterruptOutput, SessionBackgroundInput, SessionBackgroundOutput, + SessionGoalGetInput, + SessionGoalGetOutput, + SessionGoalUpdateInput, + SessionGoalUpdateOutput, + SessionTodoReplaceInput, + SessionTodoReplaceOutput, SessionMessageGetInput, SessionMessageGetOutput, SessionFormListInput, @@ -742,6 +748,32 @@ const EndpointSessionBackground = (raw: RawClient["server.session"]) => (input: raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) +const EndpointSessionGoalGet = (raw: RawClient["server.session"]) => (input: SessionGoalGetInput) => + preserveEffect()( + raw["session.goal.get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointSessionGoalUpdate = (raw: RawClient["server.session"]) => (input: SessionGoalUpdateInput) => + preserveEffect()( + raw["session.goal.update"]({ + params: { sessionID: input["sessionID"] }, + payload: { action: input["action"], objective: input["objective"], expectedRevision: input["expectedRevision"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointSessionTodoReplace = (raw: RawClient["server.session"]) => (input: SessionTodoReplaceInput) => + preserveEffect()( + raw["session.todo.replace"]({ params: { sessionID: input["sessionID"] }, payload: { todos: input["todos"] } }).pipe( + Effect.mapError(mapClientError), + ), + ) + const EndpointSessionMessageGet = (raw: RawClient["server.session"]) => (input: SessionMessageGetInput) => preserveEffect()( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( @@ -852,6 +884,8 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({ log: EndpointSessionLog(raw), interrupt: EndpointSessionInterrupt(raw), background: EndpointSessionBackground(raw), + goal: { get: EndpointSessionGoalGet(raw), update: EndpointSessionGoalUpdate(raw) }, + todo: { replace: EndpointSessionTodoReplace(raw) }, message: { get: EndpointSessionMessageGet(raw) }, form: { list: EndpointSessionFormList(raw), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 32aec4b71f19..e995c2159b07 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -87,6 +87,12 @@ import type { SessionInterruptOutput, SessionBackgroundInput, SessionBackgroundOutput, + SessionGoalGetInput, + SessionGoalGetOutput, + SessionGoalUpdateInput, + SessionGoalUpdateOutput, + SessionTodoReplaceInput, + SessionTodoReplaceOutput, SessionMessageGetInput, SessionMessageGetOutput, SessionFormListInput, @@ -1041,6 +1047,49 @@ export function make(options: ClientOptions) { }, requestOptions, ), + goal: { + get: (input: SessionGoalGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionGoalGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/goal`, + successStatus: 200, + declaredStatuses: [400, 401, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + update: (input: SessionGoalUpdateInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionGoalUpdateOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/goal`, + body: { + action: input["action"], + objective: input["objective"], + expectedRevision: input["expectedRevision"], + }, + successStatus: 200, + declaredStatuses: [400, 401, 404, 409], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + }, + todo: { + replace: (input: SessionTodoReplaceInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/session/${encodeURIComponent(input.sessionID)}/todo`, + body: { todos: input["todos"] }, + successStatus: 204, + declaredStatuses: [400, 401, 404, 409], + empty: true, + }, + requestOptions, + ), + }, message: { get: (input: SessionMessageGetInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionMessageGetOutput }>( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 472d9dc93c39..e7a0f73b687d 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -148,22 +148,6 @@ export type InstructionHash = string export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } } -export type SessionGoalID = string - -export type SessionGoalReason = { code: string; message: string } - -export type SessionGoalTodoInput = { - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - acceptance?: string -} - -export type SessionGoalTodoVerification = { - position: number - status: "pending" | "in_progress" | "completed" | "cancelled" - feedback: string -} - export type SessionMessageNative1 = { [x: string]: any } export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined } @@ -580,15 +564,6 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: Ev export type LocationRef = { directory: AbsolutePath; workspaceID?: WorkspaceID } -export type SessionGoalSnapshot = { - id: SessionGoalID - revision: number - objective: string - phase: "active" | "paused" | "blocked" | "complete" - blockedReason?: SessionGoalReason - maxGoalRounds: number -} - export type SessionMessageAssistantText1 = { type: "text"; text: string; native?: SessionMessageNative1 } export type SessionMessageAssistantReasoning1 = { @@ -1261,36 +1236,6 @@ export type SessionInstructionsUpdated = { data: { sessionID: SessionID; delta: { [x: string]: InstructionHash | "removed" }; text?: string } } -export type SessionGoalCleared = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.goal.cleared" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } -} - -export type SessionTodoUpdated = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.todo.updated" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; revision: number; todos: Array } -} - -export type SessionTodoVerified = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.todo.verified" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; revision: number; items: Array } -} - export type SessionSynthetic = { id: EventID created: number @@ -2032,16 +1977,6 @@ export type McpResourcesChanged = { data: { server: string } } -export type SessionGoalUpdated = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.goal.updated" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } -} - export type SessionToolSuccess = { id: EventID created: number @@ -2707,10 +2642,6 @@ export type SessionEventDurable = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated - | SessionGoalUpdated - | SessionGoalCleared - | SessionTodoUpdated - | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -2777,10 +2708,6 @@ export type V2Event = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated - | SessionGoalUpdated - | SessionGoalCleared - | SessionTodoUpdated - | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -4568,3 +4495,159 @@ export type ConfigShellsOutput = Array export type ConfigUpdateInput = { readonly shell: string | null } export type ConfigUpdateOutput = void + +export type SessionGoalID = string + +export type SessionGoalReason = { code: string; message: string } + +export type SessionGoalTodoInput = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string +} + +export type SessionGoalTodoVerification = { + position: number + status: "pending" | "in_progress" | "completed" | "cancelled" + feedback: string +} + +export type SessionGoalSnapshot = { + id: SessionGoalID + revision: number + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number +} + +export type SessionGoalCleared = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.cleared" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } +} + +export type SessionTodoUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; todos: Array } +} + +export type SessionTodoVerified = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.verified" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; items: Array } +} + +export type SessionGoalUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } +} +export type SessionMessageProviderState = { [x: string]: JsonValue } + +export type SessionMessageProviderState1 = { [x: string]: any } + +export type SessionGoalTodoItem = { + position: number | "Infinity" | "-Infinity" | "NaN" + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string + feedback?: string +} + +export type SessionProviderContextProvenance = { + providerID: ProviderID + provider: string + modelID: string + route: string + protocol: string + endpoint: string +} + +export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState } + +export type SessionGoalSnapshot1 = { + id: SessionGoalID + revision: number | "Infinity" | "-Infinity" | "NaN" + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" +} + +export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 } + +export type FormAnswer2 = { [x: string]: FormValue1 } + +export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue } + +export type SessionGoalState = { + goal?: SessionGoalSnapshot1 + createdAt?: number | "Infinity" | "-Infinity" | "NaN" + rounds: number | "Infinity" | "-Infinity" | "NaN" + maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" + armed: boolean + todo: Array +} + +export type CredentialKey = { + type: "key" + key: string + metadata?: { [x: string]: JsonValue } + configuration?: FormAnswer +} + +export type SessionGoalGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionGoalGetOutput = { data: SessionGoalState }["data"] + +export type SessionGoalUpdateInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly action: { + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string | undefined + readonly expectedRevision?: number | undefined + }["action"] + readonly objective?: { + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string | undefined + readonly expectedRevision?: number | undefined + }["objective"] + readonly expectedRevision?: { + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string | undefined + readonly expectedRevision?: number | undefined + }["expectedRevision"] +} + +export type SessionGoalUpdateOutput = { data: SessionGoalSnapshot1 }["data"] + +export type SessionTodoReplaceInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly todos: { + readonly todos: ReadonlyArray<{ + readonly content: string + readonly status: "pending" | "in_progress" | "completed" | "cancelled" + readonly acceptance?: string + }> + }["todos"] +} + +export type SessionTodoReplaceOutput = void diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 1f7d590b3e52..802e6f65fd7f 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -5,6 +5,7 @@ import { Effect, Stream } from "effect" import { Bus } from "../bus.js" import { Location } from "../location.js" import { Mcp } from "../mcp/index.js" +import { SessionGoal } from "../session/goal.js" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" @@ -14,6 +15,7 @@ export const Plugin = define({ const location = yield* Location.Service const mcp = yield* Mcp.Service const bus = yield* Bus.Service + const goal = yield* SessionGoal.Service const loaded = { prompts: [] as Mcp.Prompt[] } yield* bus.subscribe(Mcp.PromptsChanged).pipe( Stream.runForEach(() => @@ -52,6 +54,12 @@ export const Plugin = define({ }) .pipe(Effect.asVoid), }) + editor.add({ + name: "goal", + description: "set, clear, edit, pause, or resume the session goal", + execute: (input) => + goal.update(input.sessionID, parseGoal(input.prompt.text)).pipe(Effect.asVoid), + }) for (const prompt of loaded.prompts) { editor.add({ name: mcpCommandName(prompt.server, prompt.name), @@ -89,6 +97,15 @@ function append(template: string, input: string) { return [template, value].filter(Boolean).join("\n\n") } +/** `/goal [objective|clear|edit |pause|resume]`; a bare objective sets the goal. */ +function parseGoal(text: string): SessionGoal.UpdateInput { + const trimmed = text.trim() + const [head, ...rest] = trimmed.split(/\s+/) + if (head === "clear" || head === "pause" || head === "resume") return { action: head } + if (head === "edit") return { action: "edit", objective: rest.join(" ") } + return { action: "set", objective: trimmed } +} + function parseArguments(input: string) { return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, "")) } diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e0421b9c5802..44d6b8eb9814 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -59,6 +59,8 @@ import { Reference } from "../reference.js" import { WebSearch } from "../websearch.js" import { Ripgrep } from "../ripgrep.js" import { Session } from "../session.js" +import { SessionGoal } from "../session/goal.js" +import { SessionGoalActivation } from "../session/goal-activation.js" import { SessionCompaction } from "../session/compaction.js" import { SessionInstructions } from "../session/instructions.js" import { SubagentJob } from "../session/subagent-job.js" @@ -142,6 +144,8 @@ const services = [ WebSearch.Service, Ripgrep.Service, Session.Service, + SessionGoal.Service, + SessionGoalActivation.Service, SessionCompaction.Service, SessionInstructions.Service, SubagentJob.Service, @@ -196,6 +200,8 @@ export const requirements = LayerNode.group([ WebSearch.node, Ripgrep.node, Session.node, + SessionGoal.node, + SessionGoalActivation.node, SessionCompaction.node, SessionInstructions.node, SubagentJob.node, diff --git a/packages/core/src/session/goal-activation.ts b/packages/core/src/session/goal-activation.ts new file mode 100644 index 000000000000..1e252c83bf63 --- /dev/null +++ b/packages/core/src/session/goal-activation.ts @@ -0,0 +1,33 @@ +export * as SessionGoalActivation from "./goal-activation.js" + +import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { SessionSchema } from "./schema.js" + +/** + * Process-local willingness to run a goal. Never persisted, so a fresh process or a + * reloaded instance starts disarmed and a goal never resumes itself after restart. + * `armed` is the same kind of process-local truth as `session.interrupt`'s + * `interrupted` flag: it only means anything to the process that owns it. + */ +export interface Interface { + readonly arm: (sessionID: SessionSchema.ID) => Effect.Effect + readonly disarm: (sessionID: SessionSchema.ID) => Effect.Effect + readonly isArmed: (sessionID: SessionSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionGoalActivation") {} + +const layer = Layer.effect( + Service, + Effect.sync(() => { + const armed = new Set() + return Service.of({ + arm: (sessionID) => Effect.sync(() => void armed.add(sessionID)), + disarm: (sessionID) => Effect.sync(() => void armed.delete(sessionID)), + isArmed: (sessionID) => Effect.sync(() => armed.has(sessionID)), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/session/goal.ts b/packages/core/src/session/goal.ts index 4e16681ee8d6..d1469f18639e 100644 --- a/packages/core/src/session/goal.ts +++ b/packages/core/src/session/goal.ts @@ -1,14 +1,22 @@ export * as SessionGoal from "./goal.js" import { and, asc, eq, sql } from "drizzle-orm" -import { Effect } from "effect" -import type { Database } from "../database/database.js" +import { Context, Effect, Layer, Schema } from "effect" +import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { Bus } from "../bus.js" +import { Database } from "../database/database.js" import { SessionGoal as SessionGoalSchema } from "@opencode/schema/session-goal" +import { SessionEvent } from "./event.js" +import { SessionExecution } from "./execution.js" +import { SessionGoalActivation } from "./goal-activation.js" import { SessionGoalTable, SessionTodoTable } from "./sql.js" import type { SessionSchema } from "./schema.js" type DatabaseService = Database.Interface["db"] +/** Rounds a goal may run before it is pushed to `blocked`; the driver enforces it. */ +export const DefaultMaxGoalRounds = 8 + /** * Upsert the current goal. The bus projects each committed event exactly once and in * order, so the latest event always wins and no revision guard is needed. A new goal @@ -186,3 +194,141 @@ function fromRow(row: typeof SessionGoalTable.$inferSelect): SessionGoalSchema.S maxGoalRounds: row.max_goal_rounds, } } + +/** A goal mutation rejected because the goal is missing, in the wrong phase, or stale. */ +export class ConflictError extends Schema.TaggedError()("SessionGoal.ConflictError", { + message: Schema.String, +}) {} + +export interface UpdateInput { + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string + readonly expectedRevision?: number +} + +export interface Interface { + readonly read: (sessionID: SessionSchema.ID) => Effect.Effect + readonly readTodo: (sessionID: SessionSchema.ID) => Effect.Effect> + readonly readState: (sessionID: SessionSchema.ID) => Effect.Effect + readonly update: ( + sessionID: SessionSchema.ID, + input: UpdateInput, + ) => Effect.Effect + readonly replaceTodo: ( + sessionID: SessionSchema.ID, + todos: ReadonlyArray, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionGoal") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const bus = yield* Bus.Service + const execution = yield* SessionExecution.Service + const activation = yield* SessionGoalActivation.Service + + const readState = Effect.fn("SessionGoal.readState")(function* (sessionID: SessionSchema.ID) { + const row = yield* db + .select() + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + return { + goal: row ? fromRow(row) : undefined, + createdAt: row?.time_created, + rounds: row?.rounds ?? 0, + maxGoalRounds: row?.max_goal_rounds ?? DefaultMaxGoalRounds, + armed: yield* activation.isArmed(sessionID), + todo: yield* readTodo(db, sessionID), + } + }) + + const update = Effect.fn("SessionGoal.update")(function* (sessionID: SessionSchema.ID, input: UpdateInput) { + const current = yield* read(db, sessionID) + if (input.expectedRevision !== undefined && current?.revision !== input.expectedRevision) + return yield* new ConflictError({ + message: `Goal revision changed: expected ${input.expectedRevision}, found ${current?.revision ?? "none"}`, + }) + if (input.action === "set") { + const snapshot: SessionGoalSchema.Snapshot = { + id: SessionGoalSchema.ID.create(), + revision: 1, + objective: yield* requireObjective(input), + phase: "active", + maxGoalRounds: DefaultMaxGoalRounds, + } + yield* publishUpdated(bus, sessionID, snapshot) + yield* activation.arm(sessionID) + return snapshot + } + const goal = yield* requireGoal(current) + if (input.action === "edit") { + const snapshot = { ...goal, revision: goal.revision + 1, objective: yield* requireObjective(input) } + yield* publishUpdated(bus, sessionID, snapshot) + return snapshot + } + if (input.action === "pause") { + // Disarm first: it is the only action that stops the goal immediately, and + // it commits separately from the durable phase. Then cancel the running turn. + yield* activation.disarm(sessionID) + const snapshot = { ...goal, revision: goal.revision + 1, phase: "paused" as const } + yield* publishUpdated(bus, sessionID, snapshot) + yield* Effect.uninterruptible(execution.interrupt(sessionID, { reason: "user" })) + return snapshot + } + if (input.action === "resume") { + const snapshot = { ...goal, revision: goal.revision + 1, phase: "active" as const } + yield* publishUpdated(bus, sessionID, snapshot) + yield* activation.arm(sessionID) + return snapshot + } + yield* activation.disarm(sessionID) + yield* bus.publish(SessionEvent.Goal.Cleared, { + sessionID, + goalID: goal.id, + revision: goal.revision + 1, + }) + return { ...goal, revision: goal.revision + 1 } + }) + + const replaceTodo = Effect.fn("SessionGoal.replaceTodo")(function* ( + sessionID: SessionSchema.ID, + todos: ReadonlyArray, + ) { + const goal = yield* requireGoal(yield* read(db, sessionID)) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: goal.revision + 1, todos }) + }) + + return Service.of({ + read: (sessionID) => read(db, sessionID), + readTodo: (sessionID) => readTodo(db, sessionID), + readState, + update, + replaceTodo, + }) + }), +) + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [Database.node, Bus.node, SessionExecution.node, SessionGoalActivation.node], +}) + +function requireGoal(goal: SessionGoalSchema.Snapshot | undefined) { + return goal ? Effect.succeed(goal) : Effect.fail(new ConflictError({ message: "No goal is set for this session" })) +} + +function requireObjective(input: UpdateInput) { + return input.objective !== undefined && input.objective.length > 0 + ? Effect.succeed(input.objective) + : Effect.fail(new ConflictError({ message: `Goal action "${input.action}" requires an objective` })) +} + +function publishUpdated(bus: Bus.Interface, sessionID: SessionSchema.ID, snapshot: SessionGoalSchema.Snapshot) { + return bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot }) +} diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 3044a82e6fee..91aaa9385b39 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -6,6 +6,7 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Location } from "@opencode/core/location" import { Mcp } from "@opencode/core/mcp/index" import { CommandPlugin } from "@opencode/core/plugin/command" +import { SessionGoal } from "@opencode/core/session/goal" import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/schema/session" import { SessionInbox } from "@opencode/schema/session-inbox" @@ -25,10 +26,23 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })), ) +// The plugin only needs the goal service to register the command; the stub keeps the +// test graph to the command surface. +const goalLayer = Layer.succeed( + SessionGoal.Service, + SessionGoal.Service.of({ + read: () => Effect.succeed(undefined), + readTodo: () => Effect.succeed([]), + readState: () => Effect.die("unused SessionGoal.readState"), + update: () => Effect.die("unused SessionGoal.update"), + replaceTodo: () => Effect.die("unused SessionGoal.replaceTodo"), + }), +) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [ + AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node, SessionGoal.node]), [ Mcp.node.replace(emptyMcpLayer), Location.node.replace(locationLayer), + SessionGoal.node.replace(goalLayer), ]), ) diff --git a/packages/core/test/session-goal.test.ts b/packages/core/test/session-goal.test.ts index 52719bc0eb34..3e0eb0c0567c 100644 --- a/packages/core/test/session-goal.test.ts +++ b/packages/core/test/session-goal.test.ts @@ -9,7 +9,9 @@ import { Project } from "@opencode/core/project" import { ProjectTable } from "@opencode/core/project/sql" import { AbsolutePath } from "@opencode/core/schema" import { SessionEvent } from "@opencode/core/session/event" +import { SessionExecution } from "@opencode/core/session/execution" import { SessionGoal } from "@opencode/core/session/goal" +import { SessionGoalActivation } from "@opencode/core/session/goal-activation" import { SessionProjector } from "@opencode/core/session/projector" import { SessionSchema } from "@opencode/core/session/schema" import { SessionGoalTable, SessionTable } from "@opencode/core/session/sql" @@ -249,3 +251,68 @@ describe("SessionGoal", () => { }), ) }) + +const serviceIt = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionGoal.node, SessionGoalActivation.node]), + [Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)], + ), +) + +describe("SessionGoal.Service", () => { + serviceIt.effect("set arms the goal, pause disarms it, resume re-arms it, and clear disarms it", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const activation = yield* SessionGoalActivation.Service + + expect(yield* goal.update(sessionID, { action: "set", objective: "ship it" })).toMatchObject({ + revision: 1, + phase: "active", + }) + expect(yield* activation.isArmed(sessionID)).toBe(true) + + // `pause` is the user's cancel: it disarms the goal in this process. + expect(yield* goal.update(sessionID, { action: "pause" })).toMatchObject({ revision: 2, phase: "paused" }) + expect(yield* activation.isArmed(sessionID)).toBe(false) + + expect(yield* goal.update(sessionID, { action: "resume" })).toMatchObject({ revision: 3, phase: "active" }) + expect(yield* activation.isArmed(sessionID)).toBe(true) + + yield* goal.update(sessionID, { action: "clear" }) + expect(yield* activation.isArmed(sessionID)).toBe(false) + expect(yield* goal.read(sessionID)).toBeUndefined() + }), + ) + + serviceIt.effect("readState reports the goal, its todo list, and process-local arming", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + yield* goal.replaceTodo(sessionID, [{ content: "first", status: "pending", acceptance: "tests pass" }]) + + expect(yield* goal.readState(sessionID)).toMatchObject({ + goal: { revision: 2, objective: "ship it" }, + rounds: 0, + maxGoalRounds: 8, + armed: true, + todo: [{ position: 0, content: "first", status: "pending", acceptance: "tests pass" }], + }) + }), + ) + + serviceIt.effect("edit without a goal and a stale expectedRevision are conflicts", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const conflict = (input: SessionGoal.UpdateInput) => goal.update(sessionID, input).pipe(Effect.flip) + + expect((yield* conflict({ action: "edit", objective: "x" }))._tag).toBe("SessionGoal.ConflictError") + yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + expect((yield* conflict({ action: "edit", objective: "x", expectedRevision: 99 }))._tag).toBe( + "SessionGoal.ConflictError", + ) + }), + ) +}) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 47ac7cd47248..cf4f47cd238d 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -2,6 +2,7 @@ import { SessionMessage } from "@opencode/schema/session-message" import { SessionInbox } from "@opencode/schema/session-inbox" import { PromptInput } from "@opencode/schema/prompt-input" import { Session } from "@opencode/schema/session" +import { SessionGoal } from "@opencode/schema/session-goal" import { SessionStats } from "@opencode/schema/session-stats" import { InstructionEntry } from "@opencode/schema/instruction-entry" import { Project } from "@opencode/schema/project" @@ -705,6 +706,59 @@ export const makeSessionGroup = {} +export const State = Schema.Struct({ + goal: optional(Snapshot), + createdAt: optional(Schema.Number), + rounds: Schema.Number, + maxGoalRounds: Schema.Number, + armed: Schema.Boolean, + todo: Schema.Array(TodoItem), +}).annotate({ identifier: "SessionGoal.State" }) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 2b7f8b990337..8ff5cba7c9ac 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,5 +1,6 @@ import { Instance } from "@opencode/core/instance/service" import { Session } from "@opencode/core/session" +import { SessionGoal } from "@opencode/core/session/goal" import { SessionStats } from "@opencode/core/session/stats" import { SessionTitle } from "@opencode/core/session/title" import { SessionTransfer } from "@opencode/core/session/transfer" @@ -618,6 +619,44 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.goal.get", + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe(Effect.catchTag("Session.NotFoundError", missingSession)) + const goal = yield* SessionGoal.Service + return { data: yield* goal.readState(ctx.params.sessionID) } + }), + ) + .handle( + "session.goal.update", + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe(Effect.catchTag("Session.NotFoundError", missingSession)) + const goal = yield* SessionGoal.Service + return { + data: yield* goal + .update(ctx.params.sessionID, ctx.payload) + .pipe( + Effect.catchTag( + "SessionGoal.ConflictError", + (error) => new ConflictError({ message: error.message }), + ), + ), + } + }), + ) + .handle( + "session.todo.replace", + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe(Effect.catchTag("Session.NotFoundError", missingSession)) + const goal = yield* SessionGoal.Service + yield* goal + .replaceTodo(ctx.params.sessionID, ctx.payload.todos) + .pipe( + Effect.catchTag("SessionGoal.ConflictError", (error) => new ConflictError({ message: error.message })), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.message", Effect.fn(function* (ctx) { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 19122cfe789f..c8c94f917329 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -16,6 +16,8 @@ import { PersistentPty } from "@opencode/core/persistent-pty" import { Project } from "@opencode/core/project" import { Worktree } from "@opencode/core/worktree" import { Session } from "@opencode/core/session" +import { SessionGoal } from "@opencode/core/session/goal" +import { SessionGoalActivation } from "@opencode/core/session/goal-activation" import { Instance } from "@opencode/core/instance/service" import { SessionTransfer } from "@opencode/core/session/transfer" import { InstructionEntry } from "@opencode/core/session/instruction-entry" @@ -63,6 +65,8 @@ const applicationServiceNodes = [ Project.sweepNode, Worktree.node, Session.node, + SessionGoal.node, + SessionGoalActivation.node, Instance.node, SessionTransfer.node, InstructionEntry.node, @@ -176,6 +180,7 @@ function makeRoutes( PluginUpdate.Service, Project.Service, WellKnown.Service, + SessionGoal.Service, )(context), ), ServerInfo.layer(serviceURLs, Context.get(context, Global.Service).tmp, options.app), diff --git a/packages/server/test/session-goal.test.ts b/packages/server/test/session-goal.test.ts new file mode 100644 index 000000000000..dbc16339b93e --- /dev/null +++ b/packages/server/test/session-goal.test.ts @@ -0,0 +1,83 @@ +import { expect } from "bun:test" +import { Session } from "@opencode/schema/session" +import { SessionGoal } from "@opencode/schema/session-goal" +import { Effect, Schema } from "effect" +import { it } from "../../core/test/lib/effect" +import { ServerFetch } from "../src/fetch" + +const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) }) +const GoalState = Schema.Struct({ data: SessionGoal.State }) +const GoalSnapshot = Schema.Struct({ data: SessionGoal.Snapshot }) + +it.live("serves the session goal and todo endpoints", () => + Effect.gen(function* () { + const handler = yield* ServerFetch.make({ + app: { version: "test" }, + database: { path: ":memory:" }, + fs: { filewatcher: false }, + models: { fetch: false }, + }) + const request = (path: string, method: string, body?: unknown) => + Effect.promise(async () => { + const response = await handler( + new Request(`http://opencode.local${path}`, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ) + return { status: response.status, body: response.status === 204 ? undefined : await response.json() } + }) + + const created = Schema.decodeUnknownSync(SessionResponse)( + (yield* request("/api/session", "POST", { title: "Goal" })).body, + ) + const goal = `/api/session/${created.data.id}/goal` + const todo = `/api/session/${created.data.id}/todo` + + // A fresh session has no goal, is disarmed, and has an empty todo list. + const empty = Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body) + expect(empty.data).toMatchObject({ rounds: 0, maxGoalRounds: 8, armed: false, todo: [] }) + expect(empty.data.goal).toBeUndefined() + + // `set` creates the goal and arms it in this process. + const set = Schema.decodeUnknownSync(GoalSnapshot)( + (yield* request(goal, "POST", { action: "set", objective: "ship it" })).body, + ) + expect(set.data).toMatchObject({ revision: 1, objective: "ship it", phase: "active" }) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(true) + + // The todo list is replaced wholesale. + expect( + (yield* request(todo, "PUT", { todos: [{ content: "first", status: "pending", acceptance: "tests pass" }] })) + .status, + ).toBe(204) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.todo).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass" }, + ]) + + // `pause` is the user's cancel: it disarms and bumps the revision. + const paused = Schema.decodeUnknownSync(GoalSnapshot)((yield* request(goal, "POST", { action: "pause" })).body) + expect(paused.data).toMatchObject({ revision: 3, phase: "paused" }) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(false) + + // `resume` re-arms. + const resumed = Schema.decodeUnknownSync(GoalSnapshot)((yield* request(goal, "POST", { action: "resume" })).body) + expect(resumed.data).toMatchObject({ revision: 4, phase: "active" }) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(true) + + // `clear` removes the goal and its todo list. + yield* request(goal, "POST", { action: "clear" }) + const cleared = Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body) + expect(cleared.data).toMatchObject({ armed: false, todo: [] }) + expect(cleared.data.goal).toBeUndefined() + + // `edit` without a goal is a conflict, and so is a stale `expectedRevision`. + expect((yield* request(goal, "POST", { action: "edit", objective: "x" })).status).toBe(409) + yield* request(goal, "POST", { action: "set", objective: "ship it" }) + expect((yield* request(goal, "POST", { action: "edit", objective: "x", expectedRevision: 99 })).status).toBe(409) + + // An unknown session is not found. + expect((yield* request(`/api/session/${Session.ID.create()}/goal`, "GET")).status).toBe(404) + }).pipe(Effect.scoped), +) From 1570d2fa2836d0b1889f791b7501aed55a0caacc Mon Sep 17 00:00:00 2001 From: Te_River <158043744+Te-River@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:05:55 +0800 Subject: [PATCH 5/6] fix(protocol): tighten the goal state machine and /goal parsing --- packages/client/src/effect/api/api.ts | 3 + .../client/src/effect/generated/client.ts | 18 ++- .../client/src/promise/generated/client.ts | 7 +- .../client/src/promise/generated/types.ts | 2 +- packages/core/src/plugin/command.ts | 11 +- packages/core/src/session/goal.ts | 64 +++++++++- packages/core/test/plugin/command.test.ts | 62 +++++++++- packages/core/test/session-goal.test.ts | 112 +++++++++++++++++- packages/protocol/src/groups/session.ts | 12 +- packages/server/src/handlers/session.ts | 6 +- packages/server/test/session-goal.test.ts | 28 +++++ 11 files changed, 301 insertions(+), 24 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 7f41a82ccff2..2278cdbb386e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1324,6 +1324,7 @@ export type SessionGoalUpdateInput = { readonly action: "set" | "clear" | "edit" | "pause" | "resume" readonly objective?: string | undefined readonly expectedRevision?: number | undefined + readonly expectedGoalID?: SessionGoal.ID | undefined } export type SessionGoalUpdateOutput = SessionGoal.Snapshot export type SessionGoalUpdateOperation = ( @@ -1333,6 +1334,8 @@ export type SessionGoalUpdateOperation = ( export type SessionTodoReplaceInput = { readonly sessionID: Session.ID readonly todos: ReadonlyArray + readonly expectedRevision?: number | undefined + readonly expectedGoalID?: SessionGoal.ID | undefined } export type SessionTodoReplaceOutput = void export type SessionTodoReplaceOperation = ( diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index d4f6aaf5b3ec..e9ec9148b522 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -760,7 +760,12 @@ const EndpointSessionGoalUpdate = (raw: RawClient["server.session"]) => (input: preserveEffect()( raw["session.goal.update"]({ params: { sessionID: input["sessionID"] }, - payload: { action: input["action"], objective: input["objective"], expectedRevision: input["expectedRevision"] }, + payload: { + action: input["action"], + objective: input["objective"], + expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], + }, }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -769,9 +774,14 @@ const EndpointSessionGoalUpdate = (raw: RawClient["server.session"]) => (input: const EndpointSessionTodoReplace = (raw: RawClient["server.session"]) => (input: SessionTodoReplaceInput) => preserveEffect()( - raw["session.todo.replace"]({ params: { sessionID: input["sessionID"] }, payload: { todos: input["todos"] } }).pipe( - Effect.mapError(mapClientError), - ), + raw["session.todo.replace"]({ + params: { sessionID: input["sessionID"] }, + payload: { + todos: input["todos"], + expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], + }, + }).pipe(Effect.mapError(mapClientError)), ) const EndpointSessionMessageGet = (raw: RawClient["server.session"]) => (input: SessionMessageGetInput) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index e995c2159b07..c8b442a69087 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1068,6 +1068,7 @@ export function make(options: ClientOptions) { action: input["action"], objective: input["objective"], expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], }, successStatus: 200, declaredStatuses: [400, 401, 404, 409], @@ -1082,7 +1083,11 @@ export function make(options: ClientOptions) { { method: "PUT", path: `/api/session/${encodeURIComponent(input.sessionID)}/todo`, - body: { todos: input["todos"] }, + body: { + todos: input["todos"], + expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], + }, successStatus: 204, declaredStatuses: [400, 401, 404, 409], empty: true, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e7a0f73b687d..11de9ba32351 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4650,4 +4650,4 @@ export type SessionTodoReplaceInput = { }["todos"] } -export type SessionTodoReplaceOutput = void +export type SessionTodoReplaceOutput = void \ No newline at end of file diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 802e6f65fd7f..106242322c1d 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -97,12 +97,15 @@ function append(template: string, input: string) { return [template, value].filter(Boolean).join("\n\n") } -/** `/goal [objective|clear|edit |pause|resume]`; a bare objective sets the goal. */ +/** + * `/goal [objective|clear|edit |pause|resume]`; a bare objective sets + * the goal. The subcommands match exactly, so `/goal clear the flaky tests` sets + * that objective instead of clearing the goal. + */ function parseGoal(text: string): SessionGoal.UpdateInput { const trimmed = text.trim() - const [head, ...rest] = trimmed.split(/\s+/) - if (head === "clear" || head === "pause" || head === "resume") return { action: head } - if (head === "edit") return { action: "edit", objective: rest.join(" ") } + if (trimmed === "clear" || trimmed === "pause" || trimmed === "resume") return { action: trimmed } + if (trimmed === "edit" || trimmed.startsWith("edit ")) return { action: "edit", objective: trimmed.slice(4).trim() } return { action: "set", objective: trimmed } } diff --git a/packages/core/src/session/goal.ts b/packages/core/src/session/goal.ts index d1469f18639e..ad983877c41f 100644 --- a/packages/core/src/session/goal.ts +++ b/packages/core/src/session/goal.ts @@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect" import { makeGlobalNode } from "@opencode/util/effect/app-node" import { Bus } from "../bus.js" import { Database } from "../database/database.js" +import { KeyedMutex } from "../effect/keyed-mutex.js" import { SessionGoal as SessionGoalSchema } from "@opencode/schema/session-goal" import { SessionEvent } from "./event.js" import { SessionExecution } from "./execution.js" @@ -203,7 +204,21 @@ export class ConflictError extends Schema.TaggedError()("SessionG export interface UpdateInput { readonly action: "set" | "clear" | "edit" | "pause" | "resume" readonly objective?: string + /** CAS: the revision the client last read. Supply `expectedGoalID` with it. */ readonly expectedRevision?: number + /** CAS: the goal identity the client last read. A missing id never matches. */ + readonly expectedGoalID?: SessionGoalSchema.ID +} + +/** + * The compare-and-set token. A revision alone is not enough: `set` starts a new + * goal at revision 1, so a client holding revision 1 of the previous goal would + * otherwise pass against a different goal. The pair is compared together. + */ +export interface Expected { + readonly revision: number + /** A missing id never matches an existing goal, so a client that omits it fails closed. */ + readonly goalID: SessionGoalSchema.ID | undefined } export interface Interface { @@ -217,6 +232,7 @@ export interface Interface { readonly replaceTodo: ( sessionID: SessionSchema.ID, todos: ReadonlyArray, + expected?: Expected, ) => Effect.Effect } @@ -247,11 +263,20 @@ const layer = Layer.effect( } }) + // The CAS reads the goal and publishes its successor in two steps. The + // per-session lock keeps a concurrent mutation from slipping between them, so + // two updates carrying the same token cannot both pass. + const locks = KeyedMutex.makeUnsafe() + const update = Effect.fn("SessionGoal.update")(function* (sessionID: SessionSchema.ID, input: UpdateInput) { const current = yield* read(db, sessionID) - if (input.expectedRevision !== undefined && current?.revision !== input.expectedRevision) + const expected = + input.expectedRevision === undefined + ? undefined + : { revision: input.expectedRevision, goalID: input.expectedGoalID } + if (expected && stale(current, expected)) return yield* new ConflictError({ - message: `Goal revision changed: expected ${input.expectedRevision}, found ${current?.revision ?? "none"}`, + message: `Goal changed: expected ${expected.goalID ?? "none"}@${expected.revision}, found ${current?.id ?? "none"}@${current?.revision ?? "none"}`, }) if (input.action === "set") { const snapshot: SessionGoalSchema.Snapshot = { @@ -267,20 +292,26 @@ const layer = Layer.effect( } const goal = yield* requireGoal(current) if (input.action === "edit") { + yield* requirePhase(goal, input.action, ["active", "paused"]) const snapshot = { ...goal, revision: goal.revision + 1, objective: yield* requireObjective(input) } yield* publishUpdated(bus, sessionID, snapshot) return snapshot } if (input.action === "pause") { + yield* requirePhase(goal, input.action, ["active"]) // Disarm first: it is the only action that stops the goal immediately, and - // it commits separately from the durable phase. Then cancel the running turn. + // it commits separately from the durable phase. Then cancel the running turn, + // but only a goal this process was actually driving: the phase guard already + // guarantees `active`, so `armed` is the remaining signal. + const armed = yield* activation.isArmed(sessionID) yield* activation.disarm(sessionID) const snapshot = { ...goal, revision: goal.revision + 1, phase: "paused" as const } yield* publishUpdated(bus, sessionID, snapshot) - yield* Effect.uninterruptible(execution.interrupt(sessionID, { reason: "user" })) + if (armed) yield* Effect.uninterruptible(execution.interrupt(sessionID)) return snapshot } if (input.action === "resume") { + yield* requirePhase(goal, input.action, ["paused"]) const snapshot = { ...goal, revision: goal.revision + 1, phase: "active" as const } yield* publishUpdated(bus, sessionID, snapshot) yield* activation.arm(sessionID) @@ -298,8 +329,13 @@ const layer = Layer.effect( const replaceTodo = Effect.fn("SessionGoal.replaceTodo")(function* ( sessionID: SessionSchema.ID, todos: ReadonlyArray, + expected?: Expected, ) { const goal = yield* requireGoal(yield* read(db, sessionID)) + if (expected && stale(goal, expected)) + return yield* new ConflictError({ + message: `Goal changed: expected ${expected.goalID ?? "none"}@${expected.revision}, found ${goal.id}@${goal.revision}`, + }) yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: goal.revision + 1, todos }) }) @@ -307,8 +343,8 @@ const layer = Layer.effect( read: (sessionID) => read(db, sessionID), readTodo: (sessionID) => readTodo(db, sessionID), readState, - update, - replaceTodo, + update: (sessionID, input) => locks.withLock(sessionID)(update(sessionID, input)), + replaceTodo: (sessionID, todos, expected) => locks.withLock(sessionID)(replaceTodo(sessionID, todos, expected)), }) }), ) @@ -323,6 +359,22 @@ function requireGoal(goal: SessionGoalSchema.Snapshot | undefined) { return goal ? Effect.succeed(goal) : Effect.fail(new ConflictError({ message: "No goal is set for this session" })) } +/** True when the client's CAS token no longer matches the durable goal. */ +function stale(goal: SessionGoalSchema.Snapshot | undefined, expected: Expected) { + return goal?.revision !== expected.revision || goal?.id !== expected.goalID +} + +/** Rejects an action the goal's durable phase does not allow. */ +function requirePhase( + goal: SessionGoalSchema.Snapshot, + action: UpdateInput["action"], + allowed: ReadonlyArray, +) { + return allowed.includes(goal.phase) + ? Effect.void + : Effect.fail(new ConflictError({ message: `Goal action "${action}" is not allowed in phase "${goal.phase}"` })) +} + function requireObjective(input: UpdateInput) { return input.objective !== undefined && input.objective.length > 0 ? Effect.succeed(input.objective) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 91aaa9385b39..ed58a3a7afe2 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -9,6 +9,7 @@ import { CommandPlugin } from "@opencode/core/plugin/command" import { SessionGoal } from "@opencode/core/session/goal" import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/schema/session" +import { SessionGoal as GoalSchema } from "@opencode/schema/session-goal" import { SessionInbox } from "@opencode/schema/session-inbox" import { SessionMessage } from "@opencode/schema/session-message" import { LayerNode } from "@opencode/util/effect/layer-node" @@ -26,15 +27,26 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })), ) -// The plugin only needs the goal service to register the command; the stub keeps the -// test graph to the command surface. +// The plugin only needs the goal service to register the command; the stub records +// the parsed input so the `/goal` grammar is observable. +const goalUpdates: SessionGoal.UpdateInput[] = [] const goalLayer = Layer.succeed( SessionGoal.Service, SessionGoal.Service.of({ read: () => Effect.succeed(undefined), readTodo: () => Effect.succeed([]), readState: () => Effect.die("unused SessionGoal.readState"), - update: () => Effect.die("unused SessionGoal.update"), + update: (_sessionID, input) => + Effect.sync(() => { + goalUpdates.push(input) + return { + id: GoalSchema.ID.make("goal_test"), + revision: 1, + objective: "ship it", + phase: "active" as const, + maxGoalRounds: 8, + } + }), replaceTodo: () => Effect.die("unused SessionGoal.replaceTodo"), }), ) @@ -148,4 +160,48 @@ describe("CommandPlugin.Plugin", () => { ]) }), ) + + it.effect("parses /goal subcommands exactly", () => + Effect.gen(function* () { + const command = yield* Command.Service + yield* CommandPlugin.Plugin.effect( + host({ + command: { + list: () => Effect.die("unused command.list"), + transform: command.transform, + reload: command.reload, + }, + }), + ).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), + ), + ) + + goalUpdates.length = 0 + const run = (text: string) => + command.execute({ + name: "goal", + invocation: { sessionID: Session.ID.make("ses_test"), prompt: { text }, delivery: "steer" }, + }) + yield* run("clear") + yield* run("pause") + yield* run("resume") + yield* run("edit do the thing") + yield* run("clear the flaky tests") + yield* run("pause the cron job before deploy") + yield* run("ship it") + + expect(goalUpdates).toEqual([ + { action: "clear" }, + { action: "pause" }, + { action: "resume" }, + { action: "edit", objective: "do the thing" }, + { action: "set", objective: "clear the flaky tests" }, + { action: "set", objective: "pause the cron job before deploy" }, + { action: "set", objective: "ship it" }, + ]) + }), + ) }) diff --git a/packages/core/test/session-goal.test.ts b/packages/core/test/session-goal.test.ts index 3e0eb0c0567c..545f895add23 100644 --- a/packages/core/test/session-goal.test.ts +++ b/packages/core/test/session-goal.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { eq, sql } from "drizzle-orm" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Database } from "@opencode/core/database/database" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { LayerNode } from "@opencode/util/effect/layer-node" @@ -259,6 +259,31 @@ const serviceIt = testEffect( ), ) +// Records the interrupts `pause` issues, so the gate on the process-local arming is observable. +const interrupts: SessionSchema.ID[] = [] +const recordingExecution = Layer.succeed( + SessionExecution.Service, + SessionExecution.Service.of({ + active: Effect.succeed(new Set()), + isActive: () => Effect.succeed(false), + resume: () => Effect.void, + wake: () => Effect.void, + interrupt: (sessionID) => + Effect.sync(() => { + interrupts.push(sessionID) + return true + }), + awaitIdle: () => Effect.void, + }), +) + +const interruptIt = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionGoal.node, SessionGoalActivation.node]), + [Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(recordingExecution)], + ), +) + describe("SessionGoal.Service", () => { serviceIt.effect("set arms the goal, pause disarms it, resume re-arms it, and clear disarms it", () => Effect.gen(function* () { @@ -315,4 +340,89 @@ describe("SessionGoal.Service", () => { ) }), ) + + serviceIt.effect("a CAS token from a previous goal is rejected even at the same revision", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const first = yield* goal.update(sessionID, { action: "set", objective: "first" }) + const second = yield* goal.update(sessionID, { action: "set", objective: "second" }) + // `set` starts the new goal at revision 1, so the revision alone cannot tell + // the two goals apart. + expect(second).toMatchObject({ revision: 1, objective: "second" }) + expect(second.id).not.toBe(first.id) + + const conflict = yield* goal + .update(sessionID, { action: "edit", objective: "x", expectedRevision: 1, expectedGoalID: first.id }) + .pipe(Effect.flip) + expect(conflict._tag).toBe("SessionGoal.ConflictError") + expect(yield* goal.read(sessionID)).toMatchObject({ objective: "second" }) + + // The current goal's own token still passes. + expect( + yield* goal.update(sessionID, { action: "edit", objective: "x", expectedRevision: 1, expectedGoalID: second.id }), + ).toMatchObject({ revision: 2, objective: "x" }) + }), + ) + + serviceIt.effect("phase guards reject pause on paused, resume on active, and edit/resume on a terminal goal", () => + Effect.gen(function* () { + const { bus } = yield* setup + const goal = yield* SessionGoal.Service + const conflict = (input: SessionGoal.UpdateInput) => goal.update(sessionID, input).pipe(Effect.flip) + + const active = yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + expect((yield* conflict({ action: "resume" }))._tag).toBe("SessionGoal.ConflictError") + + yield* goal.update(sessionID, { action: "pause" }) + expect((yield* conflict({ action: "pause" }))._tag).toBe("SessionGoal.ConflictError") + + const resumed = yield* goal.update(sessionID, { action: "resume" }) + expect(resumed).toMatchObject({ phase: "active" }) + + // A terminal phase is written by the driver; the service must still refuse to move it. + yield* bus.publish(SessionEvent.Goal.Updated, { + sessionID, + snapshot: { ...active, revision: resumed.revision + 1, phase: "complete" }, + }) + expect((yield* conflict({ action: "edit", objective: "x" }))._tag).toBe("SessionGoal.ConflictError") + expect((yield* conflict({ action: "resume" }))._tag).toBe("SessionGoal.ConflictError") + }), + ) + + serviceIt.effect("replaceTodo rejects a stale CAS token", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const first = yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + yield* goal.replaceTodo(sessionID, todos, { revision: 1, goalID: first.id }) + expect(yield* goal.readTodo(sessionID)).toHaveLength(2) + + const conflict = yield* goal + .replaceTodo(sessionID, [], { revision: 1, goalID: first.id }) + .pipe(Effect.flip) + expect(conflict._tag).toBe("SessionGoal.ConflictError") + expect(yield* goal.readTodo(sessionID)).toHaveLength(2) + }), + ) + + interruptIt.effect("pause interrupts only a goal this process armed", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const activation = yield* SessionGoalActivation.Service + + interrupts.length = 0 + yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + yield* goal.update(sessionID, { action: "pause" }) + expect(interrupts).toEqual([sessionID]) + + // Active but not armed in this process (a restart leaves it disarmed): nothing to cancel. + yield* goal.update(sessionID, { action: "resume" }) + yield* activation.disarm(sessionID) + interrupts.length = 0 + yield* goal.update(sessionID, { action: "pause" }) + expect(interrupts).toEqual([]) + }), + ) }) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index cf4f47cd238d..3e6537c78e92 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -729,6 +729,7 @@ export const makeSessionGroup = new ConflictError({ message: error.message })), ) diff --git a/packages/server/test/session-goal.test.ts b/packages/server/test/session-goal.test.ts index dbc16339b93e..fb6ec5e0762a 100644 --- a/packages/server/test/session-goal.test.ts +++ b/packages/server/test/session-goal.test.ts @@ -47,6 +47,9 @@ it.live("serves the session goal and todo endpoints", () => expect(set.data).toMatchObject({ revision: 1, objective: "ship it", phase: "active" }) expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(true) + // `resume` on an active goal is a phase conflict. + expect((yield* request(goal, "POST", { action: "resume" })).status).toBe(409) + // The todo list is replaced wholesale. expect( (yield* request(todo, "PUT", { todos: [{ content: "first", status: "pending", acceptance: "tests pass" }] })) @@ -56,11 +59,19 @@ it.live("serves the session goal and todo endpoints", () => { position: 0, content: "first", status: "pending", acceptance: "tests pass" }, ]) + // A stale CAS token on the todo path is a conflict. + expect( + (yield* request(todo, "PUT", { todos: [], expectedRevision: 1, expectedGoalID: set.data.id })).status, + ).toBe(409) + // `pause` is the user's cancel: it disarms and bumps the revision. const paused = Schema.decodeUnknownSync(GoalSnapshot)((yield* request(goal, "POST", { action: "pause" })).body) expect(paused.data).toMatchObject({ revision: 3, phase: "paused" }) expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(false) + // `pause` on a paused goal is a phase conflict. + expect((yield* request(goal, "POST", { action: "pause" })).status).toBe(409) + // `resume` re-arms. const resumed = Schema.decodeUnknownSync(GoalSnapshot)((yield* request(goal, "POST", { action: "resume" })).body) expect(resumed.data).toMatchObject({ revision: 4, phase: "active" }) @@ -77,6 +88,23 @@ it.live("serves the session goal and todo endpoints", () => yield* request(goal, "POST", { action: "set", objective: "ship it" }) expect((yield* request(goal, "POST", { action: "edit", objective: "x", expectedRevision: 99 })).status).toBe(409) + // A CAS token from the previous goal is rejected even at the same revision. + const first = Schema.decodeUnknownSync(GoalSnapshot)( + (yield* request(goal, "POST", { action: "set", objective: "first" })).body, + ) + const second = Schema.decodeUnknownSync(GoalSnapshot)( + (yield* request(goal, "POST", { action: "set", objective: "second" })).body, + ) + expect(second.data.revision).toBe(1) + expect( + (yield* request(goal, "POST", { + action: "edit", + objective: "x", + expectedRevision: 1, + expectedGoalID: first.data.id, + })).status, + ).toBe(409) + // An unknown session is not found. expect((yield* request(`/api/session/${Session.ID.create()}/goal`, "GET")).status).toBe(404) }).pipe(Effect.scoped), From 469bc17785b19d1fa4d6b22b52ff352bb19e5896 Mon Sep 17 00:00:00 2001 From: Te_River <158043744+Te-River@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:03:40 +0800 Subject: [PATCH 6/6] chore(client): regenerate for the goal endpoints --- .../client/src/promise/generated/types.ts | 282 ++++++++---------- 1 file changed, 126 insertions(+), 156 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 11de9ba32351..ab040cf009fe 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -148,6 +148,22 @@ export type InstructionHash = string export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } } +export type SessionGoalID = string + +export type SessionGoalReason = { code: string; message: string } + +export type SessionGoalTodoInput = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string +} + +export type SessionGoalTodoVerification = { + position: number + status: "pending" | "in_progress" | "completed" | "cancelled" + feedback: string +} + export type SessionMessageNative1 = { [x: string]: any } export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined } @@ -160,6 +176,14 @@ export type SessionMessageToolStateRunning1 = { export type SessionInterruptResponse = { interrupted: boolean } +export type SessionGoalTodoItem = { + position: number | "Infinity" | "-Infinity" | "NaN" + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string + feedback?: string +} + export type FormID = string export type FormMetadata = { [x: string]: JsonValue } @@ -564,6 +588,24 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: Ev export type LocationRef = { directory: AbsolutePath; workspaceID?: WorkspaceID } +export type SessionGoalSnapshot = { + id: SessionGoalID + revision: number + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number +} + +export type SessionGoalSnapshot1 = { + id: SessionGoalID + revision: number | "Infinity" | "-Infinity" | "NaN" + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" +} + export type SessionMessageAssistantText1 = { type: "text"; text: string; native?: SessionMessageNative1 } export type SessionMessageAssistantReasoning1 = { @@ -1236,6 +1278,36 @@ export type SessionInstructionsUpdated = { data: { sessionID: SessionID; delta: { [x: string]: InstructionHash | "removed" }; text?: string } } +export type SessionGoalCleared = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.cleared" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } +} + +export type SessionTodoUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; todos: Array } +} + +export type SessionTodoVerified = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.verified" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; items: Array } +} + export type SessionSynthetic = { id: EventID created: number @@ -1977,6 +2049,25 @@ export type McpResourcesChanged = { data: { server: string } } +export type SessionGoalUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } +} + +export type SessionGoalState = { + goal?: SessionGoalSnapshot1 + createdAt?: number | "Infinity" | "-Infinity" | "NaN" + rounds: number | "Infinity" | "-Infinity" | "NaN" + maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" + armed: boolean + todo: Array +} + export type SessionToolSuccess = { id: EventID created: number @@ -2642,6 +2733,10 @@ export type SessionEventDurable = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated + | SessionGoalUpdated + | SessionGoalCleared + | SessionTodoUpdated + | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -2708,6 +2803,10 @@ export type V2Event = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated + | SessionGoalUpdated + | SessionGoalCleared + | SessionTodoUpdated + | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -3446,6 +3545,33 @@ export type SessionBackgroundInput = { readonly sessionID: string } export type SessionBackgroundOutput = void +export type SessionGoalGetInput = { readonly sessionID: string } + +export type SessionGoalGetOutput = { data: SessionGoalState }["data"] + +export type SessionGoalUpdateInput = { + readonly sessionID: string + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string | undefined + readonly expectedRevision?: number | undefined + readonly expectedGoalID?: string | undefined +} + +export type SessionGoalUpdateOutput = { data: SessionGoalSnapshot1 }["data"] + +export type SessionTodoReplaceInput = { + readonly sessionID: string + readonly todos: ReadonlyArray<{ + readonly content: string + readonly status: "pending" | "in_progress" | "completed" | "cancelled" + readonly acceptance?: string + }> + readonly expectedRevision?: number | "Infinity" | "-Infinity" | "NaN" | null | undefined + readonly expectedGoalID?: string | null | undefined +} + +export type SessionTodoReplaceOutput = void + export type SessionMessageGetInput = { readonly sessionID: string; readonly messageID: string } export type SessionMessageGetOutput = { data: SessionMessageInfo }["data"] @@ -4495,159 +4621,3 @@ export type ConfigShellsOutput = Array export type ConfigUpdateInput = { readonly shell: string | null } export type ConfigUpdateOutput = void - -export type SessionGoalID = string - -export type SessionGoalReason = { code: string; message: string } - -export type SessionGoalTodoInput = { - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - acceptance?: string -} - -export type SessionGoalTodoVerification = { - position: number - status: "pending" | "in_progress" | "completed" | "cancelled" - feedback: string -} - -export type SessionGoalSnapshot = { - id: SessionGoalID - revision: number - objective: string - phase: "active" | "paused" | "blocked" | "complete" - blockedReason?: SessionGoalReason - maxGoalRounds: number -} - -export type SessionGoalCleared = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.goal.cleared" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } -} - -export type SessionTodoUpdated = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.todo.updated" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; revision: number; todos: Array } -} - -export type SessionTodoVerified = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.todo.verified" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; revision: number; items: Array } -} - -export type SessionGoalUpdated = { - id: EventID - created: number - metadata?: { [x: string]: any } - type: "session.goal.updated" - durable: { aggregateID: string; seq: EventSeq; version: 1 } - location?: LocationRef - data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } -} -export type SessionMessageProviderState = { [x: string]: JsonValue } - -export type SessionMessageProviderState1 = { [x: string]: any } - -export type SessionGoalTodoItem = { - position: number | "Infinity" | "-Infinity" | "NaN" - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - acceptance?: string - feedback?: string -} - -export type SessionProviderContextProvenance = { - providerID: ProviderID - provider: string - modelID: string - route: string - protocol: string - endpoint: string -} - -export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState } - -export type SessionGoalSnapshot1 = { - id: SessionGoalID - revision: number | "Infinity" | "-Infinity" | "NaN" - objective: string - phase: "active" | "paused" | "blocked" | "complete" - blockedReason?: SessionGoalReason - maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" -} - -export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 } - -export type FormAnswer2 = { [x: string]: FormValue1 } - -export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue } - -export type SessionGoalState = { - goal?: SessionGoalSnapshot1 - createdAt?: number | "Infinity" | "-Infinity" | "NaN" - rounds: number | "Infinity" | "-Infinity" | "NaN" - maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" - armed: boolean - todo: Array -} - -export type CredentialKey = { - type: "key" - key: string - metadata?: { [x: string]: JsonValue } - configuration?: FormAnswer -} - -export type SessionGoalGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } - -export type SessionGoalGetOutput = { data: SessionGoalState }["data"] - -export type SessionGoalUpdateInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly action: { - readonly action: "set" | "clear" | "edit" | "pause" | "resume" - readonly objective?: string | undefined - readonly expectedRevision?: number | undefined - }["action"] - readonly objective?: { - readonly action: "set" | "clear" | "edit" | "pause" | "resume" - readonly objective?: string | undefined - readonly expectedRevision?: number | undefined - }["objective"] - readonly expectedRevision?: { - readonly action: "set" | "clear" | "edit" | "pause" | "resume" - readonly objective?: string | undefined - readonly expectedRevision?: number | undefined - }["expectedRevision"] -} - -export type SessionGoalUpdateOutput = { data: SessionGoalSnapshot1 }["data"] - -export type SessionTodoReplaceInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly todos: { - readonly todos: ReadonlyArray<{ - readonly content: string - readonly status: "pending" | "in_progress" | "completed" | "cancelled" - readonly acceptance?: string - }> - }["todos"] -} - -export type SessionTodoReplaceOutput = void \ No newline at end of file