diff --git a/.changeset/cold-session-batch-archive.md b/.changeset/cold-session-batch-archive.md new file mode 100644 index 000000000..de17db9e5 --- /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 487e1c4fd..472a56fb3 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 000000000..1b031b3a5 --- /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 4517e771e..f5d8df9c2 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 a12395892..fa51b2fdc 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 3921c90fb..5a5b6fa65 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 };