From 58ac73f86386c86529eb2334a30c603d531ce4b2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 15 Aug 2026 21:48:02 +0800 Subject: [PATCH 1/4] refactor: make isArchived the sole session archive authority Migrate legacy archived Session payloads, remove archivedAt and the duplicate SQLite status projections, and preserve execution status across archive transitions. Generated-by: Codex --- .../__tests__/app-shell-session-purge.test.ts | 11 +- .../desktop-transcript-range-store.test.ts | 3 +- .../runtime-host-bot-session-adapter.test.ts | 13 +- .../__tests__/runtime-host-client-uds.test.ts | 1 - .../runtime-host-desktop-candidate.test.ts | 19 +- ...me-host-session-execution-ipc-main.test.ts | 7 +- .../runtime-host-session-observer.test.ts | 9 +- .../main/runtime-host-bot-session-adapter.ts | 4 +- .../settings/settings-pages.stories.tsx | 2 +- .../runtime-host-session-driver.test.ts | 17 +- packages/core/src/session.ts | 8 +- .../transcript-data-plane-benchmark.mjs | 3 +- .../fixtures/execution-host-suite.ts | 6 +- .../src/__tests__/goal-coordinator.test.ts | 4 +- .../src/__tests__/host-kernel.test.ts | 2 + .../src/__tests__/protocol.test.ts | 6 +- .../runtime-resource-coordinator.test.ts | 1 - .../session-effect-coordinator.test.ts | 4 +- .../src/__tests__/session-projector.test.ts | 8 +- .../session-retirement-coordinator.test.ts | 13 +- .../session-retirement-protocol.test.ts | 8 +- packages/runtime-host/src/protocol/index.ts | 2 +- .../src/protocol/session-continuity.ts | 7 +- .../src/protocol/session-retirement.ts | 2 +- .../server/canonical-session-projection.ts | 1 - .../src/server/context-coordinator.ts | 2 +- .../src/server/deep-research-coordinator.ts | 2 +- .../src/server/execution-composition.ts | 2 +- .../src/server/goal-coordinator.ts | 4 +- .../src/server/hosted-execution-recovery.ts | 2 +- .../src/server/plan-coordinator.ts | 2 +- .../src/server/root-turn-coordinator.ts | 10 +- .../server/runtime-resource-coordinator.ts | 5 +- .../src/server/scheduled-task-coordinator.ts | 2 +- .../src/server/session-catalog-coordinator.ts | 2 +- .../src/server/session-effect-coordinator.ts | 2 +- .../server/session-retirement-coordinator.ts | 16 +- .../server/session-revision-coordinator.ts | 4 +- .../runtime-event-read-model.test.ts | 2 - .../runtime-kernel-interaction.test.ts | 2 - .../session-manager-terminal-ledger.test.ts | 8 - .../src/__tests__/session-manager.test.ts | 21 +- .../stream-graph-coordinator.test.ts | 2 +- packages/runtime/src/agent-run.ts | 3 +- packages/runtime/src/runtime-kernel.ts | 6 +- packages/runtime/src/session-manager.ts | 41 +--- .../runtime/src/stream-graph-coordinator.ts | 4 +- .../src/__tests__/goal-authority.test.ts | 4 +- .../src/__tests__/session-store.test.ts | 4 + .../sqlite-session-metadata-store.test.ts | 194 +++++++++++++++++- packages/storage/src/execution-stores.ts | 6 +- packages/storage/src/session-store.ts | 45 ++-- .../src/sqlite-session-metadata-schema.ts | 32 ++- .../src/sqlite-session-metadata-store.ts | 74 +++---- packages/ui/src/conversation-copy.ts | 4 +- .../ui/src/session-status-presentation.ts | 11 +- .../ui/stories/session-list-panel.stories.tsx | 9 +- 57 files changed, 407 insertions(+), 281 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts index 8abe78c452..255c3b5526 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts @@ -11,7 +11,7 @@ function summary(id: string, overrides: Partial = {}): SessionSu isArchived: true, labels: [], hasUnread: false, - status: 'archived', + status: 'active', backend: 'fake', llmConnectionSlug: 'test', connectionLocked: true, @@ -21,14 +21,9 @@ function summary(id: string, overrides: Partial = {}): SessionSu }; } -/** - * A task that left the archive. Both fields move together because that is what - * `SessionStore.unarchive` writes; flipping only `isArchived` would build a row - * the store cannot produce, and the sweep would then be tested against a state - * it will never meet. - */ +/** A task that left the archive while retaining its independent execution status. */ function restored(id: string): SessionSummary { - return summary(id, { isArchived: false, status: 'active' }); + return summary(id, { isArchived: false }); } type SweepHarness = { diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 3586fc3a5f..df6e117d85 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; +import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot, @@ -540,7 +541,7 @@ function transcriptPage( function continuitySnapshot() { return { - schemaVersion: 3 as const, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts index efde6b209c..b0bf22fa23 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts @@ -1,11 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; -import type { - SessionCatalogProjection, - SessionContinuitySnapshot, - SubscriptionFrame, - TurnSnapshot, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, + type TurnSnapshot, } from '@maka/runtime-host/protocol'; import { BotSessionUnavailableError } from '../bot-session-adapter.js'; import { @@ -401,7 +402,7 @@ function startedTurn(turn: TurnSnapshot) { function continuitySnapshot(rootTurn: TurnSnapshot | null): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'bot-session-1', metadataRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 77954d09d6..e50cd96f12 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -174,7 +174,6 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn ...projected, revision: projected.revision + 1, isArchived: archived, - status: archived ? 'archived' : 'active', }); return { ok: true, result: projected }; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index d5dfaba61a..9a5098d1f2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -6,14 +6,15 @@ import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots'; import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import type { MakaTool } from '@maka/runtime/tool-runtime'; import type { ClientCapabilityProvider, RuntimeHostConnection } from '@maka/runtime-host/client'; -import type { - ClientCapabilityCallFrame, - OperationInput, - OperationKey, - SessionAssistantStreamIdentity, - SessionCatalogProjection, - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type ClientCapabilityCallFrame, + type OperationInput, + type OperationKey, + type SessionAssistantStreamIdentity, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from '@maka/runtime-host/protocol'; import { z } from 'zod'; import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; @@ -988,7 +989,7 @@ function continuitySnapshot( overrides: Partial = {}, ): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 407a9fb857..985ea88b2e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -7,7 +7,10 @@ import test from "node:test"; import type { IpcMain } from "electron"; import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { type AttachmentRef } from '@maka/core/events'; -import type { SessionCatalogProjection } from "@maka/runtime-host/protocol"; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionCatalogProjection, +} from "@maka/runtime-host/protocol"; import { createAttachmentApprovalRegistry } from "../attachment-approval.js"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; import { @@ -620,7 +623,7 @@ function observerWithTranscript( client: { openSession: async () => runtimeHostSessionFixture({ snapshot: { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: "session-1", metadataRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 98d9be7e8b..c8b9c283cf 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -3,9 +3,10 @@ import { EventEmitter } from "node:events"; import test from "node:test"; import type { SessionEvent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; -import type { - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from "@maka/runtime-host/protocol"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; @@ -2246,7 +2247,7 @@ function continuitySnapshot( overrides: Partial = {}, ): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: "session-1", metadataRevision: 1, diff --git a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts index 846905d96a..1d485d31fc 100644 --- a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts +++ b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts @@ -82,7 +82,7 @@ export function createRuntimeHostBotSessionAdapter( throwUnavailable(error, sessionId); throw error; } - if (!session || session.isArchived || session.status === 'archived') { + if (!session || session.isArchived) { throw unavailableSession(sessionId); } if (session.permissionMode === 'explore') return 'ready'; @@ -96,7 +96,7 @@ export function createRuntimeHostBotSessionAdapter( if (isPermissionUpdateRefusal(error)) return 'permission_refused'; throw error; } - if (session.isArchived || session.status === 'archived') { + if (session.isArchived) { throw unavailableSession(sessionId); } if (session.permissionMode !== 'explore') return 'permission_refused'; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index ba99a36175..4449db4e16 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -717,7 +717,7 @@ function archivedTask( isArchived: true, labels: [], hasUnread: false, - status: 'archived', + status: 'active', backend: 'ai-sdk', llmConnectionSlug: 'zai-live', connectionLocked: true, diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 39dc8640e2..c35f0267b0 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -10,13 +10,14 @@ import type { RuntimeHostSessionSubscription, } from '@maka/runtime-host/client'; import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; -import type { - InteractionPendingSnapshot, - OperationInput, - OperationOutput, - SessionCatalogProjection, - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type InteractionPendingSnapshot, + type OperationInput, + type OperationOutput, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from '@maka/runtime-host/protocol'; import { createRuntimeHostMakaSessionDriver, @@ -1425,7 +1426,7 @@ function continuitySnapshot( overrides: Partial = {}, ): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e9dc928dbd..e03e4278ce 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -29,10 +29,6 @@ import type { SubagentWorkspaceBinding } from './subagent-workspace.js'; export { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from './explore-agent.js'; /** - * `archived` is still here and still written by `SessionStore.archive()` - * alongside `isArchived`; consolidating those two onto one authority is its own - * change (#2984, PR 3) because it rewrites stored rows. - * * `review` and `done` have no writer in current source, but they stay: this * list is read back out of storage, and narrowing it is a data migration, not a * cleanup. `resolveLegacyStatus` in the JSONL importer (removed in #2656) let @@ -49,7 +45,6 @@ export const SESSION_STATUSES = [ 'blocked', 'review', 'done', - 'archived', 'aborted', ] as const; @@ -215,7 +210,6 @@ export interface SessionHeader { labels: string[]; isArchived: boolean; - archivedAt?: number; status: SessionStatus; blockedReason?: SessionBlockedReason; statusUpdatedAt?: number; @@ -271,6 +265,8 @@ export interface SessionHeader { schemaVersion: 1; } +export type SessionHeaderPatch = Partial>; + export type BackendKind = 'ai-sdk' | 'fake'; export interface SessionSummary { diff --git a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs index 991e068fed..064cd2a78a 100644 --- a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs +++ b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createSessionStore } from '@maka/storage'; import { ClientSessionSubscription } from '../dist/client/session-subscription.js'; +import { SESSION_CONTINUITY_SCHEMA_VERSION } from '../dist/protocol/index.js'; import { createSessionTranscriptBootstrap, readSessionTranscriptPage, @@ -62,7 +63,7 @@ async function runFixture(fixture) { activeAssistantStreams: [], transcript: bootstrap, snapshot: { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: state.sessionId, metadataRevision: 1, diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 3ecfd9e884..c875ec6e08 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -665,7 +665,11 @@ export class ExecutionFixture { let stores: Awaited> | undefined; try { stores = await openInteractiveExecutionStoresForWrite(owner.lease); - await stores.sessionStore.archive(this.sessionId); + const current = await stores.sessionStore.readHeaderRecordSnapshot(this.sessionId); + await stores.sessionStore.setSessionsArchivedVersioned( + [{ sessionId: this.sessionId, expectedVersion: current.revision }], + true, + ); } finally { await stores?.sessionStore.close?.(); await owner.close(); diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index a9478a5d60..648d4d6337 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -275,9 +275,9 @@ test('session retirement forgets a terminal Goal without recreating deleted auth const retirement = await coordinator.beginSessionRetirement([session.id], 'archive'); const header = await stores.sessionStore.readHeaderRecordSnapshot(session.id); - await stores.sessionStore.setSessionsLifecycleVersioned( + await stores.sessionStore.setSessionsArchivedVersioned( [{ sessionId: session.id, expectedVersion: header.revision }], - 'archived', + true, ); retirement.commit(); diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 4d0298fcc8..2c62b2238d 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -853,6 +853,8 @@ describe('non-serving Runtime Host kernel', () => { surface: 'tui', protocolMin: CURRENT_PROTOCOL.min, protocolMax: CURRENT_PROTOCOL.max, + compatibilityEpoch: 20, + compositionId: KERNEL_COMPOSITION.descriptor.id, }), ); assert.deepEqual(decodeHostFrame(await staleWhileResident.read(1_000)), { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 80a5a4e2df..93e8c4682d 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -40,8 +40,8 @@ import { } from '../protocol/turn.js'; describe('Runtime Host bootstrap protocol', () => { - test('publishes a new compatibility epoch for legacy Automation provenance', () => { - assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 20); + test('publishes a new compatibility epoch for the Session archive wire contract', () => { + assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 21); }); test('selects the highest mutually supported protocol and rejects a gap', () => { @@ -52,7 +52,7 @@ describe('Runtime Host bootstrap protocol', () => { }); test('keeps the subscription queue Epoch correlated', () => { - assert.equal(SESSION_CONTINUITY_SCHEMA_VERSION, 3); + assert.equal(SESSION_CONTINUITY_SCHEMA_VERSION, 4); const opened = { requestId: 'open-1', operation: 'subscription.open', diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts index 8afedecd24..e9bf006b10 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -522,7 +522,6 @@ function createHarness() { if (state.sessionState === 'missing') throw new SessionNotFoundError(sessionId); return { cwd: '/workspace', - status: state.sessionState === 'archived' ? 'archived' : 'idle', isArchived: state.sessionState === 'archived', }; }, diff --git a/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts index 437a0ffedd..78eb1764ec 100644 --- a/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts @@ -55,7 +55,7 @@ test('Session recap publishes one protected result and exact retries never repea }, { readSessionHeader: async () => - ({ isArchived: true, status: 'archived' }) as unknown as SessionHeader, + ({ isArchived: true, status: 'active' }) as unknown as SessionHeader, }, ); assert.deepEqual( @@ -122,7 +122,7 @@ test('Session effect leaves Turn admission free and drain aborts accepted recap }, { readSessionHeader: async () => - ({ isArchived: true, status: 'archived' }) as unknown as SessionHeader, + ({ isArchived: true, status: 'active' }) as unknown as SessionHeader, }, ); assert.deepEqual( diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 006df52eed..fa94df27a1 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -5,7 +5,11 @@ import { createRuntimeHostSessionProjectionSeed, RuntimeHostSessionProjector, } from '../adapter/session-projector.js'; -import type { SessionContinuitySnapshot, SubscriptionFrame } from '../protocol/index.js'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionContinuitySnapshot, + type SubscriptionFrame, +} from '../protocol/index.js'; test('applies authoritative replacement once and does not complete it again at Turn terminal', () => { const projector = new RuntimeHostSessionProjector( @@ -168,7 +172,7 @@ function deltaFrame( function snapshot(overrides: Partial = {}): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index 8a351fac08..e70a8af7b9 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -132,7 +132,9 @@ describe('Host Session retirement coordinator', () => { ); assert.equal(archived.ok, true); for (const childSessionId of childSessionIds) { - assert.equal((await harness.store.readHeaderSnapshot(childSessionId)).status, 'archived'); + const header = await harness.store.readHeaderSnapshot(childSessionId); + assert.equal(header.isArchived, true); + assert.equal(header.status, 'active'); } const restored = await harness.coordinator.handlers['session.lifecycle.set']( @@ -141,7 +143,9 @@ describe('Host Session retirement coordinator', () => { ); assert.equal(restored.ok, true); for (const childSessionId of childSessionIds) { - assert.equal((await harness.store.readHeaderSnapshot(childSessionId)).status, 'active'); + const header = await harness.store.readHeaderSnapshot(childSessionId); + assert.equal(header.isArchived, false); + assert.equal(header.status, 'active'); } const target = await harness.store.readHeaderRecordSnapshot(harness.revisionId); @@ -839,8 +843,8 @@ async function withHarness( store.listPendingSessionRetirementCleanupIds(sessionId), completeSessionRetirementCleanup: (sessionId) => store.completeSessionRetirementCleanup(sessionId), - setSessionsLifecycleVersioned: (sessions, state) => - store.setSessionsLifecycleVersioned(sessions, state), + setSessionsArchivedVersioned: (sessions, isArchived) => + store.setSessionsArchivedVersioned(sessions, isArchived), removeSessionsVersioned: async (sessions) => { if (harness.failRemoveCommit) throw new Error('injected remove failure'); if (harness.updateSiblingBeforeRemoveCommit) { @@ -1037,7 +1041,6 @@ async function assertFamilyLifecycle(harness: RetirementHarness, archived: boole for (const sessionId of harness.familyIds) { const header = await harness.store.readHeaderSnapshot(sessionId); assert.equal(header.isArchived, archived); - assert.equal(header.status === 'archived', archived); } } diff --git a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts index 470ef496b8..581022cbd3 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts @@ -32,7 +32,7 @@ describe('Session retirement protocol', () => { () => HOST_OPERATION_SPECS['session.lifecycle.set'].assertOutputForInput?.( { sessionId: 'session-1', state: 'archived' }, - projection({ id: 'session-2', isArchived: true, status: 'archived' }), + projection({ id: 'session-2', isArchived: true }), ), isInvalidFrame, ); @@ -47,7 +47,11 @@ describe('Session retirement protocol', () => { }); test('preserves removal conflicts and archived lifecycle state on the wire', () => { - const archived = projection({ isArchived: true, status: 'archived' }); + const archived = projection({ + isArchived: true, + status: 'blocked', + blockedReason: 'tool_failed', + }); assert.deepEqual( decodeHostFrame({ requestId: 'request-archive', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9c5e607986..7cf48cb5ce 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -72,7 +72,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 20 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 21 as const; // Transcript pages amortize storage and network round trips with a 512 KiB raw // payload. Base64 expansion plus the bounded fragment envelope must still fit in // one transport message; narrower domains retain their own encoded limits. diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 51819d5f94..5cc0cb2e2b 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -30,7 +30,7 @@ import { type SessionTranscriptBootstrap, } from './session-transcript.js'; -export const SESSION_CONTINUITY_SCHEMA_VERSION = 3 as const; +export const SESSION_CONTINUITY_SCHEMA_VERSION = 4 as const; export const SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES = 56 * 1024; // Leave transport headroom for the response envelope and request correlation. export const SUBSCRIPTION_OPEN_RESULT_MAX_BYTES = 92 * 1024; @@ -58,7 +58,6 @@ export interface SessionContinuityIdentity { createdAt: number; lastUsedAt: number; isArchived: boolean; - archivedAt?: number; } export interface SessionContinuitySnapshot { @@ -867,7 +866,6 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent 'createdAt', 'lastUsedAt', 'isArchived', - 'archivedAt', ]); assertRequiredKeys(record, 'Session continuity identity', [ 'sessionId', @@ -887,9 +885,6 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent createdAt: requireCount(record.createdAt, 'createdAt'), lastUsedAt: requireCount(record.lastUsedAt, 'lastUsedAt'), isArchived: record.isArchived, - ...(record.archivedAt === undefined - ? {} - : { archivedAt: requireCount(record.archivedAt, 'archivedAt') }), }; } diff --git a/packages/runtime-host/src/protocol/session-retirement.ts b/packages/runtime-host/src/protocol/session-retirement.ts index e483dfa81c..b869ecc6ed 100644 --- a/packages/runtime-host/src/protocol/session-retirement.ts +++ b/packages/runtime-host/src/protocol/session-retirement.ts @@ -52,7 +52,7 @@ export const SESSION_RETIREMENT_OPERATION_SPECS = { } if ('kind' in output) return; const archived = input.state === 'archived'; - if (output.isArchived !== archived || (output.status === 'archived') !== archived) { + if (output.isArchived !== archived) { throw invalidProtocolFrame('Session lifecycle result does not match the requested state'); } }, diff --git a/packages/runtime-host/src/server/canonical-session-projection.ts b/packages/runtime-host/src/server/canonical-session-projection.ts index 86a1ea12fe..d3e7683776 100644 --- a/packages/runtime-host/src/server/canonical-session-projection.ts +++ b/packages/runtime-host/src/server/canonical-session-projection.ts @@ -105,7 +105,6 @@ export class CanonicalSessionProjectionReader { createdAt: header.createdAt, lastUsedAt: header.lastUsedAt, isArchived: header.isArchived, - ...(header.archivedAt !== undefined ? { archivedAt: header.archivedAt } : {}), }; return { session, rootTurn, goal, queue, interactions }; } diff --git a/packages/runtime-host/src/server/context-coordinator.ts b/packages/runtime-host/src/server/context-coordinator.ts index fccd79ba29..7beeb720b0 100644 --- a/packages/runtime-host/src/server/context-coordinator.ts +++ b/packages/runtime-host/src/server/context-coordinator.ts @@ -148,7 +148,7 @@ export class HostContextCoordinator { if (isSessionNotFoundError(error)) return notFound('Session does not exist'); throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return sessionArchived('Cannot compact an archived Session'); } const unavailableReason = runtimeHostExternalTurnUnavailableReason(header); diff --git a/packages/runtime-host/src/server/deep-research-coordinator.ts b/packages/runtime-host/src/server/deep-research-coordinator.ts index d0f851b22f..68a46eee43 100644 --- a/packages/runtime-host/src/server/deep-research-coordinator.ts +++ b/packages/runtime-host/src/server/deep-research-coordinator.ts @@ -107,7 +107,7 @@ export class HostDeepResearchCoordinator { > { try { const header = await this.#sessions.readHeaderSnapshot(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return { code: 'session_archived', message: 'Session is archived' }; } if (!isDeepResearchSession(header.labels)) { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 1e70bcc288..d887928d0f 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1091,7 +1091,7 @@ export async function createExecutionRuntimeHostComposition( isSessionDeliverable: async (sessionId) => { try { const header = await stores.sessionStore.readHeaderSnapshot(sessionId); - return !header.isArchived && header.status !== 'archived'; + return !header.isArchived; } catch (error) { if (isSessionNotFoundError(error)) return false; throw error; diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 581595ea55..2b6c0308bd 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -160,7 +160,7 @@ export class HostGoalCoordinator { const { goal, controlLease } = snapshot.record; try { const header = await this.#stores.sessionStore.readHeaderSnapshot(goal.sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { await this.#deleteOrphanedAuthority(snapshot); continue; } @@ -315,7 +315,7 @@ export class HostGoalCoordinator { if (isSessionNotFoundError(error)) return notFound('Session does not exist'); throw error; } - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return sessionArchived('Archived Session Goal state cannot be controlled'); } const current = this.manager.get(input.sessionId); diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 157fc85f8d..8495f6c0a4 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -149,7 +149,7 @@ export async function prepareHostedExecutionRecovery( if (replayAdmissions.length > 1) { throw new Error(`Session ${session.id} has multiple admitted Turns without Runs`); } - if (replayAdmissions[0] && (session.status === 'archived' || session.isArchived)) { + if (replayAdmissions[0] && session.isArchived) { throw new Error(`Archived Session ${session.id} has an admitted Turn without a Run`); } prepared.push({ diff --git a/packages/runtime-host/src/server/plan-coordinator.ts b/packages/runtime-host/src/server/plan-coordinator.ts index b882d7b72f..8bfcd10ce0 100644 --- a/packages/runtime-host/src/server/plan-coordinator.ts +++ b/packages/runtime-host/src/server/plan-coordinator.ts @@ -265,7 +265,7 @@ export class HostPlanCoordinator { > { try { const header = await this.#sessions.readHeaderSnapshot(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return { code: 'session_archived', message: 'Session is archived' }; } return undefined; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 74d046a657..a67866b95f 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -431,7 +431,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const header = await this.stores.sessionStore.readHeaderSnapshot(sessionId); if (header.conversationCopy?.state === 'preparing') return null; return { - isArchived: header.isArchived || header.status === 'archived', + isArchived: header.isArchived, unavailableReason: runtimeHostExternalTurnUnavailableReason(header), }; } catch (error) { @@ -704,7 +704,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { throw new RuntimeHostedRootUnavailableError( input.sessionId, 'Cannot start a hosted root execution in an archived Session', @@ -1282,7 +1282,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return completedStart(sessionArchived(request.archivedMessage)); } const unavailableReason = runtimeHostExternalTurnUnavailableReason(header); @@ -1414,7 +1414,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (isSessionNotFoundError(error)) return notFound('Session does not exist'); throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return sessionArchived('Cannot continue an archived Session'); } const unavailableReason = runtimeHostSafeBoundaryContinuationUnavailableReason(header); @@ -1593,7 +1593,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return { kind: 'complete', outcome: sessionArchived('Cannot continue an archived Session'), diff --git a/packages/runtime-host/src/server/runtime-resource-coordinator.ts b/packages/runtime-host/src/server/runtime-resource-coordinator.ts index 4f4bfb6546..8415385753 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -59,7 +59,6 @@ interface RuntimeResourceSessionReader { interface RuntimeResourceHeaderReader { readHeader(sessionId: string): Promise<{ readonly cwd: string; - readonly status: string; readonly isArchived: boolean; }>; } @@ -596,7 +595,7 @@ export class HostRuntimeResourceCoordinator async #assertActiveSession(sessionId: string): Promise { const header = await this.#sessionHeaders.readHeader(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { throw new Error('Session is archived'); } } @@ -608,7 +607,7 @@ export class HostRuntimeResourceCoordinator > { try { const header = await this.#sessionHeaders.readHeader(sessionId); - return header.isArchived || header.status === 'archived' + return header.isArchived ? { code: 'session_archived', message: 'Session is archived' } : undefined; } catch (error) { diff --git a/packages/runtime-host/src/server/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts index 7b2167b771..fe2f05f44b 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -666,7 +666,7 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority throw new Error('Session lifecycle is changing'); } const header = await this.#sessions.readHeaderSnapshot(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { throw new Error('Archived Sessions cannot own session-bound ScheduledTasks'); } return header; diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index ae953c7da0..56a613ff52 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -437,7 +437,7 @@ export class HostSessionCatalogCoordinator { if (current.revision !== input.expectedRevision) { return configurationSuccess(revisionConflict(input.expectedRevision, current.revision)); } - if (current.header.isArchived || current.header.status === 'archived') { + if (current.header.isArchived) { return configurationFailure( 'operation_conflict', 'Archived Session configuration cannot be changed', diff --git a/packages/runtime-host/src/server/session-effect-coordinator.ts b/packages/runtime-host/src/server/session-effect-coordinator.ts index c4601245d4..e25feb3e70 100644 --- a/packages/runtime-host/src/server/session-effect-coordinator.ts +++ b/packages/runtime-host/src/server/session-effect-coordinator.ts @@ -192,7 +192,7 @@ export class HostSessionEffectCoordinator { } const header = await this.#readSessionHeader(input.sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return settledRecap( recapFailure('session_archived', 'Archived Session cannot generate a recap'), ); diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 3c999109a7..a5ed0d9cac 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -49,7 +49,7 @@ type RetirementStores = Pick< | 'listPendingSessionRetirementCleanupIds' | 'completeSessionRetirementCleanup' | 'removeSessionsVersioned' - | 'setSessionsLifecycleVersioned' + | 'setSessionsArchivedVersioned' >; type RetirementRoot = Pick; @@ -206,12 +206,7 @@ export class HostSessionRetirementCoordinator { return await this.#withStableFamily(input.sessionId, async (family) => { const target = requireFamilyRecord(family, input.sessionId); const archived = input.state === 'archived'; - if ( - [...family.records.values()].every( - ({ header }) => - header.isArchived === archived && (header.status === 'archived') === archived, - ) - ) { + if ([...family.records.values()].every(({ header }) => header.isArchived === archived)) { return lifecycleSuccess( projectSessionCatalogRecord(await this.#stores.readCatalogRecord(input.sessionId)), ); @@ -220,7 +215,7 @@ export class HostSessionRetirementCoordinator { if (!archived) { let committed = false; try { - await this.#stores.setSessionsLifecycleVersioned(versionedFamily(family), 'active'); + await this.#stores.setSessionsArchivedVersioned(versionedFamily(family), false); committed = true; this.#goals.unarchiveSessions(family.sessionIds); await this.#refreshFamily(family); @@ -240,10 +235,7 @@ export class HostSessionRetirementCoordinator { await this.#finalizeWorkspacePatches(family.sessionIds); await this.#disposeBackends(family.sessionIds); const committable = await this.#refreshFamilyRecords(family); - await this.#stores.setSessionsLifecycleVersioned( - versionedFamily(committable), - 'archived', - ); + await this.#stores.setSessionsArchivedVersioned(versionedFamily(committable), true); committed = true; handles.goal.commit(); handles.scheduledTasks.commit(); diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 4cb324e91e..e9282ea7fd 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -256,7 +256,7 @@ export class HostSessionRevisionCoordinator { if (sourceHeader.conversationCopy?.state === 'preparing') { return copyFailure('not_found', 'Source Session does not exist'); } - if (kind === 'revision' && (sourceHeader.isArchived || sourceHeader.status === 'archived')) { + if (kind === 'revision' && sourceHeader.isArchived) { return copyFailure( 'operation_conflict', 'Archived Session revision families cannot create active revisions', @@ -319,7 +319,7 @@ export class HostSessionRevisionCoordinator { sessionHeaders.some( (candidate) => sessionRevisionFamilyId(candidate) === sessionRevisionFamilyId(sourceHeader) && - (candidate.isArchived || candidate.status === 'archived'), + candidate.isArchived, ) ) { return copyFailure( diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 172b380e9a..1a85310b5b 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1905,8 +1905,6 @@ class ReadOnlyStore implements SessionStore { return { ...header, hasUnread: false }; } - async archive(_sessionId: string): Promise {} - async unarchive(_sessionId: string): Promise {} async setFlagged(_sessionId: string, _isFlagged: boolean): Promise {} async rename(_sessionId: string, _name: string): Promise {} async remove(_sessionId: string): Promise {} diff --git a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index f2b15a75d4..bd65c2c711 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -642,8 +642,6 @@ function memoryStore(): SessionStore { return header; }, markSessionReadThrough: async () => header, - archive: async () => {}, - unarchive: async () => {}, setFlagged: async () => {}, rename: async () => {}, remove: async () => {}, diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 53eaecd5b6..79ac5551f1 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -2542,14 +2542,6 @@ class TinySessionStore implements SessionStore { return this.readHeader(sessionId); } - async archive(sessionId: string): Promise { - await this.updateHeader(sessionId, { isArchived: true, status: 'archived' }); - } - - async unarchive(sessionId: string): Promise { - await this.updateHeader(sessionId, { isArchived: false, status: 'active' }); - } - async setFlagged(sessionId: string, isFlagged: boolean): Promise { await this.updateHeader(sessionId, { isFlagged }); } diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index ffc9740e26..d9dd872845 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -1574,7 +1574,7 @@ describe('SessionManager claimed graph intent execution', () => { ), ).toMatchObject({ text: 'summarize the routed records' }); - await store.archive(child.id); + await store.updateHeader(child.id, { isArchived: true }); const retry = await manager.runClaimedAgentGraphIntent({ ...graphExecutionInput(claim, 'summarize the routed records'), }); @@ -2028,7 +2028,7 @@ describe('SessionManager claimed graph intent execution', () => { ); const archivedChild = await createGraphOperatorSession(store, parent.id); - await store.archive(archivedChild.id); + await store.updateHeader(archivedChild.id, { isArchived: true }); const archivedClaim = graphIntentClaim( { claimId: `graph_claim_${'3'.repeat(32)}`, @@ -17343,23 +17343,6 @@ class MemorySessionStore implements SessionStore { await hook(); } - async archive(sessionId: string): Promise { - await this.updateHeader(sessionId, { - isArchived: true, - status: 'archived', - statusUpdatedAt: 1, - }); - } - - async unarchive(sessionId: string): Promise { - await this.updateHeader(sessionId, { - isArchived: false, - status: 'active', - blockedReason: undefined, - statusUpdatedAt: 1, - }); - } - async setFlagged(sessionId: string, isFlagged: boolean): Promise { await this.updateHeader(sessionId, { isFlagged }); } diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index 4c47e05ce3..126c071b33 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -814,7 +814,7 @@ describe('host-managed agent graph coordinator', () => { readHeader: async (sessionId: string) => ({ id: sessionId, - status: 'archived', + status: 'active', isArchived: true, }) as never, }, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 63159a51f5..5cb790cc86 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -27,6 +27,7 @@ import { import type { SessionBlockedReason, SessionHeader, + SessionHeaderPatch, SessionStatus, StoredMessage, SystemNoteMessage, @@ -91,7 +92,7 @@ export interface AgentRunHooks { run: AgentRun, ): Promise; unregisterRun(active: AgentRunActiveSession, run: AgentRun): void | Promise; - updateHeader(sessionId: string, patch: Partial): Promise; + updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; updateStatus( sessionId: string, status: SessionStatus, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 16fc50cbe4..1fe3bc290c 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -17,6 +17,7 @@ import type { import type { SessionBlockedReason, SessionHeader, + SessionHeaderPatch, SessionStatus, StoredMessage, SystemNoteMessage, @@ -3296,10 +3297,7 @@ export class RuntimeKernel implements RuntimeKernelLike { await this.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason)); } - private async updateHeader( - sessionId: string, - patch: Partial, - ): Promise { + private async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise { const next = await this.deps.store.updateHeader(sessionId, patch); this.updateCachedHeader(sessionId, next); return next; diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 0798d22da1..52e6010918 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -29,6 +29,7 @@ import type { import { messageContentsEqual, normalizeMessageContent } from '@maka/core/events'; import type { SessionHeader, + SessionHeaderPatch, SessionBlockedReason, SessionStatus, SessionSummary, @@ -667,10 +668,10 @@ export interface SessionStore { listTurns(sessionId: string): Promise; appendMessage(sessionId: string, m: StoredMessage): Promise; appendMessages(sessionId: string, ms: StoredMessage[]): Promise; - updateHeader(sessionId: string, patch: Partial): Promise; + updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; updateHeaderVersioned?( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, expectedRevision: number, ): Promise; readHeaderRecordSnapshot?(sessionId: string): Promise; @@ -679,8 +680,6 @@ export interface SessionStore { input: SessionConfigurationStoreUpdate, ): Promise; markSessionReadThrough(sessionId: string, readThroughTs: number): Promise; - archive(sessionId: string): Promise; - unarchive(sessionId: string): Promise; setFlagged(sessionId: string, isFlagged: boolean): Promise; rename(sessionId: string, name: string): Promise; setGeneratedTitleIfAbsent?(sessionId: string, title: string): Promise; @@ -1143,7 +1142,7 @@ export class SessionManager { current.revision, ); } - if (current.header.isArchived || current.header.status === 'archived') { + if (current.header.isArchived) { throw new SessionConfigurationTransitionError( 'operation_conflict', 'Archived Session configuration cannot be changed', @@ -1218,7 +1217,7 @@ export class SessionManager { current.revision, ); } - if (current.header.isArchived || current.header.status === 'archived') { + if (current.header.isArchived) { throw new SessionConfigurationTransitionError( 'operation_conflict', 'Archived Session workspace cannot be relocated', @@ -1410,7 +1409,7 @@ export class SessionManager { private async recoverInterruptedSessionsWithPolicy(policy: RecoveryPolicy): Promise { const interrupted = (await listSessionsForRecovery(this.deps.store, policy)).filter( - (session) => session.status !== 'archived', + (session) => !session.isArchived, ); const recovered = new Set(); for (const session of interrupted) { @@ -1567,7 +1566,7 @@ export class SessionManager { return [...recovered]; } - async updateSession(sessionId: string, patch: Partial): Promise { + async updateSession(sessionId: string, patch: SessionHeaderPatch): Promise { const backendConfigChanged = changesBackendConfig(patch); if (backendConfigChanged && this.runtimeKernel.hasActiveRuns(sessionId)) { throw new Error('Cannot change backend configuration while a turn is running'); @@ -1598,23 +1597,6 @@ export class SessionManager { return headerToSummary(next); } - async archive(sessionId: string): Promise { - const shellRunClose = await this.deps.shellRuns?.terminateSession(sessionId); - try { - await this.deps.store.archive(sessionId); - } catch (error) { - if (shellRunClose) this.deps.shellRuns?.rollbackSessionClose(shellRunClose); - throw error; - } - if (shellRunClose) await this.deps.shellRuns?.commitSessionClose(shellRunClose); - await this.runtimeKernel.disposeBackend(sessionId); - } - - async unarchive(sessionId: string): Promise { - await this.deps.store.unarchive(sessionId); - this.deps.shellRuns?.resumeSession(sessionId); - } - async setSessionStatus( sessionId: string, status: SessionStatus, @@ -2897,7 +2879,7 @@ export class SessionManager { if (messages.some((message) => 'turnId' in message && message.turnId === claim.targetTurnId)) { throw new Error(`Claimed graph turn ${claim.targetTurnId} already has durable messages`); } - if (child.isArchived || child.status === 'archived' || child.status === 'aborted') { + if (child.isArchived || child.status === 'aborted') { throw new Error('Claimed graph execution target child session is terminated'); } if (input.abortSignal?.aborted) { @@ -5383,10 +5365,7 @@ export class SessionManager { await this.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason)); } - private async updateHeader( - sessionId: string, - patch: Partial, - ): Promise { + private async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise { const next = await this.deps.store.updateHeader(sessionId, patch); this.runtimeKernel.updateCachedHeader(sessionId, next); return next; @@ -6456,7 +6435,7 @@ function claimedAgentGraphIntentResult( }; } -export function changesBackendConfig(patch: Partial): boolean { +export function changesBackendConfig(patch: SessionHeaderPatch): boolean { return ( 'backend' in patch || 'llmConnectionSlug' in patch || diff --git a/packages/runtime/src/stream-graph-coordinator.ts b/packages/runtime/src/stream-graph-coordinator.ts index 351f62816b..dc9a2d2ae9 100644 --- a/packages/runtime/src/stream-graph-coordinator.ts +++ b/packages/runtime/src/stream-graph-coordinator.ts @@ -473,7 +473,7 @@ export class AgentGraphCoordinator { const recovered: string[] = []; for (const header of await this.#input.sessionStore.listForRecovery()) { if (this.#input.rootSessionId && header.id !== this.#input.rootSessionId) continue; - if (header.subagentParent || header.isArchived || header.status === 'archived') continue; + if (header.subagentParent || header.isArchived) continue; const graphId = await this.currentGraphId(header.id); const updates = await this.#input.controlStore.listAgentGraphScheduleUpdates(graphId); if (updates.length === 0) continue; @@ -1144,7 +1144,7 @@ export class AgentGraphCoordinator { async #assertRootSupervisor(rootSessionId: string): Promise { const header = await this.#assertRootGraphReader(rootSessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { throw new AgentGraphClientOperationError( 'session_archived', 'Archived Sessions cannot supervise an agent graph', diff --git a/packages/storage/src/__tests__/goal-authority.test.ts b/packages/storage/src/__tests__/goal-authority.test.ts index 2784383f41..662e31a62c 100644 --- a/packages/storage/src/__tests__/goal-authority.test.ts +++ b/packages/storage/src/__tests__/goal-authority.test.ts @@ -142,9 +142,9 @@ test('Session retirement atomically removes its Goal authority', async () => { const [archived, removed] = await Promise.all( sessions.map((session) => stores.sessionStore.readHeaderRecordSnapshot(session.id)), ); - await stores.sessionStore.setSessionsLifecycleVersioned( + await stores.sessionStore.setSessionsArchivedVersioned( [{ sessionId: archived.header.id, expectedVersion: archived.revision }], - 'archived', + true, ); await stores.sessionStore.removeSessionsVersioned([ { sessionId: removed.header.id, expectedVersion: removed.revision }, diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index b2d15d58c9..9d6fe73064 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -336,6 +336,10 @@ describe('SQLite SessionStore', () => { DROP TABLE agent_graph_epochs; DROP TABLE session_message_chunks; DROP TABLE session_message_payloads; + ALTER TABLE session_metadata ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); UPDATE session_metadata_schema SET version = 22 WHERE scope = 'session_metadata'; `); legacy.close(); diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index a2291c29de..13f20fa20b 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -15,7 +15,7 @@ import { MAX_EXECUTION_BOUNDARY_SERIALIZED_BYTES, type SandboxBoundarySettlement, } from '@maka/core/sandbox-boundary'; -import { type SessionHeader } from '@maka/core/session'; +import type { SessionHeader, SessionHeaderPatch } from '@maka/core/session'; import type { AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology'; import { createSqliteSessionMetadataStore, @@ -23,6 +23,7 @@ import { SessionMetadataVersionConflictError, SQLITE_SESSION_METADATA_SCHEMA_VERSION, StoredSessionMessageIncompatibleError, + type SessionConfigurationMetadataUpdate, type SqliteSessionMetadataStoreFailpoint, } from '../sqlite-session-metadata-store.js'; import { @@ -133,6 +134,179 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('migrates archived Session metadata onto isArchived alone', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-archive-authority-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path, { now: () => 100 }); + const header = fullHeader(); + await setup.create(header); + setup.close(); + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + ALTER TABLE session_metadata ADD COLUMN status TEXT; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + UPDATE session_metadata_schema SET version = 24 WHERE scope = 'session_metadata'; + `); + legacy + .prepare(` + UPDATE session_metadata + SET + payload_json = ?, + is_archived = 1, + status = 'archived', + status_updated_at = 50 + WHERE session_id = ? + `) + .run( + JSON.stringify({ + ...header, + isArchived: true, + archivedAt: 50, + status: 'archived', + blockedReason: 'tool_failed', + statusUpdatedAt: 50, + }), + header.id, + ); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path, { now: () => 200 }); + try { + const record = await migrated.read(header.id); + assert.equal(record.header.isArchived, true); + assert.equal(record.header.status, 'active'); + assert.equal(record.header.blockedReason, undefined); + assert.equal(record.header.statusUpdatedAt, undefined); + assert.equal('archivedAt' in record.header, false); + assert.equal(record.metadataVersion, 2); + } finally { + migrated.close(); + } + + const schema = new DatabaseSync(path); + try { + const columns = schema + .prepare('PRAGMA table_info(session_metadata)') + .all() as unknown as Array<{ readonly name: string }>; + assert.equal( + columns.some(({ name }) => name === 'status'), + false, + ); + assert.equal( + columns.some(({ name }) => name === 'status_updated_at'), + false, + ); + assert.equal( + schema + .prepare( + "SELECT 1 AS found FROM sqlite_schema WHERE type = 'index' AND name = 'session_metadata_by_status'", + ) + .get(), + undefined, + ); + } finally { + schema.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('changes archive state without overwriting execution status', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); + try { + const header = fullHeader({ + status: 'blocked', + blockedReason: 'tool_failed', + statusUpdatedAt: 20, + }); + await store.create(header); + + const [archived] = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 1 }], + true, + ); + + assert.equal(archived?.header.isArchived, true); + assert.equal(archived?.header.status, 'blocked'); + assert.equal(archived?.header.blockedReason, 'tool_failed'); + assert.equal(archived?.header.statusUpdatedAt, 20); + assert.equal('archivedAt' in (archived?.header ?? {}), false); + + const [restored] = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 2 }], + false, + ); + + assert.equal(restored?.header.isArchived, false); + assert.equal(restored?.header.status, 'blocked'); + assert.equal(restored?.header.blockedReason, 'tool_failed'); + assert.equal(restored?.header.statusUpdatedAt, 20); + } finally { + store.close(); + } + }); + + test('rejects Session lifecycle fields through generic metadata writes', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + const header = fullHeader(); + await store.create(header); + + await assert.rejects( + store.update(header.id, { isArchived: true } as unknown as SessionHeaderPatch), + /Session archive state requires the dedicated lifecycle writer/u, + ); + await assert.rejects( + store.update(header.id, { archivedAt: 123 } as unknown as SessionHeaderPatch), + /Invalid session header/u, + ); + await store.update(header.id, {}, { + expectedVersion: 1, + skipNoop: true, + archiveState: true, + } as unknown as Parameters[2]); + assert.equal((await store.read(header.id)).header.isArchived, false); + await assert.rejects( + store.updateSessionConfiguration(header.id, { + expectedVersion: 1, + configuration: { + backend: header.backend, + llmConnectionSlug: header.llmConnectionSlug, + connectionLocked: header.connectionLocked, + model: header.model, + thinkingLevel: header.thinkingLevel, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + labels: header.labels, + isArchived: true, + } as unknown as SessionConfigurationMetadataUpdate['configuration'], + lifecycle: { kind: 'preserve' }, + }), + /Session archive state requires the dedicated lifecycle writer/u, + ); + await assert.rejects( + store.create({ ...fullHeader({ id: 'polluted' }), archivedAt: 123 } as SessionHeader), + /Invalid session header/u, + ); + + const current = await store.read(header.id); + assert.equal(current.metadataVersion, 1); + assert.equal(current.header.isArchived, false); + assert.equal('archivedAt' in current.header, false); + } finally { + store.close(); + } + }); + test('atomically retires a revision family with CAS and tombstone retries', async () => { const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); const root = fullHeader({ @@ -145,7 +319,6 @@ describe('SqliteSessionMetadataStore', () => { revisionIndex: undefined, revisionState: undefined, isArchived: false, - archivedAt: undefined, status: 'active', blockedReason: undefined, }); @@ -159,7 +332,6 @@ describe('SqliteSessionMetadataStore', () => { revisionIndex: 2, revisionState: 'committed', isArchived: false, - archivedAt: undefined, status: 'active', blockedReason: undefined, }); @@ -168,12 +340,12 @@ describe('SqliteSessionMetadataStore', () => { await store.create(revision); await assert.rejects( - store.setLifecycleVersioned( + store.setArchivedVersioned( [ { sessionId: root.id, expectedVersion: 1 }, { sessionId: revision.id, expectedVersion: 2 }, ], - 'archived', + true, ), SessionMetadataVersionConflictError, ); @@ -183,22 +355,23 @@ describe('SqliteSessionMetadataStore', () => { assert.equal(current.header.isArchived, false); } - const archived = await store.setLifecycleVersioned( + const archived = await store.setArchivedVersioned( [ { sessionId: root.id, expectedVersion: 1 }, { sessionId: revision.id, expectedVersion: 1 }, ], - 'archived', + true, ); assert.deepEqual( archived.map((record) => ({ id: record.header.id, revision: record.metadataVersion, + isArchived: record.header.isArchived, status: record.header.status, })), [ - { id: revision.id, revision: 2, status: 'archived' }, - { id: root.id, revision: 2, status: 'archived' }, + { id: revision.id, revision: 2, isArchived: true, status: 'active' }, + { id: root.id, revision: 2, isArchived: true, status: 'active' }, ], ); @@ -995,8 +1168,7 @@ describe('SqliteSessionMetadataStore', () => { id: 'archived', name: 'Archived', isArchived: true, - archivedAt: 50, - status: 'archived', + status: 'active', blockedReason: undefined, lastMessageAt: 50, labels: ['shared'], diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 32d8fb0fd9..17db653841 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -397,16 +397,14 @@ async function createExecutionStoresForWrite sessionStore.markSessionReadThroughMessage(sessionId, messageId)), markSessionReadThrough: (sessionId, readThroughTs) => run(() => sessionStore.markSessionReadThrough(sessionId, readThroughTs)), - archive: (sessionId) => run(() => sessionStore.archive(sessionId)), - unarchive: (sessionId) => run(() => sessionStore.unarchive(sessionId)), setFlagged: (sessionId, isFlagged) => run(() => sessionStore.setFlagged(sessionId, isFlagged)), rename: (sessionId, name) => run(() => sessionStore.rename(sessionId, name)), setGeneratedTitleIfAbsent: (sessionId, title) => run(() => sessionStore.setGeneratedTitleIfAbsent(sessionId, title)), remove: (sessionId) => run(() => sessionStore.remove(sessionId)), - setSessionsLifecycleVersioned: (sessions, state) => - run(() => sessionStore.setSessionsLifecycleVersioned(sessions, state)), + setSessionsArchivedVersioned: (sessions, isArchived) => + run(() => sessionStore.setSessionsArchivedVersioned(sessions, isArchived)), removeSessionsVersioned: (sessions) => run(() => sessionStore.removeSessionsVersioned(sessions)), reconcileOrphanedAgentGraphRetirements: () => diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 147ba58117..2ff760728f 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -51,6 +51,7 @@ import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-i import { isSessionToolProfile, type SessionHeader, + type SessionHeaderPatch, type SessionConversationCopy, type SessionSummary, type StoredMessage, @@ -259,10 +260,8 @@ export interface SessionStore { listTurns(sessionId: string): Promise; appendMessage(sessionId: string, message: StoredMessage): Promise; appendMessages(sessionId: string, messages: StoredMessage[]): Promise; - updateHeader(sessionId: string, patch: Partial): Promise; + updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; markSessionReadThrough(sessionId: string, readThroughTs: number): Promise; - archive(sessionId: string): Promise; - unarchive(sessionId: string): Promise; setFlagged(sessionId: string, isFlagged: boolean): Promise; rename(sessionId: string, name: string): Promise; setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise; @@ -336,7 +335,7 @@ export interface SessionAuthorityStore extends SessionStore { readCatalogRecord(sessionId: string): Promise; updateHeaderVersioned( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, expectedRevision: number, ): Promise; updateSessionConfiguration( @@ -348,9 +347,9 @@ export interface SessionAuthorityStore extends SessionStore { messageId: string, ): Promise; probeSessionRemoval(sessionId: string): Promise; - setSessionsLifecycleVersioned( + setSessionsArchivedVersioned( sessions: readonly VersionedSessionIdentity[], - state: 'active' | 'archived', + isArchived: boolean, ): Promise; removeSessionsVersioned(sessions: readonly VersionedSessionIdentity[]): Promise; reconcileOrphanedAgentGraphRetirements(): Promise; @@ -774,14 +773,14 @@ class SqliteSessionStore implements SessionAuthorityStore { return () => this.transcriptChangeListeners.delete(listener); } - async updateHeader(sessionId: string, patch: Partial): Promise { + async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise { await this.ensureReady(); return (await this.metadata.update(sessionId, patch)).header; } async updateHeaderVersioned( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, expectedRevision: number, ): Promise { await this.ensureReady(); @@ -842,12 +841,14 @@ class SqliteSessionStore implements SessionAuthorityStore { return projectRemovalProbe(await this.metadata.probeRemoval(sessionId)); } - async setSessionsLifecycleVersioned( + async setSessionsArchivedVersioned( sessions: readonly VersionedSessionIdentity[], - state: 'active' | 'archived', + isArchived: boolean, ): Promise { await this.ensureReady(); - return (await this.metadata.setLifecycleVersioned(sessions, state)).map(projectHeaderSnapshot); + return (await this.metadata.setArchivedVersioned(sessions, isArchived)).map( + projectHeaderSnapshot, + ); } async removeSessionsVersioned(sessions: readonly VersionedSessionIdentity[]): Promise { @@ -887,26 +888,6 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.updateHeader(sessionId, { hasUnread: false }); } - async archive(sessionId: string): Promise { - const now = Date.now(); - await this.updateHeader(sessionId, { - isArchived: true, - archivedAt: now, - status: 'archived', - statusUpdatedAt: now, - }); - } - - async unarchive(sessionId: string): Promise { - await this.updateHeader(sessionId, { - isArchived: false, - archivedAt: undefined, - status: 'active', - blockedReason: undefined, - statusUpdatedAt: Date.now(), - }); - } - async setFlagged(sessionId: string, isFlagged: boolean): Promise { await this.updateHeader(sessionId, { isFlagged }); } @@ -1045,7 +1026,7 @@ export function normalizeSessionHeader( Array.isArray(header.labels) && header.labels.every((label) => typeof label === 'string') && typeof header.isArchived === 'boolean' && - (header.archivedAt === undefined || isFiniteNumber(header.archivedAt)) && + !Object.prototype.hasOwnProperty.call(header, 'archivedAt') && isSessionStatus(header.status) && (header.blockedReason === undefined || isSessionBlockedReason(header.blockedReason)) && (header.statusUpdatedAt === undefined || isFiniteNumber(header.statusUpdatedAt)) && diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 51fd3f8379..776b050a60 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 24; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 25; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -897,6 +897,36 @@ const MIGRATIONS: ReadonlyMap = new Map([ ON agent_graph_epochs(root_session_id, epoch DESC); `, ], + [ + 25, + ` + DROP INDEX session_metadata_by_status; + + UPDATE session_metadata + SET + payload_json = CASE + WHEN json_extract(payload_json, '$.status') = 'archived' + THEN json_remove( + json_set(payload_json, '$.status', 'active'), + '$.archivedAt', + '$.blockedReason', + '$.statusUpdatedAt' + ) + ELSE json_remove(payload_json, '$.archivedAt') + END, + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(strftime('%s', 'now') AS INTEGER) * 1000 + ) + WHERE + json_extract(payload_json, '$.status') = 'archived' + OR json_type(payload_json, '$.archivedAt') IS NOT NULL; + + ALTER TABLE session_metadata DROP COLUMN status; + ALTER TABLE session_metadata DROP COLUMN status_updated_at; + `, + ], ]); export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 7ab67d07db..8b097148fc 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -70,6 +70,7 @@ import { isSubagentSessionRuntime, isSubagentSessionSpawn, type SessionHeader, + type SessionHeaderPatch, type StoredMessage, type SubagentSessionParent, decodeStoredMessage, @@ -3311,7 +3312,7 @@ export class SqliteSessionMetadataStore { async update( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, options: { expectedVersion?: number; skipNoop?: boolean } = {}, ): Promise { this.assertOpen(); @@ -3328,39 +3329,35 @@ export class SqliteSessionMetadataStore { if (Object.prototype.hasOwnProperty.call(patch, 'subagentWorkspace')) { throw new Error('Subagent session workspace binding is immutable'); } - return this.transaction(() => this.updateHeaderSync(sessionId, patch, options)); + return this.transaction(() => + this.updateHeaderSync(sessionId, patch, { + ...(options.expectedVersion === undefined + ? {} + : { expectedVersion: options.expectedVersion }), + ...(options.skipNoop === undefined ? {} : { skipNoop: options.skipNoop }), + }), + ); } - async setLifecycleVersioned( + async setArchivedVersioned( sessions: readonly VersionedSessionIdentity[], - state: 'active' | 'archived', + isArchived: boolean, ): Promise { this.assertOpen(); const identities = uniqueVersionedSessionIdentities(sessions); - const now = this.now(); - const patch: Partial = - state === 'archived' - ? { - isArchived: true, - archivedAt: now, - status: 'archived', - statusUpdatedAt: now, - } - : { - isArchived: false, - archivedAt: undefined, - status: 'active', - blockedReason: undefined, - statusUpdatedAt: now, - }; return this.transaction(() => { const records = identities.map(({ sessionId, expectedVersion }) => - this.updateHeaderSync(sessionId, patch, { - expectedVersion, - skipNoop: true, - }), + this.updateHeaderSync( + sessionId, + {}, + { + expectedVersion, + skipNoop: true, + archiveState: isArchived, + }, + ), ); - if (state === 'archived') this.deleteGoalAuthorities(identities); + if (isArchived) this.deleteGoalAuthorities(identities); return records; }); } @@ -3490,8 +3487,6 @@ export class SqliteSessionMetadataStore { name, is_flagged, is_archived, - status, - status_updated_at, parent_session_id, subagent_parent_session_id, subagent_parent_run_id, @@ -3509,7 +3504,7 @@ export class SqliteSessionMetadataStore { model, metadata_version, committed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -3521,8 +3516,6 @@ export class SqliteSessionMetadataStore { header.name, booleanInteger(header.isFlagged), booleanInteger(header.isArchived), - header.status, - header.statusUpdatedAt ?? null, header.parentSessionId ?? null, header.subagentParent?.parentSessionId ?? null, header.subagentParent?.spawnedBy.parentRunId ?? null, @@ -3703,13 +3696,17 @@ export class SqliteSessionMetadataStore { private updateHeaderSync( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, options: { expectedVersion?: number; skipNoop?: boolean; catalogPreview?: { readonly kind: 'replace'; readonly value?: string }; + archiveState?: boolean; } = {}, ): SessionMetadataRecord { + if (Object.prototype.hasOwnProperty.call(patch, 'isArchived')) { + throw new Error('Session archive state requires the dedicated lifecycle writer'); + } const current = this.readRecordSync(sessionId); if (!current) throw new SessionNotFoundError(sessionId); if ( @@ -3723,7 +3720,14 @@ export class SqliteSessionMetadataStore { ); } assertConversationCopyTransition(current.header, patch); - const next = normalizeSessionHeader({ ...current.header, ...patch }, sessionId); + const next = normalizeSessionHeader( + { + ...current.header, + ...patch, + ...(options.archiveState === undefined ? {} : { isArchived: options.archiveState }), + }, + sessionId, + ); if (next.id !== sessionId) { throw new SessionMetadataConflictError('Session metadata identity cannot be changed'); } @@ -3749,8 +3753,6 @@ export class SqliteSessionMetadataStore { name = ?, is_flagged = ?, is_archived = ?, - status = ?, - status_updated_at = ?, parent_session_id = ?, subagent_parent_session_id = ?, revision_root_session_id = ?, @@ -3772,8 +3774,6 @@ export class SqliteSessionMetadataStore { next.name, booleanInteger(next.isFlagged), booleanInteger(next.isArchived), - next.status, - next.statusUpdatedAt ?? null, next.parentSessionId ?? null, next.subagentParent?.parentSessionId ?? null, next.revisionRootSessionId ?? null, @@ -3825,7 +3825,7 @@ export class SqliteSessionMetadataStore { }, options: { expectedVersion?: number; - headerPatch?: Partial; + headerPatch?: SessionHeaderPatch; } = {}, ): { boundary: ExecutionBoundary; record: SessionMetadataRecord } { const record = this.readRecordSync(sessionId); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index bf90d1f6b3..6676b61b1c 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -428,7 +428,7 @@ const CONVERSATION_COPY = { revisionVersionsAriaLabel: '任务版本', revisionVersion: (current, total) => `版本 ${current} / ${total}`, previousRevision: '查看上一版本', nextRevision: '查看下一版本', }, sessions: { - status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', review: '待审核', done: '已完成', archived: '已归档', aborted: '已中止' }, + status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', review: '待审核', done: '已完成', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: '任务操作', pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, }, @@ -566,7 +566,7 @@ const CONVERSATION_COPY = { revisionVersionsAriaLabel: 'Task versions', revisionVersion: (current, total) => `Version ${current} of ${total}`, previousRevision: 'View previous version', nextRevision: 'View next version', }, sessions: { - status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', review: 'Review', done: 'Done', archived: 'Archived', aborted: 'Stopped' }, + status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', review: 'Review', done: 'Done', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: 'Task actions', pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, }, diff --git a/packages/ui/src/session-status-presentation.ts b/packages/ui/src/session-status-presentation.ts index b99b1c8979..61004c41c2 100644 --- a/packages/ui/src/session-status-presentation.ts +++ b/packages/ui/src/session-status-presentation.ts @@ -31,11 +31,11 @@ export interface SessionStatusPresentation { * * `undefined` means no dot: `active` is the resting state and the rail does not * mark a task for being ordinary. Everything else gets one, including - * `archived` and `aborted` — they were `muted` before this change and `muted` - * resolved to a real `neutral` dot, so dropping them to `undefined` was a - * behaviour change, not a consequence of collapsing the layer. Without a dot - * they fell through to the unread branch and an aborted task with unread text - * drew the same accent dot as one that is running. + * `aborted` — it was `muted` before this change and `muted` resolved to a real + * `neutral` dot, so dropping it to `undefined` was a behaviour change, not a + * consequence of collapsing the layer. Without a dot it fell through to the + * unread branch and an aborted task with unread text drew the same accent dot + * as one that is running. */ const STATUS_SEMANTIC: Record = { active: undefined, @@ -44,7 +44,6 @@ const STATUS_SEMANTIC: Record = { blocked: 'attention', review: 'attention', done: 'success', - archived: 'neutral', aborted: 'neutral', }; diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 2a2d9d2c5f..4515f89cc4 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -36,12 +36,11 @@ function makeSession(input: { llmConnectionSlug?: string; }): SessionSummary { const status = input.status ?? 'active'; - const isArchived = input.isArchived ?? status === 'archived'; return { id: input.id, name: input.name, isFlagged: input.isFlagged ?? false, - isArchived, + isArchived: input.isArchived ?? false, labels: [], hasUnread: input.hasUnread ?? false, status, @@ -200,12 +199,6 @@ const statusSessions = [ status: 'done', lastMessageAt: NOW - 2 * 60 * 60 * 1000, }), - makeSession({ - id: 'status-archived', - name: '归档的旧实验', - status: 'archived', - lastMessageAt: NOW - 8 * 24 * 60 * 60 * 1000, - }), makeSession({ id: 'status-aborted', name: '中止的临时尝试', From f7bee57284fcf6f794fe216230c8fda66b68eb7c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 16 Aug 2026 02:10:26 +0800 Subject: [PATCH 2/4] fix: block archived session configuration updates Reject execution and workspace configuration changes through the legacy SessionManager update path when the Session is archived. Restore archived-row Storybook coverage with an independent done execution status. Generated-by: Codex --- .../src/__tests__/session-manager.test.ts | 22 +++++++++++++++++++ packages/runtime/src/session-manager.ts | 18 +++++++++++++++ .../ui/stories/session-list-panel.stories.tsx | 3 ++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index d9dd872845..516aac3287 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5129,6 +5129,28 @@ describe('SessionManager permission mode updates', () => { ]); }); + test('rejects execution configuration updates for archived sessions', async () => { + const store = new MemorySessionStore(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(5_500), + }); + const session = await manager.createSession(makeInput()); + await store.updateHeader(session.id, { isArchived: true }); + + await assert.rejects( + manager.updateSession(session.id, { model: 'replacement-model' }), + (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.equal(error.code, 'operation_conflict'); + return true; + }, + ); + assert.equal((await store.readHeader(session.id)).model, session.model); + }); + test('starts a new turn without workspace identity when safety inspection fails', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 52e6010918..525f8cea02 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1567,6 +1567,15 @@ export class SessionManager { } async updateSession(sessionId: string, patch: SessionHeaderPatch): Promise { + if (changesSessionExecutionConfiguration(patch)) { + const current = await this.deps.store.readHeader(sessionId); + if (current.isArchived) { + throw new SessionConfigurationTransitionError( + 'operation_conflict', + 'Archived Session configuration cannot be changed', + ); + } + } const backendConfigChanged = changesBackendConfig(patch); if (backendConfigChanged && this.runtimeKernel.hasActiveRuns(sessionId)) { throw new Error('Cannot change backend configuration while a turn is running'); @@ -6457,6 +6466,15 @@ export function changesBackendConfig(patch: SessionHeaderPatch): boolean { ); } +function changesSessionExecutionConfiguration(patch: SessionHeaderPatch): boolean { + return ( + changesBackendConfig(patch) || + 'connectionLocked' in patch || + 'orchestrationMode' in patch || + 'projectId' in patch + ); +} + function executionBoundaryMatchesPermissionMode( boundary: ExecutionBoundary, mode: PermissionMode, diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 4515f89cc4..517f279fb8 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -195,8 +195,9 @@ const statusSessions = [ }), makeSession({ id: 'status-done', - name: '已完成的 smoke run', + name: '已归档的 smoke run', status: 'done', + isArchived: true, lastMessageAt: NOW - 2 * 60 * 60 * 1000, }), makeSession({ From 286cccb64e526f4c6d2f6c107980a973170c60b4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 16 Aug 2026 02:12:56 +0800 Subject: [PATCH 3/4] refactor: remove unused session configuration bypass Delete the uncalled generic SessionManager update path instead of preserving a second configuration authority. Remove its implementation-detail tests; the versioned configuration transition, permission, and relocation paths retain their behavioral coverage. Generated-by: Codex --- .../__tests__/model-factory-thinking.test.ts | 17 ---- .../src/__tests__/session-manager.test.ts | 93 ------------------- packages/runtime/src/session-manager.ts | 71 -------------- 3 files changed, 181 deletions(-) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index c2ae66f347..bf60c5ab20 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import { thinkingVariantsForModel, type ThinkingLevel } from '@maka/core/model-thinking'; -import { changesBackendConfig } from '@maka/runtime/session-manager'; import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; @@ -576,19 +575,3 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ); }); }); - -describe('changesBackendConfig', () => { - test('thinkingLevel change triggers backend reconfiguration', () => { - assert.equal(changesBackendConfig({ thinkingLevel: 'high' }), true); - assert.equal(changesBackendConfig({ thinkingLevel: undefined }), true); - }); - - test('permissionMode triggers, so a mode change is enforced and not merely stored', () => { - // The backend snapshots the header at construction and decides every - // tool call against that snapshot. Persisting a lower mode without - // rebuilding leaves the live session enforcing the OLD one — which is - // how the bot guard's re-pin to `explore` became advisory. - assert.equal(changesBackendConfig({ permissionMode: 'explore' }), true); - assert.equal(changesBackendConfig({ permissionMode: 'bypass' }), true); - }); -}); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 516aac3287..8912d6b749 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5089,68 +5089,6 @@ describe('SessionManager permission mode updates', () => { expect(modeNote.data).toEqual({ from: 'explore', to: 'ask' }); }); - test('backend configuration updates rebuild an already-active backend', async () => { - const store = new MemorySessionStore(); - const backends = new BackendRegistry(); - const built: string[] = []; - backends.register('fake', (ctx) => { - built.push( - `${ctx.header.backend}:${ctx.header.llmConnectionSlug}:${ctx.header.model}:${ctx.header.cwd}`, - ); - return new TestBackend(ctx); - }); - backends.register('ai-sdk', (ctx) => { - built.push( - `${ctx.header.backend}:${ctx.header.llmConnectionSlug}:${ctx.header.model}:${ctx.header.cwd}`, - ); - return new TestBackend(ctx); - }); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(5_000) }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - expect(built).toEqual(['fake:fake:fake-model:/tmp/cwd']); - - const summary = await manager.updateSession(session.id, { - backend: 'ai-sdk', - llmConnectionSlug: 'zai-coding-plan', - model: 'glm-4.7', - cwd: '/tmp/worktree-cwd', - }); - expect(summary.backend).toBe('ai-sdk'); - expect(summary.llmConnectionSlug).toBe('zai-coding-plan'); - expect(summary.cwd).toBe('/tmp/worktree-cwd'); - expect(store.disposeCount).toBe(1); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'again' })); - expect(built).toEqual([ - 'fake:fake:fake-model:/tmp/cwd', - 'ai-sdk:zai-coding-plan:glm-4.7:/tmp/worktree-cwd', - ]); - }); - - test('rejects execution configuration updates for archived sessions', async () => { - const store = new MemorySessionStore(); - const manager = new SessionManager({ - store, - backends: new BackendRegistry(), - newId: nextId(), - now: nextNow(5_500), - }); - const session = await manager.createSession(makeInput()); - await store.updateHeader(session.id, { isArchived: true }); - - await assert.rejects( - manager.updateSession(session.id, { model: 'replacement-model' }), - (error: unknown) => { - assert.ok(error instanceof SessionConfigurationTransitionError); - assert.equal(error.code, 'operation_conflict'); - return true; - }, - ); - assert.equal((await store.readHeader(session.id)).model, session.model); - }); - test('starts a new turn without workspace identity when safety inspection fails', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -11803,37 +11741,6 @@ describe('SessionManager permission mode updates', () => { expect(output.budget.projectedBytes <= output.budget.maxBytes).toBe(true); }); - test('rejects backend configuration updates while a turn is actively streaming', async () => { - const store = new MemorySessionStore(); - const backends = new BackendRegistry(); - const gate = makeGate(); - backends.register('fake', (ctx) => new TestBackend(ctx, gate)); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(7_000) }); - const session = await manager.createSession(makeInput()); - - const iterator = manager - .sendMessage(session.id, { turnId: 'turn-1', text: 'hello' }) - [Symbol.asyncIterator](); - await iterator.next(); - - await expectRejects( - manager.updateSession(session.id, { - backend: 'ai-sdk', - llmConnectionSlug: 'zai-coding-plan', - model: 'glm-4.7', - cwd: '/tmp/worktree-cwd', - }), - /Cannot change backend configuration while a turn is running/, - ); - const header = await store.readHeader(session.id); - expect(header.backend).toBe('fake'); - expect(header.llmConnectionSlug).toBe('fake'); - - gate.release(); - await iterator.next(); - await iterator.next(); - }); - test('backend build failure after user append writes a failed terminal run fact', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 525f8cea02..68475ab5a2 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1566,46 +1566,6 @@ export class SessionManager { return [...recovered]; } - async updateSession(sessionId: string, patch: SessionHeaderPatch): Promise { - if (changesSessionExecutionConfiguration(patch)) { - const current = await this.deps.store.readHeader(sessionId); - if (current.isArchived) { - throw new SessionConfigurationTransitionError( - 'operation_conflict', - 'Archived Session configuration cannot be changed', - ); - } - } - const backendConfigChanged = changesBackendConfig(patch); - if (backendConfigChanged && this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('Cannot change backend configuration while a turn is running'); - } - - const { permissionMode, name, titleIsManual: _titleIsManual, ...rest } = patch; - const permissionSummary = - permissionMode === undefined - ? undefined - : await this.setPermissionMode(sessionId, permissionMode); - if (name === undefined && Object.keys(rest).length === 0) { - return permissionSummary ?? headerToSummary(await this.deps.store.readHeader(sessionId)); - } - - if (name !== undefined) await this.deps.store.rename(sessionId, name); - const next = - Object.keys(rest).length > 0 - ? await this.deps.store.updateHeader(sessionId, rest) - : await this.deps.store.readHeader(sessionId); - this.runtimeKernel.updateCachedHeader(sessionId, next); - if (changesBackendConfig(rest)) { - // AgentBackend instances snapshot backend/model config at construction - // time. If a stale session is rebound to a real default connection, the - // next turn must build a fresh backend instead of reusing FakeBackend or - // an AiSdkBackend pointed at a deleted connection. - await this.runtimeKernel.disposeBackend(sessionId); - } - return headerToSummary(next); - } - async setSessionStatus( sessionId: string, status: SessionStatus, @@ -6444,37 +6404,6 @@ function claimedAgentGraphIntentResult( }; } -export function changesBackendConfig(patch: SessionHeaderPatch): boolean { - return ( - 'backend' in patch || - 'llmConnectionSlug' in patch || - 'model' in patch || - 'thinkingLevel' in patch || - 'cwd' in patch || - 'collaborationMode' in patch || - // AiSdkBackend snapshots the header at construction and ToolRuntime - // reads `header.permissionMode` at every decision, so a mode change - // that does not rebuild the backend is persisted but NOT enforced — - // the live session keeps deciding with the old mode. - // - // `setPermissionMode` disposes the backend for exactly this reason. - // `updateSession` did not, so every other path that lowers a mode was - // advisory: notably the bot-incoming guard re-pinning a conversation - // to `explore`, which left an already-built `execute`/`bypass` backend - // serving the remote sender. - 'permissionMode' in patch - ); -} - -function changesSessionExecutionConfiguration(patch: SessionHeaderPatch): boolean { - return ( - changesBackendConfig(patch) || - 'connectionLocked' in patch || - 'orchestrationMode' in patch || - 'projectId' in patch - ); -} - function executionBoundaryMatchesPermissionMode( boundary: ExecutionBoundary, mode: PermissionMode, From 85a10857acfe6c2854c8845a498fff23df725cfd Mon Sep 17 00:00:00 2001 From: YayoiNanoka <1159066485@qq.com> Date: Tue, 18 Aug 2026 15:08:47 +0800 Subject: [PATCH 4/4] test: isolate archive compatibility checks --- .../__tests__/handshake-compatibility.test.ts | 71 +++++++++---------- .../sqlite-session-metadata-store.test.ts | 12 ++-- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index 3b4498fb9e..665308a5d9 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -1,15 +1,16 @@ import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { - prepareStorageRootControlDirectory, - resolveStorageRoot, + discoverMarkedStorageRoot, + STORAGE_ROOT_MARKER_FILE, + STORAGE_ROOT_MARKER_SCHEMA_VERSION, } from '@maka/storage/root-authority'; -import { connectRuntimeHost } from '../client/index.js'; +import { connectResolvedRuntimeHost, type ConnectRuntimeHostResult } from '../client/connection.js'; import { prepareRuntimeHostEndpoint } from '../control/endpoint.js'; import { removeHostRegistration, writeHostRegistration } from '../control/registration.js'; import { @@ -65,40 +66,32 @@ test('rejects an epoch-23 Host before any domain command', async () => { assert.equal(admittedRequest, undefined); }); -test('accepts a Host on the current compatibility epoch', async () => { - await withForgedHandshakePeer( - async (transport, hostEpoch, rootId) => { - const hello = decodeClientFrame(await transport.read(2_000)); - assert.ok('kind' in hello && hello.kind === 'hello'); - assert.equal(hello.compatibilityEpoch, RUNTIME_HOST_COMPATIBILITY_EPOCH); - await writeProtocolFrame(transport, { - kind: 'accepted', - rootId, - hostEpoch, - connectionId: 'current-epoch-connection', - selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION, - compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, - compositionId: 'maka.interactive', - compositionRevision: '1', - state: 'ready', - }); - }, - async (result) => { - assert.equal(result.kind, 'connected'); - }, - ); -}); - async function withForgedHandshakePeer( serve: (transport: FramedTransport, hostEpoch: string, rootId: string) => Promise, - run: (result: Awaited>) => Promise, + run: (result: ConnectRuntimeHostResult) => Promise, ): Promise { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-handshake-')); - const capability = await resolveStorageRoot({ - path: join(base, 'root'), - kind: 'interactive', + const rootPath = join(base, 'root'); + const controlDirectory = join(base, 'control'); + await mkdir(rootPath, { mode: 0o700 }); + await mkdir(controlDirectory, { mode: 0o700 }); + const rootStat = await stat(rootPath, { bigint: true }); + await writeFile( + join(rootPath, STORAGE_ROOT_MARKER_FILE), + `${JSON.stringify({ + schemaVersion: STORAGE_ROOT_MARKER_SCHEMA_VERSION, + kind: 'interactive', + rootId: randomBytes(32).toString('hex'), + rootIdentity: { + dev: rootStat.dev.toString(), + ino: rootStat.ino.toString(), + }, + })}\n`, + { mode: 0o600 }, + ); + const capability = await discoverMarkedStorageRoot({ + path: rootPath, }); - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); const hostEpoch = randomUUID(); const endpoint = await prepareRuntimeHostEndpoint({ rootId: capability.rootId, @@ -129,11 +122,17 @@ async function withForgedHandshakePeer( pid: process.pid, createdAt: new Date().toISOString(), }); - const result = await connectRuntimeHost({ - rootPath: join(base, 'root'), + const resolved = await connectResolvedRuntimeHost({ + capability, + controlDirectory, + clientInstanceId: randomUUID(), surface: 'tui', protocol: PROTOCOL, }); + if (resolved.kind === 'election_deadline_elapsed') { + throw new Error('Unexpected Runtime Host election deadline'); + } + const result = resolved; try { await run(result); } finally { diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 8eadf1db37..c8b0748278 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -589,9 +589,10 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('rejects a session metadata schema newer than v26', async () => { + test('rejects a session metadata schema newer than the supported version', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-schema-fence-')); const path = join(root, 'state.sqlite'); + const newerSchemaVersion = SQLITE_SESSION_METADATA_SCHEMA_VERSION + 1; try { const setup = createSqliteSessionMetadataStore(path); setup.close(); @@ -599,15 +600,18 @@ describe('SqliteSessionMetadataStore', () => { try { newer .prepare( - `UPDATE session_metadata_schema SET version = 27 WHERE scope = 'session_metadata'`, + `UPDATE session_metadata_schema SET version = ? WHERE scope = 'session_metadata'`, ) - .run(); + .run(newerSchemaVersion); } finally { newer.close(); } assert.throws( () => createSqliteSessionMetadataStore(path), - /schema 27 is newer than supported version 26/u, + new RegExp( + `schema ${newerSchemaVersion} is newer than supported version ${SQLITE_SESSION_METADATA_SCHEMA_VERSION}`, + 'u', + ), ); } finally { await rm(root, { recursive: true, force: true });