From d26ba000aeaa65ca33ee03303b5bdfaa386c75e8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Sat, 15 Aug 2026 12:52:12 +0800 Subject: [PATCH 01/29] feat(kap-server): add page-number mode and total to GET /api/v2/sessions The v2 session list gains a stateless 1-based `page` parameter beside the opaque page_token cursor for admin-style lists that jump arbitrarily: each request stays a full independent snapshot, no token is minted, and `page` + `page_token` together fail 40001. Every response now carries `total` (the filtered/sorted set size) in both pagination modes. --- packages/kap-server/src/routes/v2/sessions.ts | 39 +++++++++++++-- packages/kap-server/test/v2Sessions.test.ts | 50 +++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index 2ac8590b85..4ac2b7fb52 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -13,7 +13,15 @@ * version + query fingerprint + keyset position, following the search * module's token precedent) that binds every query condition of the * first page — flipping any condition mid-pagination fails with 40922 - * instead of silently serving a drifted window. + * instead of silently serving a drifted window; + * - alternatively, `page` (1-based) switches to stateless page-number + * mode for admin-style lists that jump arbitrarily: each request is a + * full independent snapshot (re-drained, re-filtered, re-sorted — the + * same per-request semantics the cursor mode already has), no token is + * minted, and none is accepted (`page` + `page_token` together is a + * 40001). Jumping to page N needs no token binding, so the 40922 + * fingerprint mechanism does not apply. Every response carries `total` + * — the filtered/sorted set size — in both modes. * * Response domains: `workspace` / `meta` / `activity` are always projected; * `git` is opt-in (`include=git`), resolved per unique `workspace.cwd` with @@ -102,9 +110,19 @@ const v2SessionsListQuerySchema = z sort: v2SortSchema.optional(), include: z.string().optional(), page_size: z.coerce.number().int().min(1).max(100).optional(), + page: z.coerce.number().int().min(1).optional(), page_token: z.string().min(1).optional(), }) .superRefine((value, ctx) => { + // Page-number mode is stateless — a token would be meaningless beside it. + if (value.page !== undefined && value.page_token !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'page and page_token are mutually exclusive', + path: ['page'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } // Unknown include domains are rejected so a typo never silently drops // paid-for data. for (const domain of includeDomains(value.include)) { @@ -169,6 +187,8 @@ const v2SessionSchema = z.object({ const v2SessionPageSchema = z.object({ items: z.array(v2SessionSchema), + /** Filtered/sorted set size — present in both pagination modes. */ + total: z.number().int(), has_more: z.boolean(), next_page_token: z.string().nullable(), }); @@ -366,7 +386,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): [ErrorCode.PAGE_TOKEN_MISMATCH]: {}, }, description: - 'List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). Opaque-cursor pagination: page_token binds the first page’s query conditions.', + 'List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). Paginate with the opaque page_token (binds the first page’s query conditions) or with the stateless 1-based page parameter; every page carries total.', tags: ['v2-sessions'], }, async (req, reply) => { @@ -443,7 +463,11 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): const sorted = filtered.toSorted(comparator); let start = 0; - if (cursor !== undefined) { + if (raw.page !== undefined) { + // Stateless page-number mode: slice the fresh snapshot directly; + // no token is minted below and none was accepted above. + start = (raw.page - 1) * query.pageSize; + } else if (cursor !== undefined) { const [cursorKey, cursorId] = cursor; // The comparator only reads the sort key + id, so a synthetic // cursor item pins the keyset position in any sort order. @@ -460,7 +484,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): const hasMore = start + query.pageSize < sorted.length; const lastServed = window.at(-1); const nextPageToken = - hasMore && lastServed !== undefined + raw.page === undefined && hasMore && lastServed !== undefined ? encodePageToken(fingerprint, sortKeyOf(query.sort)(lastServed), lastServed.id) : null; @@ -505,7 +529,12 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): }; }); - reply.send(okEnvelope({ items, has_more: hasMore, next_page_token: nextPageToken }, req.id)); + reply.send( + okEnvelope( + { items, total: sorted.length, has_more: hasMore, next_page_token: nextPageToken }, + req.id, + ), + ); }, ); diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index cd1e2ebebe..f41adafa9e 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -48,6 +48,7 @@ interface SessionWireV2 { interface PageWireV2 { items: SessionWireV2[]; + total: number; has_more: boolean; next_page_token: string | null; } @@ -352,6 +353,55 @@ describe('server /api/v2/sessions', () => { expect(resorted.code).toBe(40922); }); + it('carries total (filtered set size) in every page mode', async () => { + const all = await getData(); + expect(all.total).toBe(3); + + const filtered = await getData(`?workspace.id=${WS_A}`); + expect(filtered.total).toBe(2); + + // Cursor mode reports the same total on follow-up pages. + const page1 = await getData('?page_size=2'); + expect(page1.total).toBe(3); + const page2 = await getData(`?page_size=2&page_token=${page1.next_page_token}`); + expect(page2.total).toBe(3); + }); + + it('paginates by 1-based page without minting tokens', async () => { + const page1 = await getData('?page=1&page_size=2'); + expect(page1.items.map((item) => item.id)).toEqual(['s1', 's2']); + expect(page1.total).toBe(3); + expect(page1.has_more).toBe(true); + expect(page1.next_page_token).toBeNull(); + + const page2 = await getData('?page=2&page_size=2'); + expect(page2.items.map((item) => item.id)).toEqual(['s3']); + expect(page2.total).toBe(3); + expect(page2.has_more).toBe(false); + + // A page beyond the end is an empty, terminal snapshot — total stays. + const beyond = await getData('?page=7&page_size=2'); + expect(beyond.items).toEqual([]); + expect(beyond.total).toBe(3); + expect(beyond.has_more).toBe(false); + }); + + it('honors filters and sort in page mode', async () => { + const page = await getData(`?workspace.id=${WS_A}&sort=meta.updated_at_asc&page=2&page_size=1`); + expect(page.items.map((item) => item.id)).toEqual(['s1']); + expect(page.total).toBe(2); + expect(page.has_more).toBe(false); + }); + + it('rejects page combined with page_token (40001), and page=0', async () => { + const first = await getData('?page_size=2'); + const both = await getError(`?page=2&page_token=${first.next_page_token}`); + expect(both.code).toBe(40001); + + const zero = await getError('?page=0'); + expect(zero.code).toBe(40001); + }); + it('rejects a corrupted page_token (40922)', async () => { const body = await getError('?page_token=!!!not-a-token'); expect(body.code).toBe(40922); From bd51811d43bf7c9258ce5f2393c052b32613b342 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Sat, 15 Aug 2026 12:53:43 +0800 Subject: [PATCH 02/29] feat(kap-server): add meta.updated_before filter to GET /api/v2/sessions Symmetric with meta.updated_after (inclusive boundary, Unix ms), applied at the edge over the drained set and bound into the page_token query fingerprint like every other condition. --- packages/kap-server/src/routes/v2/sessions.ts | 16 ++++++++--- packages/kap-server/test/v2Sessions.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index 4ac2b7fb52..4517e771e1 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -31,10 +31,11 @@ * * Sorting / filtering: the session index only serves `updatedAt desc, * id desc` keyset pages, so the two other sorts and the status / - * updated_after / archived-only filters are applied at the edge after - * draining the (workspace-, archive-) filtered set — the same edge pattern - * as v1's unpaged `GET /api/v1/sessions`. All sorts share one comparator + - * one cursor encoding, so every sort paginates identically. + * updated_after / updated_before / archived-only filters are applied at + * the edge after draining the (workspace-, archive-) filtered set — the + * same edge pattern as v1's unpaged `GET /api/v1/sessions`. All sorts + * share one comparator + one cursor encoding, so every sort paginates + * identically. */ import { createHash } from 'node:crypto'; @@ -106,6 +107,7 @@ const v2SessionsListQuerySchema = z 'workspace.id': repeatedParam(z.string().min(1)), 'activity.status': repeatedParam(v2ActivityStatusSchema), 'meta.updated_after': z.coerce.number().int().nonnegative().optional(), + 'meta.updated_before': z.coerce.number().int().nonnegative().optional(), 'meta.archived': z.enum(['true', 'false', 'all']).optional(), sort: v2SortSchema.optional(), include: z.string().optional(), @@ -147,6 +149,7 @@ interface NormalizedQuery { readonly workspaceFilter?: readonly string[]; readonly statuses?: readonly V2ActivityStatus[]; readonly updatedAfter?: number; + readonly updatedBefore?: number; readonly archived: 'true' | 'false' | 'all'; readonly sort: V2Sort; readonly includeGit: boolean; @@ -262,6 +265,7 @@ function queryFingerprint(query: NormalizedQuery): string { query.workspaceFilter === undefined ? null : [...query.workspaceFilter].toSorted(), query.statuses === undefined ? null : [...query.statuses].toSorted(), query.updatedAfter ?? null, + query.updatedBefore ?? null, query.archived, query.sort, query.includeGit, @@ -396,6 +400,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): workspaceFilter: asArray(raw['workspace.id']), statuses: asArray(raw['activity.status']), updatedAfter: raw['meta.updated_after'], + updatedBefore: raw['meta.updated_before'], archived: raw['meta.archived'] ?? 'false', sort: raw.sort ?? 'meta.updated_at_desc', includeGit: includeDomains(raw.include).includes('git'), @@ -450,6 +455,9 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): if (query.updatedAfter !== undefined && summary.updatedAt < query.updatedAfter) { return false; } + if (query.updatedBefore !== undefined && summary.updatedAt > query.updatedBefore) { + return false; + } if ( query.statuses !== undefined && !query.statuses.includes(mapActivityStatus(factsOf(summary.id), summary.lastTurnReason)) diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index f41adafa9e..3921c90fba 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -276,6 +276,34 @@ describe('server /api/v2/sessions', () => { expect(page.items.map((item) => item.id)).toEqual(['s1', 's2']); }); + it('filters by meta.updated_before (inclusive), combined into a range', async () => { + const before = await getData('?meta.updated_before=4000'); + expect(before.items.map((item) => item.id)).toEqual(['s2', 's3']); + expect(before.total).toBe(2); + + const range = await getData('?meta.updated_after=3000&meta.updated_before=4000'); + expect(range.items.map((item) => item.id)).toEqual(['s2', 's3']); + + const bogus = await getError('?meta.updated_before=-1'); + expect(bogus.code).toBe(40001); + }); + + it('binds meta.updated_before into the page_token fingerprint', async () => { + const page1 = await getData('?page_size=1&meta.updated_before=4500'); + expect(page1.items.map((item) => item.id)).toEqual(['s2']); + expect(page1.has_more).toBe(true); + + // Same conditions + token paginates on … + const page2 = await getData( + `?page_size=1&meta.updated_before=4500&page_token=${page1.next_page_token}`, + ); + expect(page2.items.map((item) => item.id)).toEqual(['s3']); + + // … but dropping the condition mid-pagination is a fingerprint flip. + const drifted = await getError(`?page_size=1&page_token=${page1.next_page_token}`); + expect(drifted.code).toBe(40922); + }); + it('filters by meta.archived (default false / true / all)', async () => { const only = await getData('?meta.archived=true'); expect(only.items.map((item) => item.id)).toEqual(['s4']); From 2855ee59d340f21e133fbcada486267616fed757 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Sat, 15 Aug 2026 13:04:53 +0800 Subject: [PATCH 03/29] feat(kap-server): add POST /api/v2/sessions:archive and :restore batch endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch archive/restore for session-management views: { ids } (non-empty, ≤5000 unique after dedup) answers per-item results in input order with succeeded/failed counts — only a body validation failure fails the whole request, and an unknown id folds into its own item as 40401. The live/cold split keeps the batch cheap: a session with a live handle goes through the full ISessionLifecycleService chain (agents drain, scope teardown, mirror drain), while a cold session is never materialized — the new setColdSessionArchived helper in agent-core-v2 patches the persisted state.json (archived/archivedAt, updatedAt preserved, mirroring setArchived's touchUpdatedAt: false semantics), mirrors the flipped summary into the read-model queue, and republishes the same event.session.archived bus event the live lifecycle emits (:restore publishes nothing, matching the live restore). Hot items run with bounded concurrency and the batch ends with one shared ISessionIndexMirror.drain(). --- .changeset/cold-session-batch-archive.md | 5 + packages/agent-core-v2/src/index.ts | 1 + .../sessionLifecycle/coldSessionArchive.ts | 67 ++++ packages/kap-server/src/routes/v2/sessions.ts | 163 ++++++++++ .../apiSurface.snapshot.test.ts.snap | 8 + packages/kap-server/test/v2Sessions.test.ts | 298 +++++++++++++++++- 6 files changed, 536 insertions(+), 6 deletions(-) create mode 100644 .changeset/cold-session-batch-archive.md create mode 100644 packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts diff --git a/.changeset/cold-session-batch-archive.md b/.changeset/cold-session-batch-archive.md new file mode 100644 index 0000000000..de17db9e52 --- /dev/null +++ b/.changeset/cold-session-batch-archive.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Add a cold-session archive/restore path that patches the persisted metadata document, mirrors the flipped summary into the session-index read model, and republishes the archived bus event without materializing the session, backing the new `POST /api/v2/sessions:archive` / `:restore` batch endpoints (per-item results; live sessions still run the full lifecycle). diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 487e1c4fd4..472a56fb3e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -416,6 +416,7 @@ export * from '#/app/workspaceLifecycle/sessionLookup'; export * from '#/workspace/workspaceContext/workspaceContext'; export * from '#/workspace/sessionLifecycle/sessionLifecycle'; export * from '#/workspace/sessionLifecycle/sessionLifecycleService'; +export * from '#/workspace/sessionLifecycle/coldSessionArchive'; export * from '#/workspace/sessionLifecycle/internal/addressing'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts new file mode 100644 index 0000000000..1b031b3a52 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -0,0 +1,67 @@ +/** + * `sessionLifecycle` domain — cold-session archive/restore without session + * materialization. + * + * Writes the archived flag straight into the persisted metadata document + * (`state.json` under the handler-chain scope derived through + * `internal/addressing` from the `bootstrap` sessions scope) via the + * `storage` access-pattern store, mirrors the flipped summary into the + * `sessionIndex` mirror queue (drained by the caller, never per item), and + * publishes the same `event.session.archived` bus event the live + * `ISessionLifecycleService.archive` emits through `event` — restore + * publishes nothing, matching the live `restore` (which only flips the + * flag through `ISessionMetadata`). `updatedAt` is preserved verbatim, + * mirroring `setArchived`'s `touchUpdatedAt: false` semantics, and every + * other persisted field survives the read-modify-write untouched. Call + * only for a session with no live handle in any workspace handler — a + * live session must go through the full lifecycle so its agents drain + * and its scope tears down; the direct write deliberately races a + * concurrent resume unsynchronized (the read model heals by + * reconciliation). Existence reads from `ISessionIndex`: an unknown id + * and an index entry whose document is unreadable both report + * `not_found`. + */ + +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IEventService } from '#/app/event/event'; +import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; + +import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; + +export type ColdSessionArchiveOutcome = 'updated' | 'not_found'; + +export async function setColdSessionArchived( + accessor: ServicesAccessor, + sessionId: string, + archived: boolean, +): Promise { + const summary = await accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return 'not_found'; + const docs = accessor.get(IAtomicDocumentStore); + const metaScope = sessionScopeOf( + workspacePersistenceScope( + accessor.get(IBootstrapService).scope('sessions'), + summary.workspaceId, + ), + sessionId, + ); + let persisted: SessionMeta | undefined; + try { + persisted = await docs.get(metaScope, 'state.json'); + } catch { + persisted = undefined; + } + if (persisted === undefined) return 'not_found'; + const archivedAt = archived ? Date.now() : undefined; + await docs.set(metaScope, 'state.json', { ...persisted, archived, archivedAt }); + accessor.get(ISessionIndexMirror).record({ ...summary, archived, archivedAt }); + if (archived) { + accessor + .get(IEventService) + .publish({ type: 'event.session.archived', payload: { sessionId } }); + } + return 'updated'; +} diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index 4517e771e1..f5d8df9c29 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -36,14 +36,35 @@ * same edge pattern as v1's unpaged `GET /api/v1/sessions`. All sorts * share one comparator + one cursor encoding, so every sort paginates * identically. + * + * Batch actions: `POST /sessions:archive` / `POST /sessions:restore` + * (registered as `/sessions::{action}` — find-my-way splits a segment at + * its first `:`, so the wire path carries a single colon, same as the v1 + * `/fs::browse` precedent) take `{ ids }` (non-empty, ≤5000 unique) and + * answer per-item results — `data.results[]` in input order with + * `ok` / `error`, plus `succeeded` / `failed` counts; only a body + * validation failure fails the whole request. A live session goes + * through the full `ISessionLifecycleService` chain (agents drain, scope + * teardown, mirror drain); a cold session is never materialized — its + * archived flag is patched straight into the persisted metadata + * document, mirrored into the read model, and (`:archive` only) + * announced through the same `event.session.archived` bus event the live + * lifecycle publishes, while `:restore` publishes nothing, matching the + * live restore. An unknown id folds into its own item as 40401. The + * batch ends with one shared `ISessionIndexMirror.drain()`, never one + * per item. */ import { createHash } from 'node:crypto'; import { ISessionIndex, + ISessionIndexMirror, + ISessionLifecycleService, IWorkspaceAliases, IWorkspaceService, + liveHandlerForSession, + setColdSessionArchived, type Scope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; @@ -64,6 +85,14 @@ interface V2SessionsRouteHost { reply: { send(payload: unknown): unknown }, ) => Promise | void, ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown; headers: Record }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; } // --------------------------------------------------------------------------- @@ -199,6 +228,43 @@ const v2SessionPageSchema = z.object({ /** `40001 validation.failed` carries the offending fields (REST.md §1.4). */ const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); +// --------------------------------------------------------------------------- +// Batch archive / restore contract +// --------------------------------------------------------------------------- + +/** Cap on unique ids per batch, keeping one request's edge work bounded. */ +const BATCH_IDS_MAX = 5000; + +/** Hot-path lifecycle calls run with this many in flight at most. */ +const BATCH_CONCURRENCY = 8; + +const v2SessionsBatchBodySchema = z + .object({ ids: z.array(z.string().min(1)).min(1) }) + .superRefine((value, ctx) => { + if (new Set(value.ids).size > BATCH_IDS_MAX) { + ctx.addIssue({ + code: 'custom', + message: `ids must contain at most ${BATCH_IDS_MAX} unique entries`, + path: ['ids'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const v2SessionsBatchResultSchema = z.object({ + results: z.array( + z.object({ + id: z.string(), + ok: z.boolean(), + error: z.object({ code: z.number().int(), message: z.string() }).optional(), + }), + ), + succeeded: z.number().int(), + failed: z.number().int(), +}); + +type V2BatchItemResult = z.infer['results'][number]; + type V2GitDomain = z.infer; type V2SessionWire = z.infer; @@ -376,6 +442,77 @@ class GitDomainResolver { // Route // --------------------------------------------------------------------------- +/** + * Run one `:archive` / `:restore` batch: live sessions through the full + * `ISessionLifecycleService` chain, cold sessions through the direct cold + * patch (no materialization); per-item failures fold into the result list + * in input order. Ends with a single shared mirror drain. + */ +async function runBatchArchive( + core: Scope, + action: 'archive' | 'restore', + rawIds: readonly string[], + requestId: string, + reply: { send(payload: unknown): unknown }, +): Promise { + const archived = action === 'archive'; + const ids = [...new Set(rawIds)]; + const results: (V2BatchItemResult | undefined)[] = ids.map(() => undefined); + + // Per-item work never throws: a failure folds into its own result and + // the rest of the batch still runs. + const applyOne = async (id: string): Promise => { + try { + const liveHandler = liveHandlerForSession(core.accessor, id); + if (liveHandler !== undefined) { + const lifecycle = liveHandler.accessor.get(ISessionLifecycleService); + if (archived) await lifecycle.archive(id); + else await lifecycle.restore(id); + return { id, ok: true }; + } + const outcome = await setColdSessionArchived(core.accessor, id, archived); + return outcome === 'updated' + ? { id, ok: true } + : { + id, + ok: false, + error: { + code: ErrorCode.SESSION_NOT_FOUND, + message: `session ${id} does not exist`, + }, + }; + } catch (error) { + return { + id, + ok: false, + error: { + code: ErrorCode.INTERNAL_ERROR, + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }; + + let next = 0; + const workers = Array.from({ length: Math.min(BATCH_CONCURRENCY, ids.length) }, async () => { + while (next < ids.length) { + const index = next++; + results[index] = await applyOne(ids[index] as string); + } + }); + await Promise.all(workers); + // One drain for the whole batch — cold records queue in the mirror, and + // the hot path already drained itself per call. + await core.accessor.get(ISessionIndexMirror).drain(); + + // Every slot was assigned by the workers — no undefined entries remain. + const settled = results as V2BatchItemResult[]; + const succeeded = settled.filter((result) => result.ok).length; + reply.send( + okEnvelope({ results: settled, succeeded, failed: settled.length - succeeded }, requestId), + ); +} + export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): void { const gitResolver = new GitDomainResolver(core); @@ -551,4 +688,30 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): listRoute.options, listRoute.handler as Parameters[2], ); + + for (const action of ['archive', 'restore'] as const) { + const batchRoute = defineRoute( + { + method: 'POST', + // `/sessions::${action}` in find-my-way serves the wire path + // `/sessions:archive` / `/sessions:restore` (single colon). + path: `/sessions::${action}`, + body: v2SessionsBatchBodySchema, + success: { data: v2SessionsBatchResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + }, + description: `Batch-${action} sessions by id ({ ids }, ≤5000 unique). Per-item results — a missing session folds into its own item; cold sessions are patched without materialization.`, + tags: ['v2-sessions'], + }, + async (req, reply) => { + await runBatchArchive(core, action, req.body.ids, req.id, reply); + }, + ); + app.post( + batchRoute.path, + batchRoute.options, + batchRoute.handler as Parameters[2], + ); + } } diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index a12395892b..fa51b2fdcb 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -444,6 +444,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/workspaces/{workspace_id}/untrust", ], + [ + "POST", + "/api/v2/sessions:archive", + ], + [ + "POST", + "/api/v2/sessions:restore", + ], [ "PUT", "/api/v1/providers/{provider_id}", diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 3921c90fba..5a5b6fa65c 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -1,13 +1,16 @@ /** - * Scenario: `/api/v2/sessions` domain-grouped session list query. + * Scenario: `/api/v2/sessions` domain-grouped session list query + batch actions. * Responsibilities: envelope wire shape (business outcome in `code`: 40001 * invalid params / 40922 page_token mismatch), filters, sort orders, opaque - * page tokens, git domain dedup/cache/degradation, v2 auth error shape, and - * the activity-status mapper. - * Wiring: real kap-server; `ISessionIndex` / `IGitService` stubbed via DI seeds. + * page tokens, page-number mode + total, git domain dedup/cache/degradation, + * v2 auth error shape, the activity-status mapper, and the + * `POST /sessions:archive` / `:restore` batch endpoints (per-item results, + * live/cold split, cold path never materializes). + * Wiring: real kap-server; the list tests stub `ISessionIndex` / `IGitService` + * via DI seeds, the batch tests run real sessions in a temp home. * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/v2Sessions.test.ts`. */ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -15,6 +18,15 @@ import { Error2, ErrorCodes, ISessionIndex, + ISessionLifecycleService, + IEventService, + IWorkspaceLifecycleService, + closeSessionById, + getLiveSessionById, + liveHandlerForSession, + resumeSessionById, + sessionDirOf, + type GlobalEvent, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { @@ -22,7 +34,7 @@ import { type FsPullRequest, IGitService, } from '@moonshot-ai/agent-core-v2/app/git/git'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { mapActivityStatus } from '../src/routes/v2/sessions'; @@ -492,6 +504,280 @@ describe('server /api/v2/sessions', () => { }); }); +describe('server /api/v2/sessions batch archive/restore', () => { + interface BatchItemWire { + id: string; + ok: boolean; + error?: { code: number; message: string }; + } + + interface BatchWire { + results: BatchItemWire[]; + succeeded: number; + failed: number; + } + + interface BatchEnvelopeWire { + code: number; + msg: string; + data: BatchWire | null; + request_id: string; + details?: { path: string; message: string }[]; + } + + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-sessions-batch-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await new Promise((resolve) => setTimeout(resolve, 25)); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); + home = undefined; + } + }); + + function core(): RunningServer['core']['accessor'] { + return (server as RunningServer).core.accessor; + } + + /** Subscribe a bus-event collector; caller disposes the returned sub. */ + function collectEvents(): { events: GlobalEvent[]; dispose(): void } { + const events: GlobalEvent[] = []; + const sub = core().get(IEventService).subscribe((event) => events.push(event)); + return { + events, + dispose: () => { + sub.dispose(); + }, + }; + } + + async function createSession(): Promise<{ id: string; workspace_id: string }> { + const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home } }), + }); + const body = (await res.json()) as { + code: number; + data: { id: string; workspace_id: string }; + }; + expect(body.code).toBe(0); + return body.data; + } + + async function postBatch(path: string, body?: unknown): Promise { + const res = await authedFetch(server as RunningServer, base, path, { + method: 'POST', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + expect(res.status).toBe(200); + return (await res.json()) as BatchEnvelopeWire; + } + + async function readStateJson(workspaceId: string, id: string): Promise> { + const dir = sessionDirOf(home as string, `sessions/${workspaceId}`, id); + return JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')) as Record; + } + + async function indexArchived(id: string): Promise { + return (await core().get(ISessionIndex).get(id))?.archived; + } + + async function listedIds(query = ''): Promise { + const res = await authedFetch(server as RunningServer, base, `/api/v2/sessions${query}`); + const body = (await res.json()) as { code: number; data: { items: { id: string }[] } }; + expect(body.code).toBe(0); + return body.data.items.map((item) => item.id); + } + + it('archives a cold session without materializing it or touching a workspace handler', async () => { + const created = await createSession(); + await closeSessionById(core(), created.id); + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + + // Any materialization (resume, or the v1 single-archive route) must go + // through handlerFor; the cold path never touches it. + const handlerForSpy = vi.spyOn(core().get(IWorkspaceLifecycleService), 'handlerFor'); + const { events, dispose } = collectEvents(); + const before = await readStateJson(created.workspace_id, created.id); + + const body = await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data).toMatchObject({ + succeeded: 1, + failed: 0, + results: [{ id: created.id, ok: true }], + }); + + expect(handlerForSpy).not.toHaveBeenCalled(); + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + + // The persisted metadata flips exactly like setArchived(true): archived + // (+ archivedAt), updatedAt and every other field preserved. + const after = await readStateJson(created.workspace_id, created.id); + expect(after['archived']).toBe(true); + expect(typeof after['archivedAt']).toBe('number'); + expect(after['updatedAt']).toBe(before['updatedAt']); + expect(after['createdAt']).toBe(before['createdAt']); + expect(after['agents']).toEqual(before['agents']); + + // The route drained the mirror once: the read model already answers + // archived, and the v2 list serves the session under meta.archived=true. + expect(await indexArchived(created.id)).toBe(true); + expect(await listedIds('?meta.archived=true')).toEqual([created.id]); + expect(await listedIds()).toEqual([]); + + // Same bus event the live lifecycle publishes. + expect(events.filter((event) => event.type === 'event.session.archived')).toEqual([ + { type: 'event.session.archived', payload: { sessionId: created.id } }, + ]); + dispose(); + }); + + it('archives a live session through the full lifecycle chain', async () => { + const created = await createSession(); + const liveHandler = liveHandlerForSession(core(), created.id); + expect(liveHandler).toBeDefined(); + const lifecycle = liveHandler?.accessor.get(ISessionLifecycleService); + const archiveSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'archive'); + const resumeSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'resume'); + const { events, dispose } = collectEvents(); + + const body = await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + // The full chain ran: archive() (no resume needed for a live session) + // closed and disposed the session and published the event itself. + expect(archiveSpy).toHaveBeenCalledWith(created.id); + expect(resumeSpy).not.toHaveBeenCalled(); + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + expect( + events.some( + (event) => + event.type === 'event.session.archived' && + (event.payload as { sessionId: string }).sessionId === created.id, + ), + ).toBe(true); + expect(await indexArchived(created.id)).toBe(true); + dispose(); + }); + + it('reports per-item results in input order for a live/cold/missing mixed batch', async () => { + const live = await createSession(); + const cold = await createSession(); + await closeSessionById(core(), cold.id); + + const body = await postBatch('/api/v2/sessions:archive', { + ids: [live.id, cold.id, 'sess_missing'], + }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([ + { id: live.id, ok: true }, + { id: cold.id, ok: true }, + { + id: 'sess_missing', + ok: false, + error: { code: 40401, message: 'session sess_missing does not exist' }, + }, + ]); + expect(body.data?.succeeded).toBe(2); + expect(body.data?.failed).toBe(1); + expect(await indexArchived(live.id)).toBe(true); + expect(await indexArchived(cold.id)).toBe(true); + }); + + it('restores a cold session without materializing it and publishes no archived event', async () => { + const created = await createSession(); + await closeSessionById(core(), created.id); + await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + expect(await indexArchived(created.id)).toBe(true); + + const handlerForSpy = vi.spyOn(core().get(IWorkspaceLifecycleService), 'handlerFor'); + const { events, dispose } = collectEvents(); + const before = await readStateJson(created.workspace_id, created.id); + + const body = await postBatch('/api/v2/sessions:restore', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + expect(handlerForSpy).not.toHaveBeenCalled(); + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + + const after = await readStateJson(created.workspace_id, created.id); + expect(after['archived']).toBe(false); + expect('archivedAt' in after).toBe(false); + expect(after['updatedAt']).toBe(before['updatedAt']); + + expect(await indexArchived(created.id)).toBe(false); + expect(await listedIds()).toEqual([created.id]); + // The live restore publishes nothing either — no event at all. + expect(events.filter((event) => event.type === 'event.session.archived')).toEqual([]); + dispose(); + }); + + it('restores a live session through the lifecycle chain and keeps it live', async () => { + const created = await createSession(); + await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + // Back to live-but-archived: resume materializes regardless of the flag. + expect(await resumeSessionById(core(), created.id)).toBeDefined(); + const lifecycle = liveHandlerForSession(core(), created.id)?.accessor.get( + ISessionLifecycleService, + ); + const restoreSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'restore'); + + const body = await postBatch('/api/v2/sessions:restore', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + expect(restoreSpy).toHaveBeenCalledWith(created.id); + expect(getLiveSessionById(core(), created.id)).toBeDefined(); + expect(await indexArchived(created.id)).toBe(false); + }); + + it('validates the batch body: empty, missing, over the unique cap, duplicates', async () => { + for (const body of [{ ids: [] }, {}]) { + const rejected = await postBatch('/api/v2/sessions:archive', body); + expect(rejected.code).toBe(40001); + expect(rejected.data).toBeNull(); + } + + const tooMany = await postBatch('/api/v2/sessions:archive', { + ids: Array.from({ length: 5001 }, (_, i) => `sess_${i}`), + }); + expect(tooMany.code).toBe(40001); + + // Duplicates collapse before the cap; a repeated id runs once. + const deduped = await postBatch('/api/v2/sessions:archive', { + ids: Array.from({ length: 5001 }, () => 'sess_dup'), + }); + expect(deduped.code).toBe(0); + expect(deduped.data?.results).toHaveLength(1); + expect(deduped.data?.results[0]?.ok).toBe(false); + expect(deduped.data?.results[0]?.error?.code).toBe(40401); + }); +}); + describe('mapActivityStatus', () => { it('maps a cold persisted failure to failed, live outcomes still win', () => { const coldIdle = { busy: false, mainTurnActive: false, pendingInteraction: 'none' as const, live: false as const }; From 6ae1b2f0e6387537395ee14908d97501af6b180b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Sat, 15 Aug 2026 13:05:03 +0800 Subject: [PATCH 04/29] docs(server-api): document v2 sessions page mode, total, updated_before, and batch archive/restore --- docs/en/reference/server-api.md | 30 ++++++++++++++++++++++++++++-- docs/zh/reference/server-api.md | 30 ++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 3a056830d5..38eb6b484a 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -77,7 +77,7 @@ Error codes are grouped by band: List endpoints come in two styles: - **Cursor style**: `before_id` / `after_id` (mutually exclusive) plus `page_size` (1–100), responding with `{ items, has_more }`. Used by the session list, message list, transcript, and others. -- **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`. +- **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`. `GET /api/v2/sessions` also offers a stateless `page` page-number mode as an alternative. ## REST endpoints @@ -245,6 +245,8 @@ In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{ac | `POST /api/v1/search` | Cross-session full-text search; `mode` is `terms` (default) or `literal` (exact substring); `page_token` pagination | | `GET /api/v1/connections` | List live WebSocket connections | | `GET /api/v2/sessions` | Next-generation session list, see below | +| `POST /api/v2/sessions:archive` | Batch-archive sessions, see below | +| `POST /api/v2/sessions:restore` | Batch-restore archived sessions, see below | | `/api/v1/debug/*` | Reflection debug RPC; mounted only with `--debug-endpoints` on loopback, not a stable protocol | ### `GET /api/v2/sessions` @@ -256,13 +258,37 @@ A next-generation session query for list views — filtering, sorting, and field | `workspace.id` | Filter by workspace; repeatable | | `activity.status` | Filter by activity status: `running` / `approval` / `question` / `failed` / `idle`; repeatable | | `meta.updated_after` | Only sessions updated after this time (epoch milliseconds) | +| `meta.updated_before` | Only sessions updated before this time (epoch milliseconds) | | `meta.archived` | `true` / `false` (default) / `all` | | `sort` | `meta.updated_at_desc` (default) / `meta.updated_at_asc` / `meta.created_at_desc` | | `include` | Comma-separated extra field groups; currently only `git` (branch and PR info, deduplicated per directory and cached for 60 seconds) | | `page_size` | 1–100, default 50 | | `page_token` | Pagination token from the previous page | +| `page` | Stateless 1-based page number; mutually exclusive with `page_token` (`40001` when combined) | -Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git`. The page token binds the first page's query conditions; changing them mid-pagination returns `40922`. +Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git`. Every page additionally carries `total`, the size of the filtered set. The page token binds the first page's query conditions; changing them mid-pagination returns `40922`. `page` mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and `next_page_token` is always `null`. + +### `POST /api/v2/sessions:archive` and `POST /api/v2/sessions:restore` + +Batch archive/restore for session-management views. The body is `{ "ids": ["session_..."] }` — non-empty, at most 5000 unique ids (duplicates collapse). Live sessions go through the full lifecycle; cold sessions are patched on disk without being loaded. + +Only a body validation failure fails the whole request (`40001`). Otherwise the response is per-item: `data.results` keeps the input order with `{ id, ok }` or `{ id, ok: false, error }` (an unknown id reports `40401` in its own item), plus `succeeded` / `failed` counts. + +```json +{ + "code": 0, + "msg": "success", + "data": { + "results": [ + { "id": "session_a", "ok": true }, + { "id": "session_b", "ok": false, "error": { "code": 40401, "message": "session session_b does not exist" } } + ], + "succeeded": 1, + "failed": 1 + }, + "request_id": "req_..." +} +``` ## WebSocket protocol diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 9ff6758e63..6208baa9f0 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -77,7 +77,7 @@ HTTP 状态码几乎总是 200,业务结果以 `code` 为准。例外情况: 列表端点有两种分页风格: - **游标式**:`before_id` / `after_id`(互斥)加 `page_size`(1–100),响应为 `{ items, has_more }`。用于会话列表、消息列表、转录等。 -- **`page_token`**:不透明令牌(内部绑定了查询条件指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。 +- **`page_token`**:不透明令牌(内部绑定了查询条件指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。`GET /api/v2/sessions` 另提供无状态的 `page` 页码模式作为替代。 ## REST 端点 @@ -245,6 +245,8 @@ PTY 终端接口,仅 loopback 绑定时挂载。 | `POST /api/v1/search` | 跨会话全文搜索,`mode` 为 `terms`(默认)或 `literal`(精确子串),`page_token` 分页 | | `GET /api/v1/connections` | 列出当前在线的 WebSocket 连接 | | `GET /api/v2/sessions` | 新一代会话列表,见下节 | +| `POST /api/v2/sessions:archive` | 批量归档会话,见下节 | +| `POST /api/v2/sessions:restore` | 批量恢复已归档会话,见下节 | | `/api/v1/debug/*` | 反射式调试 RPC,仅 `--debug-endpoints` 且 loopback 时挂载,不属于稳定协议 | ### `GET /api/v2/sessions` @@ -256,13 +258,37 @@ PTY 终端接口,仅 loopback 绑定时挂载。 | `workspace.id` | 按工作区过滤,可重复 | | `activity.status` | 按活动状态过滤:`running` / `approval` / `question` / `failed` / `idle`,可重复 | | `meta.updated_after` | 只看该时间(epoch 毫秒)之后更新过的会话 | +| `meta.updated_before` | 只看该时间(epoch 毫秒)之前更新过的会话 | | `meta.archived` | `true` / `false`(默认)/ `all` | | `sort` | `meta.updated_at_desc`(默认)/ `meta.updated_at_asc` / `meta.created_at_desc` | | `include` | 逗号分隔的附加字段组;目前支持 `git`(分支与 PR 信息,按目录去重并缓存 60 秒) | | `page_size` | 1–100,默认 50 | | `page_token` | 上一页返回的翻页令牌 | +| `page` | 无状态的 1 起始页码;与 `page_token` 互斥(同传返回 `40001`) | -响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组。翻页令牌绑定首页查询条件,中途改条件返回 `40922`。 +响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组。每页额外携带 `total`,即过滤后的集合大小。翻页令牌绑定首页查询条件,中途改条件返回 `40922`。`page` 模式是跳页用的无状态替代:每次请求都是独立快照,不签发令牌,`next_page_token` 恒为 `null`。 + +### `POST /api/v2/sessions:archive` 与 `POST /api/v2/sessions:restore` + +面向会话管理页的批量归档/恢复。请求体为 `{ "ids": ["session_..."] }`——非空、去重后不超过 5000 条。仍在线的会话走完整生命周期;未加载的冷会话直接改写磁盘上的元数据,不会被加载。 + +只有请求体校验失败才会让整个请求失败(`40001`);其余情况按条返回:`data.results` 保持输入顺序,每项为 `{ id, ok }` 或 `{ id, ok: false, error }`(不存在的 id 在自身条目里报 `40401`),并附 `succeeded` / `failed` 计数。 + +```json +{ + "code": 0, + "msg": "success", + "data": { + "results": [ + { "id": "session_a", "ok": true }, + { "id": "session_b", "ok": false, "error": { "code": 40401, "message": "session session_b does not exist" } } + ], + "succeeded": 1, + "failed": 1 + }, + "request_id": "req_..." +} +``` ## WebSocket 协议 From 5614588295dad340473c0d149e6ce766fdd22833 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 10:59:17 +0800 Subject: [PATCH 05/29] fix(kap-server): deep-import workspace lifecycle symbols in the v2 sessions route CI's tsgo/rolldown (Linux) fail to bind liveHandlerForSession and IWorkspaceLifecycleService through the agent-core-v2 package-root barrel even though it re-exports them; the same files use the established deep-import pattern already used for the git domain. --- packages/kap-server/src/routes/v2/sessions.ts | 4 +++- packages/kap-server/test/v2Sessions.test.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index f5d8df9c29..17c4b48d86 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -63,12 +63,14 @@ import { ISessionLifecycleService, IWorkspaceAliases, IWorkspaceService, - liveHandlerForSession, setColdSessionArchived, type Scope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { IGitService, type FsPullRequest } from '@moonshot-ai/agent-core-v2/app/git/git'; +// Deep import like the git domain above (the package-root barrel regressed on +// CI's tsgo/rolldown for this symbol — see PR discussion). +import { liveHandlerForSession } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/sessionLookup'; import { z } from 'zod'; import { defineRoute } from '../../middleware/defineRoute'; diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 5a5b6fa65c..1674d91924 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -20,10 +20,8 @@ import { ISessionIndex, ISessionLifecycleService, IEventService, - IWorkspaceLifecycleService, closeSessionById, getLiveSessionById, - liveHandlerForSession, resumeSessionById, sessionDirOf, type GlobalEvent, @@ -34,6 +32,10 @@ import { type FsPullRequest, IGitService, } from '@moonshot-ai/agent-core-v2/app/git/git'; +// Deep imports like the git domain above (the package-root barrel regressed +// on CI's tsgo/rolldown for these symbols — see PR discussion). +import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; +import { liveHandlerForSession } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/sessionLookup'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; From fb0a45dcd7e5b2aba6e9f2ce54d2e6b6646058af Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 11:09:03 +0800 Subject: [PATCH 06/29] fix(kap-server): inline the live-handler lookup in the batch route The previous deep imports still fail to resolve on CI's Linux toolchain (tsgo TS2307, rolldown MISSING_EXPORT) while every other module path from the same package binds fine. Keep the route self-contained: the hot-path lookup is a five-line loop over IWorkspaceLifecycleService's handlers (mirrors agent-core-v2's liveHandlerForSession), and the tests assert non-materialization behaviorally via the live map instead of importing the same two symbols for spies. --- packages/kap-server/src/routes/v2/sessions.ts | 25 +++++++++++++++---- packages/kap-server/test/v2Sessions.test.ts | 21 ++++++---------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index 17c4b48d86..1cba4e65c6 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -62,15 +62,14 @@ import { ISessionIndexMirror, ISessionLifecycleService, IWorkspaceAliases, + IWorkspaceLifecycleService, IWorkspaceService, setColdSessionArchived, + type IWorkspaceScopeHandle, type Scope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { IGitService, type FsPullRequest } from '@moonshot-ai/agent-core-v2/app/git/git'; -// Deep import like the git domain above (the package-root barrel regressed on -// CI's tsgo/rolldown for this symbol — see PR discussion). -import { liveHandlerForSession } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/sessionLookup'; import { z } from 'zod'; import { defineRoute } from '../../middleware/defineRoute'; @@ -444,6 +443,23 @@ class GitDomainResolver { // Route // --------------------------------------------------------------------------- +/** + * Find the live workspace handler owning this session WITHOUT materializing + * it (mirrors agent-core-v2's `liveHandlerForSession`, kept local because the + * package-root barrel regressed on CI's tsgo/rolldown for that symbol). + */ +function liveHandlerForSession( + accessor: Scope['accessor'], + sessionId: string, +): IWorkspaceScopeHandle | undefined { + for (const handler of accessor.get(IWorkspaceLifecycleService).handlers.list()) { + if (handler.accessor.get(ISessionLifecycleService).get(sessionId) !== undefined) { + return handler; + } + } + return undefined; +} + /** * Run one `:archive` / `:restore` batch: live sessions through the full * `ISessionLifecycleService` chain, cold sessions through the direct cold @@ -456,8 +472,7 @@ async function runBatchArchive( rawIds: readonly string[], requestId: string, reply: { send(payload: unknown): unknown }, -): Promise { - const archived = action === 'archive'; +): Promise { const archived = action === 'archive'; const ids = [...new Set(rawIds)]; const results: (V2BatchItemResult | undefined)[] = ids.map(() => undefined); diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 1674d91924..4bf23bc24b 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -32,10 +32,6 @@ import { type FsPullRequest, IGitService, } from '@moonshot-ai/agent-core-v2/app/git/git'; -// Deep imports like the git domain above (the package-root barrel regressed -// on CI's tsgo/rolldown for these symbols — see PR discussion). -import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; -import { liveHandlerForSession } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/sessionLookup'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; @@ -617,9 +613,8 @@ describe('server /api/v2/sessions batch archive/restore', () => { await closeSessionById(core(), created.id); expect(getLiveSessionById(core(), created.id)).toBeUndefined(); - // Any materialization (resume, or the v1 single-archive route) must go - // through handlerFor; the cold path never touches it. - const handlerForSpy = vi.spyOn(core().get(IWorkspaceLifecycleService), 'handlerFor'); + // Materialization (resume, or the v1 single-archive route) would put the + // session back in the live map — it must stay empty on the cold path. const { events, dispose } = collectEvents(); const before = await readStateJson(created.workspace_id, created.id); @@ -631,7 +626,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { results: [{ id: created.id, ok: true }], }); - expect(handlerForSpy).not.toHaveBeenCalled(); expect(getLiveSessionById(core(), created.id)).toBeUndefined(); // The persisted metadata flips exactly like setArchived(true): archived @@ -658,9 +652,9 @@ describe('server /api/v2/sessions batch archive/restore', () => { it('archives a live session through the full lifecycle chain', async () => { const created = await createSession(); - const liveHandler = liveHandlerForSession(core(), created.id); - expect(liveHandler).toBeDefined(); - const lifecycle = liveHandler?.accessor.get(ISessionLifecycleService); + const liveHandle = getLiveSessionById(core(), created.id); + expect(liveHandle).toBeDefined(); + const lifecycle = liveHandle?.accessor.get(ISessionLifecycleService); const archiveSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'archive'); const resumeSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'resume'); const { events, dispose } = collectEvents(); @@ -715,7 +709,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); expect(await indexArchived(created.id)).toBe(true); - const handlerForSpy = vi.spyOn(core().get(IWorkspaceLifecycleService), 'handlerFor'); const { events, dispose } = collectEvents(); const before = await readStateJson(created.workspace_id, created.id); @@ -723,7 +716,7 @@ describe('server /api/v2/sessions batch archive/restore', () => { expect(body.code).toBe(0); expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); - expect(handlerForSpy).not.toHaveBeenCalled(); + // Still not materialized (the live map stays empty on the cold path). expect(getLiveSessionById(core(), created.id)).toBeUndefined(); const after = await readStateJson(created.workspace_id, created.id); @@ -743,7 +736,7 @@ describe('server /api/v2/sessions batch archive/restore', () => { await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); // Back to live-but-archived: resume materializes regardless of the flag. expect(await resumeSessionById(core(), created.id)).toBeDefined(); - const lifecycle = liveHandlerForSession(core(), created.id)?.accessor.get( + const lifecycle = getLiveSessionById(core(), created.id)?.accessor.get( ISessionLifecycleService, ); const restoreSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'restore'); From 10bd0cd5985747aba6a074366006459d84e42658 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 11:16:27 +0800 Subject: [PATCH 07/29] fix(kap-server): drive the batch hot path through getLiveSessionById The phantom only hits the workspaceLifecycle-group symbols in these two files on CI's Linux toolchain; getLiveSessionById is observed to bind fine there. It returns the session's live scope directly (no resume), which is exactly what the batch hot path needs. --- packages/kap-server/src/routes/v2/sessions.ts | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index 1cba4e65c6..461609d506 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -62,10 +62,9 @@ import { ISessionIndexMirror, ISessionLifecycleService, IWorkspaceAliases, - IWorkspaceLifecycleService, IWorkspaceService, + getLiveSessionById, setColdSessionArchived, - type IWorkspaceScopeHandle, type Scope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; @@ -443,23 +442,6 @@ class GitDomainResolver { // Route // --------------------------------------------------------------------------- -/** - * Find the live workspace handler owning this session WITHOUT materializing - * it (mirrors agent-core-v2's `liveHandlerForSession`, kept local because the - * package-root barrel regressed on CI's tsgo/rolldown for that symbol). - */ -function liveHandlerForSession( - accessor: Scope['accessor'], - sessionId: string, -): IWorkspaceScopeHandle | undefined { - for (const handler of accessor.get(IWorkspaceLifecycleService).handlers.list()) { - if (handler.accessor.get(ISessionLifecycleService).get(sessionId) !== undefined) { - return handler; - } - } - return undefined; -} - /** * Run one `:archive` / `:restore` batch: live sessions through the full * `ISessionLifecycleService` chain, cold sessions through the direct cold @@ -472,7 +454,8 @@ async function runBatchArchive( rawIds: readonly string[], requestId: string, reply: { send(payload: unknown): unknown }, -): Promise { const archived = action === 'archive'; +): Promise { + const archived = action === 'archive'; const ids = [...new Set(rawIds)]; const results: (V2BatchItemResult | undefined)[] = ids.map(() => undefined); @@ -480,9 +463,11 @@ async function runBatchArchive( // the rest of the batch still runs. const applyOne = async (id: string): Promise => { try { - const liveHandler = liveHandlerForSession(core.accessor, id); - if (liveHandler !== undefined) { - const lifecycle = liveHandler.accessor.get(ISessionLifecycleService); + // The hot path needs the session's LIVE scope without materializing a + // cold one — getLiveSessionById answers exactly that (never resumes). + const liveHandle = getLiveSessionById(core.accessor, id); + if (liveHandle !== undefined) { + const lifecycle = liveHandle.accessor.get(ISessionLifecycleService); if (archived) await lifecycle.archive(id); else await lifecycle.restore(id); return { id, ok: true }; From 02731068ace54d897f8b09dd86f65208aabd7ed4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 11:34:00 +0800 Subject: [PATCH 08/29] refactor(kap-server): move the batch live/cold split into agent-core-v2 setSessionArchivedBatch owns the split next to the cold patch: live sessions go through the full lifecycle chain via the workspace handler accessor (the v1-proven resolution path), cold sessions through the direct write. The route becomes a thin wire-code adapter, and the batch tests assert the live chain behaviorally (disposal, events, index) instead of spying through scope accessors. --- .../sessionLifecycle/coldSessionArchive.ts | 67 +++++++++++++++- packages/kap-server/src/routes/v2/sessions.ts | 78 +++++-------------- packages/kap-server/test/v2Sessions.test.ts | 19 +---- 3 files changed, 88 insertions(+), 76 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 1b031b3a52..0d6997bfc3 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -1,8 +1,13 @@ /** * `sessionLifecycle` domain — cold-session archive/restore without session - * materialization. + * materialization, plus the live/cold batch orchestration built on top of it. * - * Writes the archived flag straight into the persisted metadata document + * `setSessionArchivedBatch` answers a batch of ids in input order: live + * sessions through the full lifecycle chain (never resumed), cold sessions + * through the direct write below (never materialized), failures folded + * per item. + * + * The direct write puts the archived flag straight into the persisted metadata document * (`state.json` under the handler-chain scope derived through * `internal/addressing` from the `bootstrap` sessions scope) via the * `storage` access-pattern store, mirrors the flipped summary into the @@ -26,8 +31,10 @@ import type { ServicesAccessor } from '#/_base/di/instantiation'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IEventService } from '#/app/event/event'; import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { liveHandlerForSession } from '#/app/workspaceLifecycle/sessionLookup'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; @@ -65,3 +72,59 @@ export async function setColdSessionArchived( } return 'updated'; } + +export type SessionArchiveBatchItemOutcome = + | { id: string; ok: true } + | { id: string; ok: false; reason: 'not_found' | 'error'; message: string }; + +/** + * Batch archive/restore with the live/cold split: a session with a live + * handle goes through the full `ISessionLifecycleService` chain (workspace + * handler accessor — the canonical owner, same path as the v1 action + * route), everything else through the direct cold patch above (never + * materialized). Per-item failures fold into the outcome list in input + * order — the batch itself never throws for item work. Live items run with + * bounded concurrency; mirror records queue and are drained by the CALLER + * (one drain for the whole batch). + */ +export async function setSessionArchivedBatch( + accessor: ServicesAccessor, + ids: readonly string[], + archived: boolean, +): Promise { + const outcomes: (SessionArchiveBatchItemOutcome | undefined)[] = ids.map(() => undefined); + const applyOne = async (id: string): Promise => { + try { + const liveHandler = liveHandlerForSession(accessor, id); + if (liveHandler !== undefined) { + const lifecycle = liveHandler.accessor.get(ISessionLifecycleService); + if (archived) await lifecycle.archive(id); + else await lifecycle.restore(id); + return { id, ok: true }; + } + const outcome = await setColdSessionArchived(accessor, id, archived); + return outcome === 'updated' + ? { id, ok: true } + : { id, ok: false, reason: 'not_found', message: `session ${id} does not exist` }; + } catch (error) { + return { + id, + ok: false, + reason: 'error', + message: error instanceof Error ? error.message : String(error), + }; + } + }; + + const BATCH_CONCURRENCY = 8; + let next = 0; + const workers = Array.from({ length: Math.min(BATCH_CONCURRENCY, ids.length) }, async () => { + while (next < ids.length) { + const index = next++; + outcomes[index] = await applyOne(ids[index] as string); + } + }); + await Promise.all(workers); + // Every slot was assigned by the workers — no undefined entries remain. + return outcomes as SessionArchiveBatchItemOutcome[]; +} diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index 461609d506..e068801393 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -60,11 +60,9 @@ import { createHash } from 'node:crypto'; import { ISessionIndex, ISessionIndexMirror, - ISessionLifecycleService, IWorkspaceAliases, IWorkspaceService, - getLiveSessionById, - setColdSessionArchived, + setSessionArchivedBatch, type Scope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; @@ -235,9 +233,6 @@ const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() } /** Cap on unique ids per batch, keeping one request's edge work bounded. */ const BATCH_IDS_MAX = 5000; -/** Hot-path lifecycle calls run with this many in flight at most. */ -const BATCH_CONCURRENCY = 8; - const v2SessionsBatchBodySchema = z .object({ ids: z.array(z.string().min(1)).min(1) }) .superRefine((value, ctx) => { @@ -443,10 +438,10 @@ class GitDomainResolver { // --------------------------------------------------------------------------- /** - * Run one `:archive` / `:restore` batch: live sessions through the full - * `ISessionLifecycleService` chain, cold sessions through the direct cold - * patch (no materialization); per-item failures fold into the result list - * in input order. Ends with a single shared mirror drain. + * Run one `:archive` / `:restore` batch: the domain package owns the + * live/cold split (live through the full lifecycle chain, cold through the + * direct patch); this adapter maps its per-item outcomes onto wire error + * codes and ends with a single shared mirror drain. */ async function runBatchArchive( core: Scope, @@ -457,61 +452,26 @@ async function runBatchArchive( ): Promise { const archived = action === 'archive'; const ids = [...new Set(rawIds)]; - const results: (V2BatchItemResult | undefined)[] = ids.map(() => undefined); - - // Per-item work never throws: a failure folds into its own result and - // the rest of the batch still runs. - const applyOne = async (id: string): Promise => { - try { - // The hot path needs the session's LIVE scope without materializing a - // cold one — getLiveSessionById answers exactly that (never resumes). - const liveHandle = getLiveSessionById(core.accessor, id); - if (liveHandle !== undefined) { - const lifecycle = liveHandle.accessor.get(ISessionLifecycleService); - if (archived) await lifecycle.archive(id); - else await lifecycle.restore(id); - return { id, ok: true }; - } - const outcome = await setColdSessionArchived(core.accessor, id, archived); - return outcome === 'updated' - ? { id, ok: true } - : { - id, - ok: false, - error: { - code: ErrorCode.SESSION_NOT_FOUND, - message: `session ${id} does not exist`, - }, - }; - } catch (error) { - return { - id, - ok: false, - error: { - code: ErrorCode.INTERNAL_ERROR, - message: error instanceof Error ? error.message : String(error), + const outcomes = await setSessionArchivedBatch(core.accessor, ids, archived); + const results: V2BatchItemResult[] = outcomes.map((outcome) => + outcome.ok + ? { id: outcome.id, ok: true } + : { + id: outcome.id, + ok: false, + error: + outcome.reason === 'not_found' + ? { code: ErrorCode.SESSION_NOT_FOUND, message: outcome.message } + : { code: ErrorCode.INTERNAL_ERROR, message: outcome.message }, }, - }; - } - }; - - let next = 0; - const workers = Array.from({ length: Math.min(BATCH_CONCURRENCY, ids.length) }, async () => { - while (next < ids.length) { - const index = next++; - results[index] = await applyOne(ids[index] as string); - } - }); - await Promise.all(workers); + ); // One drain for the whole batch — cold records queue in the mirror, and // the hot path already drained itself per call. await core.accessor.get(ISessionIndexMirror).drain(); - // Every slot was assigned by the workers — no undefined entries remain. - const settled = results as V2BatchItemResult[]; - const succeeded = settled.filter((result) => result.ok).length; + const succeeded = results.filter((result) => result.ok).length; reply.send( - okEnvelope({ results: settled, succeeded, failed: settled.length - succeeded }, requestId), + okEnvelope({ results, succeeded, failed: results.length - succeeded }, requestId), ); } diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 4bf23bc24b..8d37933f66 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -18,7 +18,6 @@ import { Error2, ErrorCodes, ISessionIndex, - ISessionLifecycleService, IEventService, closeSessionById, getLiveSessionById, @@ -652,21 +651,16 @@ describe('server /api/v2/sessions batch archive/restore', () => { it('archives a live session through the full lifecycle chain', async () => { const created = await createSession(); - const liveHandle = getLiveSessionById(core(), created.id); - expect(liveHandle).toBeDefined(); - const lifecycle = liveHandle?.accessor.get(ISessionLifecycleService); - const archiveSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'archive'); - const resumeSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'resume'); + expect(getLiveSessionById(core(), created.id)).toBeDefined(); const { events, dispose } = collectEvents(); const body = await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); expect(body.code).toBe(0); expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); - // The full chain ran: archive() (no resume needed for a live session) - // closed and disposed the session and published the event itself. - expect(archiveSpy).toHaveBeenCalledWith(created.id); - expect(resumeSpy).not.toHaveBeenCalled(); + // The full chain ran: archive() closed and disposed the session (the + // cold path would have left the live handle untouched) and published + // the event itself. expect(getLiveSessionById(core(), created.id)).toBeUndefined(); expect( events.some( @@ -736,16 +730,11 @@ describe('server /api/v2/sessions batch archive/restore', () => { await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); // Back to live-but-archived: resume materializes regardless of the flag. expect(await resumeSessionById(core(), created.id)).toBeDefined(); - const lifecycle = getLiveSessionById(core(), created.id)?.accessor.get( - ISessionLifecycleService, - ); - const restoreSpy = vi.spyOn(lifecycle as ISessionLifecycleService, 'restore'); const body = await postBatch('/api/v2/sessions:restore', { ids: [created.id] }); expect(body.code).toBe(0); expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); - expect(restoreSpy).toHaveBeenCalledWith(created.id); expect(getLiveSessionById(core(), created.id)).toBeDefined(); expect(await indexArchived(created.id)).toBe(false); }); From d8603523bf6111bdc5bcf7f0ac49e6858ec8aa9d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 11:37:56 +0800 Subject: [PATCH 09/29] fix(agent-core-v2): import sessionLookup relatively from coldSessionArchive The '#/app/workspaceLifecycle/*' specifier resolves from src/ and src/app/* files on CI's Linux toolchain but not from src/workspace/sessionLifecycle/ (tsgo TS2307, rolldown follows); a relative import bypasses the package-imports mapping. --- .../src/workspace/sessionLifecycle/coldSessionArchive.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 0d6997bfc3..0e6f7433c2 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -31,12 +31,15 @@ import type { ServicesAccessor } from '#/_base/di/instantiation'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IEventService } from '#/app/event/event'; import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; -import { liveHandlerForSession } from '#/app/workspaceLifecycle/sessionLookup'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; +// Relative on purpose: the `#/app/workspaceLifecycle/*` specifier fails to +// resolve from this directory on CI's tsgo/rolldown (Linux) while resolving +// everywhere else; relative paths bypass the package-imports mapping. +import { liveHandlerForSession } from '../../app/workspaceLifecycle/sessionLookup'; export type ColdSessionArchiveOutcome = 'updated' | 'not_found'; From 88215ed2e3692264bc95407bccff9a2ed2734f07 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 11:49:59 +0800 Subject: [PATCH 10/29] fix(agent-core-v2): migrate the batch hot path to ISessionManager Main's workspace/session DI refactor removed the workspaceLifecycle lookup modules; the live branch now goes through the App-level ISessionManager (the same entry the v1 action route uses post-refactor) with getLiveSessionById from the new sessionManager lookup. --- .../sessionLifecycle/coldSessionArchive.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 0e6f7433c2..01884e2bf5 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -13,7 +13,7 @@ * `storage` access-pattern store, mirrors the flipped summary into the * `sessionIndex` mirror queue (drained by the caller, never per item), and * publishes the same `event.session.archived` bus event the live - * `ISessionLifecycleService.archive` emits through `event` — restore + * `ISessionManager.archive` emits through `event` — restore * publishes nothing, matching the live `restore` (which only flips the * flag through `ISessionMetadata`). `updatedAt` is preserved verbatim, * mirroring `setArchived`'s `touchUpdatedAt: false` semantics, and every @@ -30,16 +30,13 @@ import type { ServicesAccessor } from '#/_base/di/instantiation'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IEventService } from '#/app/event/event'; +import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { getLiveSessionById } from '#/app/sessionManager/sessionLookup'; import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; -// Relative on purpose: the `#/app/workspaceLifecycle/*` specifier fails to -// resolve from this directory on CI's tsgo/rolldown (Linux) while resolving -// everywhere else; relative paths bypass the package-imports mapping. -import { liveHandlerForSession } from '../../app/workspaceLifecycle/sessionLookup'; export type ColdSessionArchiveOutcome = 'updated' | 'not_found'; @@ -82,13 +79,12 @@ export type SessionArchiveBatchItemOutcome = /** * Batch archive/restore with the live/cold split: a session with a live - * handle goes through the full `ISessionLifecycleService` chain (workspace - * handler accessor — the canonical owner, same path as the v1 action - * route), everything else through the direct cold patch above (never - * materialized). Per-item failures fold into the outcome list in input - * order — the batch itself never throws for item work. Live items run with - * bounded concurrency; mirror records queue and are drained by the CALLER - * (one drain for the whole batch). + * handle goes through the full `ISessionManager` chain (the same App-level + * entry as the v1 action route — never a resume), everything else through + * the direct cold patch above (never materialized). Per-item failures fold + * into the outcome list in input order — the batch itself never throws for + * item work. Live items run with bounded concurrency; mirror records queue + * and are drained by the CALLER (one drain for the whole batch). */ export async function setSessionArchivedBatch( accessor: ServicesAccessor, @@ -98,11 +94,12 @@ export async function setSessionArchivedBatch( const outcomes: (SessionArchiveBatchItemOutcome | undefined)[] = ids.map(() => undefined); const applyOne = async (id: string): Promise => { try { - const liveHandler = liveHandlerForSession(accessor, id); - if (liveHandler !== undefined) { - const lifecycle = liveHandler.accessor.get(ISessionLifecycleService); - if (archived) await lifecycle.archive(id); - else await lifecycle.restore(id); + // Hot path: a live session goes through ISessionManager — the same + // App-level entry the v1 action route uses (never a resume). + if (getLiveSessionById(accessor, id) !== undefined) { + const manager = accessor.get(ISessionManager); + if (archived) await manager.archive(id); + else await manager.restore(id); return { id, ok: true }; } const outcome = await setColdSessionArchived(accessor, id, archived); From 191204cb3743e9ea33762902d61e94a4a94afbd4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 15:07:37 +0800 Subject: [PATCH 11/29] feat(kap-server): add the id,archived item projection to GET /api/v2/sessions fields=id,archived trims each item to { id, archived } for select-all-matching flows (the session admin page's Gmail-style select-all). Only that projection gets the relaxed page_size ceiling (10000); unknown fields, non-pair subsets, and include=git combinations are 40001, and the projection binds into the page_token fingerprint so shapes never flip mid-pagination. --- .changeset/v2-sessions-ids-projection.md | 5 + docs/en/reference/server-api.md | 5 +- docs/zh/reference/server-api.md | 5 +- packages/kap-server/src/routes/v2/sessions.ts | 106 +++++++++++++++++- packages/kap-server/test/v2Sessions.test.ts | 65 +++++++++++ 5 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 .changeset/v2-sessions-ids-projection.md diff --git a/.changeset/v2-sessions-ids-projection.md b/.changeset/v2-sessions-ids-projection.md new file mode 100644 index 0000000000..5a2c4b1f05 --- /dev/null +++ b/.changeset/v2-sessions-ids-projection.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": patch +--- + +Add an `id,archived` item projection to `GET /api/v2/sessions` (`fields=id,archived`): each item trims to `{ id, archived }` for select-all-matching flows, and only that projection gets the relaxed `page_size` ceiling (10000). The projection binds into the page-token fingerprint, rejects unknown fields and `include=git` with `40001`. diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 38eb6b484a..9f6a9bae9e 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -262,11 +262,12 @@ A next-generation session query for list views — filtering, sorting, and field | `meta.archived` | `true` / `false` (default) / `all` | | `sort` | `meta.updated_at_desc` (default) / `meta.updated_at_asc` / `meta.created_at_desc` | | `include` | Comma-separated extra field groups; currently only `git` (branch and PR info, deduplicated per directory and cached for 60 seconds) | -| `page_size` | 1–100, default 50 | +| `fields` | Comma-separated item projection; currently only `id,archived`, trimming each item to `{ id, archived }` (select-all-matching flows). Not combinable with `include=git` (`40001`) | +| `page_size` | 1–100, default 50; up to 10000 with the `id,archived` projection | | `page_token` | Pagination token from the previous page | | `page` | Stateless 1-based page number; mutually exclusive with `page_token` (`40001` when combined) | -Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git`. Every page additionally carries `total`, the size of the filtered set. The page token binds the first page's query conditions; changing them mid-pagination returns `40922`. `page` mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and `next_page_token` is always `null`. +Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git` — or just `{ id, archived }` under `fields=id,archived`. Every page additionally carries `total`, the size of the filtered set. The page token binds the first page's query conditions (including the projection); changing them mid-pagination returns `40922`. `page` mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and `next_page_token` is always `null`. ### `POST /api/v2/sessions:archive` and `POST /api/v2/sessions:restore` diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 6208baa9f0..4a8ecde201 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -262,11 +262,12 @@ PTY 终端接口,仅 loopback 绑定时挂载。 | `meta.archived` | `true` / `false`(默认)/ `all` | | `sort` | `meta.updated_at_desc`(默认)/ `meta.updated_at_asc` / `meta.created_at_desc` | | `include` | 逗号分隔的附加字段组;目前支持 `git`(分支与 PR 信息,按目录去重并缓存 60 秒) | -| `page_size` | 1–100,默认 50 | +| `fields` | 逗号分隔的字段投影;目前仅支持 `id,archived`,每项裁剪为 `{ id, archived }`(用于全选匹配场景)。不可与 `include=git` 同传(`40001`) | +| `page_size` | 1–100,默认 50;使用 `id,archived` 投影时上限放宽至 10000 | | `page_token` | 上一页返回的翻页令牌 | | `page` | 无状态的 1 起始页码;与 `page_token` 互斥(同传返回 `40001`) | -响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组。每页额外携带 `total`,即过滤后的集合大小。翻页令牌绑定首页查询条件,中途改条件返回 `40922`。`page` 模式是跳页用的无状态替代:每次请求都是独立快照,不签发令牌,`next_page_token` 恒为 `null`。 +响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组;`fields=id,archived` 时仅返回 `{ id, archived }`。每页额外携带 `total`,即过滤后的集合大小。翻页令牌绑定首页查询条件(含投影),中途改条件返回 `40922`。`page` 模式是跳页用的无状态替代:每次请求都是独立快照,不签发令牌,`next_page_token` 恒为 `null`。 ### `POST /api/v2/sessions:archive` 与 `POST /api/v2/sessions:restore` diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index e068801393..3ca023b668 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -129,6 +129,28 @@ function includeDomains(include: string | undefined): string[] { .filter((value) => value.length > 0); } +/** The one supported item projection: `fields=id,archived` (any order) — a + * lightweight ids-only shape for select-all-matching flows; only that form + * gets the relaxed page_size ceiling. */ +const KNOWN_FIELDS = new Set(['id', 'archived']); +const IDS_PROJECTION_PAGE_SIZE_MAX = 10000; +const FULL_PAGE_SIZE_MAX = 100; + +function parseFields(raw: string | undefined): string[] { + return [ + ...new Set( + (raw ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ), + ]; +} + +function isIdsProjection(fields: readonly string[]): boolean { + return fields.length === 2 && fields.every((field) => KNOWN_FIELDS.has(field)); +} + const v2SessionsListQuerySchema = z .object({ 'workspace.id': repeatedParam(z.string().min(1)), @@ -138,7 +160,8 @@ const v2SessionsListQuerySchema = z 'meta.archived': z.enum(['true', 'false', 'all']).optional(), sort: v2SortSchema.optional(), include: z.string().optional(), - page_size: z.coerce.number().int().min(1).max(100).optional(), + fields: z.string().optional(), + page_size: z.coerce.number().int().min(1).max(IDS_PROJECTION_PAGE_SIZE_MAX).optional(), page: z.coerce.number().int().min(1).optional(), page_token: z.string().min(1).optional(), }) @@ -164,6 +187,47 @@ const v2SessionsListQuerySchema = z }); } } + const fields = parseFields(value.fields); + for (const field of fields) { + if (!KNOWN_FIELDS.has(field)) { + ctx.addIssue({ + code: 'custom', + message: `unknown field '${field}'`, + path: ['fields'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + } + const projection = fields.length > 0 && fields.every((field) => KNOWN_FIELDS.has(field)); + if (projection && !isIdsProjection(fields)) { + ctx.addIssue({ + code: 'custom', + message: "unsupported fields projection; the only supported value is 'id,archived'", + path: ['fields'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (projection && includeDomains(value.include).includes('git')) { + ctx.addIssue({ + code: 'custom', + message: 'include=git is not available with the ids projection', + path: ['include'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + // The 100-item ceiling guards the full summary shape; the ids projection + // is deliberately cheap, so it alone may page much larger. + const pageSizeMax = projection ? IDS_PROJECTION_PAGE_SIZE_MAX : FULL_PAGE_SIZE_MAX; + if (value.page_size !== undefined && value.page_size > pageSizeMax) { + ctx.addIssue({ + code: 'custom', + message: projection + ? `page_size must be at most ${IDS_PROJECTION_PAGE_SIZE_MAX}` + : `page_size must be at most ${FULL_PAGE_SIZE_MAX} without the ids projection`, + path: ['page_size'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } }); /** Fastify delivers a repeated param as an array and a single one as a scalar. */ @@ -181,6 +245,9 @@ interface NormalizedQuery { readonly sort: V2Sort; readonly includeGit: boolean; readonly pageSize: number; + /** True when the ids projection (`fields=id,archived`) trims each item to + * the lightweight select-all shape. */ + readonly projection: boolean; } // --------------------------------------------------------------------------- @@ -215,8 +282,14 @@ const v2SessionSchema = z.object({ git: v2GitDomainSchema.optional(), }); +const v2SessionIdProjectionSchema = z.object({ + id: z.string(), + archived: z.boolean(), +}); + const v2SessionPageSchema = z.object({ - items: z.array(v2SessionSchema), + /** Full summaries, or `{id, archived}` pairs under `fields=id,archived`. */ + items: z.array(z.union([v2SessionSchema, v2SessionIdProjectionSchema])), /** Filtered/sorted set size — present in both pagination modes. */ total: z.number().int(), has_more: z.boolean(), @@ -262,6 +335,7 @@ type V2BatchItemResult = z.infer['results'][ type V2GitDomain = z.infer; type V2SessionWire = z.infer; +type V2SessionIdProjection = z.infer; // --------------------------------------------------------------------------- // Errors @@ -331,6 +405,9 @@ function queryFingerprint(query: NormalizedQuery): string { query.sort, query.includeGit, query.pageSize, + // The projection changes the item shape — a token minted across that + // boundary would silently flip shapes mid-pagination. + query.projection, ]; return createHash('sha256').update(JSON.stringify(canonical)).digest('base64url').slice(0, 16); } @@ -489,7 +566,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): [ErrorCode.PAGE_TOKEN_MISMATCH]: {}, }, description: - 'List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). Paginate with the opaque page_token (binds the first page’s query conditions) or with the stateless 1-based page parameter; every page carries total.', + "List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). Paginate with the opaque page_token (binds the first page’s query conditions) or with the stateless 1-based page parameter; every page carries total. fields=id,archived trims each item to the lightweight ids projection (select-all-matching flows; page_size ceiling relaxed to 10000).", tags: ['v2-sessions'], }, async (req, reply) => { @@ -504,6 +581,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): sort: raw.sort ?? 'meta.updated_at_desc', includeGit: includeDomains(raw.include).includes('git'), pageSize: raw.page_size ?? DEFAULT_PAGE_SIZE, + projection: parseFields(raw.fields).length > 0, }; const fingerprint = queryFingerprint(query); @@ -595,6 +673,28 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): ? encodePageToken(fingerprint, sortKeyOf(query.sort)(lastServed), lastServed.id) : null; + // Ids projection: trim each item to {id, archived} — no workspace-root + // back-fill, no git domain, no per-session live lookups beyond whatever + // the activity filter already resolved. + if (query.projection) { + const projected: V2SessionIdProjection[] = window.map((summary) => ({ + id: summary.id, + archived: summary.archived, + })); + reply.send( + okEnvelope( + { + items: projected, + total: sorted.length, + has_more: hasMore, + next_page_token: nextPageToken, + }, + req.id, + ), + ); + return; + } + // cwd: the session's own frozen value wins; the registry back-fills // sessions persisted before cwd was stored; unrecoverable → null. const roots = new Map( diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 8d37933f66..82d2e878f1 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -342,6 +342,71 @@ describe('server /api/v2/sessions', () => { } }); + it('projects items to {id, archived} with fields=id,archived (relaxed page_size ceiling)', async () => { + const page = await getData('?fields=id,archived&page_size=10000'); + expect(page.total).toBe(3); + expect(page.items).toEqual([ + { id: 's1', archived: false }, + { id: 's2', archived: false }, + { id: 's3', archived: false }, + ]); + + // The projection composes with the filters + sort, and archived flags + // travel with the ids (meta.archived=all includes the archived row). + const all = await getData('?fields=id,archived&meta.archived=all&sort=meta.updated_at_asc'); + expect(all.items).toEqual([ + { id: 's4', archived: true }, + { id: 's3', archived: false }, + { id: 's2', archived: false }, + { id: 's1', archived: false }, + ]); + + // The relaxed ceiling only exists with the projection. + const full = await getError('?page_size=101'); + expect(full.code).toBe(40001); + const tooBig = await getError('?fields=id,archived&page_size=10001'); + expect(tooBig.code).toBe(40001); + }); + + it('rejects malformed fields projections (40001)', async () => { + // Unknown field + expect((await getError('?fields=id,foo')).code).toBe(40001); + // Known field(s) but not the one supported pair + expect((await getError('?fields=id')).code).toBe(40001); + expect((await getError('?fields=archived')).code).toBe(40001); + // The git domain is not projectable + expect((await getError('?fields=id,archived&include=git')).code).toBe(40001); + }); + + it('paginates the ids projection with an opaque cursor', async () => { + const page1 = await getData('?fields=id,archived&page_size=2'); + expect(page1.items).toEqual([ + { id: 's1', archived: false }, + { id: 's2', archived: false }, + ]); + expect(page1.has_more).toBe(true); + + const page2 = await getData( + `?fields=id,archived&page_size=2&page_token=${page1.next_page_token}`, + ); + expect(page2.items).toEqual([{ id: 's3', archived: false }]); + expect(page2.has_more).toBe(false); + }); + + it('binds the projection into the page_token fingerprint', async () => { + const full = await getData('?page_size=2'); + // A token minted on the full shape does not continue as a projection… + expect( + (await getError(`?fields=id,archived&page_size=2&page_token=${full.next_page_token}`)).code, + ).toBe(40922); + + // … and the reverse direction flips too. + const projected = await getData('?fields=id,archived&page_size=2'); + expect((await getError(`?page_size=2&page_token=${projected.next_page_token}`)).code).toBe( + 40922, + ); + }); + it('paginates with an opaque cursor across pages', async () => { const page1 = await getData('?page_size=2'); expect(page1.items.map((item) => item.id)).toEqual(['s1', 's2']); From 53a9e3f3bc3c3dc108f35a6e4f29995d436a62ba Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 17:30:32 +0800 Subject: [PATCH 12/29] fix(agent-core-v2): serialize the batch cold write against in-flight resumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on #2983: while a resume is in flight the live registry hides the handle, so the batch route could classify the session as cold and its direct write would race the materializing metadata service (its stale in-memory document wins the next write, silently un-archiving the session after the endpoint reported success). The batch now settles the resume first: SessionManager registers the whole resume promise synchronously at the App level (controllerForSession is async, so the controller's own resuming map learns about it a few microtasks late) and whenResumeSettled awaits it before classification — a settled resume lands the item on the live chain, a failed one falls back to the cold path. Also folds the module header down to the package's external-role comment convention. --- .../src/app/sessionManager/sessionManager.ts | 1 + .../sessionManager/sessionManagerService.ts | 19 +++++++- .../sessionLifecycle/coldSessionArchive.ts | 47 +++++-------------- .../sessionLifecycleService.ts | 4 ++ .../app/sessionExport/sessionExport.test.ts | 1 + packages/kap-server/test/v2Sessions.test.ts | 23 +++++++++ 6 files changed, 58 insertions(+), 37 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts index e1d3cc3d60..84e6804c8a 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts @@ -29,6 +29,7 @@ export interface ISessionManager { create(options: CreateManagedSessionOptions): Promise; resume(sessionId: string, options?: ResumeSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; + whenResumeSettled(sessionId: string): Promise; list(): readonly ISessionScopeHandle[]; close(sessionId: string): Promise; archive(sessionId: string): Promise; diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index 4d518a8ed6..209af0a470 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -31,6 +31,7 @@ export class SessionManager implements ISessionManager { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); private readonly owners = new Map(); + private readonly pendingResumes = new Map>(); private readonly controllers = new Map(); private readonly controllerEntries = new Set(); private readonly willCreateEmitter = new Emitter(); @@ -61,13 +62,29 @@ export class SessionManager implements ISessionManager { } async resume(sessionId: string, options?: ResumeSessionOptions): Promise { - return (await this.controllerForSession(sessionId))?.resume(sessionId, options); + const inflight = this.pendingResumes.get(sessionId); + if (inflight !== undefined) return inflight; + // Register synchronously at the App level: controllerForSession is async, + // so the controller's own resuming map only learns about this resume a + // few microtasks later — whenResumeSettled must see it from this call's + // very first tick. + const promise = (async () => + (await this.controllerForSession(sessionId))?.resume(sessionId, options))().finally(() => + this.pendingResumes.delete(sessionId), + ); + this.pendingResumes.set(sessionId, promise); + return promise; } get(sessionId: string): ISessionScopeHandle | undefined { return this.sessions.get(sessionId); } + async whenResumeSettled(sessionId: string): Promise { + await this.pendingResumes.get(sessionId)?.catch(() => undefined); + await this.owners.get(sessionId)?.whenResumeSettled(sessionId); + } + list(): readonly ISessionScopeHandle[] { return [...this.sessions.values()]; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 01884e2bf5..839f96e4b2 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -2,29 +2,15 @@ * `sessionLifecycle` domain — cold-session archive/restore without session * materialization, plus the live/cold batch orchestration built on top of it. * - * `setSessionArchivedBatch` answers a batch of ids in input order: live - * sessions through the full lifecycle chain (never resumed), cold sessions - * through the direct write below (never materialized), failures folded - * per item. - * - * The direct write puts the archived flag straight into the persisted metadata document - * (`state.json` under the handler-chain scope derived through - * `internal/addressing` from the `bootstrap` sessions scope) via the - * `storage` access-pattern store, mirrors the flipped summary into the - * `sessionIndex` mirror queue (drained by the caller, never per item), and - * publishes the same `event.session.archived` bus event the live - * `ISessionManager.archive` emits through `event` — restore - * publishes nothing, matching the live `restore` (which only flips the - * flag through `ISessionMetadata`). `updatedAt` is preserved verbatim, - * mirroring `setArchived`'s `touchUpdatedAt: false` semantics, and every - * other persisted field survives the read-modify-write untouched. Call - * only for a session with no live handle in any workspace handler — a - * live session must go through the full lifecycle so its agents drain - * and its scope tears down; the direct write deliberately races a - * concurrent resume unsynchronized (the read model heals by - * reconciliation). Existence reads from `ISessionIndex`: an unknown id - * and an index entry whose document is unreadable both report - * `not_found`. + * `setSessionArchivedBatch` answers a batch of ids in input order: an + * in-flight resume is settled through `sessionManager` before classifying + * (the live registry hides the handle while one runs), live sessions go + * through the full `sessionManager` lifecycle chain (never resumed), and + * cold sessions are patched straight into the persisted metadata document + * through `persistence` (existence reads from `sessionIndex`), mirrored + * into the `sessionIndex` read model, and announced through `event` — + * never materialized. Plain functions over a STABLE accessor; own no + * scoped state. */ import type { ServicesAccessor } from '#/_base/di/instantiation'; @@ -77,15 +63,6 @@ export type SessionArchiveBatchItemOutcome = | { id: string; ok: true } | { id: string; ok: false; reason: 'not_found' | 'error'; message: string }; -/** - * Batch archive/restore with the live/cold split: a session with a live - * handle goes through the full `ISessionManager` chain (the same App-level - * entry as the v1 action route — never a resume), everything else through - * the direct cold patch above (never materialized). Per-item failures fold - * into the outcome list in input order — the batch itself never throws for - * item work. Live items run with bounded concurrency; mirror records queue - * and are drained by the CALLER (one drain for the whole batch). - */ export async function setSessionArchivedBatch( accessor: ServicesAccessor, ids: readonly string[], @@ -94,10 +71,9 @@ export async function setSessionArchivedBatch( const outcomes: (SessionArchiveBatchItemOutcome | undefined)[] = ids.map(() => undefined); const applyOne = async (id: string): Promise => { try { - // Hot path: a live session goes through ISessionManager — the same - // App-level entry the v1 action route uses (never a resume). + const manager = accessor.get(ISessionManager); + await manager.whenResumeSettled(id); if (getLiveSessionById(accessor, id) !== undefined) { - const manager = accessor.get(ISessionManager); if (archived) await manager.archive(id); else await manager.restore(id); return { id, ok: true }; @@ -125,6 +101,5 @@ export async function setSessionArchivedBatch( } }); await Promise.all(workers); - // Every slot was assigned by the workers — no undefined entries remain. return outcomes as SessionArchiveBatchItemOutcome[]; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index cb63b1dad0..1b6e048c2a 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -429,6 +429,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return promise; } + async whenResumeSettled(sessionId: string): Promise { + await this.resuming.get(sessionId)?.catch(() => undefined); + } + private async doResume( sessionId: string, opts?: ResumeSessionOptions, diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 32f1063ad3..8929d6f556 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -890,6 +890,7 @@ function registerSessionExportServices( }, resume: async () => options.lifecycleHandle, get: () => options.lifecycleHandle, + whenResumeSettled: async () => {}, list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), close: async () => {}, archive: async () => {}, diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 82d2e878f1..acd353e7be 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -738,6 +738,29 @@ describe('server /api/v2/sessions batch archive/restore', () => { dispose(); }); + it('settles an in-flight resume before classifying (no cold-write race)', async () => { + const created = await createSession(); + await closeSessionById(core(), created.id); + + // Resume WITHOUT awaiting: while it is in flight the live registry hides + // the handle, so an unsettled batch would misclassify as cold and its + // direct write would race the materializing metadata service. + const resumePromise = resumeSessionById(core(), created.id); + const batchPromise = postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + + const handle = await resumePromise; + expect(handle).toBeDefined(); + const body = await batchPromise; + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + // The settle made the item live-classified: the full archive chain ran + // (the session is closed, not merely patched on disk), and the archived + // flag survived the resume's own metadata writes. + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + expect(await indexArchived(created.id)).toBe(true); + expect((await readStateJson(created.workspace_id, created.id))['archived']).toBe(true); + }); + it('reports per-item results in input order for a live/cold/missing mixed batch', async () => { const live = await createSession(); const cold = await createSession(); From 63a8130a7b9b7d25a7a2a655ed653665a67a6c4e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 18:42:10 +0800 Subject: [PATCH 13/29] fix(agent-core-v2): publish SessionArchived as an Event2 class in cold archive --- .../sessionLifecycle/coldSessionArchive.ts | 5 ++-- packages/kap-server/test/v2Sessions.test.ts | 23 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 839f96e4b2..fd92b0072b 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -23,6 +23,7 @@ import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStor import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; +import { SessionArchived } from './sessionLifecycleEvents'; export type ColdSessionArchiveOutcome = 'updated' | 'not_found'; @@ -52,9 +53,7 @@ export async function setColdSessionArchived( await docs.set(metaScope, 'state.json', { ...persisted, archived, archivedAt }); accessor.get(ISessionIndexMirror).record({ ...summary, archived, archivedAt }); if (archived) { - accessor - .get(IEventService) - .publish({ type: 'event.session.archived', payload: { sessionId } }); + accessor.get(IEventService).publish(new SessionArchived({ payload: { sessionId } })); } return 'updated'; } diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index acd353e7be..62e36444ed 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -23,7 +23,7 @@ import { getLiveSessionById, resumeSessionById, sessionDirOf, - type GlobalEvent, + type Event2, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { @@ -621,8 +621,8 @@ describe('server /api/v2/sessions batch archive/restore', () => { } /** Subscribe a bus-event collector; caller disposes the returned sub. */ - function collectEvents(): { events: GlobalEvent[]; dispose(): void } { - const events: GlobalEvent[] = []; + function collectEvents(): { events: Event2[]; dispose(): void } { + const events: Event2[] = []; const sub = core().get(IEventService).subscribe((event) => events.push(event)); return { events, @@ -707,10 +707,16 @@ describe('server /api/v2/sessions batch archive/restore', () => { expect(await listedIds('?meta.archived=true')).toEqual([created.id]); expect(await listedIds()).toEqual([]); - // Same bus event the live lifecycle publishes. - expect(events.filter((event) => event.type === 'event.session.archived')).toEqual([ - { type: 'event.session.archived', payload: { sessionId: created.id } }, - ]); + // Same bus event the live lifecycle publishes (Event2 instances also + // carry `time` — compare the meaningful shape). + expect( + events + .filter((event) => event.type === 'event.session.archived') + .map((event) => ({ + type: event.type, + payload: (event as { readonly payload?: unknown }).payload, + })), + ).toEqual([{ type: 'event.session.archived', payload: { sessionId: created.id } }]); dispose(); }); @@ -731,7 +737,8 @@ describe('server /api/v2/sessions batch archive/restore', () => { events.some( (event) => event.type === 'event.session.archived' && - (event.payload as { sessionId: string }).sessionId === created.id, + ((event as { readonly payload?: unknown }).payload as { sessionId: string }) + .sessionId === created.id, ), ).toBe(true); expect(await indexArchived(created.id)).toBe(true); From 46db11d6fd1c46342f9a37811dcc302a9437197c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 21:24:52 +0800 Subject: [PATCH 14/29] fix(agent-core-v2): serialize batch archive/restore with session lifecycle transitions --- .../src/app/sessionManager/sessionManager.ts | 1 + .../sessionManager/sessionManagerService.ts | 39 +++++-- .../sessionLifecycle/coldSessionArchive.ts | 57 +++++----- .../app/sessionExport/sessionExport.test.ts | 2 + .../sessionManagerService.test.ts | 100 ++++++++++++++++++ .../coldSessionArchive.test.ts | 75 +++++++++++++ 6 files changed, 243 insertions(+), 31 deletions(-) create mode 100644 packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts index 84e6804c8a..e20a4518ea 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts @@ -30,6 +30,7 @@ export interface ISessionManager { resume(sessionId: string, options?: ResumeSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; whenResumeSettled(sessionId: string): Promise; + withLifecycleSerialization(sessionId: string, work: () => Promise): Promise; list(): readonly ISessionScopeHandle[]; close(sessionId: string): Promise; archive(sessionId: string): Promise; diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index 209af0a470..b8c356c8ad 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -32,6 +32,7 @@ export class SessionManager implements ISessionManager { private readonly sessions = new Map(); private readonly owners = new Map(); private readonly pendingResumes = new Map>(); + private readonly lifecycleChains = new Map>(); private readonly controllers = new Map(); private readonly controllerEntries = new Set(); private readonly willCreateEmitter = new Emitter(); @@ -68,10 +69,9 @@ export class SessionManager implements ISessionManager { // so the controller's own resuming map only learns about this resume a // few microtasks later — whenResumeSettled must see it from this call's // very first tick. - const promise = (async () => - (await this.controllerForSession(sessionId))?.resume(sessionId, options))().finally(() => - this.pendingResumes.delete(sessionId), - ); + const promise = this.serializeLifecycle(sessionId, async () => + (await this.controllerForSession(sessionId))?.resume(sessionId, options), + ).finally(() => this.pendingResumes.delete(sessionId)); this.pendingResumes.set(sessionId, promise); return promise; } @@ -85,12 +85,37 @@ export class SessionManager implements ISessionManager { await this.owners.get(sessionId)?.whenResumeSettled(sessionId); } + /** + * Per-session lifecycle chain: resume / restore / close / the batch + * archive-restore critical section all queue here, so a cold meta write can + * never interleave with a materializing resume (which would later flush its + * stale in-memory state over the write), and a close cannot complete + * between the batch's live check and its archive call. + */ + private serializeLifecycle(sessionId: string, work: () => Promise): Promise { + const prev = this.lifecycleChains.get(sessionId) ?? Promise.resolve(); + const run = prev.then(work, work); + const next = run.then( + () => undefined, + () => undefined, + ); + this.lifecycleChains.set(sessionId, next); + void next.finally(() => { + if (this.lifecycleChains.get(sessionId) === next) this.lifecycleChains.delete(sessionId); + }); + return run; + } + + withLifecycleSerialization(sessionId: string, work: () => Promise): Promise { + return this.serializeLifecycle(sessionId, work); + } + list(): readonly ISessionScopeHandle[] { return [...this.sessions.values()]; } async close(sessionId: string): Promise { - await this.owners.get(sessionId)?.close(sessionId); + await this.serializeLifecycle(sessionId, async () => this.owners.get(sessionId)?.close(sessionId)); } async archive(sessionId: string): Promise { @@ -98,7 +123,9 @@ export class SessionManager implements ISessionManager { } async restore(sessionId: string, options?: ResumeSessionOptions): Promise { - return (await this.controllerForSession(sessionId))?.restore(sessionId, options); + return this.serializeLifecycle(sessionId, async () => + (await this.controllerForSession(sessionId))?.restore(sessionId, options), + ); } async delete(sessionId: string): Promise { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index fd92b0072b..c26abd59f2 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -2,15 +2,17 @@ * `sessionLifecycle` domain — cold-session archive/restore without session * materialization, plus the live/cold batch orchestration built on top of it. * - * `setSessionArchivedBatch` answers a batch of ids in input order: an - * in-flight resume is settled through `sessionManager` before classifying - * (the live registry hides the handle while one runs), live sessions go - * through the full `sessionManager` lifecycle chain (never resumed), and - * cold sessions are patched straight into the persisted metadata document - * through `persistence` (existence reads from `sessionIndex`), mirrored - * into the `sessionIndex` read model, and announced through `event` — - * never materialized. Plain functions over a STABLE accessor; own no - * scoped state. + * `setSessionArchivedBatch` answers a batch of ids in input order: each + * id's classify + mutate runs inside `sessionManager`'s per-session + * lifecycle serialization (resume / restore / close queue on the same + * chain), so no resume can materialize stale state over a cold write and no + * close can slip between the live check and the archive call. Live sessions + * go through the full `sessionManager` lifecycle chain (never resumed), + * and cold sessions are patched straight into the persisted metadata + * document through `persistence` (existence reads from `sessionIndex`), + * mirrored into the `sessionIndex` read model, and announced through + * `event` — never materialized. Plain functions over a STABLE accessor; + * own no scoped state. */ import type { ServicesAccessor } from '#/_base/di/instantiation'; @@ -20,6 +22,7 @@ import { ISessionManager } from '#/app/sessionManager/sessionManager'; import { getLiveSessionById } from '#/app/sessionManager/sessionLookup'; import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; @@ -42,12 +45,10 @@ export async function setColdSessionArchived( ), sessionId, ); - let persisted: SessionMeta | undefined; - try { - persisted = await docs.get(metaScope, 'state.json'); - } catch { - persisted = undefined; - } + // Missing document → not_found; storage/decode failures propagate to the + // caller's per-item error mapping (a corrupt state.json is an internal + // error, never "session does not exist"). + const persisted = await docs.get(metaScope, 'state.json'); if (persisted === undefined) return 'not_found'; const archivedAt = archived ? Date.now() : undefined; await docs.set(metaScope, 'state.json', { ...persisted, archived, archivedAt }); @@ -71,16 +72,22 @@ export async function setSessionArchivedBatch( const applyOne = async (id: string): Promise => { try { const manager = accessor.get(ISessionManager); - await manager.whenResumeSettled(id); - if (getLiveSessionById(accessor, id) !== undefined) { - if (archived) await manager.archive(id); - else await manager.restore(id); - return { id, ok: true }; - } - const outcome = await setColdSessionArchived(accessor, id, archived); - return outcome === 'updated' - ? { id, ok: true } - : { id, ok: false, reason: 'not_found', message: `session ${id} does not exist` }; + return await manager.withLifecycleSerialization(id, async () => { + await manager.whenResumeSettled(id); + const live = getLiveSessionById(accessor, id); + if (live !== undefined) { + // Restore on a live session is a plain metadata flip — the handle + // is already materialized, and manager.restore would reenter the + // serialization this critical section holds. + if (archived) await manager.archive(id); + else await live.accessor.get(ISessionMetadata).setArchived(false); + return { id, ok: true }; + } + const outcome = await setColdSessionArchived(accessor, id, archived); + return outcome === 'updated' + ? { id, ok: true } + : { id, ok: false, reason: 'not_found', message: `session ${id} does not exist` }; + }); } catch (error) { return { id, diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 8929d6f556..ac9d56f7e2 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -891,6 +891,8 @@ function registerSessionExportServices( resume: async () => options.lifecycleHandle, get: () => options.lifecycleHandle, whenResumeSettled: async () => {}, + withLifecycleSerialization: async (_sessionId: string, work: () => Promise): Promise => + work(), list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), close: async () => {}, archive: async () => {}, diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index f405cf9431..f6247409be 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -52,6 +52,106 @@ function controller(sessionId = 'session-1'): { } describe('SessionManager', () => { + it('serializes resume, close, and lifecycle critical sections per session', async () => { + const didCreate = new Emitter(); + const didClose = new Emitter(); + const handle = { id: 'session-1' } as unknown as ISessionScopeHandle; + let releaseResume!: () => void; + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + const order: string[] = []; + const service = { + onWillCreateSession: Event.None, + onDidCreateSession: didCreate.event, + onWillCloseSession: Event.None, + onDidCloseSession: didClose.event, + onDidArchiveSession: Event.None, + onDidForkSession: Event.None, + create: async () => handle, + get: () => undefined, + list: () => [], + resume: async () => { + order.push('resume:start'); + await resumeGate; + didCreate.fire({ sessionId: 'session-1', handle, source: 'startup' }); + order.push('resume:end'); + return handle; + }, + close: async () => { + order.push('close'); + didClose.fire({ sessionId: 'session-1' }); + }, + archive: async () => {}, + restore: async () => handle, + delete: async () => {}, + fork: async () => handle, + createChild: async () => handle, + dispose: () => {}, + } as unknown as SessionLifecycleService; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + const resumePromise = manager.resume('session-1'); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section'); + }); + const closePromise = manager.close('session-1'); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(order).toEqual(['resume:start']); + releaseResume(); + await Promise.all([resumePromise, section, closePromise]); + expect(order).toEqual(['resume:start', 'resume:end', 'section', 'close']); + manager.dispose(); + }); + + it('holds a resume started during a lifecycle critical section', async () => { + const fake = controller(); + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const order: string[] = []; + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const resumePromise = manager.resume('session-1').then((handle) => { + order.push('resume'); + return handle; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, resumePromise]); + expect(order).toEqual(['section:start', 'section:end', 'resume']); + manager.dispose(); + }); + it('owns one global live-session registry across workspace controllers', async () => { const fake = controller(); const workspace = { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts new file mode 100644 index 0000000000..5371fdfa6c --- /dev/null +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IEventService } from '#/app/event/event'; +import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { + setSessionArchivedBatch, +} from '#/workspace/sessionLifecycle/coldSessionArchive'; + +function accessor( + entries: ReadonlyArray, unknown]>, +): ServicesAccessor { + return { + get(id: ServiceIdentifier): T { + for (const [key, value] of entries) { + if (key === id) return value as T; + } + throw new Error(`Unexpected service request: ${String(id)}`); + }, + }; +} + +const summary = { + id: 's1', + workspaceId: 'wd', + cwd: '/workspace', + createdAt: 1, + updatedAt: 1, + archived: false, +} as const; + +function coldPathAccessor(storeGet: () => Promise): ServicesAccessor { + return accessor([ + [ + ISessionManager, + { + withLifecycleSerialization: (_id: string, work: () => Promise) => work(), + whenResumeSettled: async () => {}, + get: () => undefined, + }, + ], + [ISessionIndex, { get: async () => summary }], + [IBootstrapService, { scope: () => 'sessions' }], + [IAtomicDocumentStore, { get: storeGet, set: async () => {} }], + [ISessionIndexMirror, { record: () => {} }], + [IEventService, { publish: () => {} }], + ]); +} + +describe('setSessionArchivedBatch', () => { + it('maps a metadata read failure to a per-item internal error, not not_found', async () => { + const outcomes = await setSessionArchivedBatch( + coldPathAccessor(async () => { + throw new Error('disk on fire'); + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: false, reason: 'error', message: 'disk on fire' }]); + }); + + it('maps a missing metadata document to not_found', async () => { + const outcomes = await setSessionArchivedBatch( + coldPathAccessor(async () => undefined), + ['s1'], + true, + ); + expect(outcomes).toEqual([ + { id: 's1', ok: false, reason: 'not_found', message: 'session s1 does not exist' }, + ]); + }); +}); From 62f2e938fadb273eda0f74fc3f4a89a745fca4ad Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 23:30:50 +0800 Subject: [PATCH 15/29] fix(agent-core-v2): serialize session delete with the lifecycle chain --- .../sessionManager/sessionManagerService.ts | 24 +++++++----- .../sessionManagerService.test.ts | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index b8c356c8ad..9d5df283ec 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -86,11 +86,13 @@ export class SessionManager implements ISessionManager { } /** - * Per-session lifecycle chain: resume / restore / close / the batch - * archive-restore critical section all queue here, so a cold meta write can - * never interleave with a materializing resume (which would later flush its - * stale in-memory state over the write), and a close cannot complete - * between the batch's live check and its archive call. + * Per-session lifecycle chain: resume / restore / close / delete / the + * batch archive-restore critical section all queue here, so a cold meta + * write can never interleave with a materializing resume (which would + * later flush its stale in-memory state over the write), a close cannot + * complete between the batch's live check and its archive call, and a + * delete cannot remove the directory out from under a cold write (which + * would resurrect a metadata-only ghost). */ private serializeLifecycle(sessionId: string, work: () => Promise): Promise { const prev = this.lifecycleChains.get(sessionId) ?? Promise.resolve(); @@ -129,11 +131,13 @@ export class SessionManager implements ISessionManager { } async delete(sessionId: string): Promise { - const controller = await this.controllerForSession(sessionId); - if (controller === undefined) { - throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); - } - await controller.delete(sessionId); + await this.serializeLifecycle(sessionId, async () => { + const controller = await this.controllerForSession(sessionId); + if (controller === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + await controller.delete(sessionId); + }); } async fork(options: ForkSessionOptions): Promise { diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index f6247409be..8fa1c9b286 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -152,6 +152,43 @@ describe('SessionManager', () => { manager.dispose(); }); + it('serializes delete with the per-session lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { delete: () => Promise }).delete = async () => { + order.push('delete'); + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const deletePromise = manager.delete('session-1'); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, deletePromise]); + expect(order).toEqual(['section:start', 'section:end', 'delete']); + manager.dispose(); + }); + it('owns one global live-session registry across workspace controllers', async () => { const fake = controller(); const workspace = { From 7c7d8963e4ca39fcc5f42e62c1d368be74e6de09 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 23:30:50 +0800 Subject: [PATCH 16/29] fix(agent-core-v2): mirror the persisted metadata on cold archive, not the index summary --- .../sessionLifecycle/coldSessionArchive.ts | 20 +++++- .../coldSessionArchive.test.ts | 66 ++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index c26abd59f2..996c5f34be 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -51,8 +51,24 @@ export async function setColdSessionArchived( const persisted = await docs.get(metaScope, 'state.json'); if (persisted === undefined) return 'not_found'; const archivedAt = archived ? Date.now() : undefined; - await docs.set(metaScope, 'state.json', { ...persisted, archived, archivedAt }); - accessor.get(ISessionIndexMirror).record({ ...summary, archived, archivedAt }); + const nextMeta: SessionMeta = { ...persisted, archived, archivedAt }; + await docs.set(metaScope, 'state.json', nextMeta); + // Mirror from the AUTHORITATIVE persisted meta — the index summary can lag + // behind it (a failed/lagging mirror), and recording the stale copy would + // regress fresher fields (title, last prompt, timestamps) in the list API. + // The summary only contributes what meta does not own (workspaceId…). + accessor.get(ISessionIndexMirror).record({ + ...summary, + cwd: nextMeta.cwd ?? summary.cwd, + title: nextMeta.title, + lastPrompt: nextMeta.lastPrompt, + createdAt: nextMeta.createdAt, + updatedAt: nextMeta.updatedAt, + custom: nextMeta.custom, + lastTurnReason: nextMeta.lastTurnReason, + archived, + archivedAt, + }); if (archived) { accessor.get(IEventService).publish(new SessionArchived({ payload: { sessionId } })); } diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index 5371fdfa6c..569336cb50 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -4,8 +4,13 @@ import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiati import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IEventService } from '#/app/event/event'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; -import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { + ISessionIndex, + ISessionIndexMirror, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { setSessionArchivedBatch, } from '#/workspace/sessionLifecycle/coldSessionArchive'; @@ -23,16 +28,22 @@ function accessor( }; } -const summary = { +const summary: SessionSummary = { id: 's1', workspaceId: 'wd', cwd: '/workspace', createdAt: 1, updatedAt: 1, archived: false, -} as const; +}; + +interface ColdPathOptions { + readonly storeGet: () => Promise; + readonly indexSummary?: SessionSummary; + readonly onMirrorRecord?: (recorded: SessionSummary) => void; +} -function coldPathAccessor(storeGet: () => Promise): ServicesAccessor { +function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { return accessor([ [ ISessionManager, @@ -42,10 +53,13 @@ function coldPathAccessor(storeGet: () => Promise): ServicesAccessor { get: () => undefined, }, ], - [ISessionIndex, { get: async () => summary }], + [ISessionIndex, { get: async () => options.indexSummary ?? summary }], [IBootstrapService, { scope: () => 'sessions' }], - [IAtomicDocumentStore, { get: storeGet, set: async () => {} }], - [ISessionIndexMirror, { record: () => {} }], + [IAtomicDocumentStore, { get: options.storeGet, set: async () => {} }], + [ + ISessionIndexMirror, + { record: (recorded: SessionSummary) => options.onMirrorRecord?.(recorded) }, + ], [IEventService, { publish: () => {} }], ]); } @@ -53,8 +67,10 @@ function coldPathAccessor(storeGet: () => Promise): ServicesAccessor { describe('setSessionArchivedBatch', () => { it('maps a metadata read failure to a per-item internal error, not not_found', async () => { const outcomes = await setSessionArchivedBatch( - coldPathAccessor(async () => { - throw new Error('disk on fire'); + coldPathAccessor({ + storeGet: async () => { + throw new Error('disk on fire'); + }, }), ['s1'], true, @@ -64,7 +80,7 @@ describe('setSessionArchivedBatch', () => { it('maps a missing metadata document to not_found', async () => { const outcomes = await setSessionArchivedBatch( - coldPathAccessor(async () => undefined), + coldPathAccessor({ storeGet: async () => undefined }), ['s1'], true, ); @@ -72,4 +88,34 @@ describe('setSessionArchivedBatch', () => { { id: 's1', ok: false, reason: 'not_found', message: 'session s1 does not exist' }, ]); }); + + it('mirrors the persisted metadata, not a stale index summary', async () => { + const recorded: SessionSummary[] = []; + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + indexSummary: { ...summary, title: 'stale', lastPrompt: 'stale-p', updatedAt: 1 }, + storeGet: async () => ({ + id: 's1', + title: 'fresh', + lastPrompt: 'fresh-p', + createdAt: 1, + updatedAt: 9, + archived: false, + }), + onMirrorRecord: (r) => recorded.push(r), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: true }]); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ + workspaceId: 'wd', + title: 'fresh', + lastPrompt: 'fresh-p', + updatedAt: 9, + archived: true, + }); + expect(typeof recorded[0]?.archivedAt).toBe('number'); + }); }); From c28156ab52c38f5b9531b770bc4af2ad6b15f3f7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 23:55:37 +0800 Subject: [PATCH 17/29] docs(agent-core-v2): bring sessionManager comments and new tests to package conventions --- .../sessionManager/sessionManagerService.ts | 24 +++++++++---------- .../sessionManagerService.test.ts | 13 +++++++--- .../coldSessionArchive.test.ts | 11 +++++++++ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index 9d5df283ec..d8cb72423c 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -1,3 +1,14 @@ +/** + * `sessionManager` domain — the App-scope session lifecycle facade. + * + * Owns the global live-session registry across per-workspace controllers and + * routes create / resume / restore / close / archive / delete / fork / + * createChild to the owning controller; per-session lifecycle transitions + * (and the batch archive critical section) queue on one serialization chain + * per session. Cold id→workspace lookups go through `sessionIndex`; + * workspace materialization through `workspaces`. App scope. + */ + import { DisposableStore } from '#/_base/di/lifecycle'; import { Emitter, type Event, type IWaitUntil } from '#/_base/event'; import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope'; @@ -65,10 +76,6 @@ export class SessionManager implements ISessionManager { async resume(sessionId: string, options?: ResumeSessionOptions): Promise { const inflight = this.pendingResumes.get(sessionId); if (inflight !== undefined) return inflight; - // Register synchronously at the App level: controllerForSession is async, - // so the controller's own resuming map only learns about this resume a - // few microtasks later — whenResumeSettled must see it from this call's - // very first tick. const promise = this.serializeLifecycle(sessionId, async () => (await this.controllerForSession(sessionId))?.resume(sessionId, options), ).finally(() => this.pendingResumes.delete(sessionId)); @@ -85,15 +92,6 @@ export class SessionManager implements ISessionManager { await this.owners.get(sessionId)?.whenResumeSettled(sessionId); } - /** - * Per-session lifecycle chain: resume / restore / close / delete / the - * batch archive-restore critical section all queue here, so a cold meta - * write can never interleave with a materializing resume (which would - * later flush its stale in-memory state over the write), a close cannot - * complete between the batch's live check and its archive call, and a - * delete cannot remove the directory out from under a cold write (which - * would resurrect a metadata-only ghost). - */ private serializeLifecycle(sessionId: string, work: () => Promise): Promise { const prev = this.lifecycleChains.get(sessionId) ?? Promise.resolve(); const run = prev.then(work, work); diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index 8fa1c9b286..f70010c1e6 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -51,6 +51,13 @@ function controller(sessionId = 'session-1'): { return { service, handle }; } +/** Deterministic "the queued contender had its chance": every chain hop is + * microtask-scheduled, so draining microtasks proves a blocked operation + * has NOT run — a wrongly unchained one would start within a few ticks. */ +async function drainMicrotasks(ticks = 50): Promise { + for (let i = 0; i < ticks; i++) await Promise.resolve(); +} + describe('SessionManager', () => { it('serializes resume, close, and lifecycle critical sections per session', async () => { const didCreate = new Emitter(); @@ -107,7 +114,7 @@ describe('SessionManager', () => { order.push('section'); }); const closePromise = manager.close('session-1'); - await new Promise((resolve) => setTimeout(resolve, 10)); + await drainMicrotasks(); expect(order).toEqual(['resume:start']); releaseResume(); await Promise.all([resumePromise, section, closePromise]); @@ -144,7 +151,7 @@ describe('SessionManager', () => { order.push('resume'); return handle; }); - await new Promise((resolve) => setTimeout(resolve, 10)); + await drainMicrotasks(); expect(order).toEqual(['section:start']); releaseSection(); await Promise.all([section, resumePromise]); @@ -181,7 +188,7 @@ describe('SessionManager', () => { order.push('section:end'); }); const deletePromise = manager.delete('session-1'); - await new Promise((resolve) => setTimeout(resolve, 10)); + await drainMicrotasks(); expect(order).toEqual(['section:start']); releaseSection(); await Promise.all([section, deletePromise]); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index 569336cb50..eb2b6bac55 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -1,3 +1,14 @@ +/** + * Scenario: `setSessionArchivedBatch` cold-path outcome mapping. + * Responsibilities: a metadata read failure becomes a per-item internal + * error (never not_found), a missing metadata document is not_found, and + * the mirrored summary is built from the authoritative persisted metadata + * rather than a stale index copy. + * Wiring: pure stubs — ISessionManager (serialization passthrough), + * ISessionIndex, IAtomicDocumentStore, ISessionIndexMirror, IEventService. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/workspace/sessionLifecycle/coldSessionArchive.test.ts`. + */ + import { describe, expect, it } from 'vitest'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; From 6d0ea97eb5a275755783c2404a4d91c0bc2af683 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:32:11 +0800 Subject: [PATCH 18/29] fix(agent-core-v2): normalize legacy session metadata before the cold archive write --- .../sessionLifecycle/coldSessionArchive.ts | 37 ++++++++++------ .../coldSessionArchive.test.ts | 43 ++++++++++++++++++- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 996c5f34be..66a2206e62 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -21,9 +21,11 @@ import { IEventService } from '#/app/event/event'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; import { getLiveSessionById } from '#/app/sessionManager/sessionLookup'; import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; +import { normalizeSessionMeta } from '#/session/sessionMetadata/sessionMetadataService'; import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; import { SessionArchived } from './sessionLifecycleEvents'; @@ -48,8 +50,12 @@ export async function setColdSessionArchived( // Missing document → not_found; storage/decode failures propagate to the // caller's per-item error mapping (a corrupt state.json is an internal // error, never "session does not exist"). - const persisted = await docs.get(metaScope, 'state.json'); - if (persisted === undefined) return 'not_found'; + const raw = await docs.get(metaScope, 'state.json'); + if (raw === undefined) return 'not_found'; + // Normalize legacy (v1) representations first — ISO-string timestamps, + // customTitle, workDir — or the write-back and the mirror would persist / + // broadcast the legacy shape and poison the read model. + const persisted = normalizeSessionMeta(raw, sessionId); const archivedAt = archived ? Date.now() : undefined; const nextMeta: SessionMeta = { ...persisted, archived, archivedAt }; await docs.set(metaScope, 'state.json', nextMeta); @@ -57,18 +63,21 @@ export async function setColdSessionArchived( // behind it (a failed/lagging mirror), and recording the stale copy would // regress fresher fields (title, last prompt, timestamps) in the list API. // The summary only contributes what meta does not own (workspaceId…). - accessor.get(ISessionIndexMirror).record({ - ...summary, - cwd: nextMeta.cwd ?? summary.cwd, - title: nextMeta.title, - lastPrompt: nextMeta.lastPrompt, - createdAt: nextMeta.createdAt, - updatedAt: nextMeta.updatedAt, - custom: nextMeta.custom, - lastTurnReason: nextMeta.lastTurnReason, - archived, - archivedAt, - }); + accessor.get(ISessionIndexMirror).record( + buildSessionSummary({ + id: sessionId, + workspaceId: summary.workspaceId, + cwd: nextMeta.cwd ?? summary.cwd, + title: nextMeta.title, + lastPrompt: nextMeta.lastPrompt, + createdAt: nextMeta.createdAt, + updatedAt: nextMeta.updatedAt, + archived, + archivedAt, + custom: nextMeta.custom, + lastTurnReason: nextMeta.lastTurnReason, + }), + ); if (archived) { accessor.get(IEventService).publish(new SessionArchived({ payload: { sessionId } })); } diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index eb2b6bac55..644e35d335 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -52,6 +52,7 @@ interface ColdPathOptions { readonly storeGet: () => Promise; readonly indexSummary?: SessionSummary; readonly onMirrorRecord?: (recorded: SessionSummary) => void; + readonly onStoreSet?: (value: unknown) => void; } function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { @@ -66,7 +67,15 @@ function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { ], [ISessionIndex, { get: async () => options.indexSummary ?? summary }], [IBootstrapService, { scope: () => 'sessions' }], - [IAtomicDocumentStore, { get: options.storeGet, set: async () => {} }], + [ + IAtomicDocumentStore, + { + get: options.storeGet, + set: async (_scope: string, _key: string, value: unknown) => { + options.onStoreSet?.(value); + }, + }, + ], [ ISessionIndexMirror, { record: (recorded: SessionSummary) => options.onMirrorRecord?.(recorded) }, @@ -129,4 +138,36 @@ describe('setSessionArchivedBatch', () => { }); expect(typeof recorded[0]?.archivedAt).toBe('number'); }); + + it('normalizes legacy v1 metadata before persisting and mirroring', async () => { + const recorded: SessionSummary[] = []; + const written: unknown[] = []; + const legacy = { + // v1 shape: ISO-string timestamps, customTitle, workDir, no version. + workDir: '/workspace', + customTitle: 'legacy title', + createdAt: '2026-07-21T19:40:00.000Z', + updatedAt: '2026-07-22T02:00:00.000Z', + archived: false, + } as unknown as SessionMeta; + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + storeGet: async () => legacy, + onMirrorRecord: (r) => recorded.push(r), + onStoreSet: (v) => written.push(v), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: true }]); + + const rec = recorded[0]; + expect(rec?.title).toBe('legacy title'); + expect(rec?.updatedAt).toBe(Date.parse('2026-07-22T02:00:00.000Z')); + + const persisted = written[0] as Record; + expect(persisted['version']).toBe(2); + expect(typeof persisted['updatedAt']).toBe('number'); + expect(persisted['customTitle']).toBeUndefined(); + }); }); From e825bc2bc8193b2f375dbb66df635355cf86f1a5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:32:11 +0800 Subject: [PATCH 19/29] fix(kap-server): serialize the v1 single-session archive with the lifecycle chain --- packages/kap-server/src/routes/sessions.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 79e661d324..c6cd184728 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -892,7 +892,15 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (archived === undefined || archiveHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } - await core.accessor.get(ISessionManager).archive(parsed.id); + // Serialize the archive against the batch endpoints' per-session + // critical sections: two concurrent archive calls would otherwise + // both pass the live check and double-fire the lifecycle (duplicate + // events, the controller's session count decremented twice). The + // batch's own archive call stays UNSERIALIZED — it already runs + // inside the chain, and chaining it would self-deadlock. + await core.accessor.get(ISessionManager).withLifecycleSerialization(parsed.id, () => + core.accessor.get(ISessionManager).archive(parsed.id), + ); requestLog(req)?.info({ session_id: parsed.id, action: 'archive' }, 'session action completed'); reply.send(okEnvelope({ archived: true }, req.id)); } catch (error) { From fa04d35ae17d4e1f1d0fe660c84188bf1332577e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:48:58 +0800 Subject: [PATCH 20/29] chore: drop changesets for internal-only protocol work --- .changeset/cold-session-batch-archive.md | 5 ----- .changeset/v2-sessions-ids-projection.md | 5 ----- 2 files changed, 10 deletions(-) delete mode 100644 .changeset/cold-session-batch-archive.md delete mode 100644 .changeset/v2-sessions-ids-projection.md diff --git a/.changeset/cold-session-batch-archive.md b/.changeset/cold-session-batch-archive.md deleted file mode 100644 index de17db9e52..0000000000 --- a/.changeset/cold-session-batch-archive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/agent-core-v2": patch ---- - -Add a cold-session archive/restore path that patches the persisted metadata document, mirrors the flipped summary into the session-index read model, and republishes the archived bus event without materializing the session, backing the new `POST /api/v2/sessions:archive` / `:restore` batch endpoints (per-item results; live sessions still run the full lifecycle). diff --git a/.changeset/v2-sessions-ids-projection.md b/.changeset/v2-sessions-ids-projection.md deleted file mode 100644 index 5a2c4b1f05..0000000000 --- a/.changeset/v2-sessions-ids-projection.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kap-server": patch ---- - -Add an `id,archived` item projection to `GET /api/v2/sessions` (`fields=id,archived`): each item trims to `{ id, archived }` for select-all-matching flows, and only that projection gets the relaxed `page_size` ceiling (10000). The projection binds into the page-token fingerprint, rejects unknown fields and `include=git` with `40001`. From 568e47d3ac899a6538d3d536e0bf07fbf553e79c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:48:58 +0800 Subject: [PATCH 21/29] fix(agent-core-v2): encode cold-archived metadata for v1 readers --- .../src/session/sessionMetadata/sessionMetadataService.ts | 2 +- .../src/workspace/sessionLifecycle/coldSessionArchive.ts | 7 +++++-- .../workspace/sessionLifecycle/coldSessionArchive.test.ts | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 786bd1690d..2f2265bf32 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -345,7 +345,7 @@ function isSessionTitleKind(value: unknown): value is SessionTitleKind { type PersistedSessionMeta = SessionMeta & { readonly isCustomTitle: boolean }; -function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta { +export function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta { return { ...meta, isCustomTitle: meta.titleKind === 'custom' }; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 66a2206e62..9f264831c6 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -25,7 +25,7 @@ import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import { normalizeSessionMeta } from '#/session/sessionMetadata/sessionMetadataService'; +import { normalizeSessionMeta, encodeSessionMeta } from '#/session/sessionMetadata/sessionMetadataService'; import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; import { SessionArchived } from './sessionLifecycleEvents'; @@ -57,8 +57,11 @@ export async function setColdSessionArchived( // broadcast the legacy shape and poison the read model. const persisted = normalizeSessionMeta(raw, sessionId); const archivedAt = archived ? Date.now() : undefined; + // Persist through the metadata service's own encoder: it double-writes + // `isCustomTitle` for v1 readers — without it a custom title would look + // replaceable to v1 and get overwritten by the next prompt. const nextMeta: SessionMeta = { ...persisted, archived, archivedAt }; - await docs.set(metaScope, 'state.json', nextMeta); + await docs.set(metaScope, 'state.json', encodeSessionMeta(nextMeta)); // Mirror from the AUTHORITATIVE persisted meta — the index summary can lag // behind it (a failed/lagging mirror), and recording the stale copy would // regress fresher fields (title, last prompt, timestamps) in the list API. diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index 644e35d335..961d97d177 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -169,5 +169,7 @@ describe('setSessionArchivedBatch', () => { expect(persisted['version']).toBe(2); expect(typeof persisted['updatedAt']).toBe('number'); expect(persisted['customTitle']).toBeUndefined(); + // The v1-reader compatibility field rides the write (custom title). + expect(persisted['isCustomTitle']).toBe(true); }); }); From aa05630e2e3aa2157049d389713b51677550a22b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:48:58 +0800 Subject: [PATCH 22/29] fix(agent-core-v2): serialize fork and createChild with the source session's chain --- .../sessionManager/sessionManagerService.ts | 45 ++++++++++--------- .../sessionManagerService.test.ts | 38 ++++++++++++++++ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index d8cb72423c..b51702b1c5 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -3,10 +3,11 @@ * * Owns the global live-session registry across per-workspace controllers and * routes create / resume / restore / close / archive / delete / fork / - * createChild to the owning controller; per-session lifecycle transitions - * (and the batch archive critical section) queue on one serialization chain - * per session. Cold id→workspace lookups go through `sessionIndex`; - * workspace materialization through `workspaces`. App scope. + * createChild to the owning controller; per-session work (resume / restore / + * close / delete / fork / createChild and the batch archive critical + * section) queues on one serialization chain per session. Cold + * id→workspace lookups go through `sessionIndex`; workspace + * materialization through `workspaces`. App scope. */ import { DisposableStore } from '#/_base/di/lifecycle'; @@ -139,25 +140,29 @@ export class SessionManager implements ISessionManager { } async fork(options: ForkSessionOptions): Promise { - const controller = await this.controllerForSession(options.sourceSessionId); - if (controller === undefined) { - throw new Error2( - ErrorCodes.SESSION_NOT_FOUND, - `session ${options.sourceSessionId} does not exist`, - ); - } - return controller.fork(options); + return this.serializeLifecycle(options.sourceSessionId, async () => { + const controller = await this.controllerForSession(options.sourceSessionId); + if (controller === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${options.sourceSessionId} does not exist`, + ); + } + return controller.fork(options); + }); } async createChild(options: CreateChildSessionOptions): Promise { - const controller = await this.controllerForSession(options.sourceSessionId); - if (controller === undefined) { - throw new Error2( - ErrorCodes.SESSION_NOT_FOUND, - `session ${options.sourceSessionId} does not exist`, - ); - } - return controller.createChild(options); + return this.serializeLifecycle(options.sourceSessionId, async () => { + const controller = await this.controllerForSession(options.sourceSessionId); + if (controller === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${options.sourceSessionId} does not exist`, + ); + } + return controller.createChild(options); + }); } dispose(): void { diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index f70010c1e6..253d468130 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -196,6 +196,44 @@ describe('SessionManager', () => { manager.dispose(); }); + it('serializes fork of the source session with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { fork: () => Promise }).fork = async () => { + order.push('fork'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const forkPromise = manager.fork({ sourceSessionId: 'session-1' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, forkPromise]); + expect(order).toEqual(['section:start', 'section:end', 'fork']); + manager.dispose(); + }); + it('owns one global live-session registry across workspace controllers', async () => { const fake = controller(); const workspace = { From 46d88dedb136d5581483c40f5e99c1aff739015a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 11:00:03 +0800 Subject: [PATCH 23/29] refactor(agent-core-v2): chain every session lifecycle method and hand batch sections unguarded ops --- .../src/app/sessionManager/sessionManager.ts | 10 ++++- .../sessionManager/sessionManagerService.ts | 35 ++++++++++++++---- .../sessionLifecycle/coldSessionArchive.ts | 19 +++++----- .../app/sessionExport/sessionExport.test.ts | 8 ++-- .../sessionManagerService.test.ts | 37 +++++++++++++++++++ .../coldSessionArchive.test.ts | 7 +++- packages/kap-server/src/routes/sessions.ts | 13 ++----- 7 files changed, 97 insertions(+), 32 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts index e20a4518ea..d95de7e4c6 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts @@ -18,6 +18,11 @@ export interface CreateManagedSessionOptions extends CreateSessionOptions { readonly workspaceId?: string; } +export interface UnguardedSessionLifecycle { + archive(): Promise; + restore(): Promise; +} + export interface ISessionManager { readonly _serviceBrand: undefined; readonly onWillCreateSession?: Event; @@ -30,7 +35,10 @@ export interface ISessionManager { resume(sessionId: string, options?: ResumeSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; whenResumeSettled(sessionId: string): Promise; - withLifecycleSerialization(sessionId: string, work: () => Promise): Promise; + withLifecycleSerialization( + sessionId: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise; list(): readonly ISessionScopeHandle[]; close(sessionId: string): Promise; archive(sessionId: string): Promise; diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index b51702b1c5..be408cbbf9 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -30,7 +30,11 @@ import { import type { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceInstanceManager } from '#/workspace/workspaceInstance/workspaceInstanceManager'; -import { ISessionManager, type CreateManagedSessionOptions } from './sessionManager'; +import { + ISessionManager, + type CreateManagedSessionOptions, + type UnguardedSessionLifecycle, +} from './sessionManager'; interface SessionControllerEntry { readonly generation: string; @@ -107,8 +111,16 @@ export class SessionManager implements ISessionManager { return run; } - withLifecycleSerialization(sessionId: string, work: () => Promise): Promise { - return this.serializeLifecycle(sessionId, work); + withLifecycleSerialization( + sessionId: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise { + return this.serializeLifecycle(sessionId, () => + work({ + archive: () => this.archiveInner(sessionId), + restore: () => this.restoreInner(sessionId), + }), + ); } list(): readonly ISessionScopeHandle[] { @@ -119,14 +131,23 @@ export class SessionManager implements ISessionManager { await this.serializeLifecycle(sessionId, async () => this.owners.get(sessionId)?.close(sessionId)); } - async archive(sessionId: string): Promise { + private async archiveInner(sessionId: string): Promise { await (await this.controllerForSession(sessionId))?.archive(sessionId); } + async archive(sessionId: string): Promise { + await this.serializeLifecycle(sessionId, () => this.archiveInner(sessionId)); + } + + private async restoreInner( + sessionId: string, + options?: ResumeSessionOptions, + ): Promise { + return (await this.controllerForSession(sessionId))?.restore(sessionId, options); + } + async restore(sessionId: string, options?: ResumeSessionOptions): Promise { - return this.serializeLifecycle(sessionId, async () => - (await this.controllerForSession(sessionId))?.restore(sessionId, options), - ); + return this.serializeLifecycle(sessionId, () => this.restoreInner(sessionId, options)); } async delete(sessionId: string): Promise { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 9f264831c6..1b4767a660 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -4,9 +4,10 @@ * * `setSessionArchivedBatch` answers a batch of ids in input order: each * id's classify + mutate runs inside `sessionManager`'s per-session - * lifecycle serialization (resume / restore / close queue on the same - * chain), so no resume can materialize stale state over a cold write and no - * close can slip between the live check and the archive call. Live sessions + * lifecycle serialization (every lifecycle transition queues on the same + * chain; the section's own archive/restore ride the unguarded view), so + * no resume can materialize stale state over a cold write and no close + * can slip between the live check and the archive call. Live sessions * go through the full `sessionManager` lifecycle chain (never resumed), * and cold sessions are patched straight into the persisted metadata * document through `persistence` (existence reads from `sessionIndex`), @@ -23,7 +24,6 @@ import { getLiveSessionById } from '#/app/sessionManager/sessionLookup'; import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { normalizeSessionMeta, encodeSessionMeta } from '#/session/sessionMetadata/sessionMetadataService'; @@ -100,15 +100,14 @@ export async function setSessionArchivedBatch( const applyOne = async (id: string): Promise => { try { const manager = accessor.get(ISessionManager); - return await manager.withLifecycleSerialization(id, async () => { + return await manager.withLifecycleSerialization(id, async (unguarded) => { await manager.whenResumeSettled(id); const live = getLiveSessionById(accessor, id); if (live !== undefined) { - // Restore on a live session is a plain metadata flip — the handle - // is already materialized, and manager.restore would reenter the - // serialization this critical section holds. - if (archived) await manager.archive(id); - else await live.accessor.get(ISessionMetadata).setArchived(false); + // The section holds the chain — the lifecycle calls go through the + // unguarded view so they can't self-deadlock it. + if (archived) await unguarded.archive(); + else await unguarded.restore(); return { id, ok: true }; } const outcome = await setColdSessionArchived(accessor, id, archived); diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index ac9d56f7e2..a131d2c324 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -44,7 +44,7 @@ import { } from '#/app/sessionExport/sessionExportService'; import { writeExportZip } from '#/app/sessionExport/zip'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { ISessionManager, type UnguardedSessionLifecycle } from '#/app/sessionManager/sessionManager'; import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { Error2 } from '#/errors'; @@ -891,8 +891,10 @@ function registerSessionExportServices( resume: async () => options.lifecycleHandle, get: () => options.lifecycleHandle, whenResumeSettled: async () => {}, - withLifecycleSerialization: async (_sessionId: string, work: () => Promise): Promise => - work(), + withLifecycleSerialization: async ( + _sessionId: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise => work({ archive: async () => {}, restore: async () => undefined }), list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), close: async () => {}, archive: async () => {}, diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index 253d468130..7eda919558 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -234,6 +234,43 @@ describe('SessionManager', () => { manager.dispose(); }); + it('serializes archive with the per-session lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { archive: () => Promise }).archive = async () => { + order.push('archive'); + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const archivePromise = manager.archive('session-1'); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, archivePromise]); + expect(order).toEqual(['section:start', 'section:end', 'archive']); + manager.dispose(); + }); + it('owns one global live-session registry across workspace controllers', async () => { const fake = controller(); const workspace = { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index 961d97d177..992ab1ab28 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IEventService } from '#/app/event/event'; -import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { ISessionManager, type UnguardedSessionLifecycle } from '#/app/sessionManager/sessionManager'; import { ISessionIndex, ISessionIndexMirror, @@ -60,7 +60,10 @@ function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { [ ISessionManager, { - withLifecycleSerialization: (_id: string, work: () => Promise) => work(), + withLifecycleSerialization: ( + _id: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise => work({ archive: async () => {}, restore: async () => undefined }), whenResumeSettled: async () => {}, get: () => undefined, }, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index c6cd184728..de7c34a667 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -892,15 +892,10 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (archived === undefined || archiveHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } - // Serialize the archive against the batch endpoints' per-session - // critical sections: two concurrent archive calls would otherwise - // both pass the live check and double-fire the lifecycle (duplicate - // events, the controller's session count decremented twice). The - // batch's own archive call stays UNSERIALIZED — it already runs - // inside the chain, and chaining it would self-deadlock. - await core.accessor.get(ISessionManager).withLifecycleSerialization(parsed.id, () => - core.accessor.get(ISessionManager).archive(parsed.id), - ); + // archive() enters the session's lifecycle chain itself — serialized + // against the batch endpoints' critical sections and every other + // transition with no caller-side wrapping. + await core.accessor.get(ISessionManager).archive(parsed.id); requestLog(req)?.info({ session_id: parsed.id, action: 'archive' }, 'session action completed'); reply.send(okEnvelope({ archived: true }, req.id)); } catch (error) { From 2c0e315daac6dad16e4c04da2fe65efcea038082 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 11:33:02 +0800 Subject: [PATCH 24/29] fix(agent-core-v2): propagate failed resumes to the next settle --- .../sessionManager/sessionManagerService.ts | 13 ++++++++- .../sessionLifecycleService.ts | 10 ++++++- .../sessionManagerService.test.ts | 29 +++++++++++++++++++ .../coldSessionArchive.test.ts | 21 +++++++++++++- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index be408cbbf9..f9b6da7b4c 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -48,6 +48,7 @@ export class SessionManager implements ISessionManager { private readonly sessions = new Map(); private readonly owners = new Map(); private readonly pendingResumes = new Map>(); + private readonly resumeFailures = new Map(); private readonly lifecycleChains = new Map>(); private readonly controllers = new Map(); private readonly controllerEntries = new Set(); @@ -81,10 +82,18 @@ export class SessionManager implements ISessionManager { async resume(sessionId: string, options?: ResumeSessionOptions): Promise { const inflight = this.pendingResumes.get(sessionId); if (inflight !== undefined) return inflight; + // A fresh attempt supersedes any earlier failure record. + this.resumeFailures.delete(sessionId); const promise = this.serializeLifecycle(sessionId, async () => (await this.controllerForSession(sessionId))?.resume(sessionId, options), ).finally(() => this.pendingResumes.delete(sessionId)); this.pendingResumes.set(sessionId, promise); + // Keep the rejection observable past the registry cleanup: a resume that + // fails mid-materialization leaves no trace in the live registry, and a + // later settle must still see it instead of treating the session as cold. + void promise.catch((error: unknown) => { + this.resumeFailures.set(sessionId, error); + }); return promise; } @@ -93,7 +102,9 @@ export class SessionManager implements ISessionManager { } async whenResumeSettled(sessionId: string): Promise { - await this.pendingResumes.get(sessionId)?.catch(() => undefined); + await this.pendingResumes.get(sessionId); + const failure = this.resumeFailures.get(sessionId); + if (failure !== undefined) throw failure; await this.owners.get(sessionId)?.whenResumeSettled(sessionId); } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 24a370ef93..93bfe05984 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -238,6 +238,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); + private readonly resumeFailures = new Map(); constructor( private readonly instantiation: IInstantiationService, @@ -416,6 +417,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (inflight !== undefined) return inflight; const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); + // A fresh attempt supersedes any earlier failure record. + this.resumeFailures.delete(sessionId); const promise = this.doResume(sessionId, opts) .catch((error: unknown) => { this.telemetry @@ -423,6 +426,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec .track2('session_load_failed', { reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', }); + // Keep the rejection observable past the registry cleanup — a later + // settle must see the failure rather than treat the session as cold. + this.resumeFailures.set(sessionId, error); throw error; }) .finally(() => this.resuming.delete(sessionId)); @@ -431,7 +437,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } async whenResumeSettled(sessionId: string): Promise { - await this.resuming.get(sessionId)?.catch(() => undefined); + await this.resuming.get(sessionId); + const failure = this.resumeFailures.get(sessionId); + if (failure !== undefined) throw failure; } private async doResume( diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index 7eda919558..1861faea30 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -271,6 +271,35 @@ describe('SessionManager', () => { manager.dispose(); }); + it('propagates a failed resume to the next settle until a fresh attempt supersedes', async () => { + let fail = true; + const fake = controller(); + (fake.service as unknown as { resume: () => Promise }).resume = async () => { + if (fail) throw new Error('boom'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + await expect(manager.resume('session-1')).rejects.toThrow('boom'); + await expect(manager.whenResumeSettled('session-1')).rejects.toThrow('boom'); + + fail = false; + await manager.resume('session-1'); + await expect(manager.whenResumeSettled('session-1')).resolves.toBeUndefined(); + manager.dispose(); + }); + it('owns one global live-session registry across workspace controllers', async () => { const fake = controller(); const workspace = { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index 992ab1ab28..9669ee6ee0 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -53,6 +53,7 @@ interface ColdPathOptions { readonly indexSummary?: SessionSummary; readonly onMirrorRecord?: (recorded: SessionSummary) => void; readonly onStoreSet?: (value: unknown) => void; + readonly resumeError?: unknown; } function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { @@ -64,7 +65,9 @@ function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { _id: string, work: (unguarded: UnguardedSessionLifecycle) => Promise, ): Promise => work({ archive: async () => {}, restore: async () => undefined }), - whenResumeSettled: async () => {}, + whenResumeSettled: async () => { + if (options.resumeError !== undefined) throw options.resumeError; + }, get: () => undefined, }, ], @@ -175,4 +178,20 @@ describe('setSessionArchivedBatch', () => { // The v1-reader compatibility field rides the write (custom title). expect(persisted['isCustomTitle']).toBe(true); }); + + it('fails the item when a concurrent resume failed instead of cold-classifying', async () => { + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + storeGet: async () => { + throw new Error('unreachable — the settle throws first'); + }, + resumeError: new Error('resume boom'), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([ + { id: 's1', ok: false, reason: 'error', message: 'resume boom' }, + ]); + }); }); From 165fdf37c7564c4c370847bb5a9e787ade287c41 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 12:07:06 +0800 Subject: [PATCH 25/29] fix(agent-core-v2): roll back the unannounced handle when a resume fails mid-materialization --- .../sessionManager/sessionManagerService.ts | 11 ++++------ .../sessionLifecycleService.ts | 22 ++++++++++++------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index f9b6da7b4c..f4dde65141 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -5,9 +5,10 @@ * routes create / resume / restore / close / archive / delete / fork / * createChild to the owning controller; per-session work (resume / restore / * close / delete / fork / createChild and the batch archive critical - * section) queues on one serialization chain per session. Cold - * id→workspace lookups go through `sessionIndex`; workspace - * materialization through `workspaces`. App scope. + * section) queues on one serialization chain per session, and a failed + * resume is recorded so the next settle observes it until a fresh attempt + * supersedes it. Cold id→workspace lookups go through `sessionIndex`; + * workspace materialization through `workspaces`. App scope. */ import { DisposableStore } from '#/_base/di/lifecycle'; @@ -82,15 +83,11 @@ export class SessionManager implements ISessionManager { async resume(sessionId: string, options?: ResumeSessionOptions): Promise { const inflight = this.pendingResumes.get(sessionId); if (inflight !== undefined) return inflight; - // A fresh attempt supersedes any earlier failure record. this.resumeFailures.delete(sessionId); const promise = this.serializeLifecycle(sessionId, async () => (await this.controllerForSession(sessionId))?.resume(sessionId, options), ).finally(() => this.pendingResumes.delete(sessionId)); this.pendingResumes.set(sessionId, promise); - // Keep the rejection observable past the registry cleanup: a resume that - // fails mid-materialization leaves no trace in the live registry, and a - // later settle must still see it instead of treating the session as cold. void promise.catch((error: unknown) => { this.resumeFailures.set(sessionId, error); }); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 93bfe05984..65e63fc140 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -16,7 +16,10 @@ * never persisted. Pending metadata writes and the index mirror are * drained before any teardown, so a listing right after close/archive/delete * never reads a stale outcome. Session start and - * resume failures are reported through telemetry. Each Session scope + * resume failures are reported through telemetry; a failed resume also + * rolls the unannounced handle back out of the registry and is recorded, + * so the next settle observes the failure until a fresh attempt + * supersedes it. Each Session scope * receives a telemetry view bound to its session id, while failures before * a scope is available use an ephemeral context view. Closing a session * never touches the handler itself. @@ -417,7 +420,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (inflight !== undefined) return inflight; const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); - // A fresh attempt supersedes any earlier failure record. this.resumeFailures.delete(sessionId); const promise = this.doResume(sessionId, opts) .catch((error: unknown) => { @@ -426,8 +428,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec .track2('session_load_failed', { reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', }); - // Keep the rejection observable past the registry cleanup — a later - // settle must see the failure rather than treat the session as cold. this.resumeFailures.set(sessionId, error); throw error; }) @@ -459,11 +459,17 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec additionalDirs: opts?.additionalDirs, mcpServers: opts?.mcpServers, }); - const agents = handle.accessor.get(IAgentLifecycleService); - if (agents.get(MAIN_AGENT_ID) === undefined) { - await agents.create({ agentId: MAIN_AGENT_ID }); + try { + const agents = handle.accessor.get(IAgentLifecycleService); + if (agents.get(MAIN_AGENT_ID) === undefined) { + await agents.create({ agentId: MAIN_AGENT_ID }); + } + await this.announceCreated({ sessionId, handle, source: 'resume' }); + } catch (error) { + this.sessions.delete(sessionId); + handle.dispose(); + throw error; } - await this.announceCreated({ sessionId, handle, source: 'resume' }); return handle; } From b0c43b6e3ca1ac8fb5e7410720e5d891ca130f06 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 12:56:18 +0800 Subject: [PATCH 26/29] fix(agent-core-v2): read and migrate the legacy session-meta location on cold archive --- .../sessionLifecycle/coldSessionArchive.ts | 36 +++++++++--------- .../sessionLifecycle/internal/addressing.ts | 4 ++ .../coldSessionArchive.test.ts | 37 ++++++++++++++++--- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts index 1b4767a660..4351c009a9 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -10,10 +10,15 @@ * can slip between the live check and the archive call. Live sessions * go through the full `sessionManager` lifecycle chain (never resumed), * and cold sessions are patched straight into the persisted metadata - * document through `persistence` (existence reads from `sessionIndex`), - * mirrored into the `sessionIndex` read model, and announced through - * `event` — never materialized. Plain functions over a STABLE accessor; - * own no scoped state. + * document through `persistence` (existence reads from `sessionIndex`, + * with the pre-unification `session-meta/` fallback, migrating the + * document to the canonical location), normalized and encoded through + * the metadata service's own paths so v1 readers stay compatible, + * mirrored into the `sessionIndex` read model from the authoritative + * persisted meta (never the possibly-stale index summary), and announced + * through `event` — never materialized; storage/decode failures + * propagate as per-item errors, never as not_found. Plain functions over + * a STABLE accessor; own no scoped state. */ import type { ServicesAccessor } from '#/_base/di/instantiation'; @@ -27,7 +32,7 @@ import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStor import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { normalizeSessionMeta, encodeSessionMeta } from '#/session/sessionMetadata/sessionMetadataService'; -import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; +import { sessionScopeOf, legacySessionMetaScopeOf, workspacePersistenceScope } from './internal/addressing'; import { SessionArchived } from './sessionLifecycleEvents'; export type ColdSessionArchiveOutcome = 'updated' | 'not_found'; @@ -47,25 +52,18 @@ export async function setColdSessionArchived( ), sessionId, ); - // Missing document → not_found; storage/decode failures propagate to the - // caller's per-item error mapping (a corrupt state.json is an internal - // error, never "session does not exist"). - const raw = await docs.get(metaScope, 'state.json'); + let raw = await docs.get(metaScope, 'state.json'); + let legacyMetaScope: string | undefined; + if (raw === undefined) { + legacyMetaScope = legacySessionMetaScopeOf(metaScope); + raw = await docs.get(legacyMetaScope, 'state.json'); + } if (raw === undefined) return 'not_found'; - // Normalize legacy (v1) representations first — ISO-string timestamps, - // customTitle, workDir — or the write-back and the mirror would persist / - // broadcast the legacy shape and poison the read model. const persisted = normalizeSessionMeta(raw, sessionId); const archivedAt = archived ? Date.now() : undefined; - // Persist through the metadata service's own encoder: it double-writes - // `isCustomTitle` for v1 readers — without it a custom title would look - // replaceable to v1 and get overwritten by the next prompt. const nextMeta: SessionMeta = { ...persisted, archived, archivedAt }; await docs.set(metaScope, 'state.json', encodeSessionMeta(nextMeta)); - // Mirror from the AUTHORITATIVE persisted meta — the index summary can lag - // behind it (a failed/lagging mirror), and recording the stale copy would - // regress fresher fields (title, last prompt, timestamps) in the list API. - // The summary only contributes what meta does not own (workspaceId…). + if (legacyMetaScope !== undefined) await docs.delete(legacyMetaScope, 'state.json'); accessor.get(ISessionIndexMirror).record( buildSessionSummary({ id: sessionId, diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts index 5aa167b4c4..1b355de8ab 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts @@ -27,3 +27,7 @@ export function sessionDirOf(homeDir: string, handlerScope: string, sessionId: s export function agentScopeOf(sessionScope: string, agentId: string): string { return `${sessionScope}/agents/${agentId}`; } + +export function legacySessionMetaScopeOf(sessionScope: string): string { + return `${sessionScope}/session-meta`; +} diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index 9669ee6ee0..f85b6c792a 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -49,10 +49,11 @@ const summary: SessionSummary = { }; interface ColdPathOptions { - readonly storeGet: () => Promise; + readonly storeGet: (scope: string, key: string) => Promise; readonly indexSummary?: SessionSummary; readonly onMirrorRecord?: (recorded: SessionSummary) => void; - readonly onStoreSet?: (value: unknown) => void; + readonly onStoreSet?: (scope: string, key: string, value: unknown) => void; + readonly onStoreDelete?: (scope: string, key: string) => void; readonly resumeError?: unknown; } @@ -76,9 +77,12 @@ function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { [ IAtomicDocumentStore, { - get: options.storeGet, - set: async (_scope: string, _key: string, value: unknown) => { - options.onStoreSet?.(value); + get: (scope: string, key: string) => options.storeGet(scope, key), + set: async (scope: string, key: string, value: unknown) => { + options.onStoreSet?.(scope, key, value); + }, + delete: async (scope: string, key: string) => { + options.onStoreDelete?.(scope, key); }, }, ], @@ -160,7 +164,7 @@ describe('setSessionArchivedBatch', () => { coldPathAccessor({ storeGet: async () => legacy, onMirrorRecord: (r) => recorded.push(r), - onStoreSet: (v) => written.push(v), + onStoreSet: (_scope, _key, value) => written.push(value), }), ['s1'], true, @@ -194,4 +198,25 @@ describe('setSessionArchivedBatch', () => { { id: 's1', ok: false, reason: 'error', message: 'resume boom' }, ]); }); + + it('reads and migrates the legacy session-meta location before answering not_found', async () => { + const written: Array<{ scope: string; value: unknown }> = []; + const deleted: string[] = []; + const meta: SessionMeta = { id: 's1', createdAt: 1, updatedAt: 2, archived: false }; + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + storeGet: async (scope) => (scope.endsWith('/session-meta') ? meta : undefined), + onStoreSet: (scope, _key, value) => written.push({ scope, value }), + onStoreDelete: (scope) => deleted.push(scope), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: true }]); + // Written to the canonical location, and the legacy document removed. + expect(written).toHaveLength(1); + expect(written[0]?.scope.endsWith('/session-meta')).toBe(false); + expect(deleted).toHaveLength(1); + expect(deleted[0]?.endsWith('/session-meta')).toBe(true); + }); }); From f18d154ca4b0eb1f78a424f72e89bb57b2018d8a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 13:33:59 +0800 Subject: [PATCH 27/29] fix(agent-core-v2): serialize explicit-id session creation with the lifecycle chain create() with a caller-supplied sessionId bypassed the per-session chain, so a concurrent batch archive could classify the half-created session as cold and write archived state that the live metadata service later overwrites. Creation now queues on the target id's chain whenever an explicit id is present. Also type the resume-failure maps as Error and normalize at the catch site, satisfying only-throw-error. --- .../sessionManager/sessionManagerService.ts | 8 ++-- .../sessionLifecycleService.ts | 4 +- .../sessionManagerService.test.ts | 38 +++++++++++++++++++ .../coldSessionArchive.test.ts | 2 +- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index d8a4e90429..0f58331857 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -37,7 +37,7 @@ export class SessionManager implements ISessionManager { private readonly sessions = new Map(); private readonly owners = new Map(); private readonly pendingResumes = new Map>(); - private readonly resumeFailures = new Map(); + private readonly resumeFailures = new Map(); private readonly lifecycleChains = new Map>(); private readonly controllers = new Map(); private readonly controllerEntries = new Set(); @@ -65,7 +65,9 @@ export class SessionManager implements ISessionManager { ? { root: options.workDir } : { workspaceId: options.workspaceId, root: options.workDir }, ); - return this.controllerForWorkspace(workspace.id).create(options); + const controller = this.controllerForWorkspace(workspace.id); + if (options.sessionId === undefined) return controller.create(options); + return this.serializeLifecycle(options.sessionId, () => controller.create(options)); } async resume(sessionId: string, options?: ResumeSessionOptions): Promise { @@ -77,7 +79,7 @@ export class SessionManager implements ISessionManager { ).finally(() => this.pendingResumes.delete(sessionId)); this.pendingResumes.set(sessionId, promise); void promise.catch((error: unknown) => { - this.resumeFailures.set(sessionId, error); + this.resumeFailures.set(sessionId, error instanceof Error ? error : new Error('session resume failed')); }); return promise; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index dc29d8d861..be88aa77fd 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -133,7 +133,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); - private readonly resumeFailures = new Map(); + private readonly resumeFailures = new Map(); constructor( private readonly instantiation: IInstantiationService, @@ -320,7 +320,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec .track2('session_load_failed', { reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', }); - this.resumeFailures.set(sessionId, error); + this.resumeFailures.set(sessionId, error instanceof Error ? error : new Error('session resume failed')); throw error; }) .finally(() => this.resuming.delete(sessionId)); diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index 78e7afe0dc..0d220704b8 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -231,6 +231,44 @@ describe('SessionManager', () => { manager.dispose(); }); + it('serializes create with an explicit session id with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { create: () => Promise }).create = async () => { + order.push('create'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const createPromise = manager.create({ sessionId: 'session-1', workDir: '/workspace' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, createPromise]); + expect(order).toEqual(['section:start', 'section:end', 'create']); + manager.dispose(); + }); + it('serializes archive with the per-session lifecycle chain', async () => { const order: string[] = []; const fake = controller(); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts index 5aa94572fe..b8467aa3ec 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -44,7 +44,7 @@ interface ColdPathOptions { readonly onMirrorRecord?: (recorded: SessionSummary) => void; readonly onStoreSet?: (scope: string, key: string, value: unknown) => void; readonly onStoreDelete?: (scope: string, key: string) => void; - readonly resumeError?: unknown; + readonly resumeError?: Error; } function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { From b079259c5b38f7a63aad5b7363597e40c35910eb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 13:34:08 +0800 Subject: [PATCH 28/29] style(kap-server): strip comments from the session routes per the no-comments convention --- packages/kap-server/src/routes/sessions.ts | 3 -- packages/kap-server/src/routes/v2/sessions.ts | 19 ----------- packages/kap-server/test/v2Sessions.test.ts | 34 ------------------- 3 files changed, 56 deletions(-) diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 12e2cdae3f..a8aefaa6bf 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -740,9 +740,6 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (archived === undefined || archiveHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } - // archive() enters the session's lifecycle chain itself — serialized - // against the batch endpoints' critical sections and every other - // transition with no caller-side wrapping. await core.accessor.get(ISessionManager).archive(parsed.id); requestLog(req)?.info({ session_id: parsed.id, action: 'archive' }, 'session action completed'); reply.send(okEnvelope({ archived: true }, req.id)); diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index dbea6e8f35..8a0c6c729e 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -67,9 +67,6 @@ function includeDomains(include: string | undefined): string[] { .filter((value) => value.length > 0); } -/** The one supported item projection: `fields=id,archived` (any order) — a - * lightweight ids-only shape for select-all-matching flows; only that form - * gets the relaxed page_size ceiling. */ const KNOWN_FIELDS = new Set(['id', 'archived']); const IDS_PROJECTION_PAGE_SIZE_MAX = 10000; const FULL_PAGE_SIZE_MAX = 100; @@ -150,8 +147,6 @@ const v2SessionsListQuerySchema = z params: { code: ErrorCode.VALIDATION_FAILED }, }); } - // The 100-item ceiling guards the full summary shape; the ids projection - // is deliberately cheap, so it alone may page much larger. const pageSizeMax = projection ? IDS_PROJECTION_PAGE_SIZE_MAX : FULL_PAGE_SIZE_MAX; if (value.page_size !== undefined && value.page_size > pageSizeMax) { ctx.addIssue({ @@ -179,8 +174,6 @@ interface NormalizedQuery { readonly sort: V2Sort; readonly includeGit: boolean; readonly pageSize: number; - /** True when the ids projection (`fields=id,archived`) trims each item to - * the lightweight select-all shape. */ readonly projection: boolean; } @@ -216,9 +209,7 @@ const v2SessionIdProjectionSchema = z.object({ }); const v2SessionPageSchema = z.object({ - /** Full summaries, or `{id, archived}` pairs under `fields=id,archived`. */ items: z.array(z.union([v2SessionSchema, v2SessionIdProjectionSchema])), - /** Filtered/sorted set size — present in both pagination modes. */ total: z.number().int(), has_more: z.boolean(), next_page_token: z.string().nullable(), @@ -226,11 +217,7 @@ const v2SessionPageSchema = z.object({ const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); -// --------------------------------------------------------------------------- -// Batch archive / restore contract -// --------------------------------------------------------------------------- -/** Cap on unique ids per batch, keeping one request's edge work bounded. */ const BATCH_IDS_MAX = 5000; const v2SessionsBatchBodySchema = z @@ -314,8 +301,6 @@ function queryFingerprint(query: NormalizedQuery): string { query.sort, query.includeGit, query.pageSize, - // The projection changes the item shape — a token minted across that - // boundary would silently flip shapes mid-pagination. query.projection, ]; return createHash('sha256').update(JSON.stringify(canonical)).digest('base64url').slice(0, 16); @@ -527,8 +512,6 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): let start = 0; if (raw.page !== undefined) { - // Stateless page-number mode: slice the fresh snapshot directly; - // no token is minted below and none was accepted above. start = (raw.page - 1) * query.pageSize; } else if (cursor !== undefined) { const [cursorKey, cursorId] = cursor; @@ -625,8 +608,6 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): const batchRoute = defineRoute( { method: 'POST', - // `/sessions::${action}` in find-my-way serves the wire path - // `/sessions:archive` / `/sessions:restore` (single colon). path: `/sessions::${action}`, body: v2SessionsBatchBodySchema, success: { data: v2SessionsBatchResultSchema }, diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 085f2365b5..5349efae3e 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -280,13 +280,11 @@ describe('server /api/v2/sessions', () => { expect(page1.items.map((item) => item.id)).toEqual(['s2']); expect(page1.has_more).toBe(true); - // Same conditions + token paginates on … const page2 = await getData( `?page_size=1&meta.updated_before=4500&page_token=${page1.next_page_token}`, ); expect(page2.items.map((item) => item.id)).toEqual(['s3']); - // … but dropping the condition mid-pagination is a fingerprint flip. const drifted = await getError(`?page_size=1&page_token=${page1.next_page_token}`); expect(drifted.code).toBe(40922); }); @@ -329,8 +327,6 @@ describe('server /api/v2/sessions', () => { { id: 's3', archived: false }, ]); - // The projection composes with the filters + sort, and archived flags - // travel with the ids (meta.archived=all includes the archived row). const all = await getData('?fields=id,archived&meta.archived=all&sort=meta.updated_at_asc'); expect(all.items).toEqual([ { id: 's4', archived: true }, @@ -339,7 +335,6 @@ describe('server /api/v2/sessions', () => { { id: 's1', archived: false }, ]); - // The relaxed ceiling only exists with the projection. const full = await getError('?page_size=101'); expect(full.code).toBe(40001); const tooBig = await getError('?fields=id,archived&page_size=10001'); @@ -347,12 +342,9 @@ describe('server /api/v2/sessions', () => { }); it('rejects malformed fields projections (40001)', async () => { - // Unknown field expect((await getError('?fields=id,foo')).code).toBe(40001); - // Known field(s) but not the one supported pair expect((await getError('?fields=id')).code).toBe(40001); expect((await getError('?fields=archived')).code).toBe(40001); - // The git domain is not projectable expect((await getError('?fields=id,archived&include=git')).code).toBe(40001); }); @@ -373,12 +365,10 @@ describe('server /api/v2/sessions', () => { it('binds the projection into the page_token fingerprint', async () => { const full = await getData('?page_size=2'); - // A token minted on the full shape does not continue as a projection… expect( (await getError(`?fields=id,archived&page_size=2&page_token=${full.next_page_token}`)).code, ).toBe(40922); - // … and the reverse direction flips too. const projected = await getData('?fields=id,archived&page_size=2'); expect((await getError(`?page_size=2&page_token=${projected.next_page_token}`)).code).toBe( 40922, @@ -437,7 +427,6 @@ describe('server /api/v2/sessions', () => { const filtered = await getData(`?workspace.id=${WS_A}`); expect(filtered.total).toBe(2); - // Cursor mode reports the same total on follow-up pages. const page1 = await getData('?page_size=2'); expect(page1.total).toBe(3); const page2 = await getData(`?page_size=2&page_token=${page1.next_page_token}`); @@ -456,7 +445,6 @@ describe('server /api/v2/sessions', () => { expect(page2.total).toBe(3); expect(page2.has_more).toBe(false); - // A page beyond the end is an empty, terminal snapshot — total stays. const beyond = await getData('?page=7&page_size=2'); expect(beyond.items).toEqual([]); expect(beyond.total).toBe(3); @@ -591,7 +579,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { return (server as RunningServer).core.accessor; } - /** Subscribe a bus-event collector; caller disposes the returned sub. */ function collectEvents(): { events: Event2[]; dispose(): void } { const events: Event2[] = []; const sub = core().get(IEventService).subscribe((event) => events.push(event)); @@ -648,8 +635,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { await closeSessionById(core(), created.id); expect(getLiveSessionById(core(), created.id)).toBeUndefined(); - // Materialization (resume, or the v1 single-archive route) would put the - // session back in the live map — it must stay empty on the cold path. const { events, dispose } = collectEvents(); const before = await readStateJson(created.workspace_id, created.id); @@ -663,8 +648,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { expect(getLiveSessionById(core(), created.id)).toBeUndefined(); - // The persisted metadata flips exactly like setArchived(true): archived - // (+ archivedAt), updatedAt and every other field preserved. const after = await readStateJson(created.workspace_id, created.id); expect(after['archived']).toBe(true); expect(typeof after['archivedAt']).toBe('number'); @@ -672,14 +655,10 @@ describe('server /api/v2/sessions batch archive/restore', () => { expect(after['createdAt']).toBe(before['createdAt']); expect(after['agents']).toEqual(before['agents']); - // The route drained the mirror once: the read model already answers - // archived, and the v2 list serves the session under meta.archived=true. expect(await indexArchived(created.id)).toBe(true); expect(await listedIds('?meta.archived=true')).toEqual([created.id]); expect(await listedIds()).toEqual([]); - // Same bus event the live lifecycle publishes (Event2 instances also - // carry `time` — compare the meaningful shape). expect( events .filter((event) => event.type === 'event.session.archived') @@ -700,9 +679,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { expect(body.code).toBe(0); expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); - // The full chain ran: archive() closed and disposed the session (the - // cold path would have left the live handle untouched) and published - // the event itself. expect(getLiveSessionById(core(), created.id)).toBeUndefined(); expect( events.some( @@ -720,9 +696,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { const created = await createSession(); await closeSessionById(core(), created.id); - // Resume WITHOUT awaiting: while it is in flight the live registry hides - // the handle, so an unsettled batch would misclassify as cold and its - // direct write would race the materializing metadata service. const resumePromise = resumeSessionById(core(), created.id); const batchPromise = postBatch('/api/v2/sessions:archive', { ids: [created.id] }); @@ -731,9 +704,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { const body = await batchPromise; expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); - // The settle made the item live-classified: the full archive chain ran - // (the session is closed, not merely patched on disk), and the archived - // flag survived the resume's own metadata writes. expect(getLiveSessionById(core(), created.id)).toBeUndefined(); expect(await indexArchived(created.id)).toBe(true); expect((await readStateJson(created.workspace_id, created.id))['archived']).toBe(true); @@ -776,7 +746,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { expect(body.code).toBe(0); expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); - // Still not materialized (the live map stays empty on the cold path). expect(getLiveSessionById(core(), created.id)).toBeUndefined(); const after = await readStateJson(created.workspace_id, created.id); @@ -786,7 +755,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { expect(await indexArchived(created.id)).toBe(false); expect(await listedIds()).toEqual([created.id]); - // The live restore publishes nothing either — no event at all. expect(events.filter((event) => event.type === 'event.session.archived')).toEqual([]); dispose(); }); @@ -794,7 +762,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { it('restores a live session through the lifecycle chain and keeps it live', async () => { const created = await createSession(); await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); - // Back to live-but-archived: resume materializes regardless of the flag. expect(await resumeSessionById(core(), created.id)).toBeDefined(); const body = await postBatch('/api/v2/sessions:restore', { ids: [created.id] }); @@ -817,7 +784,6 @@ describe('server /api/v2/sessions batch archive/restore', () => { }); expect(tooMany.code).toBe(40001); - // Duplicates collapse before the cap; a repeated id runs once. const deduped = await postBatch('/api/v2/sessions:archive', { ids: Array.from({ length: 5001 }, () => 'sess_dup'), }); From be79e0e426452435ff8aebdf0d161512cdc6293a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 13:44:20 +0800 Subject: [PATCH 29/29] fix(agent-core-v2): serialize explicit fork and child target ids on the lifecycle chain fork() and createChild() with a newSessionId locked only the source id, so a batch archive of the target could slip into the creation window: the index already knows the half-created session, the batch writes archived state to its document, and the fork's in-memory metadata later overwrites it. Both operations now acquire the deduped, sorted key set so multi-key sections always take locks in one deterministic order. --- .../sessionManager/sessionManagerService.ts | 56 +++++++++----- .../sessionManagerService.test.ts | 76 +++++++++++++++++++ 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index 0f58331857..3b591b3931 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -109,6 +109,16 @@ export class SessionManager implements ISessionManager { return run; } + private serializeLifecycleForKeys(keys: readonly string[], work: () => Promise): Promise { + const [first, ...rest] = keys; + if (first === undefined) return work(); + return this.serializeLifecycle(first, () => this.serializeLifecycleForKeys(rest, work)); + } + + private lifecycleKeys(...ids: (string | undefined)[]): string[] { + return [...new Set(ids.filter((id): id is string => id !== undefined))].sort(); + } + withLifecycleSerialization( sessionId: string, work: (unguarded: UnguardedSessionLifecycle) => Promise, @@ -159,29 +169,35 @@ export class SessionManager implements ISessionManager { } async fork(options: ForkSessionOptions): Promise { - return this.serializeLifecycle(options.sourceSessionId, async () => { - const controller = await this.controllerForSession(options.sourceSessionId); - if (controller === undefined) { - throw new Error2( - ErrorCodes.SESSION_NOT_FOUND, - `session ${options.sourceSessionId} does not exist`, - ); - } - return controller.fork(options); - }); + return this.serializeLifecycleForKeys( + this.lifecycleKeys(options.sourceSessionId, options.newSessionId), + async () => { + const controller = await this.controllerForSession(options.sourceSessionId); + if (controller === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${options.sourceSessionId} does not exist`, + ); + } + return controller.fork(options); + }, + ); } async createChild(options: CreateChildSessionOptions): Promise { - return this.serializeLifecycle(options.sourceSessionId, async () => { - const controller = await this.controllerForSession(options.sourceSessionId); - if (controller === undefined) { - throw new Error2( - ErrorCodes.SESSION_NOT_FOUND, - `session ${options.sourceSessionId} does not exist`, - ); - } - return controller.createChild(options); - }); + return this.serializeLifecycleForKeys( + this.lifecycleKeys(options.sourceSessionId, options.newSessionId), + async () => { + const controller = await this.controllerForSession(options.sourceSessionId); + if (controller === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${options.sourceSessionId} does not exist`, + ); + } + return controller.createChild(options); + }, + ); } dispose(): void { diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index 0d220704b8..20f635f66d 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -231,6 +231,82 @@ describe('SessionManager', () => { manager.dispose(); }); + it('serializes fork of an explicit target id with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { fork: () => Promise }).fork = async () => { + order.push('fork'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-2', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const forkPromise = manager.fork({ sourceSessionId: 'session-1', newSessionId: 'session-2' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, forkPromise]); + expect(order).toEqual(['section:start', 'section:end', 'fork']); + manager.dispose(); + }); + + it('serializes createChild of an explicit target id with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { createChild: () => Promise }).createChild = async () => { + order.push('createChild'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-2', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const childPromise = manager.createChild({ sourceSessionId: 'session-1', newSessionId: 'session-2' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, childPromise]); + expect(order).toEqual(['section:start', 'section:end', 'createChild']); + manager.dispose(); + }); + it('serializes create with an explicit session id with the lifecycle chain', async () => { const order: string[] = []; const fake = controller();