From 7b604322b6994b0696372d8887c8159d6e972893 Mon Sep 17 00:00:00 2001 From: likun666661 <90952590+likun666661@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:59:20 +0800 Subject: [PATCH] model indexed subagent session relations --- .../__tests__/subagent-session-parent.test.ts | 83 +++++++++ packages/core/src/index.ts | 5 + packages/core/src/runtime-inputs.ts | 10 +- packages/core/src/session.ts | 91 +++++++++ .../src/__tests__/session-manager.test.ts | 40 ++++ packages/runtime/src/session-manager.ts | 7 + .../src/__tests__/session-store.test.ts | 106 ++++++++++- .../sqlite-session-metadata-store.test.ts | 176 +++++++++++++++++- .../__tests__/sqlite-session-store.test.ts | 40 ++++ packages/storage/src/session-store.ts | 42 ++++- .../src/sqlite-session-metadata-schema.ts | 16 +- .../src/sqlite-session-metadata-store.ts | 14 +- 12 files changed, 617 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/__tests__/subagent-session-parent.test.ts diff --git a/packages/core/src/__tests__/subagent-session-parent.test.ts b/packages/core/src/__tests__/subagent-session-parent.test.ts new file mode 100644 index 00000000000..67df7169b62 --- /dev/null +++ b/packages/core/src/__tests__/subagent-session-parent.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { SessionSummary, SubagentSessionParent } from '../session.js'; +import { childSessionsForParent, isSubagentSessionParent } from '../session.js'; + +const relation: SubagentSessionParent = { + kind: 'subagent', + parentSessionId: 'parent-session', + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground', +}; + +describe('subagent session parent relation', () => { + test('strictly decodes standalone and swarm relations', () => { + assert.equal(isSubagentSessionParent(relation), true); + assert.equal( + isSubagentSessionParent({ + ...relation, + swarm: { swarmId: 'swarm-1', itemId: 'item-1' }, + }), + true, + ); + }); + + test('rejects malformed, unsupported, or extended persisted relations', () => { + assert.equal(isSubagentSessionParent({ ...relation, lifecycle: 'detached' }), false); + assert.equal( + isSubagentSessionParent({ + ...relation, + spawnedBy: { parentRunId: 'parent-run', parentTurnId: 'parent-turn' }, + }), + false, + ); + assert.equal(isSubagentSessionParent({ ...relation, parentSessionId: 'bad\nid' }), false); + assert.equal(isSubagentSessionParent({ ...relation, unexpected: true }), false); + }); + + test('derives reverse children without conflating ordinary branches', () => { + const childA = summary('child-a', { subagentParent: relation }); + const branch = summary('branch', { + parentSessionId: 'parent-session', + branchOfTurnId: 'parent-turn', + }); + const childB = summary('child-b', { + subagentParent: { ...relation, swarm: { swarmId: 'swarm-1', itemId: 'item-1' } }, + }); + const otherChild = summary('other-child', { + subagentParent: { ...relation, parentSessionId: 'other-parent' }, + }); + + assert.deepEqual( + childSessionsForParent([childA, branch, childB, otherChild], 'parent-session').map( + (session) => session.id, + ), + ['child-a', 'child-b'], + ); + }); +}); + +function summary(id: string, overrides: Partial = {}): SessionSummary { + return { + id, + cwd: '/tmp', + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'fake', + connectionLocked: false, + model: 'fake-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + ...overrides, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 63cbb866c46..4eb89bea767 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -165,6 +165,8 @@ export type { SessionChangedReason, SessionStatus, SessionBlockedReason, + SubagentSessionLifecycle, + SubagentSessionParent, TurnRecord, TurnStateMessage, TurnStatus, @@ -182,11 +184,14 @@ export type { export { SESSION_STATUSES, SESSION_BLOCKED_REASONS, + SUBAGENT_SESSION_LIFECYCLES, TURN_STATUSES, + childSessionsForParent, STEP_LIMIT_NOTICE_TEXT, deriveTurnRecords, isSessionStatus, isSessionBlockedReason, + isSubagentSessionParent, isTurnStatus, decodeStoredMessageForRead, decodeStoredMessageForRecovery, diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index 368d52cf6c5..22d9a0fbbf0 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -3,7 +3,12 @@ */ import type { AttachmentRef, QuoteRef } from './events.js'; -import type { BackendKind, SessionBlockedReason, SessionStatus } from './session.js'; +import type { + BackendKind, + SessionBlockedReason, + SessionStatus, + SubagentSessionParent, +} from './session.js'; import type { PermissionMode } from './permission.js'; import type { ThinkingLevel } from './model-thinking.js'; import type { CollaborationMode } from './collaboration.js'; @@ -31,6 +36,7 @@ export interface CreateSessionInput { blockedReason?: SessionBlockedReason; parentSessionId?: string; branchOfTurnId?: string; + subagentParent?: SubagentSessionParent; revisionRootSessionId?: string; revisionParentSessionId?: string; revisionOfTurnId?: string; @@ -114,4 +120,6 @@ export interface SessionListFilter { isArchived?: boolean; isFlagged?: boolean; labelSlug?: string; + /** Return linked subagent sessions owned by this parent session. */ + subagentParentSessionId?: string; } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 08b0337e56b..a18b5011206 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -63,6 +63,33 @@ export const TURN_STATUSES = ['running', 'completed', 'aborted', 'failed'] as co export type TurnStatus = (typeof TURN_STATUSES)[number]; +export const SUBAGENT_SESSION_LIFECYCLES = ['foreground'] as const; + +export type SubagentSessionLifecycle = (typeof SUBAGENT_SESSION_LIFECYCLES)[number]; + +/** + * Durable control-plane lineage for a subagent session. + * + * The relation lives only on the child. Parents do not persist a reciprocal + * child-id array; reverse lookup is a read-model concern. Cross-session + * provenance deliberately stays out of AgentRun.parentRunId so runs inside the + * child session can retain normal session-inline history semantics. + */ +export interface SubagentSessionParent { + kind: 'subagent'; + parentSessionId: string; + spawnedBy: { + parentRunId: string; + parentTurnId: string; + toolCallId: string; + }; + swarm?: { + swarmId: string; + itemId: string; + }; + lifecycle: SubagentSessionLifecycle; +} + export function isSessionStatus(value: unknown): value is SessionStatus { return typeof value === 'string' && (SESSION_STATUSES as readonly string[]).includes(value); } @@ -103,8 +130,11 @@ export interface SessionHeader { status: SessionStatus; blockedReason?: SessionBlockedReason; statusUpdatedAt?: number; + /** Ordinary branch lineage. Subagent lineage uses subagentParent instead. */ parentSessionId?: string; branchOfTurnId?: string; + /** Immutable control-plane relation for a linked child-agent session. */ + subagentParent?: SubagentSessionParent; /** Stable root id for an edit-and-resend version family. */ revisionRootSessionId?: string; /** Immediate previous version in the same conversation slot. */ @@ -156,6 +186,7 @@ export interface SessionSummary { statusUpdatedAt?: number; parentSessionId?: string; branchOfTurnId?: string; + subagentParent?: SubagentSessionParent; revisionRootSessionId?: string; revisionParentSessionId?: string; revisionOfTurnId?: string; @@ -181,6 +212,66 @@ export interface SessionSummary { orchestrationMode?: OrchestrationMode; } +const SUBAGENT_SESSION_PARENT_SHAPE = defineObjectShape()( + ['kind', 'parentSessionId', 'spawnedBy', 'lifecycle'], + ['swarm'], +); +const SUBAGENT_SESSION_SPAWN_SHAPE = defineObjectShape()( + ['parentRunId', 'parentTurnId', 'toolCallId'], + [], +); +const SUBAGENT_SESSION_SWARM_SHAPE = defineObjectShape< + NonNullable +>()(['swarmId', 'itemId'], []); +const SESSION_LINEAGE_ID_MAX_CHARS = 512; +const SESSION_LINEAGE_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; + +/** Strict decoder guard for the persisted child-session relation. */ +export function isSubagentSessionParent(value: unknown): value is SubagentSessionParent { + if ( + !isRecord(value) || + !hasExactShape(value, SUBAGENT_SESSION_PARENT_SHAPE) || + value.kind !== 'subagent' || + !isSessionLineageId(value.parentSessionId) || + value.lifecycle !== 'foreground' || + !isRecord(value.spawnedBy) || + !hasExactShape(value.spawnedBy, SUBAGENT_SESSION_SPAWN_SHAPE) || + !isSessionLineageId(value.spawnedBy.parentRunId) || + !isSessionLineageId(value.spawnedBy.parentTurnId) || + !isSessionLineageId(value.spawnedBy.toolCallId) + ) { + return false; + } + return ( + value.swarm === undefined || + (isRecord(value.swarm) && + hasExactShape(value.swarm, SUBAGENT_SESSION_SWARM_SHAPE) && + isSessionLineageId(value.swarm.swarmId) && + isSessionLineageId(value.swarm.itemId)) + ); +} + +/** Read-model projection; input order is preserved. */ +export function childSessionsForParent( + sessions: readonly SessionSummary[], + parentSessionId: string, +): SessionSummary[] { + return sessions.filter( + (session) => + isSubagentSessionParent(session.subagentParent) && + session.subagentParent.parentSessionId === parentSessionId, + ); +} + +function isSessionLineageId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= SESSION_LINEAGE_ID_MAX_CHARS && + !SESSION_LINEAGE_CONTROL_CHARACTERS.test(value) + ); +} + export type SessionChangedReason = | 'created' | 'updated' diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index cef5656424e..9cdec631994 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -64,6 +64,45 @@ import { } from '../expert-catalog.js'; import { AGENT_TEAM_CHILD_TOOL_NAMES } from '../agent-team-tool-names.js'; +describe('SessionManager child-session read model', () => { + test('lists typed child sessions without treating branches as children', async () => { + const store = new MemorySessionStore(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(1), + }); + const parent = await manager.createSession(makeInput({ name: 'Parent' })); + const child = await manager.createSession( + makeInput({ + name: 'Child', + subagentParent: { + kind: 'subagent', + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground', + }, + }), + ); + await manager.createSession( + makeInput({ + name: 'Branch', + parentSessionId: parent.id, + branchOfTurnId: 'parent-turn', + }), + ); + + expect((await manager.listChildSessions(parent.id)).map((session) => session.id)).toEqual([ + child.id, + ]); + }); +}); + describe('SessionManager automatic titles', () => { test('starts after user persistence and does not block the turn', async () => { const store = new MemorySessionStore(); @@ -13019,6 +13058,7 @@ class MemorySessionStore implements SessionStore { statusUpdatedAt: 1, ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}), ...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}), + ...(input.subagentParent ? { subagentParent: input.subagentParent } : {}), ...(input.revisionRootSessionId ? { revisionRootSessionId: input.revisionRootSessionId } : {}), diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 993f02f5d31..1b7cfda4621 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -57,6 +57,7 @@ import type { import { DEFAULT_SESSION_NAME, DEEP_RESEARCH_SESSION_LABEL, + childSessionsForParent, failureClassFromCompleteStopReason, isDeepResearchSession, isSessionInlineRun, @@ -446,6 +447,11 @@ export class SessionManager { return this.deps.store.list(filter); } + async listChildSessions(parentSessionId: string): Promise { + const sessions = await this.deps.store.list({ subagentParentSessionId: parentSessionId }); + return childSessionsForParent(sessions, parentSessionId); + } + /** Invalidate backend snapshots now, or immediately after active turns settle. */ async refreshIdleBackends(): Promise { const sessions = await this.deps.store.list(); @@ -2271,6 +2277,7 @@ export function headerToSummary(h: SessionHeader): SessionSummary { ...(h.statusUpdatedAt !== undefined ? { statusUpdatedAt: h.statusUpdatedAt } : {}), ...(h.parentSessionId ? { parentSessionId: h.parentSessionId } : {}), ...(h.branchOfTurnId ? { branchOfTurnId: h.branchOfTurnId } : {}), + ...(h.subagentParent ? { subagentParent: h.subagentParent } : {}), ...(h.revisionRootSessionId ? { revisionRootSessionId: h.revisionRootSessionId } : {}), ...(h.revisionParentSessionId ? { revisionParentSessionId: h.revisionParentSessionId } : {}), ...(h.revisionOfTurnId ? { revisionOfTurnId: h.revisionOfTurnId } : {}), diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 92429bd20fa..64071f65b7f 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -3,7 +3,12 @@ import { mkdir, mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { describe, test } from 'node:test'; -import type { CreateSessionInput, SessionHeader, StoredMessage } from '@maka/core'; +import type { + CreateSessionInput, + SessionHeader, + StoredMessage, + SubagentSessionParent, +} from '@maka/core'; import { createLegacyFileSessionStore as createSessionStore } from '../session-store.js'; describe('FileSessionStore CRUD', () => { @@ -148,6 +153,90 @@ describe('FileSessionStore CRUD', () => { }); }); + test('persists typed subagent lineage but rejects relation scans without SQLite', async () => { + await withStore(async (store) => { + const parent = await store.create(makeInput({ name: 'Parent' })); + const child = await store.create( + makeInput({ + name: 'Child', + subagentParent: makeSubagentParent(parent.id), + }), + ); + await store.create( + makeInput({ + name: 'Other child', + subagentParent: makeSubagentParent('other-parent'), + }), + ); + + assert.deepEqual((await store.readHeader(child.id)).subagentParent, { + ...makeSubagentParent(parent.id), + }); + await assert.rejects( + () => store.list({ subagentParentSessionId: parent.id }), + /require SQLite session metadata/, + ); + }); + }); + + test('keeps subagent lineage distinct from branch and revision lineage', async () => { + await withStore(async (store) => { + await assert.rejects( + () => + store.create( + makeInput({ + parentSessionId: 'branch-parent', + branchOfTurnId: 'branch-turn', + subagentParent: makeSubagentParent(), + }), + ), + /Invalid subagent session lineage/, + ); + await assert.rejects( + () => + store.create( + makeInput({ + revisionRootSessionId: 'root-session', + revisionParentSessionId: 'previous-version', + revisionOfTurnId: 'turn-edited', + revisionIndex: 2, + revisionState: 'preparing', + subagentParent: makeSubagentParent(), + }), + ), + /Invalid subagent session lineage/, + ); + }); + }); + + test('rejects malformed or mutated subagent lineage', async () => { + await withStore(async (store) => { + await assert.rejects( + () => + store.create( + makeInput({ + subagentParent: { + ...makeSubagentParent(), + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + }, + } as SubagentSessionParent, + }), + ), + /Invalid subagent session lineage/, + ); + + const child = await store.create( + makeInput({ subagentParent: makeSubagentParent('parent-session') }), + ); + await assert.rejects( + () => store.updateHeader(child.id, { subagentParent: undefined }), + /parent relation is immutable/, + ); + }); + }); + test('persists edit-and-resend revision lineage separately from branch lineage', async () => { await withStore(async (store) => { const header = await store.create( @@ -440,10 +529,12 @@ describe('FileSessionStore CRUD', () => { assert.equal(header.status, 'active'); assert.equal(header.titleIsManual, true); assert.equal(header.orchestrationMode, 'default'); + assert.equal(header.subagentParent, undefined); const [summary] = await store.list(); assert.equal(summary?.permissionMode, 'ask'); assert.equal(summary?.status, 'active'); assert.equal(summary?.orchestrationMode, 'default'); + assert.equal(summary?.subagentParent, undefined); }); }); @@ -1395,6 +1486,19 @@ function makeInput(overrides: Partial = {}): CreateSessionIn }; } +function makeSubagentParent(parentSessionId = 'parent-session'): SubagentSessionParent { + return { + kind: 'subagent', + parentSessionId, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground', + }; +} + function makeRawHeader(overrides: Partial = {}): SessionHeader { return { id: 'raw-session', diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index dcd9162ebe1..6fa5aad507c 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { describe, test } from 'node:test'; import type { SessionHeader } from '@maka/core'; import { @@ -18,7 +19,7 @@ describe('SqliteSessionMetadataStore', () => { try { const store = createSqliteSessionMetadataStore(path, { now: () => 100 }); const header = fullHeader(); - assert.equal(store.schemaVersion(), 2); + assert.equal(store.schemaVersion(), 3); assert.equal(store.journalMode(), 'wal'); assert.deepEqual(await store.create(header), { header, @@ -29,7 +30,7 @@ describe('SqliteSessionMetadataStore', () => { const reopened = createSqliteSessionMetadataStore(path, { now: () => 200 }); try { - assert.equal(reopened.schemaVersion(), 2); + assert.equal(reopened.schemaVersion(), 3); assert.deepEqual(await reopened.read(header.id), { header, metadataVersion: 1, @@ -50,7 +51,7 @@ describe('SqliteSessionMetadataStore', () => { const metadata = createSqliteSessionMetadataStore(path); try { assert.equal(runtime.schemaVersion(), 4); - assert.equal(metadata.schemaVersion(), 2); + assert.equal(metadata.schemaVersion(), 3); await metadata.create(fullHeader()); await runtime.appendRuntimeEvent('session-1', 'run-1', { id: 'event-1', @@ -73,6 +74,106 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('backfills the relation index when upgrading a populated v2 database', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-metadata-v2-')); + const path = join(root, 'sessions.sqlite'); + const parentSessionId = 'parent-session'; + const header = fullHeader({ + id: 'child-session', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + subagentParent: { + kind: 'subagent', + parentSessionId, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground', + }, + }); + const db = new DatabaseSync(path); + db.exec(` + CREATE TABLE session_metadata_schema ( + scope TEXT PRIMARY KEY, + version INTEGER NOT NULL + ); + INSERT INTO session_metadata_schema(scope, version) VALUES ('session_metadata', 2); + CREATE TABLE session_metadata ( + session_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL, + last_message_at INTEGER, + name TEXT NOT NULL, + is_flagged INTEGER NOT NULL, + is_archived INTEGER NOT NULL, + status TEXT NOT NULL, + status_updated_at INTEGER, + parent_session_id TEXT, + revision_root_session_id TEXT, + revision_index INTEGER, + has_unread INTEGER NOT NULL, + backend TEXT NOT NULL, + llm_connection_slug TEXT NOT NULL, + model TEXT NOT NULL, + metadata_version INTEGER NOT NULL, + committed_at INTEGER NOT NULL + ); + `); + db.prepare(` + INSERT INTO session_metadata( + session_id, payload_json, created_at, last_used_at, last_message_at, + name, is_flagged, is_archived, status, status_updated_at, + parent_session_id, revision_root_session_id, revision_index, has_unread, + backend, llm_connection_slug, model, metadata_version, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + header.id, + JSON.stringify(header), + header.createdAt, + header.lastUsedAt, + header.lastMessageAt ?? null, + header.name, + 0, + 0, + header.status, + header.statusUpdatedAt ?? null, + null, + null, + null, + 1, + header.backend, + header.llmConnectionSlug, + header.model, + 1, + 100, + ); + db.close(); + + const store = createSqliteSessionMetadataStore(path); + try { + assert.equal(store.schemaVersion(), 3); + assert.deepEqual( + ( + await store.list({ + subagentParentSessionId: parentSessionId, + }) + ).map((record) => record.header.id), + [header.id], + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test('filters indexed flags, archive state, and normalized labels in recency order', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { @@ -128,6 +229,75 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('queries typed subagent relations through the dedicated parent index', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + const subagentParent = { + kind: 'subagent' as const, + parentSessionId: 'parent-session', + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground' as const, + }; + try { + await store.create( + fullHeader({ + id: 'child-session', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + subagentParent, + }), + ); + await store.create( + fullHeader({ + id: 'ordinary-branch', + parentSessionId: 'parent-session', + branchOfTurnId: 'parent-turn', + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + }), + ); + await store.create( + fullHeader({ + id: 'other-child', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + subagentParent: { ...subagentParent, parentSessionId: 'other-parent' }, + }), + ); + + const children = await store.list({ + subagentParentSessionId: subagentParent.parentSessionId, + }); + assert.deepEqual( + children.map((record) => record.header.id), + ['child-session'], + ); + assert.deepEqual(children[0]?.header.subagentParent, subagentParent); + await assert.rejects( + () => store.update('child-session', { subagentParent: undefined }), + /parent relation is immutable/, + ); + } finally { + store.close(); + } + }); + test('updates metadata and labels with a compare-and-set version', async () => { const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(10) }); try { diff --git a/packages/storage/src/__tests__/sqlite-session-store.test.ts b/packages/storage/src/__tests__/sqlite-session-store.test.ts index 8891e0c8928..ad6c276dc7d 100644 --- a/packages/storage/src/__tests__/sqlite-session-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-store.test.ts @@ -63,6 +63,46 @@ describe('default SQLite session metadata store', () => { } }); + test('lists linked child sessions through SQLite without conflating ordinary branches', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-default-session-relations-')); + const store = createSessionStore(root); + try { + const parent = await store.create(makeInput({ name: 'Parent' })); + const child = await store.create( + makeInput({ + name: 'Child', + subagentParent: { + kind: 'subagent', + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground', + }, + }), + ); + await store.create( + makeInput({ + name: 'Branch', + parentSessionId: parent.id, + branchOfTurnId: 'parent-turn', + }), + ); + + const children = await store.list({ subagentParentSessionId: parent.id }); + assert.deepEqual( + children.map((session) => session.id), + [child.id], + ); + assert.equal(children[0]?.subagentParent?.parentSessionId, parent.id); + } finally { + store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('imports an existing JSONL catalog once and preserves later SQLite-only updates', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-default-session-migration-')); const legacy = createLegacyFileSessionStore(root); diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index aa556b8d931..9fdee99dcd5 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -26,6 +26,7 @@ import { isOrchestrationMode, isPermissionMode, isSessionBlockedReason, + isSubagentSessionParent, isSessionStatus, normalizeUserSessionName, } from '@maka/core'; @@ -357,6 +358,7 @@ class FileSessionStore implements SessionStore { statusUpdatedAt: now, ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}), ...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}), + ...(input.subagentParent ? { subagentParent: input.subagentParent } : {}), ...(input.revisionRootSessionId ? { revisionRootSessionId: input.revisionRootSessionId } : {}), @@ -378,9 +380,7 @@ class FileSessionStore implements SessionStore { schemaVersion: 1, }; - if (!isValidRevisionLineage(header)) { - throw new Error('Invalid session revision lineage'); - } + assertValidSessionLineage(header); await this.withQueue(id, async () => { await mkdir(this.sessionDir(id), { recursive: true }); @@ -393,6 +393,9 @@ class FileSessionStore implements SessionStore { } async list(filter?: SessionListFilter): Promise { + if (filter?.subagentParentSessionId !== undefined) { + throw new Error('Subagent session relation queries require SQLite session metadata'); + } let entries; try { entries = await readdir(this.sessionsRoot, { withFileTypes: true }); @@ -553,13 +556,14 @@ class FileSessionStore implements SessionStore { } async updateHeader(sessionId: string, patch: Partial): Promise { + if (Object.prototype.hasOwnProperty.call(patch, 'subagentParent')) { + throw new Error('Subagent session parent relation is immutable'); + } let nextHeader: SessionHeader | undefined; await this.withQueue(sessionId, async () => { const { header, messages } = await this.readFilePartsUnlocked(sessionId); nextHeader = { ...header, ...patch }; - if (!isValidRevisionLineage(nextHeader)) { - throw new Error('Invalid session revision lineage'); - } + assertValidSessionLineage(nextHeader); const lines = [ JSON.stringify(nextHeader), ...messages.map((message) => JSON.stringify(message)), @@ -1071,6 +1075,7 @@ export function normalizeSessionHeader( (header.parentSessionId === undefined || typeof header.parentSessionId === 'string') && (header.branchOfTurnId === undefined || typeof header.branchOfTurnId === 'string') && isValidRevisionLineage(header) && + isValidSubagentSessionLineage(header) && (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && typeof header.hasUnread === 'boolean' && isBackendKind(header.backend) && @@ -1115,6 +1120,30 @@ function isValidRevisionLineage(header: SessionHeader): boolean { ); } +function assertValidSessionLineage(header: SessionHeader): void { + if (!isValidRevisionLineage(header)) { + throw new Error('Invalid session revision lineage'); + } + if (!isValidSubagentSessionLineage(header)) { + throw new Error('Invalid subagent session lineage'); + } +} + +function isValidSubagentSessionLineage(header: SessionHeader): boolean { + if (header.subagentParent === undefined) return true; + return ( + isSubagentSessionParent(header.subagentParent) && + isSafeSessionId(header.subagentParent.parentSessionId) && + header.parentSessionId === undefined && + header.branchOfTurnId === undefined && + header.revisionRootSessionId === undefined && + header.revisionParentSessionId === undefined && + header.revisionOfTurnId === undefined && + header.revisionIndex === undefined && + header.revisionState === undefined + ); +} + function isBackendKind(value: unknown): value is SessionHeader['backend'] { return value === 'ai-sdk' || value === 'fake' || value === 'pi-agent'; } @@ -1142,6 +1171,7 @@ function toSummary(header: SessionHeader, messages: StoredMessage[] = []): Sessi ...(header.statusUpdatedAt !== undefined ? { statusUpdatedAt: header.statusUpdatedAt } : {}), ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), + ...(header.subagentParent ? { subagentParent: header.subagentParent } : {}), ...(header.revisionRootSessionId ? { revisionRootSessionId: header.revisionRootSessionId } : {}), diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 64a24206f19..08f5894c70c 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 2; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 3; const MIGRATIONS: ReadonlyMap = new Map([ [ @@ -72,6 +72,20 @@ const MIGRATIONS: ReadonlyMap = new Map([ ); `, ], + [ + 3, + ` + ALTER TABLE session_metadata ADD COLUMN subagent_parent_session_id TEXT; + + UPDATE session_metadata + SET subagent_parent_session_id = + json_extract(payload_json, '$.subagentParent.parentSessionId') + WHERE json_type(payload_json, '$.subagentParent.parentSessionId') = 'text'; + + CREATE INDEX session_metadata_by_subagent_parent + ON session_metadata(subagent_parent_session_id, session_id); + `, + ], ]); export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index fb369914d5e..3a4b34d8af2 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -184,6 +184,11 @@ export class SqliteSessionMetadataStore { `); parameters.push(filter.labelSlug); } + if (filter.subagentParentSessionId !== undefined) { + assertSafeSessionId(filter.subagentParentSessionId); + where.push('metadata.subagent_parent_session_id = ?'); + parameters.push(filter.subagentParentSessionId); + } const rows = this.db .prepare(` SELECT session_id, payload_json, metadata_version, committed_at @@ -204,6 +209,9 @@ export class SqliteSessionMetadataStore { ): Promise { this.assertOpen(); assertSafeSessionId(sessionId); + if (Object.prototype.hasOwnProperty.call(patch, 'subagentParent')) { + throw new Error('Subagent session parent relation is immutable'); + } return this.transaction(() => { const current = this.readRecordSync(sessionId); if (!current) throw new Error(`Session metadata not found: ${sessionId}`); @@ -235,6 +243,7 @@ export class SqliteSessionMetadataStore { status = ?, status_updated_at = ?, parent_session_id = ?, + subagent_parent_session_id = ?, revision_root_session_id = ?, revision_index = ?, has_unread = ?, @@ -256,6 +265,7 @@ export class SqliteSessionMetadataStore { next.status, next.statusUpdatedAt ?? null, next.parentSessionId ?? null, + next.subagentParent?.parentSessionId ?? null, next.revisionRootSessionId ?? null, next.revisionIndex ?? null, booleanInteger(next.hasUnread), @@ -388,6 +398,7 @@ export class SqliteSessionMetadataStore { status, status_updated_at, parent_session_id, + subagent_parent_session_id, revision_root_session_id, revision_index, has_unread, @@ -396,7 +407,7 @@ export class SqliteSessionMetadataStore { model, metadata_version, committed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) .run( header.id, @@ -410,6 +421,7 @@ export class SqliteSessionMetadataStore { header.status, header.statusUpdatedAt ?? null, header.parentSessionId ?? null, + header.subagentParent?.parentSessionId ?? null, header.revisionRootSessionId ?? null, header.revisionIndex ?? null, booleanInteger(header.hasUnread),