diff --git a/packages/core/schema.json b/packages/core/schema.json index 698e2f1ad..ff6946241 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "68fc67ad-c3bd-4c0f-923d-db9817bf7475", + "id": "847009df-a964-45cd-b79f-6d07bf06e3e2", "prevIds": [ - "169b3aad-5dd0-4a14-a772-423b22ab2217" + "68fc67ad-c3bd-4c0f-923d-db9817bf7475" ], "ddl": [ { @@ -70,6 +70,14 @@ "name": "session_input", "entityType": "tables" }, + { + "name": "session_lineage_origin", + "entityType": "tables" + }, + { + "name": "session_lineage", + "entityType": "tables" + }, { "name": "session_message", "entityType": "tables" @@ -1066,6 +1074,126 @@ "entityType": "columns", "table": "session_input" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "root_id", + "entityType": "columns", + "table": "session_lineage_origin" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_lineage_origin" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session_lineage_origin" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "edge_kind", + "entityType": "columns", + "table": "session_lineage_origin" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "session_lineage_origin" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_lineage" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "root_id", + "entityType": "columns", + "table": "session_lineage" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mode", + "entityType": "columns", + "table": "session_lineage" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session_lineage" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "edge_kind", + "entityType": "columns", + "table": "session_lineage" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "legacy_parent_id", + "entityType": "columns", + "table": "session_lineage" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "epoch_started_at", + "entityType": "columns", + "table": "session_lineage" + }, { "type": "text", "notNull": false, @@ -1716,6 +1844,21 @@ "entityType": "fks", "table": "session_input" }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_lineage_session_id_session_id_fk", + "entityType": "fks", + "table": "session_lineage" + }, { "columns": [ "session_id" @@ -1796,6 +1939,16 @@ "entityType": "pks", "table": "project_directory" }, + { + "columns": [ + "root_id", + "session_id" + ], + "nameExplicit": false, + "name": "session_lineage_origin_pk", + "entityType": "pks", + "table": "session_lineage_origin" + }, { "columns": [ "session_id", @@ -1932,6 +2085,15 @@ "table": "session_input", "entityType": "pks" }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_lineage_pk", + "table": "session_lineage", + "entityType": "pks" + }, { "columns": [ "id" @@ -2163,6 +2325,48 @@ "entityType": "indexes", "table": "session_input" }, + { + "columns": [ + { + "value": "root_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_lineage_origin_root_idx", + "entityType": "indexes", + "table": "session_lineage_origin" + }, + { + "columns": [ + { + "value": "root_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_lineage_root_idx", + "entityType": "indexes", + "table": "session_lineage" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_lineage_parent_idx", + "entityType": "indexes", + "table": "session_lineage" + }, { "columns": [ { diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 62455690e..4442393e5 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -59,6 +59,8 @@ const MERGE_TABLES = [ "event", "permission", "session", + "session_lineage", + "session_lineage_origin", "session_message", "session_input", "session_context_epoch", diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index cf0271a66..9d026c645 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -43,5 +43,6 @@ export const migrations = ( import("./migration/20260813162312_shocking_karnak"), import("./migration/20260820000001_add_session_directories"), import("./migration/20260828201050_normal_stryfe"), + import("./migration/20260913205004_session-lineage"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260913205004_session-lineage.ts b/packages/core/src/database/migration/20260913205004_session-lineage.ts new file mode 100644 index 000000000..01550c33f --- /dev/null +++ b/packages/core/src/database/migration/20260913205004_session-lineage.ts @@ -0,0 +1,35 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260913205004_session-lineage", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_lineage_origin\` ( + \`root_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`title\` text NOT NULL, + \`edge_kind\` text NOT NULL, + \`deleted_at\` integer NOT NULL, + CONSTRAINT \`session_lineage_origin_pk\` PRIMARY KEY(\`root_id\`, \`session_id\`) + ); + `) + yield* tx.run(` + CREATE TABLE \`session_lineage\` ( + \`session_id\` text PRIMARY KEY, + \`root_id\` text NOT NULL, + \`mode\` text NOT NULL, + \`parent_id\` text, + \`edge_kind\` text, + \`legacy_parent_id\` text, + \`epoch_started_at\` integer, + CONSTRAINT \`fk_session_lineage_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE INDEX \`session_lineage_origin_root_idx\` ON \`session_lineage_origin\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_root_idx\` ON \`session_lineage\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_parent_idx\` ON \`session_lineage\` (\`parent_id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 4ff28f1da..b687865f2 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -178,6 +178,28 @@ export default { CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_lineage_origin\` ( + \`root_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`title\` text NOT NULL, + \`edge_kind\` text NOT NULL, + \`deleted_at\` integer NOT NULL, + CONSTRAINT \`session_lineage_origin_pk\` PRIMARY KEY(\`root_id\`, \`session_id\`) + ); + `) + yield* tx.run(` + CREATE TABLE \`session_lineage\` ( + \`session_id\` text PRIMARY KEY, + \`root_id\` text NOT NULL, + \`mode\` text NOT NULL, + \`parent_id\` text, + \`edge_kind\` text, + \`legacy_parent_id\` text, + \`epoch_started_at\` integer, + CONSTRAINT \`fk_session_lineage_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_message\` ( \`id\` text PRIMARY KEY, @@ -271,6 +293,9 @@ export default { yield* tx.run( `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, ) + yield* tx.run(`CREATE INDEX \`session_lineage_origin_root_idx\` ON \`session_lineage_origin\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_root_idx\` ON \`session_lineage\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_parent_idx\` ON \`session_lineage\` (\`parent_id\`);`) yield* tx.run( `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, ) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 1fa7ee6aa..b0eea148c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -66,6 +66,49 @@ export const SessionTable = sqliteTable( ], ) +/** + * Durable Files Changed lineage. A missing row is deliberately meaningful: it + * denotes a pre-rollout (legacy) session, never an inferred relationship. + */ +export const SessionLineageTable = sqliteTable( + "session_lineage", + { + session_id: text() + .$type() + .primaryKey() + .references(() => SessionTable.id, { onDelete: "cascade" }), + root_id: text().$type().notNull(), + mode: text().$type<"legacy" | "partial" | "full">().notNull(), + parent_id: text().$type(), + edge_kind: text().$type<"task_spawn" | "session_spawn">(), + legacy_parent_id: text().$type(), + epoch_started_at: integer(), + }, + (table) => [ + index("session_lineage_root_idx").on(table.root_id), + index("session_lineage_parent_idx").on(table.parent_id), + ], +) + +/** + * The root-owned, deletion-safe minimum required to render historical Files + * Changed receipts. It intentionally excludes child context and evidence. + */ +export const SessionLineageOriginTable = sqliteTable( + "session_lineage_origin", + { + root_id: text().$type().notNull(), + session_id: text().$type().notNull(), + title: text().notNull(), + edge_kind: text().$type<"task_spawn" | "session_spawn">().notNull(), + deleted_at: integer().notNull(), + }, + (table) => [ + primaryKey({ columns: [table.root_id, table.session_id] }), + index("session_lineage_origin_root_idx").on(table.root_id), + ], +) + export const MessageTable = sqliteTable( "message", { diff --git a/packages/opencode/src/session/lineage.ts b/packages/opencode/src/session/lineage.ts new file mode 100644 index 000000000..3caaafdfb --- /dev/null +++ b/packages/opencode/src/session/lineage.ts @@ -0,0 +1,212 @@ +import { Database } from "@opencode-ai/core/database/database" +import { SessionLineageOriginTable, SessionLineageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { and, eq, isNull } from "drizzle-orm" +import { Effect } from "effect" +import { SessionID } from "./schema" + +export namespace SessionLineage { + export type Mode = "legacy" | "partial" | "full" + export type EdgeKind = "task_spawn" | "session_spawn" + export type Origin = { + sessionID: SessionID + title: string + edgeKind: EdgeKind + deletedAt: number + } + export type Descendant = { + sessionID: SessionID + parentID: SessionID + title: string + edgeKind: EdgeKind + mode: Exclude + } + export type Info = + | { mode: "legacy"; rootID: undefined; root: undefined; descendants: []; retainedOrigins: Origin[] } + | { + mode: Exclude + rootID: SessionID + root: { sessionID: SessionID; title: string } + legacyParentID?: SessionID + descendants: Descendant[] + retainedOrigins: Origin[] + } + + export function register( + database: Database.Interface, + input: { sessionID: SessionID; parentID?: SessionID; edgeKind?: EdgeKind }, + ) { + return Effect.gen(function* () { + if (!input.parentID) { + yield* database.db + .insert(SessionLineageTable) + .values({ session_id: input.sessionID, root_id: input.sessionID, mode: "full" }) + .run() + .pipe(Effect.orDie) + return + } + + const parent = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, input.parentID)) + .get() + .pipe(Effect.orDie) + if (!parent) { + yield* database.db + .insert(SessionLineageTable) + .values({ + session_id: input.sessionID, + root_id: input.sessionID, + mode: "full", + legacy_parent_id: input.parentID, + }) + .run() + .pipe(Effect.orDie) + return + } + if (parent.mode === "legacy") + return yield* Effect.die(`Invalid persisted lineage mode for ${input.parentID}`) + + yield* database.db + .insert(SessionLineageTable) + .values({ + session_id: input.sessionID, + root_id: parent.root_id, + mode: parent.mode, + parent_id: input.parentID, + edge_kind: input.edgeKind ?? "session_spawn", + }) + .run() + .pipe(Effect.orDie) + }) + } + + /** Opens the explicit partial epoch for a legacy session; it never backfills history. */ + export function beginPartial(database: Database.Interface, sessionID: SessionID, boundary = Date.now()) { + return Effect.gen(function* () { + const existing = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (existing) return + yield* database.db + .insert(SessionLineageTable) + .values({ session_id: sessionID, root_id: sessionID, mode: "partial", epoch_started_at: boundary }) + .run() + .pipe(Effect.orDie) + }) + } + + export function get( + database: Database.Interface, + sessionID: SessionID, + options?: { retainedOrigins?: boolean }, + ) { + return Effect.gen(function* () { + const lineage = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!lineage) + return { + mode: "legacy", + rootID: undefined, + root: undefined, + descendants: [], + retainedOrigins: [], + } satisfies Info + if (lineage.mode === "legacy") return yield* Effect.die(`Invalid persisted lineage mode for ${sessionID}`) + + const root = yield* database.db + .select({ id: SessionTable.id, title: SessionTable.title }) + .from(SessionTable) + .where(eq(SessionTable.id, lineage.root_id)) + .get() + .pipe(Effect.orDie) + if (!root) return yield* Effect.die(`Missing lineage root: ${lineage.root_id}`) + + const descendants = yield* database.db + .select({ lineage: SessionLineageTable, session: SessionTable }) + .from(SessionLineageTable) + .innerJoin(SessionTable, eq(SessionTable.id, SessionLineageTable.session_id)) + .where(and(eq(SessionLineageTable.root_id, lineage.root_id), isNull(SessionTable.time_archived))) + .all() + .pipe(Effect.orDie) + const retainedOrigins = options?.retainedOrigins + ? yield* database.db + .select() + .from(SessionLineageOriginTable) + .where(eq(SessionLineageOriginTable.root_id, lineage.root_id)) + .all() + .pipe(Effect.orDie) + : [] + return { + mode: lineage.mode, + rootID: lineage.root_id, + root: { sessionID: root.id, title: root.title }, + ...(lineage.legacy_parent_id ? { legacyParentID: lineage.legacy_parent_id } : {}), + descendants: descendants.flatMap((item) => { + if ( + item.lineage.session_id === lineage.root_id || + !item.lineage.parent_id || + !item.lineage.edge_kind || + item.lineage.mode === "legacy" + ) + return [] + return [ + { + sessionID: item.lineage.session_id, + parentID: item.lineage.parent_id, + title: item.session.title, + edgeKind: item.lineage.edge_kind, + mode: item.lineage.mode, + }, + ] + }), + retainedOrigins: retainedOrigins.map((origin) => ({ + sessionID: origin.session_id, + title: origin.title, + edgeKind: origin.edge_kind, + deletedAt: origin.deleted_at, + })), + } satisfies Info + }) + } + + export function retainBeforeDelete(database: Database.Interface, input: { sessionID: SessionID; title: string }) { + return Effect.gen(function* () { + const lineage = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!lineage) return + if (lineage.root_id === input.sessionID) { + yield* database.db + .delete(SessionLineageOriginTable) + .where(eq(SessionLineageOriginTable.root_id, input.sessionID)) + .run() + .pipe(Effect.orDie) + return + } + if (!lineage.edge_kind) return + yield* database.db + .insert(SessionLineageOriginTable) + .values({ + root_id: lineage.root_id, + session_id: input.sessionID, + title: input.title, + edge_kind: lineage.edge_kind, + deleted_at: Date.now(), + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + } +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 27b275c7f..6d8ac5c1d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -33,6 +33,7 @@ import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ExternalDiff } from "@/session/external-diff" +import { SessionLineage } from "@/session/lineage" import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" @@ -418,6 +419,7 @@ export interface Interface { readonly listGlobal: (input?: GlobalListInput) => Effect.Effect readonly create: (input?: { parentID?: SessionID + lineageEdgeKind?: SessionLineage.EdgeKind title?: string agent?: string model?: Schema.Schema.Type @@ -450,6 +452,11 @@ export interface Interface { readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect + readonly lineage: ( + sessionID: SessionID, + options?: { retainedOrigins?: boolean }, + ) => Effect.Effect + readonly beginPartialLineage: (sessionID: SessionID, boundary?: number) => Effect.Effect readonly remove: (sessionID: SessionID) => Effect.Effect readonly updateMessage: (msg: T) => Effect.Effect readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect @@ -506,6 +513,7 @@ const layer: Layer.Layer< agent?: string model?: Schema.Schema.Type parentID?: SessionID + lineageEdgeKind?: SessionLineage.EdgeKind workspaceID?: WorkspaceV2.ID directory: string path?: string @@ -537,6 +545,11 @@ const layer: Layer.Layer< yield* Effect.logInfo("created", result) yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result }) + yield* SessionLineage.register(database, { + sessionID: result.id, + parentID: input.parentID, + edgeKind: input.lineageEdgeKind, + }) return result }) @@ -627,6 +640,7 @@ const layer: Layer.Layer< // External baselines are host-local and never belong to a fork or a // transcript. Tombstone them before deleting session records so an // in-flight mutation cannot recreate ownership after teardown starts. + yield* SessionLineage.retainBeforeDelete(database, { sessionID, title: session.title }) ExternalDiff.remove(sessionID) yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session }) yield* events.remove(sessionID) @@ -675,6 +689,7 @@ const layer: Layer.Layer< const create = Effect.fn("Session.create")(function* (input?: { parentID?: SessionID + lineageEdgeKind?: SessionLineage.EdgeKind title?: string agent?: string model?: Schema.Schema.Type @@ -686,6 +701,7 @@ const layer: Layer.Layer< const workspace = yield* InstanceState.workspaceID return yield* createNext({ parentID: input?.parentID, + lineageEdgeKind: input?.lineageEdgeKind, directory: ctx.directory, path: sessionPath(ctx.worktree, ctx.directory), title: input?.title, @@ -697,6 +713,18 @@ const layer: Layer.Layer< }) }) + const lineage: Interface["lineage"] = Effect.fn("Session.lineage")(function* (sessionID, options) { + yield* get(sessionID) + return yield* SessionLineage.get(database, sessionID, options) + }) + + const beginPartialLineage: Interface["beginPartialLineage"] = Effect.fn("Session.beginPartialLineage")( + function* (sessionID, boundary) { + yield* get(sessionID) + yield* SessionLineage.beginPartial(database, sessionID, boundary) + }, + ) + const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { const ctx = yield* InstanceState.context const original = yield* get(input.sessionID) @@ -1112,6 +1140,8 @@ const layer: Layer.Layer< diff, messages, children, + lineage, + beginPartialLineage, remove, updateMessage, removeMessage, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1384e5d19..f6dbf29ab 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -157,6 +157,7 @@ export const TaskTool = Tool.define( session ?? (yield* sessions.create({ parentID: ctx.sessionID, + lineageEdgeKind: "task_spawn", title: params.description + ` (@${next.name} subagent)`, agent: next.name, permission: [ diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 86086eb58..0870a747f 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Database } from "@opencode-ai/core/database/database" +import { SessionLineageTable } from "@opencode-ai/core/session/sql" import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" @@ -18,11 +20,13 @@ import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" import { ExternalDiff } from "@/session/external-diff" import path from "path" +import { eq } from "drizzle-orm" const it = testEffect( AppNodeBuilder.build( LayerNode.group([ SessionNs.node, + Database.node, EventV2Bridge.node, SessionProjector.node, CrossSpawnSpawner.node, @@ -208,6 +212,139 @@ describe("step-finish token propagation via event", () => { }) describe("Session", () => { + it.instance("creates one full lineage root for each new session", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* session.create({ title: "lineage root" }) + + expect(yield* session.lineage(created.id)).toMatchObject({ + mode: "full", + rootID: created.id, + root: { sessionID: created.id, title: "lineage root" }, + descendants: [], + }) + }), + ) + + it.instance("keeps registered task and session spawns under the parent root with typed edges", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const parent = yield* session.create({ title: "parent" }) + const task = yield* session.create({ + parentID: parent.id, + lineageEdgeKind: "task_spawn", + title: "task child", + }) + const spawned = yield* session.create({ parentID: parent.id, title: "spawn child" }) + + expect(yield* session.lineage(task.id)).toMatchObject({ + mode: "full", + rootID: parent.id, + root: { sessionID: parent.id }, + descendants: [ + { sessionID: task.id, parentID: parent.id, edgeKind: "task_spawn", mode: "full" }, + { sessionID: spawned.id, parentID: parent.id, edgeKind: "session_spawn", mode: "full" }, + ], + }) + }), + ) + + it.instance("keeps legacy parents outside aggregation until an explicit partial epoch opens", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const legacy = yield* session.create({ title: "before rollout" }) + yield* database.db.delete(SessionLineageTable).where(eq(SessionLineageTable.session_id, legacy.id)).run() + + expect(yield* session.lineage(legacy.id)).toMatchObject({ + mode: "legacy", + rootID: undefined, + root: undefined, + descendants: [], + }) + + const beforeEpoch = yield* session.create({ parentID: legacy.id, title: "untracked parent spawn" }) + expect(yield* session.lineage(beforeEpoch.id)).toMatchObject({ + mode: "full", + rootID: beforeEpoch.id, + legacyParentID: legacy.id, + descendants: [], + }) + + yield* session.beginPartialLineage(legacy.id, 42) + const afterEpoch = yield* session.create({ parentID: legacy.id, title: "tracked parent spawn" }) + expect(yield* session.lineage(afterEpoch.id)).toMatchObject({ + mode: "partial", + rootID: legacy.id, + descendants: [{ sessionID: afterEpoch.id, edgeKind: "session_spawn", mode: "partial" }], + }) + }), + ) + + it.instance("gives forks independent roots without inherited lineage descendants", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const parent = yield* session.create({ title: "source" }) + const child = yield* session.create({ parentID: parent.id, title: "source child" }) + const fork = yield* session.fork({ sessionID: parent.id }) + + expect(yield* session.lineage(fork.id)).toMatchObject({ + mode: "full", + rootID: fork.id, + root: { sessionID: fork.id }, + descendants: [], + }) + expect(yield* session.lineage(parent.id)).toMatchObject({ + rootID: parent.id, + descendants: [{ sessionID: child.id }], + }) + }), + ) + + it.instance("hides archived children and retains only their root-owned origin projection after deletion", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const root = yield* session.create({ title: "root" }) + const child = yield* session.create({ parentID: root.id, title: "child", metadata: { private: "do not retain" } }) + + yield* session.setArchived({ sessionID: child.id, time: 1 }) + expect((yield* session.lineage(root.id)).descendants).toEqual([]) + + yield* session.remove(child.id) + const lineage = yield* session.lineage(root.id, { retainedOrigins: true }) + expect(lineage).toMatchObject({ + rootID: root.id, + descendants: [], + retainedOrigins: [{ sessionID: child.id, title: "child", edgeKind: "session_spawn" }], + }) + expect(lineage.retainedOrigins[0]).not.toHaveProperty("metadata") + }), + ) + + it.instance("queries only registered descendants of the requested concurrent root", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const [first, second] = yield* Effect.all( + [session.create({ title: "first root" }), session.create({ title: "second root" })], + { concurrency: "unbounded" }, + ) + const [firstChild, secondChild] = yield* Effect.all( + [ + session.create({ parentID: first.id, title: "first child" }), + session.create({ parentID: second.id, title: "second child" }), + ], + { concurrency: "unbounded" }, + ) + + expect((yield* session.lineage(first.id)).descendants).toEqual([ + expect.objectContaining({ sessionID: firstChild.id, parentID: first.id }), + ]) + expect((yield* session.lineage(second.id)).descendants).toEqual([ + expect.objectContaining({ sessionID: secondChild.id, parentID: second.id }), + ]) + }), + ) + it.live("remove works without an instance", () => Effect.gen(function* () { const session = yield* SessionNs.Service diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2bcf05a2a..c879ec35e 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -385,6 +385,10 @@ describe("tool.task", () => { expect(result.metadata.sessionId).not.toBe("ses_missing") expect(result.output).toContain(``) expect(seen?.sessionID).toBe(result.metadata.sessionId) + expect(yield* sessions.lineage(result.metadata.sessionId)).toMatchObject({ + rootID: chat.id, + descendants: [{ sessionID: result.metadata.sessionId, parentID: chat.id, edgeKind: "task_spawn" }], + }) }), )