From 949f44870e7de723d8a3e2897bf22156f64328a4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:04:34 +0800 Subject: [PATCH 01/16] refactor: publish canonical session catalog activity Carry the storage catalog's materialized activity timestamp through Runtime Host projections and cursors. This removes the protocol's dependence on the redundant last-used timestamp and gives all clients one ordering fact. Generated-by: Maka --- .../src/__tests__/external-session-coordinator.test.ts | 1 + .../src/__tests__/session-catalog-coordinator.test.ts | 1 + .../src/__tests__/session-catalog-protocol.test.ts | 8 +++++++- .../src/__tests__/session-retirement-protocol.test.ts | 2 +- .../src/__tests__/session-revision-protocol.test.ts | 2 +- packages/runtime-host/src/protocol/session-catalog.ts | 6 +++--- .../src/server/session-catalog-coordinator.ts | 4 ++-- packages/storage/src/__tests__/session-store.test.ts | 1 + packages/storage/src/session-store.ts | 3 +++ packages/storage/src/sqlite-session-catalog-query.ts | 1 + packages/storage/src/sqlite-session-metadata-store.ts | 7 +++++++ 11 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts index a253500333..967b9e5569 100644 --- a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts @@ -500,6 +500,7 @@ function coordinatorFixture( header, revision: 1, committedAt: 1, + activityAt: header.lastMessageAt ?? header.lastUsedAt ?? header.createdAt, summary: headerToSummary(header), }); return header; diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 8c2c6a669c..ec06be8b72 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -1344,6 +1344,7 @@ function headerSnapshot(header: SessionHeader, revision: number) { function catalogRecord(header: SessionHeader, revision: number): SessionCatalogRecord { return { ...headerSnapshot(header, revision), + activityAt: header.lastMessageAt ?? header.lastUsedAt ?? header.createdAt, summary: headerToSummary(header), }; } diff --git a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts index 92829b2bdf..73b7446593 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts @@ -32,6 +32,12 @@ import { } from '../protocol/index.js'; describe('Session catalog protocol', () => { + test('publishes canonical catalog activity without the redundant last-used timestamp', () => { + const catalog = projection(); + + assert.deepEqual(decodeSessionCatalogItem(catalog), catalog); + }); + test('decodes versioned live run state without collapsing absent and known-empty', () => { const unknown = projection(); const knownEmpty = { @@ -576,7 +582,7 @@ function projection(overrides: Partial = {}): SessionC hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 2, + activityAt: 2, name: 'Session', isFlagged: false, isArchived: false, 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 69737f8fd5..ac131e064a 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts @@ -119,7 +119,7 @@ function projection(overrides: Partial = {}): SessionC hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: 'Session', isFlagged: false, isArchived: false, diff --git a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts index 11f47ac531..da25a96a24 100644 --- a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts @@ -144,7 +144,7 @@ function sessionProjection(id: string): SessionCatalogProjection { hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: 'Session', isFlagged: false, isArchived: false, diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 7cb0004aee..2c38605979 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -92,7 +92,7 @@ const PROJECTION_REQUIRED_FIELDS = [ 'revision', 'workspace', 'createdAt', - 'lastUsedAt', + 'activityAt', 'name', 'isFlagged', 'isArchived', @@ -211,7 +211,7 @@ export interface SessionCatalogProjection { readonly revision: number; readonly workspace: WorkspaceProjection; readonly createdAt: number; - readonly lastUsedAt: number; + readonly activityAt: number; readonly name: string; readonly isFlagged: boolean; readonly isArchived: boolean; @@ -646,7 +646,7 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr revision: positiveRevision(record.revision, 'Session revision'), workspace: decodeWorkspaceProjection(record.workspace), createdAt: timestamp(record.createdAt, 'Session createdAt'), - lastUsedAt: timestamp(record.lastUsedAt, 'Session lastUsedAt'), + activityAt: timestamp(record.activityAt, 'Session activityAt'), name: sessionName(record.name), isFlagged: boolean(record.isFlagged, 'Session flagged state'), isArchived: boolean(record.isArchived, 'Session archived state'), diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 760453eb89..43d9ab98cc 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -882,7 +882,7 @@ export function projectSessionCatalogRecord( hostCwd: header.cwd, }, createdAt: header.createdAt, - lastUsedAt: header.lastUsedAt, + activityAt: record.activityAt, name: header.name, isFlagged: header.isFlagged, isArchived: header.isArchived, @@ -1025,7 +1025,7 @@ function encodeCursor(record: SessionCatalogRecord): string { return Buffer.from( JSON.stringify({ version: 1, - activityAt: catalogActivityAt(record.header), + activityAt: record.activityAt, sessionId: record.header.id, }), 'utf8', diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 71e81abe19..c31dfed333 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -195,6 +195,7 @@ describe('SQLite SessionStore', () => { assert.equal(page.kind, 'page'); if (page.kind !== 'page') assert.fail('expected a catalog page'); assert.equal(page.records[0]?.summary.lastMessagePreview, 'hello from SQLite'); + assert.equal(page.records[0]?.activityAt, 10); } finally { await store.close?.(); } diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index a85b307f08..20e8285558 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -126,6 +126,7 @@ export type ProbeSessionRemovalResult = | { readonly kind: 'absent' }; export interface SessionCatalogRecord extends SessionHeaderSnapshot { + readonly activityAt: number; readonly summary: SessionSummary; } @@ -726,6 +727,7 @@ class SqliteSessionStore implements SessionAuthorityStore { revision, records: page.records.map((record) => ({ ...projectHeaderSnapshot(record), + activityAt: record.activityAt, summary: toCatalogSummary(record.header, record.lastMessagePreview), })), hasMore: page.hasMore, @@ -764,6 +766,7 @@ class SqliteSessionStore implements SessionAuthorityStore { const record = await this.metadata.readCatalogRecord(sessionId); return { ...projectHeaderSnapshot(record), + activityAt: record.activityAt, summary: toCatalogSummary(record.header, record.lastMessagePreview), }; } diff --git a/packages/storage/src/sqlite-session-catalog-query.ts b/packages/storage/src/sqlite-session-catalog-query.ts index 80ee7f08f2..9a7e0bc47a 100644 --- a/packages/storage/src/sqlite-session-catalog-query.ts +++ b/packages/storage/src/sqlite-session-catalog-query.ts @@ -63,6 +63,7 @@ export function buildSqliteSessionCatalogPageQuery( metadata.payload_json, metadata.metadata_version, metadata.committed_at, + projection.activity_at, projection.last_message_preview FROM session_catalog_projection projection JOIN session_metadata metadata diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 27e818c293..bbf4d5c4cb 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -190,6 +190,7 @@ export interface SessionMetadataRecord { } export interface SessionMetadataCatalogRecord extends SessionMetadataRecord { + readonly activityAt: number; readonly lastMessagePreview?: string; } @@ -1039,6 +1040,7 @@ export class SqliteSessionMetadataStore { metadata.payload_json, metadata.metadata_version, metadata.committed_at, + projection.activity_at, projection.last_message_preview FROM session_catalog_projection projection JOIN session_metadata metadata @@ -4977,6 +4979,7 @@ interface OrphanedAgentGraphOperatorRow extends OwnedAgentGraphOperatorRow { } interface SessionMetadataCatalogRow extends SessionMetadataRow { + activity_at: number; last_message_preview: string | null; } @@ -5281,9 +5284,13 @@ function decodeRecord(row: SessionMetadataRow): SessionMetadataRecord { } function decodeCatalogRecord(row: SessionMetadataCatalogRow): SessionMetadataCatalogRecord { + if (!Number.isSafeInteger(row.activity_at) || row.activity_at < 0) { + throw new Error(`Invalid SQLite Session catalog activity for ${row.session_id}`); + } const lastMessagePreview = decodeCatalogPreview(row.last_message_preview, row.session_id); return { ...decodeRecord(row), + activityAt: row.activity_at, ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), }; } From 945c5daf463ff2d8e47334b88a7e844698804863 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:11:00 +0800 Subject: [PATCH 02/16] refactor: share canonical catalog projection Generated-by: Maka --- .../runtime-host-run-command.test.ts | 2 +- .../runtime-host-session-driver.test.ts | 17 +++-- packages/cli/src/activation-command.ts | 5 +- packages/cli/src/runtime-host-run-command.ts | 4 +- .../cli/src/runtime-host-session-driver.ts | 60 ++-------------- packages/core/src/session.ts | 5 ++ packages/runtime-host/src/client/index.ts | 1 + .../src/client/session-catalog-summary.ts | 71 +++++++++++++++++++ 8 files changed, 101 insertions(+), 64 deletions(-) create mode 100644 packages/runtime-host/src/client/session-catalog-summary.ts diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 708d6e6de4..9471ba4a8f 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -1741,7 +1741,7 @@ function sessionProjection(id: string): SessionCatalogProjection { hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: 'Run once', isFlagged: false, isArchived: false, 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 8814fd01d7..193fca0057 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -39,9 +39,9 @@ import { type SessionContinuitySnapshot, type SubscriptionFrame, } from '@maka/runtime-host/protocol'; +import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; import { createRuntimeHostMakaSessionDriver, - runtimeHostSessionSummary, type RuntimeHostMakaSessionDriverInput, } from '../runtime-host-session-driver.js'; import { @@ -52,9 +52,16 @@ import { import { WAIT_BUDGET_MS } from './tui-terminal-mock.js'; describe('Runtime Host Maka Session driver', () => { + test('maps authoritative Catalog activity into Session summaries', () => { + assert.equal( + projectSessionCatalogSummary(sessionProjection({ activityAt: 42 })).activityAt, + 42, + ); + }); + test('maps authoritative live Turn ids into Session summaries', () => { assert.deepEqual( - runtimeHostSessionSummary( + projectSessionCatalogSummary( sessionProjection({ status: 'running', liveRunState: { schemaVersion: 1, runningTurnIds: ['turn-1', 'turn-2'] }, @@ -62,13 +69,13 @@ describe('Runtime Host Maka Session driver', () => { ).runningTurnIds, ['turn-1', 'turn-2'], ); - const knownEmpty = runtimeHostSessionSummary( + const knownEmpty = projectSessionCatalogSummary( sessionProjection({ liveRunState: { schemaVersion: 1, runningTurnIds: [] } }), ); assert.equal(Object.hasOwn(knownEmpty, 'runningTurnIds'), true); assert.deepEqual(knownEmpty.runningTurnIds, []); assert.equal( - Object.hasOwn(runtimeHostSessionSummary(sessionProjection()), 'runningTurnIds'), + Object.hasOwn(projectSessionCatalogSummary(sessionProjection()), 'runningTurnIds'), false, ); }); @@ -1892,7 +1899,7 @@ function sessionProjection( hostCwd: '/tmp', }, createdAt: 1, - lastUsedAt: 2, + activityAt: 2, name: 'Session', isFlagged: false, isArchived: false, diff --git a/packages/cli/src/activation-command.ts b/packages/cli/src/activation-command.ts index d1c7e33d7e..1c026840ab 100644 --- a/packages/cli/src/activation-command.ts +++ b/packages/cli/src/activation-command.ts @@ -27,10 +27,9 @@ import type { SessionSummary } from '@maka/core/session'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { redactSecrets } from '@maka/core/redaction'; import { assertSessionBundleRootLayout } from '@maka/storage'; -import { readRuntimeHostSessions } from '@maka/runtime-host/client'; +import { projectSessionCatalogSummary, readRuntimeHostSessions } from '@maka/runtime-host/client'; import { connectRuntimeHostCli, resolveRuntimeHostCliTarget } from './runtime-host-cli-context.js'; import { createRuntimeHostRunContext } from './runtime-host-run-command.js'; -import { runtimeHostSessionSummary } from './runtime-host-session-driver.js'; import type { MakaRunOutcome } from './run-command-core.js'; import { sessionEventSandboxBoundaryFailureReason } from './sandbox-boundary-failure.js'; @@ -884,7 +883,7 @@ async function listRuntimeHostActivationSessions(stateRoot: string): Promise - 'kind' in session ? [] : [runtimeHostSessionSummary(session)], + 'kind' in session ? [] : [projectSessionCatalogSummary(session)], ); } finally { await connected.close(); diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index a50ceca5e0..5044bb7264 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -24,6 +24,7 @@ import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { ExecutionBoundaryReadModel } from '@maka/core/sandbox-boundary'; import type { SessionSummary } from '@maka/core/session'; import { + projectSessionCatalogSummary, readRuntimeHostSessions, readRuntimeHostProjects, RuntimeHostOperationError, @@ -47,7 +48,6 @@ import { } from './runtime-host-cli-context.js'; import { createRuntimeHostMakaSessionDriver, - runtimeHostSessionSummary, type RuntimeHostMakaSessionDriver, } from './runtime-host-session-driver.js'; import type { CreateSessionRequest, MakaPreparedSessionTurn } from './session-driver.js'; @@ -543,7 +543,7 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { } function runtimeHostSessionSummaries(items: readonly SessionCatalogItem[]): SessionSummary[] { - return items.flatMap((item) => ('kind' in item ? [] : [runtimeHostSessionSummary(item)])); + return items.flatMap((item) => ('kind' in item ? [] : [projectSessionCatalogSummary(item)])); } type TurnOutcomeObservation = diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index bb324b62c4..d943743a7d 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -47,6 +47,7 @@ import { } from '@maka/runtime-host/adapter'; import type { DirectRequestOperationKey, RuntimeHostConnection } from '@maka/runtime-host/client'; import { + projectSessionCatalogSummary, readRuntimeHostResources, readRuntimeHostSessions, RuntimeHostOperationError, @@ -235,13 +236,13 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { // over the starting boundary and silently override that default. this.#permissionMode = input.permissionMode; const session = await this.#createSession(input.name ?? DEFAULT_SESSION_NAME); - return runtimeHostSessionSummary(session); + return projectSessionCatalogSummary(session); } async listSessions(): Promise { const sessions = (await readRuntimeHostSessions(this.#connection)) .flatMap(representableSession) - .map(runtimeHostSessionSummary); + .map(projectSessionCatalogSummary); if (this.#executionLocation.kind === 'host') return sessions; return sessions .map((session, index) => ({ session, index })) @@ -298,7 +299,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { turnId, runId: started.runId, events, - summary: runtimeHostSessionSummary(configuration.session), + summary: projectSessionCatalogSummary(configuration.session), ...(skillInvocation ? { skillInvocation } : {}), }; } catch (error) { @@ -477,7 +478,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } let session = await getRuntimeHostSession(this.#connection, sessionId); if (!session) throw new Error(`Session not found: ${sessionId}`); - let summary = runtimeHostSessionSummary(session); + let summary = projectSessionCatalogSummary(session); if (options.relocateCwd === undefined) { await assertSessionResumeAvailable(summary, this.#executionLocation); } @@ -503,7 +504,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { oldCwdDirty, }; } - summary = runtimeHostSessionSummary(session); + summary = projectSessionCatalogSummary(session); await assertSessionResumeAvailable(summary, this.#executionLocation); } const expectedChannelGeneration = this.#channelGeneration; @@ -1007,7 +1008,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { : {}), events: opened.channel.eventsForTurn(turnId), messages: opened.messages, - summary: runtimeHostSessionSummary(configuration.session), + summary: projectSessionCatalogSummary(configuration.session), } satisfies MakaAttachedSessionTurn; for (const listener of this.#startedTurnListeners) listener(turn); opened.channel.activate(turnId); @@ -1248,50 +1249,3 @@ async function loadCurrentMessages( await draining.catch(() => undefined); } } - -export function runtimeHostSessionSummary(session: SessionCatalogProjection): SessionSummary { - return { - id: session.id, - cwd: session.workspace.hostCwd, - ...(session.workspace.target.kind === 'project' - ? { projectId: session.workspace.target.projectId } - : {}), - name: session.name, - isFlagged: session.isFlagged, - isArchived: session.isArchived, - labels: [...session.labels], - hasUnread: session.hasUnread, - ...(session.lastMessageAt === undefined ? {} : { lastMessageAt: session.lastMessageAt }), - ...(session.lastMessagePreview === undefined - ? {} - : { lastMessagePreview: session.lastMessagePreview }), - status: session.status, - ...(session.blockedReason === undefined ? {} : { blockedReason: session.blockedReason }), - ...(session.statusUpdatedAt === undefined ? {} : { statusUpdatedAt: session.statusUpdatedAt }), - ...(session.liveRunState === undefined - ? {} - : { runningTurnIds: [...session.liveRunState.runningTurnIds] }), - ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }), - ...(session.branchOfTurnId === undefined ? {} : { branchOfTurnId: session.branchOfTurnId }), - ...(session.subagent === undefined ? {} : { subagent: session.subagent }), - ...(session.revisionRootSessionId === undefined - ? {} - : { revisionRootSessionId: session.revisionRootSessionId }), - ...(session.revisionParentSessionId === undefined - ? {} - : { revisionParentSessionId: session.revisionParentSessionId }), - ...(session.revisionOfTurnId === undefined - ? {} - : { revisionOfTurnId: session.revisionOfTurnId }), - ...(session.revisionIndex === undefined ? {} : { revisionIndex: session.revisionIndex }), - ...(session.revisionState === undefined ? {} : { revisionState: session.revisionState }), - backend: session.backend, - llmConnectionSlug: session.llmConnectionSlug, - connectionLocked: session.connectionLocked, - model: session.model, - ...(session.thinkingLevel === undefined ? {} : { thinkingLevel: session.thinkingLevel }), - permissionMode: session.permissionMode, - collaborationMode: session.collaborationMode, - orchestrationMode: session.orchestrationMode, - }; -} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d9fc065994..304f6c3767 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -372,6 +372,11 @@ export interface SessionSummary { orchestrationMode?: OrchestrationMode; } +/** A complete Session catalog row. Its order key is authoritative and never synthesized by clients. */ +export interface SessionCatalogSummary extends SessionSummary { + activityAt: number; +} + export function sessionRevisionFamilyId( session: Pick, ): string { diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 924e2a1514..5433a5d950 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -109,6 +109,7 @@ export { type RuntimeHostCapabilityProviderService, } from './capability-provider-service.js'; export { loadOrCreateRuntimeHostClientInstanceId } from './client-instance-identity.js'; +export { projectSessionCatalogSummary } from './session-catalog-summary.js'; export { consumeAccessCredentialDelivery } from '../control/access-credential-delivery.js'; export { createOAuthPresentationClientProvider, diff --git a/packages/runtime-host/src/client/session-catalog-summary.ts b/packages/runtime-host/src/client/session-catalog-summary.ts new file mode 100644 index 0000000000..f7a114f460 --- /dev/null +++ b/packages/runtime-host/src/client/session-catalog-summary.ts @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionCatalogSummary } from '@maka/core/session'; +import type { SessionCatalogProjection } from '../protocol/session-catalog.js'; + +export function projectSessionCatalogSummary( + session: SessionCatalogProjection, +): SessionCatalogSummary { + return { + id: session.id, + cwd: session.workspace.hostCwd, + ...(session.workspace.target.kind === 'project' + ? { projectId: session.workspace.target.projectId } + : {}), + activityAt: session.activityAt, + name: session.name, + isFlagged: session.isFlagged, + isArchived: session.isArchived, + labels: [...session.labels], + hasUnread: session.hasUnread, + ...(session.lastMessageAt === undefined ? {} : { lastMessageAt: session.lastMessageAt }), + ...(session.lastMessagePreview === undefined + ? {} + : { lastMessagePreview: session.lastMessagePreview }), + status: session.status, + ...(session.blockedReason === undefined ? {} : { blockedReason: session.blockedReason }), + ...(session.statusUpdatedAt === undefined ? {} : { statusUpdatedAt: session.statusUpdatedAt }), + ...(session.liveRunState === undefined + ? {} + : { runningTurnIds: [...session.liveRunState.runningTurnIds] }), + ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }), + ...(session.branchOfTurnId === undefined ? {} : { branchOfTurnId: session.branchOfTurnId }), + ...(session.subagent === undefined ? {} : { subagent: session.subagent }), + ...(session.revisionRootSessionId === undefined + ? {} + : { revisionRootSessionId: session.revisionRootSessionId }), + ...(session.revisionParentSessionId === undefined + ? {} + : { revisionParentSessionId: session.revisionParentSessionId }), + ...(session.revisionOfTurnId === undefined + ? {} + : { revisionOfTurnId: session.revisionOfTurnId }), + ...(session.revisionIndex === undefined ? {} : { revisionIndex: session.revisionIndex }), + ...(session.revisionState === undefined ? {} : { revisionState: session.revisionState }), + backend: session.backend, + llmConnectionSlug: session.llmConnectionSlug, + connectionLocked: session.connectionLocked, + model: session.model, + ...(session.thinkingLevel === undefined ? {} : { thinkingLevel: session.thinkingLevel }), + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode, + orchestrationMode: session.orchestrationMode, + }; +} From 43322c3e0a054f3488fa915cf3ae1d27a4b8cb34 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:15:41 +0800 Subject: [PATCH 03/16] refactor: accept complete desktop catalog snapshots Generated-by: Maka --- .../runtime-host-bot-session-adapter.test.ts | 2 +- .../runtime-host-client-operations.test.ts | 2 +- .../__tests__/runtime-host-client-uds.test.ts | 2 +- .../runtime-host-desktop-candidate.test.ts | 2 +- ...me-host-external-sessions-ipc-main.test.ts | 2 +- .../runtime-host-search-ipc-main.test.ts | 2 +- ...time-host-session-catalog-ipc-main.test.ts | 2 +- ...host-session-catalog-running-turns.test.ts | 2 +- ...me-host-session-execution-ipc-main.test.ts | 2 +- .../runtime-host-session-catalog-ipc-main.ts | 48 ++----------------- apps/desktop/src/preload/preload.ts | 44 ++++++++--------- .../src/shared/desktop-session-projection.ts | 8 +++- 12 files changed, 40 insertions(+), 78 deletions(-) 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 216ceb4e47..3627ef6737 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 @@ -417,7 +417,7 @@ function session( hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: id, isFlagged: false, isArchived: false, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 8a0bcb9317..9ef2232e37 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -912,7 +912,7 @@ function session( hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: id, isFlagged: false, isArchived: false, 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 e31131f836..941b9d72e4 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 @@ -599,7 +599,7 @@ function session( hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: 'Desktop Host Session', isFlagged: false, isArchived: false, 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 7eee1772a4..e65ff31ea7 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 @@ -1175,7 +1175,7 @@ function session(id: string): SessionCatalogProjection { hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: id, isFlagged: false, isArchived: false, diff --git a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts index 19d5158557..3a5c46557f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts @@ -205,7 +205,7 @@ function session(id: string): SessionCatalogProjection { hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: 'Imported', isFlagged: false, isArchived: false, diff --git a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts index 9765c5ba3b..86de22e1c9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts @@ -161,7 +161,7 @@ function catalogSession(id: string, name: string): SessionCatalogProjection { hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, lastMessageAt: 1, name, isFlagged: false, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts index 1c4526dd12..cc765db891 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts @@ -45,7 +45,7 @@ function projection(overrides: Partial = {}): SessionC hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 2, + activityAt: 2, name: 'Session', isFlagged: false, isArchived: false, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts index a12ec1dfb1..7d2fca2de3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts @@ -105,7 +105,7 @@ function session(id: string): SessionCatalogProjection { hostCwd: '/workspace', }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: id, isFlagged: false, isArchived: false, 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 2a3574cbe2..c6b723dffb 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 @@ -1076,7 +1076,7 @@ function session(cwd = "/workspace"): SessionCatalogProjection { hostCwd: cwd, }, createdAt: 1, - lastUsedAt: 1, + activityAt: 1, name: "Session", isFlagged: false, isArchived: false, diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index a146d86ecf..e0d583153c 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -23,7 +23,8 @@ import { isOrchestrationMode } from '@maka/core/orchestration'; import { isPermissionMode } from '@maka/core/permission'; import { isThinkingLevel } from '@maka/core/model-thinking'; import { type CreateSessionRequestInput, type SessionListFilter } from '@maka/core/runtime-inputs'; -import { type SessionChangedEvent, type SessionChangedReason, type SessionSummary } from '@maka/core/session'; +import { type SessionChangedEvent, type SessionChangedReason, type SessionCatalogSummary } from '@maka/core/session'; +import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; import type { SessionCatalogProjection, SessionCreateInput, @@ -56,7 +57,7 @@ type RuntimeHostSessionCatalogClient = Pick< | 'updateSessionMetadata' >; -export interface DesktopHostSessionSummary extends SessionSummary { +export interface DesktopHostSessionSummary extends SessionCatalogSummary { labelsTruncated: boolean; } @@ -337,49 +338,8 @@ export function toDesktopHostSessionSummary( session: SessionCatalogProjection, ): DesktopHostSessionSummary { return { - id: session.id, - cwd: session.workspace.hostCwd, - ...(session.workspace.target.kind === 'project' - ? { projectId: session.workspace.target.projectId } - : {}), - name: session.name, - isFlagged: session.isFlagged, - isArchived: session.isArchived, - labels: [...session.labels], + ...projectSessionCatalogSummary(session), labelsTruncated: session.labelsTruncated, - hasUnread: session.hasUnread, - ...(session.lastMessageAt === undefined ? {} : { lastMessageAt: session.lastMessageAt }), - ...(session.lastMessagePreview === undefined - ? {} - : { lastMessagePreview: session.lastMessagePreview }), - status: session.status, - ...(session.liveRunState === undefined - ? {} - : { runningTurnIds: [...session.liveRunState.runningTurnIds] }), - ...(session.blockedReason === undefined ? {} : { blockedReason: session.blockedReason }), - ...(session.statusUpdatedAt === undefined ? {} : { statusUpdatedAt: session.statusUpdatedAt }), - ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }), - ...(session.branchOfTurnId === undefined ? {} : { branchOfTurnId: session.branchOfTurnId }), - ...(session.subagent === undefined ? {} : { subagent: session.subagent }), - ...(session.revisionRootSessionId === undefined - ? {} - : { revisionRootSessionId: session.revisionRootSessionId }), - ...(session.revisionParentSessionId === undefined - ? {} - : { revisionParentSessionId: session.revisionParentSessionId }), - ...(session.revisionOfTurnId === undefined - ? {} - : { revisionOfTurnId: session.revisionOfTurnId }), - ...(session.revisionIndex === undefined ? {} : { revisionIndex: session.revisionIndex }), - ...(session.revisionState === undefined ? {} : { revisionState: session.revisionState }), - backend: session.backend, - llmConnectionSlug: session.llmConnectionSlug, - connectionLocked: session.connectionLocked, - model: session.model, - ...(session.thinkingLevel === undefined ? {} : { thinkingLevel: session.thinkingLevel }), - permissionMode: session.permissionMode, - collaborationMode: session.collaborationMode, - orchestrationMode: session.orchestrationMode, }; } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a9ea2df06c..0f3797dbdc 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -119,7 +119,12 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { TurnOrchestration, SessionListFilter, RegenerateTurnInput } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; -import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; +import type { + SessionCatalogSummary, + SessionChangedEvent, + SessionSummary, + TurnRecord, +} from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { E2eFixtureState } from '@maka/core/e2e-fixture'; import type { @@ -226,7 +231,6 @@ const runtimeHostMetadata = new Map< string, { readonly profileId: string; readonly profileName: string; readonly profileKind: 'local' | 'remote' } >(); -const runtimeHostSessionCache = new Map(); const newTaskChangeListeners = new Set<() => void>(); type RuntimeHostProfileWireEvent = DesktopRuntimeHostProfileChangedEvent; @@ -242,7 +246,6 @@ ipcRenderer.on( runtimeHostScopes.delete(previousHostId); if (change.removed) { runtimeHostMetadata.delete(previousHostId); - runtimeHostSessionCache.delete(previousHostId); runtimeHostProfiles.delete(change.profileId); } } @@ -319,10 +322,6 @@ async function runtimeHostScopeList(): Promise { if (authoritativeHostIds.has(hostId)) continue; runtimeHostScopes.delete(hostId); } - for (const hostId of runtimeHostSessionCache.keys()) { - if (authoritativeHostIds.has(hostId)) continue; - runtimeHostSessionCache.delete(hostId); - } return readyScopes; } } @@ -772,29 +771,26 @@ async function listDesktopSessions( 'sessions:list', parent.scope, { ...filter, subagentParentSessionId: parent.sessionId }, - ) as SessionSummary[]; + ) as SessionCatalogSummary[]; return sessions.map((session) => projectSessionSummary(parent.scope, session)); } const scopes = await runtimeHostScopeList(); - const settled = await Promise.allSettled( + const groups = await Promise.all( scopes.map(async (scope) => { - const sessions = await ipcRenderer.invoke('sessions:list', scope, filter) as SessionSummary[]; - const projected = sessions.map((session) => projectSessionSummary(scope, session)); - if (!filter) runtimeHostSessionCache.set(scope.hostId, projected); - return projected; + const sessions = await ipcRenderer.invoke( + 'sessions:list', + scope, + filter, + ) as SessionCatalogSummary[]; + return sessions.map((session) => projectSessionSummary(scope, session)); }), ); - const groups = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []); - if (groups.length === 0) { - throw settled.find((result) => result.status === 'rejected')?.reason ?? - new Error('No Runtime Host is available'); - } - const sessions = filter - ? groups.flat() - : [...runtimeHostSessionCache.values()].flat(); - return sessions.sort( - (left, right) => (right.lastMessageAt ?? 0) - (left.lastMessageAt ?? 0), - ); + return groups.flat().sort((left, right) => { + if (left.activityAt === undefined || right.activityAt === undefined) { + throw new Error('Runtime Host Session Catalog activity is unavailable'); + } + return right.activityAt - left.activityAt || left.id.localeCompare(right.id); + }); } function sendActiveRuntimeHost(channel: string, ...args: unknown[]): void { diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index 654e1628e6..b7b6744d6a 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -25,11 +25,17 @@ import type { StorageRef, ToolResultContent, } from '@maka/core/events'; -import type { SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; +import type { + SessionSummary, + StoredMessage, + TurnRecord, +} from '@maka/core/session'; import type { UsageStats } from '@maka/core/settings'; import { desktopSessionKey, type DesktopHostRef } from './runtime-host-identity.js'; export interface DesktopSessionSummary extends SessionSummary { + /** Present on authoritative Session Catalog snapshots, absent from command responses. */ + readonly activityAt?: number; readonly runtimeHostId: string; readonly profileId: string; readonly profileName: string; From 09f403c99c5809fcda98fc6bc4fd2759fa1ace48 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:32:52 +0800 Subject: [PATCH 04/16] refactor: retire session last-used authority Generated-by: Maka --- .../desktop-transcript-range-store.test.ts | 1 - .../runtime-host-bot-session-adapter.test.ts | 1 - .../__tests__/runtime-host-client.test.ts | 1 - .../runtime-host-desktop-candidate.test.ts | 1 - ...me-host-session-execution-ipc-main.test.ts | 1 - .../runtime-host-session-observer.test.ts | 1 - .../__tests__/session-mode-ipc-main.test.ts | 1 - .../main/__tests__/session-read-state.test.ts | 85 +------------------ .../src/main/e2e-fixture/seed-helpers.ts | 1 - .../src/renderer/app-shell-chat-actions.ts | 3 - .../desktop/src/renderer/app-shell-effects.ts | 2 - apps/desktop/src/renderer/app-shell.tsx | 3 - .../src/renderer/session-read-state.ts | 65 ++------------ .../renderer/use-app-shell-session-list.ts | 22 +---- .../runtime-host-run-command.test.ts | 2 - .../runtime-host-session-driver.test.ts | 2 - packages/core/src/execution-inspect.ts | 4 +- packages/core/src/session.ts | 1 - .../agent-graph-two-client-uds.test.ts | 1 - .../canonical-session-projection.test.ts | 1 - .../src/__tests__/connection-session.test.ts | 1 - .../execution-inspect-protocol.test.ts | 1 - .../external-session-coordinator.test.ts | 3 +- .../src/__tests__/goal-protocol.test.ts | 1 - .../memory-extraction-coordinator.test.ts | 1 - .../src/__tests__/protocol.test.ts | 1 - .../session-catalog-coordinator.test.ts | 3 +- .../session-continuity-coordinator.test.ts | 14 ++- .../src/__tests__/session-projector.test.ts | 1 - .../session-revision-graph-references.test.ts | 1 - .../session-subscription-client.test.ts | 1 - packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/session-continuity.ts | 4 - .../server/canonical-session-projection.ts | 1 - .../src/server/session-catalog-coordinator.ts | 4 - .../src/__tests__/admission-limiter.test.ts | 1 - .../src/__tests__/ai-sdk-backend.test.ts | 1 - .../src/__tests__/ask-user-question.test.ts | 1 - .../src/__tests__/code-mode-backend.test.ts | 1 - .../__tests__/computer-use-model-loop.test.ts | 1 - .../computer-use-privacy-boundary.test.ts | 1 - .../computer-use-provider-protocol.test.ts | 1 - .../src/__tests__/deferred-guard.test.ts | 1 - .../__tests__/deferred-tools-backend.test.ts | 1 - .../__tests__/interaction-authority.test.ts | 1 - .../runtime/src/__tests__/loop-gate.test.ts | 1 - .../mid-turn-capacity-backend.test.ts | 1 - .../overflow-reactive-recovery.test.ts | 1 - .../pre-dispatch-refusal-ledger.test.ts | 1 - .../runtime-event-read-model.test.ts | 1 - .../runtime-kernel-interaction.test.ts | 1 - .../session-manager-terminal-ledger.test.ts | 2 - .../src/__tests__/session-manager.test.ts | 1 - .../src/__tests__/subagent-tools.test.ts | 1 - .../src/__tests__/swarm-orchestration.test.ts | 1 - .../src/__tests__/tool-args-violation.test.ts | 1 - .../src/__tests__/tool-artifacts.test.ts | 1 - ...-result-archive-capability-backend.test.ts | 1 - .../tool-runtime-argument-ownership.test.ts | 1 - .../tool-runtime-durable-boundary.test.ts | 1 - .../__tests__/tool-runtime-progress.test.ts | 1 - .../tool-runtime-sandbox-boundary.test.ts | 1 - .../__tests__/tool-runtime-settlement.test.ts | 1 - .../tool-runtime-sqlite-boundary.test.ts | 1 - .../tool-runtime-turn-close-outcome.test.ts | 1 - packages/runtime/src/agent-run.ts | 1 - packages/runtime/src/execution-inspect.ts | 1 - packages/runtime/src/session-manager.ts | 2 +- .../__tests__/operational-state-store.test.ts | 1 - .../sqlite-session-metadata-store.test.ts | 5 +- packages/storage/src/session-store.ts | 2 - .../src/sqlite-session-metadata-schema.ts | 67 ++++++++++++++- .../src/sqlite-session-metadata-store.ts | 13 +-- 73 files changed, 98 insertions(+), 265 deletions(-) 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 855e72f429..308b9dce42 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 @@ -570,7 +570,6 @@ function continuitySnapshot() { metadataRevision: 1, status: 'running' as const, createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 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 3627ef6737..6482a9a2bf 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 @@ -460,7 +460,6 @@ function continuitySnapshot(rootTurn: TurnSnapshot | null): SessionContinuitySna metadataRevision: 1, status: rootTurn ? 'running' : 'active', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts index 7eec64685a..01fd41646e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -174,7 +174,6 @@ function subscription( metadataRevision: 1, status: 'active', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 1, 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 e65ff31ea7..2dcf4a381f 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 @@ -1045,7 +1045,6 @@ function continuitySnapshot( metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 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 c6b723dffb..7ae4953469 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 @@ -968,7 +968,6 @@ function observerWithTranscript( metadataRevision: 1, status: "running", createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 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 aa94abec77..b022782d46 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 @@ -2468,7 +2468,6 @@ function continuitySnapshot( metadataRevision: 1, status: "running", createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 1, diff --git a/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts b/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts index 73ecf9189c..a628321533 100644 --- a/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts @@ -47,7 +47,6 @@ function projection(sessionId: string) { labels: [], status: 'active' as const, createdAt: 1, - lastUsedAt: 1, backend: 'fake' as const, llmConnectionSlug: 'fake', connectionLocked: false, diff --git a/apps/desktop/src/main/__tests__/session-read-state.test.ts b/apps/desktop/src/main/__tests__/session-read-state.test.ts index 04a4b2a9e6..6d9a39449f 100644 --- a/apps/desktop/src/main/__tests__/session-read-state.test.ts +++ b/apps/desktop/src/main/__tests__/session-read-state.test.ts @@ -19,76 +19,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import { - applyLocalSessionRead, - applySessionReadOverrides, - createSessionListRefresher, - rememberSessionReadBoundary, - type SessionReadBoundaries, -} from '../../renderer/session-read-state.js'; +import type { SessionSummary } from '@maka/core/session'; +import { createSessionListRefresher } from '../../renderer/session-read-state.js'; describe('renderer session read state', () => { - it('keeps a late stale list response from restoring unread on a locally read session', async () => { - const readBoundaries: SessionReadBoundaries = {}; - const staleList = deferred(); - - const listAfterLocalRead = staleList.promise.then((sessions) => applySessionReadOverrides(sessions, readBoundaries)); - rememberSessionReadBoundary(readBoundaries, 's1', [messageAt(200)]); - staleList.resolve([session({ id: 's1', hasUnread: true, lastMessageAt: 200 })]); - - assert.equal((await listAfterLocalRead)[0]?.hasUnread, false); - }); - - it('allows a newer message to restore unread after the local read boundary', () => { - const readBoundaries: SessionReadBoundaries = {}; - rememberSessionReadBoundary(readBoundaries, 's1', [messageAt(200)]); - - const [next] = applySessionReadOverrides([ - session({ id: 's1', hasUnread: true, lastMessageAt: 250 }), - ], readBoundaries); - - assert.equal(next?.hasUnread, true); - }); - - it('keeps the same list reference when no read override applies', () => { - const sessions = [session({ id: 's1', hasUnread: true, lastMessageAt: 250 })]; - - const next = applySessionReadOverrides(sessions, {}); - - assert.equal(next, sessions); - }); - - it('keeps newer unread when an older local read result arrives later', () => { - const readBoundaries: SessionReadBoundaries = {}; - - const [next] = applyLocalSessionRead( - readBoundaries, - [session({ id: 's1', hasUnread: true, lastMessageAt: 250 })], - 's1', - [messageAt(200)], - ); - - assert.equal(next?.lastMessageAt, 250); - assert.equal(next?.hasUnread, true); - }); - - it('clears unread when a local read reaches the current last message', () => { - const readBoundaries: SessionReadBoundaries = {}; - - const [next] = applyLocalSessionRead( - readBoundaries, - [session({ id: 's1', hasUnread: true, lastMessageAt: 200 })], - 's1', - [messageAt(200)], - ); - - assert.equal(next?.lastMessageAt, 200); - assert.equal(next?.hasUnread, false); - }); - it('coalesces concurrent refreshes into one in-flight request and one trailing request', async () => { - const readBoundaries: SessionReadBoundaries = {}; const firstList = deferred(); const trailingList = deferred(); const listResults = [firstList.promise, trailingList.promise]; @@ -101,7 +36,6 @@ describe('renderer session read state', () => { listCalls += 1; return result ?? []; }, - readBoundaries: () => readBoundaries, currentSessions: () => currentSessions, commitSessions: (next) => { currentSessions = next; @@ -109,7 +43,6 @@ describe('renderer session read state', () => { onError: () => {}, }); - rememberSessionReadBoundary(readBoundaries, 's1', [messageAt(200)]); const firstRefresh = refresher.refresh(); const secondRefresh = refresher.refresh(); const thirdRefresh = refresher.refresh(); @@ -128,7 +61,6 @@ describe('renderer session read state', () => { }); it('keeps the current list when the latest list refresh fails', async () => { - const readBoundaries: SessionReadBoundaries = {}; const original = [session({ id: 's1', hasUnread: true, lastMessageAt: 250 })]; const errors: unknown[] = []; let currentSessions = original; @@ -137,7 +69,6 @@ describe('renderer session read state', () => { listSessions: async () => { throw new Error('list failed'); }, - readBoundaries: () => readBoundaries, currentSessions: () => currentSessions, commitSessions: (next) => { currentSessions = next; @@ -161,7 +92,6 @@ describe('renderer session read state', () => { const refresher = createSessionListRefresher({ captureRequestContext: () => requestContext, listSessions: () => listed.promise, - readBoundaries: () => ({}), currentSessions: () => [], commitSessions: (_sessions, context) => { committedContext = context; @@ -208,14 +138,3 @@ function session(overrides: Partial & { id: string }): SessionSu permissionMode: overrides.permissionMode ?? 'ask', }; } - -function messageAt(ts: number): StoredMessage { - return { - type: 'assistant', - id: `m-${ts}`, - turnId: `t-${ts}`, - ts, - text: 'ok', - modelId: 'test-model', - }; -} diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index bf4935cc76..c2d27c4a12 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -56,7 +56,6 @@ export function header(input: { workspaceRoot: 'e2e-fixture', cwd: '/workspace/maka', createdAt: input.now - 3_600_000, - lastUsedAt: input.lastMessageAt, lastMessageAt: input.lastMessageAt, name: input.name, titleIsManual: true, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 8d11e3ba29..583143e93e 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -138,7 +138,6 @@ export function createAppShellChatActions(deps: { * looking at". Both halves matter — the section AND the session id — which * is why the send path asks it instead of comparing the id itself. */ isShellSurfaceOwnerActive: (owner: ComposerImportOwner) => boolean; - markSessionReadLocally: (sessionId: string, readMessages: readonly StoredMessage[]) => void; messageRetryPendingRef: RefBox>; refreshSessions: () => Promise; setActiveId: (sessionId: string | undefined) => void; @@ -188,7 +187,6 @@ export function createAppShellChatActions(deps: { clearPendingSessionAction, isNewChatSendSurfaceActive, isShellSurfaceOwnerActive, - markSessionReadLocally, messageRetryPendingRef, refreshSessions, setActiveId, @@ -688,7 +686,6 @@ export function createAppShellChatActions(deps: { const snapshot = range.snapshot(); if (snapshot.sessionId !== sessionId) return false; const next = [...snapshot.messages]; - markSessionReadLocally(sessionId, next); setMessages(next); setMessageLoadErrorBySession((current) => { if (!current[sessionId]) return current; diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index a63f6a7138..ed37f4cafb 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -411,7 +411,6 @@ export function useActiveSessionEvents(options: { activeId: string | undefined; activeIdRef: RefBox; handleEvent: (sessionId: string, event: SessionEvent) => void; - markSessionReadLocally: (sessionId: string, readMessages: readonly StoredMessage[]) => void; beginObservationSeed?: (sessionId: string) => number; completeObservationSeed?: (sessionId: string, generation?: number) => void; setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; @@ -430,7 +429,6 @@ export function useActiveSessionEvents(options: { if (!isDisposed() && options.activeIdRef.current === sessionId) { const snapshot = store.snapshot(); const next = [...snapshot.messages]; - options.markSessionReadLocally(sessionId, next); options.setMessages(next); if (snapshot.ready) options.setMessageLoadPending(false); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 9fe3e73f5f..1033527a04 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -359,7 +359,6 @@ function AppShellContent({ refreshSessions, seedSessions, upsertSessionSummary, - markSessionReadLocally, activeId, activeIdRef, bootstrapSelectionLease, @@ -1887,7 +1886,6 @@ function AppShellContent({ clearPendingSessionAction, isNewChatSendSurfaceActive, isShellSurfaceOwnerActive, - markSessionReadLocally, messageRetryPendingRef, refreshSessions, setActiveId, @@ -2424,7 +2422,6 @@ function AppShellContent({ activeId, activeIdRef, handleEvent, - markSessionReadLocally, beginObservationSeed, completeObservationSeed, setMessageLoadErrorBySession, diff --git a/apps/desktop/src/renderer/session-read-state.ts b/apps/desktop/src/renderer/session-read-state.ts index 9e406e057b..d043a16bf0 100644 --- a/apps/desktop/src/renderer/session-read-state.ts +++ b/apps/desktop/src/renderer/session-read-state.ts @@ -9,67 +9,27 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ -import type { SessionSummary, StoredMessage } from '@maka/core/session'; - -export type SessionReadBoundaries = Record; +import type { SessionSummary } from '@maka/core/session'; export interface SessionListRefresher { refresh(): Promise; } export interface SessionListRefresherOptions { - /** Capture renderer-owned state before the authority read it must be compared with. */ captureRequestContext: () => TRequestContext; listSessions: () => Promise; - readBoundaries: () => Readonly; currentSessions: () => T[]; commitSessions: (sessions: T[], requestContext: TRequestContext) => void; onError: (error: unknown) => void; } -export function rememberSessionReadBoundary( - boundaries: SessionReadBoundaries, - sessionId: string, - messages: readonly StoredMessage[], -): void { - const boundary = latestMessageTs(messages); - if (boundary === undefined) return; - boundaries[sessionId] = Math.max(boundaries[sessionId] ?? 0, boundary); -} - -export function applySessionReadOverrides( - sessions: T[], - boundaries: Readonly, -): T[] { - let changed = false; - const next = sessions.map((session) => { - const boundary = boundaries[session.id]; - if (boundary === undefined || !session.hasUnread) return session; - if ((session.lastMessageAt ?? 0) > boundary) return session; - changed = true; - return { ...session, hasUnread: false }; - }); - return changed ? next : sessions; -} - -export function applyLocalSessionRead( - boundaries: SessionReadBoundaries, - sessions: T[], - sessionId: string, - readMessages: readonly StoredMessage[], -): T[] { - rememberSessionReadBoundary(boundaries, sessionId, readMessages); - return applySessionReadOverrides(sessions, boundaries); -} - export function createSessionListRefresher( options: SessionListRefresherOptions, ): SessionListRefresher { @@ -80,14 +40,12 @@ export function createSessionListRefresher => { let result = options.currentSessions(); while (completedGeneration < requestedGeneration) { - // Session events can arrive in bursts (especially while spawning a swarm). Keep one - // list IPC in flight and collapse everything that arrived during it into one trailing read. const generation = requestedGeneration; const requestContext = options.captureRequestContext(); try { const listed = await options.listSessions(); if (generation === requestedGeneration) { - result = applySessionReadOverrides(listed, options.readBoundaries()); + result = listed; options.commitSessions(result, requestContext); } else { result = options.currentSessions(); @@ -113,12 +71,3 @@ export function createSessionListRefresher | null>(null); const sessionsRef = useRef([]); - const sessionReadBoundariesRef = useRef({}); const refresherRef = useRef | null>(null); function commitSessions(next: DesktopSessionSummary[]): void { @@ -72,7 +66,7 @@ export function useAppShellSessionList( updater: (current: DesktopSessionSummary[]) => DesktopSessionSummary[], ): void { setSessionsState((current) => { - const next = mergeSessionSummaryListForDisplay(current, updater(current)); + const next = updater(current); sessionsRef.current = next; return next; }); @@ -82,7 +76,6 @@ export function useAppShellSessionList( refresherRef.current = createSessionListRefresher({ captureRequestContext: () => options.liveTurnBySessionRef.current, listSessions: () => window.maka.sessions.list(), - readBoundaries: () => sessionReadBoundariesRef.current, currentSessions: () => sessionsRef.current, commitSessions: (next, observedLiveTurnBySession) => { const normalized = next.map(normalizeSessionSummaryForDisplay); @@ -113,8 +106,7 @@ export function useAppShellSessionList( function seedSessions( snapshotSessions: readonly DesktopSessionSummary[], ): DesktopSessionSummary[] { - const next = applySessionReadOverrides([...snapshotSessions], sessionReadBoundariesRef.current) - .map(normalizeSessionSummaryForDisplay); + const next = snapshotSessions.map(normalizeSessionSummaryForDisplay); commitSessions(next); return next; } @@ -126,15 +118,6 @@ export function useAppShellSessionList( ]); } - function markSessionReadLocally(sessionId: string, readMessages: readonly StoredMessage[]): void { - setSessions((current) => applyLocalSessionRead( - sessionReadBoundariesRef.current, - current, - sessionId, - readMessages, - )); - } - return { sessions, authoritativeSessionIds, @@ -143,6 +126,5 @@ export function useAppShellSessionList( refreshSessions, seedSessions, upsertSessionSummary, - markSessionReadLocally, }; } diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 9471ba4a8f..8b913e757d 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -150,7 +150,6 @@ describe('Runtime Host maka run adapter', () => { target: { kind: 'host_path', path: cwd }, hostCwd: cwd, }, - lastUsedAt: 10, lastMessageAt: 10, }, ], @@ -1542,7 +1541,6 @@ function continuitySnapshot( metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 1, 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 193fca0057..ffc543628a 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -390,7 +390,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, rootTurn: null, @@ -1840,7 +1839,6 @@ function continuitySnapshot( metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 1, diff --git a/packages/core/src/execution-inspect.ts b/packages/core/src/execution-inspect.ts index 1c822ed671..1a5303454f 100644 --- a/packages/core/src/execution-inspect.ts +++ b/packages/core/src/execution-inspect.ts @@ -103,7 +103,6 @@ export interface SessionInspectSummary { name: string; status: SessionHeader['status']; createdAt: number; - lastUsedAt: number; lastMessageAt?: number; isArchived: boolean; parentSessionId?: string; @@ -326,7 +325,7 @@ function isSessionSummary(value: unknown): value is SessionInspectSummary { return ( hasShape( value, - ['sessionId', 'name', 'status', 'createdAt', 'lastUsedAt', 'isArchived'], + ['sessionId', 'name', 'status', 'createdAt', 'isArchived'], [ 'lastMessageAt', 'parentSessionId', @@ -342,7 +341,6 @@ function isSessionSummary(value: unknown): value is SessionInspectSummary { typeof value.name === 'string' && SESSION_STATUSES.includes(value.status as (typeof SESSION_STATUSES)[number]) && isCount(value.createdAt) && - isCount(value.lastUsedAt) && isOptionalCount(value.lastMessageAt) && typeof value.isArchived === 'boolean' && [ diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 304f6c3767..ec09d97315 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -212,7 +212,6 @@ export interface SessionHeader { // Lifecycle timestamps createdAt: number; - lastUsedAt: number; lastMessageAt?: number; // User metadata diff --git a/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts index 2467fdba47..e05eb35488 100644 --- a/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts @@ -359,7 +359,6 @@ function canonical(hostEpoch: string): CanonicalSessionProjection { metadataRevision: 1, status: 'active', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, rootTurn: null, diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index d68c5e8111..a0fcd301a4 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -68,7 +68,6 @@ test('projects the canonical root lifecycle and the attachment queue from real S metadataRevision: 1, status: session.status, createdAt: session.createdAt, - lastUsedAt: session.lastUsedAt, isArchived: false, }, rootTurn: null, diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index d0e6c7303a..a80204812e 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -1420,7 +1420,6 @@ function canonicalProjection(sessionId: string): CanonicalSessionProjection { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, rootTurn: { diff --git a/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts index 382063de65..c9504e9acc 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts @@ -362,7 +362,6 @@ function sessionDocument(sessionId: string): SessionInspectDocument { name: 'Session', status: 'active', createdAt: 1, - lastUsedAt: 2, isArchived: false, }, agentRuns: [], diff --git a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts index 967b9e5569..6a7826628e 100644 --- a/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/external-session-coordinator.test.ts @@ -500,7 +500,7 @@ function coordinatorFixture( header, revision: 1, committedAt: 1, - activityAt: header.lastMessageAt ?? header.lastUsedAt ?? header.createdAt, + activityAt: header.lastMessageAt ?? header.createdAt, summary: headerToSummary(header), }); return header; @@ -641,7 +641,6 @@ function sessionHeader(id: string, cwd: string, name: string): SessionHeader { workspaceRoot: '/workspace', cwd, createdAt: 1, - lastUsedAt: 1, name, titleIsManual: false, isFlagged: false, diff --git a/packages/runtime-host/src/__tests__/goal-protocol.test.ts b/packages/runtime-host/src/__tests__/goal-protocol.test.ts index 5ee22aa0f4..f922dad0ce 100644 --- a/packages/runtime-host/src/__tests__/goal-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/goal-protocol.test.ts @@ -117,7 +117,6 @@ test('Goal projection is part of the exact Session continuity schema', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 2, isArchived: false, }, projectionRevision: 1, diff --git a/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts b/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts index c5edc83c3d..3a80bdfefd 100644 --- a/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/memory-extraction-coordinator.test.ts @@ -2507,7 +2507,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace/maka', cwd: '/workspace/maka', createdAt: 1, - lastUsedAt: 1, name: 'Memory test', titleIsManual: false, isFlagged: false, diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 4064810411..b4907025ec 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1724,7 +1724,6 @@ function continuitySnapshot(hostEpoch: string) { metadataRevision: 1, status: 'running' as const, createdAt: 1, - lastUsedAt: 2, isArchived: false, }, projectionRevision: 1, diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index ec06be8b72..058cf6e1ad 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -1317,7 +1317,6 @@ function sessionHeader(sessionId: string, labels: readonly string[]): SessionHea workspaceRoot: '/workspace', cwd: '/workspace', createdAt: 1, - lastUsedAt: 1, name: 'Session', titleIsManual: false, isFlagged: false, @@ -1344,7 +1343,7 @@ function headerSnapshot(header: SessionHeader, revision: number) { function catalogRecord(header: SessionHeader, revision: number): SessionCatalogRecord { return { ...headerSnapshot(header, revision), - activityAt: header.lastMessageAt ?? header.lastUsedAt ?? header.createdAt, + activityAt: header.lastMessageAt ?? header.createdAt, summary: headerToSummary(header), }; } diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index f996dfe35d..928eabd3cb 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -404,7 +404,7 @@ test('detached canonical refreshes coalesce before Store I/O', async () => { const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); - projection = canonical({ lastUsedAt: 2 }); + projection = canonical({ metadataRevision: 2 }); coordinator.enqueueCanonicalRefresh(SESSION_ID); coordinator.enqueueCanonicalRefresh(SESSION_ID); await refreshEntered.promise; @@ -433,18 +433,17 @@ test('in-flight canonical refresh observes an invalidation after its first read' const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); - const stale = canonical({ lastUsedAt: 2 }); coordinator.enqueueCanonicalRefresh(SESSION_ID); await waitFor(() => reads === 2); - firstRefreshRead.resolve(stale); - projection = canonical({ lastUsedAt: 3 }); + firstRefreshRead.resolve(canonical({ metadataRevision: 2 })); + projection = canonical({ metadataRevision: 3 }); coordinator.enqueueCanonicalRefresh(SESSION_ID); await waitFor(() => reads === 3 && sink.frames.length === 2); assert.deepEqual( sink.frames.map((frame) => frame.kind === 'subscription.session_projection' - ? frame.snapshot.session.lastUsedAt + ? frame.snapshot.session.metadataRevision : undefined, ), [2, 3], @@ -2013,7 +2012,7 @@ function connectionContext(connectionId: string): ConnectionContext { function canonical( overrides: { - lastUsedAt?: number; + metadataRevision?: number; rootTurn?: CanonicalSessionProjection['rootTurn']; interactions?: CanonicalSessionProjection['interactions']; queue?: CanonicalSessionProjection['queue']; @@ -2022,10 +2021,9 @@ function canonical( return { session: { sessionId: SESSION_ID, - metadataRevision: 1, + metadataRevision: overrides.metadataRevision ?? 1, status: 'active', createdAt: 1, - lastUsedAt: overrides.lastUsedAt ?? 1, isArchived: false, }, rootTurn: diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 4ffad9e9cd..0ec5d253f8 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -294,7 +294,6 @@ function snapshot(overrides: Partial = {}): SessionCo metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 1, diff --git a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts index ed1ac3b05c..fa4aab83e2 100644 --- a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts @@ -365,7 +365,6 @@ function sessionHeader(id: string): SessionHeader { workspaceRoot: '/workspace', cwd: '/workspace', createdAt: 1, - lastUsedAt: 1, name: id, titleIsManual: false, isFlagged: false, diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index 1734826ef9..314b876c55 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -1237,7 +1237,6 @@ function openResult( metadataRevision: 1, status: 'running' as const, createdAt: 1, - lastUsedAt: 2, isArchived: false, }, projectionRevision: 1, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d1418d69c2..a4435d55a7 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ 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 = 42 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 43 as const; +// 43: Session continuity and inspection stop carrying the retired Session +// last-used timestamp. Older peers reject those strict projection shapes. // 42: Turn provider retry progress adds `provider_capacity`. Older peers reject // that strict retry-reason enum value, so mixed versions must fail handshake. // 41: Context compaction returns a typed terminal outcome on both Turn diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 0060c0bdfd..7bf46f219c 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -76,7 +76,6 @@ export interface SessionContinuityIdentity { metadataRevision: number; status: SessionLifecycleStatus; createdAt: number; - lastUsedAt: number; isArchived: boolean; } @@ -903,7 +902,6 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent 'metadataRevision', 'status', 'createdAt', - 'lastUsedAt', 'isArchived', ]); assertRequiredKeys(record, 'Session continuity identity', [ @@ -911,7 +909,6 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent 'metadataRevision', 'status', 'createdAt', - 'lastUsedAt', 'isArchived', ]); if (typeof record.isArchived !== 'boolean') { @@ -922,7 +919,6 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent metadataRevision: requirePositiveCount(record.metadataRevision, 'metadataRevision'), status: decodeSessionStatus(record.status), createdAt: requireCount(record.createdAt, 'createdAt'), - lastUsedAt: requireCount(record.lastUsedAt, 'lastUsedAt'), isArchived: record.isArchived, }; } diff --git a/packages/runtime-host/src/server/canonical-session-projection.ts b/packages/runtime-host/src/server/canonical-session-projection.ts index 72761f60aa..2594acca4c 100644 --- a/packages/runtime-host/src/server/canonical-session-projection.ts +++ b/packages/runtime-host/src/server/canonical-session-projection.ts @@ -122,7 +122,6 @@ export class CanonicalSessionProjectionReader { metadataRevision, status: header.status, createdAt: header.createdAt, - lastUsedAt: header.lastUsedAt, isArchived: header.isArchived, }; return { session, rootTurn, goal, queue, interactions }; diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 43d9ab98cc..722c48a85d 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -1067,10 +1067,6 @@ function decodeCursor(cursor: string): DecodedSessionCatalogCursor | undefined { } } -function catalogActivityAt(header: SessionHeader): number { - return header.lastMessageAt ?? header.lastUsedAt ?? header.createdAt; -} - function normalizedSessionName(name: string): string { const normalized = normalizeUserSessionName(name); if (!normalized.ok) throw new SessionOperationFailure('invalid_request', normalized.error); diff --git a/packages/runtime/src/__tests__/admission-limiter.test.ts b/packages/runtime/src/__tests__/admission-limiter.test.ts index f33c68eeb8..7f45e0badf 100644 --- a/packages/runtime/src/__tests__/admission-limiter.test.ts +++ b/packages/runtime/src/__tests__/admission-limiter.test.ts @@ -359,7 +359,6 @@ function testHeader(): SessionHeader { workspaceRoot: '/tmp', cwd: '/tmp', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 98844d2eef..58927845ba 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -13910,7 +13910,6 @@ function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): Sessio workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/ask-user-question.test.ts b/packages/runtime/src/__tests__/ask-user-question.test.ts index c678058056..9eee1664cb 100644 --- a/packages/runtime/src/__tests__/ask-user-question.test.ts +++ b/packages/runtime/src/__tests__/ask-user-question.test.ts @@ -32,7 +32,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/code-mode-backend.test.ts b/packages/runtime/src/__tests__/code-mode-backend.test.ts index 36167b2119..61b37f8c83 100644 --- a/packages/runtime/src/__tests__/code-mode-backend.test.ts +++ b/packages/runtime/src/__tests__/code-mode-backend.test.ts @@ -1107,7 +1107,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/computer-use-model-loop.test.ts b/packages/runtime/src/__tests__/computer-use-model-loop.test.ts index abf54361d9..2e071cad89 100644 --- a/packages/runtime/src/__tests__/computer-use-model-loop.test.ts +++ b/packages/runtime/src/__tests__/computer-use-model-loop.test.ts @@ -452,7 +452,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Computer model loop', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts b/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts index b96eb8c1cc..79f82bc247 100644 --- a/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts +++ b/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts @@ -501,7 +501,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace', cwd: '/workspace', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index d8594ada4c..cbac5ac106 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -1186,7 +1186,6 @@ function header(providerType: LlmConnection['providerType'], model: string): Ses workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: providerType, titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/deferred-guard.test.ts b/packages/runtime/src/__tests__/deferred-guard.test.ts index 33d8d083e7..8669f2a930 100644 --- a/packages/runtime/src/__tests__/deferred-guard.test.ts +++ b/packages/runtime/src/__tests__/deferred-guard.test.ts @@ -38,7 +38,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts index d2bfdf30da..048b5cf5d2 100644 --- a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts +++ b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts @@ -898,7 +898,6 @@ function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): Sessio workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/interaction-authority.test.ts b/packages/runtime/src/__tests__/interaction-authority.test.ts index c3c82eda9b..b14cd7dd59 100644 --- a/packages/runtime/src/__tests__/interaction-authority.test.ts +++ b/packages/runtime/src/__tests__/interaction-authority.test.ts @@ -596,7 +596,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/loop-gate.test.ts b/packages/runtime/src/__tests__/loop-gate.test.ts index 5d947976a5..a8eba05045 100644 --- a/packages/runtime/src/__tests__/loop-gate.test.ts +++ b/packages/runtime/src/__tests__/loop-gate.test.ts @@ -58,7 +58,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index 7f92d91814..3a7d02888a 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -1794,7 +1794,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 6b4e9e401f..74409aa1ea 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -1817,7 +1817,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index dc07667ee6..770425676e 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -457,7 +457,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace', cwd: '/workspace', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, 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 ca7f35a795..c1fc1829a4 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1970,7 +1970,6 @@ function makeHeader(id: string): SessionHeader { workspaceRoot: '/tmp/work', cwd: '/tmp/work', createdAt: ts, - lastUsedAt: ts, name: 'Session', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index 04027bb9d9..4eab4a25e4 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -620,7 +620,6 @@ function memoryStore(): SessionStore { workspaceRoot: '/tmp/maka-runtime-kernel-interaction', cwd: '/tmp/maka-runtime-kernel-interaction', createdAt: 1, - lastUsedAt: 1, name: 'Interaction cleanup', titleIsManual: true, isFlagged: false, 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 ccf6fdc4b3..5e37609b57 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -1083,7 +1083,6 @@ describe('SessionManager terminal ledger invariants', () => { workspaceRoot: '/tmp/workspace', cwd: '/tmp/cwd', createdAt: 1, - lastUsedAt: 1, name: 'Session', titleIsManual: true, isFlagged: false, @@ -2289,7 +2288,6 @@ class TinySessionStore implements SessionStore { workspaceRoot: '/tmp/workspace', cwd: input.cwd, createdAt: 1, - lastUsedAt: 1, name: input.name ?? 'Session', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index ab2a4dc8d1..f6c138c6c3 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -17452,7 +17452,6 @@ class MemorySessionStore implements SessionStore { cwd: input.cwd, ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), createdAt: 1, - lastUsedAt: 1, name: input.name ?? 'New Chat', titleIsManual: false, isFlagged: false, diff --git a/packages/runtime/src/__tests__/subagent-tools.test.ts b/packages/runtime/src/__tests__/subagent-tools.test.ts index 8e6f0449a1..13543be2b1 100644 --- a/packages/runtime/src/__tests__/subagent-tools.test.ts +++ b/packages/runtime/src/__tests__/subagent-tools.test.ts @@ -1255,7 +1255,6 @@ function childHeader(cwd: string): SessionHeader { workspaceRoot: cwd, cwd, createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/swarm-orchestration.test.ts b/packages/runtime/src/__tests__/swarm-orchestration.test.ts index d4b9de3876..a50aa0c6d7 100644 --- a/packages/runtime/src/__tests__/swarm-orchestration.test.ts +++ b/packages/runtime/src/__tests__/swarm-orchestration.test.ts @@ -33,7 +33,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-args-violation.test.ts b/packages/runtime/src/__tests__/tool-args-violation.test.ts index 18d7675be8..d5b6942c0a 100644 --- a/packages/runtime/src/__tests__/tool-args-violation.test.ts +++ b/packages/runtime/src/__tests__/tool-args-violation.test.ts @@ -377,7 +377,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace', cwd: '/workspace', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-artifacts.test.ts b/packages/runtime/src/__tests__/tool-artifacts.test.ts index 24a07fc292..62038fef3d 100644 --- a/packages/runtime/src/__tests__/tool-artifacts.test.ts +++ b/packages/runtime/src/__tests__/tool-artifacts.test.ts @@ -198,7 +198,6 @@ function testHeader(): SessionHeader { workspaceRoot: '/workspace/maka', cwd: '/workspace/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts b/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts index bc1f8d539b..659c854215 100644 --- a/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts +++ b/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts @@ -280,7 +280,6 @@ function header(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts b/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts index 4faba0d105..1b7b2b0aee 100644 --- a/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts @@ -118,7 +118,6 @@ function testHeader(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 03f4d92948..c6c216dd81 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -617,7 +617,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace/repo', cwd: '/workspace/repo', createdAt: 1, - lastUsedAt: 1, name: 'test', titleIsManual: false, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-progress.test.ts b/packages/runtime/src/__tests__/tool-runtime-progress.test.ts index c9071c8c39..71b0ce7148 100644 --- a/packages/runtime/src/__tests__/tool-runtime-progress.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-progress.test.ts @@ -79,7 +79,6 @@ function testHeader(): SessionHeader { workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, - lastUsedAt: 1, name: 'Test', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index 75a2d8f921..b2c36647b3 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -960,7 +960,6 @@ function header(cwd = process.cwd()): SessionHeader { workspaceRoot: cwd, cwd, createdAt: 1, - lastUsedAt: 1, name: 'test', titleIsManual: false, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 133c531620..2a84d0956d 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -561,7 +561,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace/repo', cwd: '/workspace/repo', createdAt: 1, - lastUsedAt: 1, name: 'test', titleIsManual: false, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index 107c08e5f0..ecfd601d01 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -566,7 +566,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace/repo', cwd: '/workspace/repo', createdAt: 1, - lastUsedAt: 1, name: 'test', titleIsManual: false, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-turn-close-outcome.test.ts b/packages/runtime/src/__tests__/tool-runtime-turn-close-outcome.test.ts index 9b569a9676..1db39eb6e4 100644 --- a/packages/runtime/src/__tests__/tool-runtime-turn-close-outcome.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-turn-close-outcome.test.ts @@ -141,7 +141,6 @@ function header(): SessionHeader { workspaceRoot: '/workspace/repo', cwd: '/workspace/repo', createdAt: 1, - lastUsedAt: 1, name: 'test', titleIsManual: false, isFlagged: false, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index f2332b1cad..0a43b14069 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -1057,7 +1057,6 @@ export class AgentRun { : (this.finalStatus ?? { status: 'active' as const }); try { await this.input.hooks.updateHeader(this.sessionId, { - lastUsedAt: lastTs, lastMessageAt: lastTs, hasUnread: true, ...buildStatusPatch(nextStatus.status, lastTs, nextStatus.blockedReason), diff --git a/packages/runtime/src/execution-inspect.ts b/packages/runtime/src/execution-inspect.ts index a2a9ad6460..63b794928c 100644 --- a/packages/runtime/src/execution-inspect.ts +++ b/packages/runtime/src/execution-inspect.ts @@ -126,7 +126,6 @@ export async function inspectSessionDocument( name: resolvedHeader.name, status: resolvedHeader.status, createdAt: resolvedHeader.createdAt, - lastUsedAt: resolvedHeader.lastUsedAt, ...(resolvedHeader.lastMessageAt !== undefined ? { lastMessageAt: resolvedHeader.lastMessageAt } : {}), diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 582bafe686..0707b96998 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4374,7 +4374,7 @@ export class SessionManager { status: run?.status ?? (child.status === 'aborted' ? 'cancelled' : 'created'), permissionMode: run?.permissionMode ?? child.permissionMode, createdAt: run?.createdAt ?? child.createdAt, - updatedAt: run?.updatedAt ?? child.lastUsedAt, + updatedAt: run?.updatedAt ?? child.lastMessageAt ?? child.createdAt, ...(run?.completedAt !== undefined ? { completedAt: run.completedAt } : {}), ...(run?.completedAt !== undefined ? { durationMs: Math.max(0, run.completedAt - run.createdAt) } diff --git a/packages/storage/src/__tests__/operational-state-store.test.ts b/packages/storage/src/__tests__/operational-state-store.test.ts index c906847c13..a9243a1140 100644 --- a/packages/storage/src/__tests__/operational-state-store.test.ts +++ b/packages/storage/src/__tests__/operational-state-store.test.ts @@ -1467,7 +1467,6 @@ function sessionHeader(): SessionHeader { workspaceRoot: '/workspace', cwd: '/workspace', createdAt: 1, - lastUsedAt: 2, name: 'Session', titleIsManual: true, isFlagged: false, 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 2edea01ace..57690b3458 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -84,7 +84,7 @@ describe('SqliteSessionMetadataStore', () => { const migrated = createSqliteSessionMetadataStore(path); try { - assert.equal(migrated.schemaVersion(), 28); + assert.equal(migrated.schemaVersion(), 29); assert.equal((await migrated.read(legacyHeader.id)).header.externalOrigin, undefined); } finally { migrated.close(); @@ -1800,7 +1800,6 @@ describe('SqliteSessionMetadataStore', () => { fullHeader({ id: 'older', name: 'Older', - lastUsedAt: 10, lastMessageAt: 20, labels: ['alpha', 'shared'], isFlagged: true, @@ -1810,7 +1809,6 @@ describe('SqliteSessionMetadataStore', () => { fullHeader({ id: 'newer', name: 'Newer', - lastUsedAt: 30, lastMessageAt: 40, labels: ['shared'], isFlagged: true, @@ -2983,7 +2981,6 @@ function fullHeader(overrides: Partial = {}): SessionHeader { workspaceRoot: '/workspace', cwd: '/workspace/repo', createdAt: 1, - lastUsedAt: 2, lastMessageAt: 3, name: 'Session', titleIsManual: true, diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 20e8285558..9a12a04503 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -1034,7 +1034,6 @@ function buildSessionHeader( cwd: input.cwd, ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), createdAt: now, - lastUsedAt: now, name, titleIsManual: false, isFlagged: false, @@ -1096,7 +1095,6 @@ export function normalizeSessionHeader( header.projectId === null || (typeof header.projectId === 'string' && header.projectId.length > 0)) && isFiniteNumber(header.createdAt) && - isFiniteNumber(header.lastUsedAt) && (header.lastMessageAt === undefined || isFiniteNumber(header.lastMessageAt)) && typeof header.name === 'string' && typeof header.titleIsManual === 'boolean' && diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 3ffd2a37cd..e1c6f105dd 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 28; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 29; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1055,6 +1055,71 @@ const MIGRATIONS: ReadonlyMap = new Map([ AND external_source_session_id IS NOT NULL; `, ], + [ + 29, + ` + DROP TRIGGER session_catalog_after_insert; + DROP TRIGGER session_catalog_after_update; + DROP INDEX IF EXISTS session_metadata_by_recency; + + UPDATE session_metadata + SET + payload_json = json_remove(payload_json, '$.lastUsedAt'), + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(unixepoch('now', 'subsec') * 1000 AS INTEGER) + ) + WHERE json_type(payload_json, '$.lastUsedAt') IS NOT NULL; + + CREATE TRIGGER session_catalog_after_insert + AFTER INSERT ON session_metadata + BEGIN + INSERT INTO session_catalog_projection( + session_id, + activity_at, + last_message_at, + last_message_preview, + is_archived, + is_flagged, + subagent_parent_session_id + ) VALUES ( + NEW.session_id, + COALESCE(NEW.last_message_at, NEW.created_at), + NEW.last_message_at, + NULL, + NEW.is_archived, + NEW.is_flagged, + NEW.subagent_parent_session_id + ); + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + CREATE TRIGGER session_catalog_after_update + AFTER UPDATE ON session_metadata + BEGIN + UPDATE session_catalog_projection + SET + activity_at = CASE + WHEN NEW.last_message_at IS NOT OLD.last_message_at + THEN COALESCE(NEW.last_message_at, OLD.created_at) + ELSE activity_at + END, + last_message_at = NEW.last_message_at, + is_archived = NEW.is_archived, + is_flagged = NEW.is_flagged, + subagent_parent_session_id = NEW.subagent_parent_session_id + WHERE session_id = NEW.session_id; + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + `, + ], ]); 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 bbf4d5c4cb..4b0d4da308 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -1265,9 +1265,11 @@ export class SqliteSessionMetadataStore { SELECT session_id, payload_json, metadata_version, committed_at FROM session_metadata metadata ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} - ORDER BY - COALESCE(last_message_at, last_used_at, created_at) DESC, - session_id ASC + ORDER BY ( + SELECT activity_at + FROM session_catalog_projection projection + WHERE projection.session_id = metadata.session_id + ) DESC, session_id ASC `, ) .all(...parameters) as unknown as SessionMetadataRow[]; @@ -3677,7 +3679,7 @@ export class SqliteSessionMetadataStore { header.id, JSON.stringify(header), header.createdAt, - header.lastUsedAt, + header.lastMessageAt ?? header.createdAt, header.lastMessageAt ?? null, header.name, booleanInteger(header.isFlagged), @@ -3941,7 +3943,7 @@ export class SqliteSessionMetadataStore { SET payload_json = ?, created_at = ?, - last_used_at = ?, + last_used_at = last_used_at, last_message_at = ?, name = ?, is_flagged = ?, @@ -3962,7 +3964,6 @@ export class SqliteSessionMetadataStore { .run( JSON.stringify(next), next.createdAt, - next.lastUsedAt, next.lastMessageAt ?? null, next.name, booleanInteger(next.isFlagged), From 0b3d3c14a9a7fcce7f474e0d25d367243fc4aa9a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:34:22 +0800 Subject: [PATCH 05/16] refactor: remove session settings list patches Generated-by: Maka --- .../app-shell-session-settings-actions.test.ts | 3 --- .../renderer/app-shell-session-settings-actions.ts | 11 +---------- apps/desktop/src/renderer/app-shell.tsx | 1 - 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 63ff7460d0..3b980403cf 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -118,9 +118,6 @@ function createHarness(options: { for (const key of Object.keys(pendingBySession)) delete pendingBySession[key]; Object.assign(pendingBySession, next); }, - setSessions: (update) => { - sessionsRef.current = update(sessionsRef.current); - }, toastApi: { success: (title, description) => successes.push({ title, description }), error: (title, _description, _details, target) => { diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 0c4f37d782..0877c689dc 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -71,9 +71,6 @@ export function createAppShellSessionSettingsActions(deps: { setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise; setPendingPermissionModeBySession: BooleanRecordUpdater; setPendingSessionModelBySession: BooleanRecordUpdater; - setSessions: ( - updater: (current: DesktopSessionSummary[]) => DesktopSessionSummary[], - ) => void; toastApi: ToastApi; }): AppShellSessionSettingsActions { const { @@ -89,7 +86,6 @@ export function createAppShellSessionSettingsActions(deps: { setNewTaskPermissionMode, setPendingPermissionModeBySession, setPendingSessionModelBySession, - setSessions, toastApi, } = deps; const copy = getShellCopy(uiLocale).sessionSettingsActions; @@ -147,9 +143,6 @@ export function createAppShellSessionSettingsActions(deps: { if (sessionId) { const next = await window.maka.sessions.setPermissionMode(sessionId, mode); nextMode = next.permissionMode === 'bypass' ? 'bypass' : 'ask'; - setSessions((prev) => - prev.map((session) => (session.id === sessionId ? next : session)), - ); } else { await setNewTaskPermissionMode(mode); } @@ -186,7 +179,6 @@ export function createAppShellSessionSettingsActions(deps: { })); try { const next = await window.maka.sessions.setModel(sessionId, input); - setSessions((prev) => prev.map((session) => (session.id === next.id ? next : session))); if (activeIdRef.current === sessionId) { const connectionChanged = previous?.llmConnectionSlug !== next.llmConnectionSlug; const to = modelEndpointLabel(next.llmConnectionSlug, next.model, connectionChanged); @@ -234,8 +226,7 @@ export function createAppShellSessionSettingsActions(deps: { [sessionId]: true, })); try { - const next = await window.maka.sessions.setThinkingLevel(sessionId, level); - setSessions((prev) => prev.map((session) => (session.id === next.id ? next : session))); + await window.maka.sessions.setThinkingLevel(sessionId, level); if (activeIdRef.current === sessionId) { toastApi.success(copy.thinkingUpdatedTitle, level ? copy.thinkingLabels[level] : copy.thinkingDefault); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1033527a04..8fe453b258 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -989,7 +989,6 @@ function AppShellContent({ setNewTaskPermissionMode, setPendingPermissionModeBySession, setPendingSessionModelBySession, - setSessions, toastApi, }); From e2967aa06ea7a33ad810a4d4ea611fe110e79d65 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:36:23 +0800 Subject: [PATCH 06/16] refactor: remove desktop catalog upserts Generated-by: Maka --- .../app-shell-busy-race-settlement.test.ts | 1 - .../__tests__/app-shell-first-send-cleanup.test.ts | 1 - .../main/__tests__/app-shell-turn-actions.test.ts | 1 - apps/desktop/src/renderer/app-shell-chat-actions.ts | 3 --- .../src/renderer/app-shell-revision-actions.ts | 3 --- apps/desktop/src/renderer/app-shell-turn-actions.ts | 3 --- apps/desktop/src/renderer/app-shell.tsx | 13 +------------ .../src/renderer/use-app-shell-session-list.ts | 8 +------- 8 files changed, 2 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 7148daff0e..7b0edd27b9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -106,7 +106,6 @@ function createActionsDeps() { setInteractionBySession: () => undefined, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, - upsertSessionSummary: () => undefined, newChatModel: null, pendingNewChatThinkingLevel: null, newChatPermissionChoice: undefined, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 372b91f6da..967d777dc9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -107,7 +107,6 @@ function createActionsDeps() { setInteractionBySession: () => undefined, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, - upsertSessionSummary: () => undefined, newChatModel: null, pendingNewChatThinkingLevel: null, newChatPermissionChoice: undefined, diff --git a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts index 7a27ebb13f..dca60bfefa 100644 --- a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts @@ -54,7 +54,6 @@ test('preserves a Branch copy identity after an ambiguous failure and completes refreshSessions: async () => [], setMessages: () => undefined, toastApi: { info() {}, success() {}, error() {} }, - upsertSessionSummary: () => undefined, }); try { diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 583143e93e..3c74ddfd47 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -159,7 +159,6 @@ export function createAppShellChatActions(deps: { diagnosticTarget?: { sessionId: string } | { profileId: string }, ) => void; toastApi: ToastApi; - upsertSessionSummary: (session: DesktopSessionSummary) => void; newChatModel: PendingNewChatModel; pendingNewChatThinkingLevel: PendingNewChatThinkingLevel; /** @@ -201,7 +200,6 @@ export function createAppShellChatActions(deps: { onExecutionBoundaryChanged, showModelSetupToast, toastApi, - upsertSessionSummary, newChatModel, pendingNewChatThinkingLevel, newChatPermissionChoice, @@ -400,7 +398,6 @@ export function createAppShellChatActions(deps: { // Consumed: the choice is now the created Session's, not the next // draft's. A failed create leaves it in place so a retry keeps it. if (newChatPermissionChoice) clearNewChatPermissionChoice(); - upsertSessionSummary(session); optimisticSessionId = session.id; optimisticTurnId = turnId; armTurnActive(session.id, turnId); diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 4c13bf3a19..7bd256fb07 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -99,7 +99,6 @@ export function createAppShellRevisionActions(deps: { commitRevisionDraft: (draft: TurnRevisionDraft | null) => void; revisionDraftRef: RefBox; toastApi: ToastApi; - upsertSessionSummary: (session: DesktopSessionSummary) => void; }): AppShellRevisionActions { const { uiLocale, @@ -114,7 +113,6 @@ export function createAppShellRevisionActions(deps: { commitRevisionDraft, revisionDraftRef, toastApi, - upsertSessionSummary, } = deps; const copy = getDesktopConversationCopy(uiLocale).actions; let revisionPreparationAbort: AbortController | undefined; @@ -338,7 +336,6 @@ export function createAppShellRevisionActions(deps: { const prepared = { ...startedDraft, draftSessionId: newSession.id }; composerRef.current?.setDraft(newSession.id, text); commitRevisionDraft(prepared); - upsertSessionSummary(newSession); openSessionInChat(newSession.id); setMessages([]); const { messages: preparedMessages, settled } = await readSettledMessages(newSession.id, { diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts index cb0179c91c..1c594d0cc5 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts @@ -58,7 +58,6 @@ export function createAppShellTurnActions(deps: { refreshSessions: () => Promise; setMessages: MessageListUpdater; toastApi: ToastApi; - upsertSessionSummary: (session: DesktopSessionSummary) => void; }): AppShellTurnActions { const { uiLocale, @@ -71,7 +70,6 @@ export function createAppShellTurnActions(deps: { refreshSessions, setMessages, toastApi, - upsertSessionSummary, } = deps; const copy = getDesktopConversationCopy(uiLocale).actions; @@ -106,7 +104,6 @@ export function createAppShellTurnActions(deps: { copyId: copyAttempt.copyId, }); copyAttempt.complete(); - upsertSessionSummary(newSession); if (activeIdRef.current === sessionId) { openSessionInChat(newSession.id); setMessages([]); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 8fe453b258..9d5f404721 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -358,7 +358,6 @@ function AppShellContent({ setSessions, refreshSessions, seedSessions, - upsertSessionSummary, activeId, activeIdRef, bootstrapSelectionLease, @@ -1446,8 +1445,7 @@ function AppShellContent({ } if (!snapshot) return; // Seed sessions. Display normalization MUST run here too — this is - // a third renderer state entry alongside commitSessions / - // upsertSessionSummary (#452): without it, legacy blocked/unknown + // Display normalization prevents legacy blocked/unknown // sessions flash an 已阻塞 group on first paint until the first // refreshSessions() overwrites the seed. const next = seedSessions(snapshot.sessions); @@ -1899,7 +1897,6 @@ function AppShellContent({ onExecutionBoundaryChanged: reloadActiveExecutionBoundary, showModelSetupToast, toastApi, - upsertSessionSummary, newChatModel: newChatModel ?? null, pendingNewChatThinkingLevel: newChatThinkingLevel ?? null, newChatPermissionChoice: newTaskPermissionChoice, @@ -1920,7 +1917,6 @@ function AppShellContent({ refreshSessions, setMessages, toastApi, - upsertSessionSummary, }); const handleSwitchToBypassAndRetry = useCallback( async (turnId: string) => { @@ -1950,7 +1946,6 @@ function AppShellContent({ commitRevisionDraft, revisionDraftRef, toastApi, - upsertSessionSummary, }); async function taskSubmissionReadyAtSend(): Promise { @@ -3475,13 +3470,7 @@ function AppShellContent({ paletteOpen={paletteOpen} closePalette={closePalette} commandOptions={commandOptions} - /* Seeding is for the navigation, not for correctness: the import IPC - already emits `sessions:changed`, so the task reaches the rail on its - own even if the user closes Settings mid-import. Seeding it here just - means `openSessionInChat` has something to open without waiting for - the refresh to land. */ onExternalSessionImported={(session) => { - upsertSessionSummary(session); closeSettings(); openSessionInChat(session.id); }} diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index b733acceb8..c6d931ac59 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -111,12 +111,7 @@ export function useAppShellSessionList( return next; } - function upsertSessionSummary(session: DesktopSessionSummary): void { - setSessions((current) => [ - normalizeSessionSummaryForDisplay(session), - ...current.filter((entry) => entry.id !== session.id), - ]); - } + return { sessions, @@ -125,6 +120,5 @@ export function useAppShellSessionList( setSessions, refreshSessions, seedSessions, - upsertSessionSummary, }; } From f2671ff058feabd6ed5c9807095e641f3840c825 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:37:30 +0800 Subject: [PATCH 07/16] refactor: delete session summary merge layer Generated-by: Maka --- .../app-shell-session-ui-state.test.ts | 52 +------------------ .../renderer/session-status-presentation.ts | 30 ----------- 2 files changed, 1 insertion(+), 81 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 0ae90c179b..5771c5243c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -24,11 +24,7 @@ import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health import type { SessionSummary } from '@maka/core/session'; import { armLiveTurn, confirmLiveTurn } from '@maka/ui'; import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; -import { - mergeSessionSummaryListForDisplay, - mergeSessionSummaryForDisplay, - normalizeSessionSummaryForDisplay, -} from '../../renderer/session-status-presentation.js'; +import { normalizeSessionSummaryForDisplay } from '../../renderer/session-status-presentation.js'; import { clearAppShellSessionUiStateForSession, createAppShellSessionUiStateController, @@ -80,36 +76,6 @@ function seededState(): AppShellSessionUiState { } describe('session live run display state', () => { - it('preserves known live state when a mutation response omits it', () => { - const current = { - id: 'session-1', - status: 'running', - runningTurnIds: ['turn-live'], - } as SessionSummary; - const mutation = { id: 'session-1', status: 'running' } as SessionSummary; - - assert.deepEqual(mergeSessionSummaryForDisplay(current, mutation).runningTurnIds, [ - 'turn-live', - ]); - }); - - it('lets known-empty replace prior running state and clear a stale running status', () => { - const current = { - id: 'session-1', - status: 'running', - runningTurnIds: ['turn-live'], - } as SessionSummary; - const catalog = { - id: 'session-1', - status: 'running', - runningTurnIds: [], - } as unknown as SessionSummary; - - const merged = mergeSessionSummaryForDisplay(current, catalog); - assert.deepEqual(merged.runningTurnIds, []); - assert.equal(merged.status, 'active'); - }); - it('keeps persisted running as a fallback only while live state is unknown', () => { const unknown = { id: 'unknown', status: 'running' } as SessionSummary; const knownEmpty = { @@ -122,22 +88,6 @@ describe('session live run display state', () => { assert.equal(normalizeSessionSummaryForDisplay(knownEmpty).status, 'active'); }); - it('preserves live authority when the list state boundary accepts a metadata replacement', () => { - const current = { - id: 'session-live', - status: 'running', - runningTurnIds: ['turn-live'], - } as SessionSummary; - const mutation = { - id: 'session-live', - status: 'running', - permissionMode: 'bypass', - } as SessionSummary; - - assert.deepEqual(mergeSessionSummaryListForDisplay([current], [mutation]), [ - { ...mutation, runningTurnIds: ['turn-live'] }, - ]); - }); }); describe('app shell session UI state controller', () => { diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 0dbf4204a0..74deaa7cb8 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -82,36 +82,6 @@ export function normalizeSessionSummaryForDisplay(sess return { ...rest, status: 'active' } as T; } -/** - * Mutation responses describe persisted metadata and legitimately omit live - * run state. Preserve the last authoritative state in that case; an explicit - * empty or non-empty array from a catalog read always replaces it. - */ -export function mergeSessionSummaryForDisplay( - current: T | undefined, - incoming: T, -): T { - const merged: T = - incoming.runningTurnIds === undefined && current?.runningTurnIds !== undefined - ? ({ ...incoming, runningTurnIds: [...current.runningTurnIds] } as T) - : incoming; - return normalizeSessionSummaryForDisplay(merged); -} - -/** - * Apply the same live-state preservation rule at the shared list state - * boundary, so every metadata mutation path gets it even when a refresh fails. - */ -export function mergeSessionSummaryListForDisplay( - current: readonly T[], - incoming: readonly T[], -): T[] { - const currentById = new Map(current.map((session) => [session.id, session])); - return incoming.map((session) => - mergeSessionSummaryForDisplay(currentById.get(session.id), session), - ); -} - /** * Generalized Chinese phrasing for a failed turn's `errorClass` * Mirrors `describeBlockedReason()` in `@maka/ui`, under the same rule: a UI From 72decb8aa9870342f7aedbb7f632b94756b53119 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:41:54 +0800 Subject: [PATCH 08/16] refactor: drop legacy session activity column Generated-by: Maka --- .../transcript-data-plane-benchmark.mjs | 1 - .../sqlite-session-metadata-store.test.ts | 4 ++++ .../src/sqlite-session-metadata-schema.ts | 21 +++++++++++++++++++ .../src/sqlite-session-metadata-store.ts | 5 +---- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs index 83eae9224c..9a4fa8c40f 100644 --- a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs +++ b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs @@ -88,7 +88,6 @@ async function runFixture(fixture) { metadataRevision: 1, status: 'active', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, projectionRevision: 1, 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 57690b3458..124f1e7d8a 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -104,6 +104,10 @@ describe('SqliteSessionMetadataStore', () => { columns.some(({ name }) => name === 'external_source_session_id'), true, ); + assert.equal( + columns.some(({ name }) => name === 'last_used_at'), + false, + ); const externalOriginIndex = schema .prepare( `SELECT sql FROM sqlite_master diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index e1c6f105dd..c7b92e8e1a 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -1143,6 +1143,19 @@ export function migrateSqliteSessionMetadataDatabase( if (ownsTransaction) db.exec('BEGIN IMMEDIATE'); try { const current = readSqliteSessionMetadataSchemaVersion(db); + if ( + current > 0 && + current < 29 && + hasColumn(db, 'session_metadata', 'session_id') && + !hasColumn(db, 'session_metadata', 'last_used_at') + ) { + db.exec(` + ALTER TABLE session_metadata + ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0; + UPDATE session_metadata + SET last_used_at = COALESCE(last_message_at, created_at); + `); + } if (current > SQLITE_SESSION_METADATA_SCHEMA_VERSION) { throw new Error( `SQLite session metadata schema ${current} is newer than supported version ${SQLITE_SESSION_METADATA_SCHEMA_VERSION}`, @@ -1156,6 +1169,9 @@ export function migrateSqliteSessionMetadataDatabase( const sql = MIGRATIONS.get(version); if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`); db.exec(sql); + if (version === 29 && hasColumn(db, 'session_metadata', 'last_used_at')) { + db.exec('ALTER TABLE session_metadata DROP COLUMN last_used_at'); + } db.prepare(` INSERT INTO session_metadata_schema(scope, version) VALUES ('session_metadata', ?) @@ -1169,6 +1185,11 @@ export function migrateSqliteSessionMetadataDatabase( } } +function hasColumn(db: DatabaseSync, table: string, column: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + return rows.some((row) => row.name === column); +} + export function readSqliteSessionMetadataSchemaVersion(db: DatabaseSync): number { const row = db .prepare(` diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 4b0d4da308..4f8e741d8c 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -3648,7 +3648,6 @@ export class SqliteSessionMetadataStore { session_id, payload_json, created_at, - last_used_at, last_message_at, name, is_flagged, @@ -3672,14 +3671,13 @@ export class SqliteSessionMetadataStore { model, metadata_version, committed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( header.id, JSON.stringify(header), header.createdAt, - header.lastMessageAt ?? header.createdAt, header.lastMessageAt ?? null, header.name, booleanInteger(header.isFlagged), @@ -3943,7 +3941,6 @@ export class SqliteSessionMetadataStore { SET payload_json = ?, created_at = ?, - last_used_at = last_used_at, last_message_at = ?, name = ?, is_flagged = ?, From a72a0423aaaf5867e2855a51264442d33784f95a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:44:39 +0800 Subject: [PATCH 09/16] refactor: remove remaining desktop catalog patches Generated-by: Maka --- apps/desktop/src/renderer/app-shell.tsx | 10 ++-------- .../renderer/use-app-shell-session-list.ts | 20 +++++-------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 9d5f404721..e8b51ab186 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -355,7 +355,6 @@ function AppShellContent({ sessions, authoritativeSessionIds, sessionsRef, - setSessions, refreshSessions, seedSessions, activeId, @@ -1048,15 +1047,11 @@ function AppShellContent({ // Abandoning the proposal is what leaves Plan: Runtime writes the // Session back to `agent` itself as part of it. await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); - setSessions((current) => current.map((session) => ( - session.id === sessionId ? { ...session, collaborationMode: 'agent' } : session - ))); } else { - const next = await window.maka.sessions.setCollaborationMode( + await window.maka.sessions.setCollaborationMode( sessionId, active ? 'plan' : 'agent', ); - setSessions((current) => current.map((session) => session.id === next.id ? next : session)); } await refreshSessions(); return true; @@ -1113,8 +1108,7 @@ function AppShellContent({ } try { - const next = await window.maka.sessions.setOrchestrationMode(sessionId, mode); - setSessions((current) => current.map((session) => session.id === next.id ? next : session)); + await window.maka.sessions.setOrchestrationMode(sessionId, mode); await refreshSessions(); return true; } catch (error) { diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index c6d931ac59..546364efb7 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { type LiveTurnProjection, useUiLocale } from '@maka/ui'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -52,8 +52,10 @@ export function useAppShellSessionList( const uiLocaleRef = useRef(uiLocale); uiLocaleRef.current = uiLocale; const [sessions, setSessionsState] = useState([]); - const [authoritativeSessionIds, setAuthoritativeSessionIds] = - useState | null>(null); + const authoritativeSessionIds = useMemo( + () => new Set(sessions.map(({ id }) => id)), + [sessions], + ); const sessionsRef = useRef([]); const refresherRef = useRef | null>(null); @@ -62,16 +64,6 @@ export function useAppShellSessionList( setSessionsState(next); } - function setSessions( - updater: (current: DesktopSessionSummary[]) => DesktopSessionSummary[], - ): void { - setSessionsState((current) => { - const next = updater(current); - sessionsRef.current = next; - return next; - }); - } - if (!refresherRef.current) { refresherRef.current = createSessionListRefresher({ captureRequestContext: () => options.liveTurnBySessionRef.current, @@ -86,7 +78,6 @@ export function useAppShellSessionList( clearTurnTransientStateIfCurrent: options.clearTurnTransientStateIfCurrent, }); commitSessions(normalized); - setAuthoritativeSessionIds(new Set(normalized.map(({ id }) => id))); }, onError: (error) => { const locale = uiLocaleRef.current; @@ -117,7 +108,6 @@ export function useAppShellSessionList( sessions, authoritativeSessionIds, sessionsRef, - setSessions, refreshSessions, seedSessions, }; From 3dac6d9a4e47bac2bd713e5f71551ab7c0095849 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:56:10 +0800 Subject: [PATCH 10/16] chore: restore canonical ASF source header Generated-by: Maka --- apps/desktop/src/renderer/session-read-state.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/session-read-state.ts b/apps/desktop/src/renderer/session-read-state.ts index d043a16bf0..92fd92b47a 100644 --- a/apps/desktop/src/renderer/session-read-state.ts +++ b/apps/desktop/src/renderer/session-read-state.ts @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import type { SessionSummary } from '@maka/core/session'; From cff7f977fc9ee491895858f1335a5f2f97c1d60f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:42:56 +0800 Subject: [PATCH 11/16] fix(desktop): repaint committed session settings Generated-by: Maka --- ...app-shell-session-settings-actions.test.ts | 29 +++++++++++++++++++ .../app-shell-session-settings-actions.ts | 17 ++++++++++- apps/desktop/src/renderer/app-shell.tsx | 13 +++++++-- .../renderer/session-status-presentation.ts | 21 ++++++++++++++ .../renderer/use-app-shell-session-list.ts | 20 ++++++++++++- 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 3b980403cf..6ddf25a36e 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -23,6 +23,7 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import type { StoredMessage } from '@maka/core/session'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; +import { replaceCommittedSessionSettings } from '../../renderer/session-status-presentation.js'; function deferred() { let resolve!: (value: T) => void; @@ -72,6 +73,10 @@ function createHarness(options: { const errors: string[] = []; const errorTargets: Array<{ sessionId: string } | undefined> = []; const successes: Array<{ title: string; description?: string }> = []; + const committedSessionSettings: Array<{ + sessionId: string; + patch: Partial; + }> = []; const newTaskPermissionModes: string[] = []; const modelResult = deferred(); const thinkingResult = deferred(); @@ -109,6 +114,9 @@ function createHarness(options: { pendingPermissionModeChangesRef: { current: new Set() }, pendingSessionModelChangesRef: { current: pending }, refreshSessions: async () => sessions, + applyCommittedSessionSettings: (sessionId, patch) => { + committedSessionSettings.push({ sessionId, patch }); + }, saveComposerDefaults: () => undefined, sessionsRef, setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode), @@ -131,6 +139,7 @@ function createHarness(options: { return { actions, activeIdRef, + committedSessionSettings, errors, errorTargets, modelCalls, @@ -146,6 +155,22 @@ function createHarness(options: { }; } +describe('committed session settings', () => { + it('repaints an existing row without inserting or reordering catalog entries', () => { + const current = [session('session-a'), session('session-b')]; + const next = replaceCommittedSessionSettings(current, 'session-b', { + collaborationMode: 'plan', + }); + + assert.deepEqual(next.map(({ id }) => id), ['session-a', 'session-b']); + assert.equal(next[1]?.collaborationMode, 'plan'); + assert.strictEqual( + replaceCommittedSessionSettings(current, 'missing', { collaborationMode: 'plan' }), + current, + ); + }); +}); + describe('AppShell session settings actions', () => { it('keeps a new-task permission choice in the draft instead of mutating a Host default', async () => { const harness = createHarness(); @@ -181,6 +206,10 @@ describe('AppShell session settings actions', () => { assert.equal(switched, true); assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); + assert.deepEqual(harness.committedSessionSettings, [{ + sessionId: 'session-a', + patch: { permissionMode: 'bypass' }, + }]); }); it('does not report success when the Host returns another permission mode', async () => { diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 0877c689dc..70d3c4066b 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -31,6 +31,10 @@ import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.j type RefBox = { current: T }; type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; +type CommittedSessionSettingsPatch = Partial>; type ToastApi = { success(title: string, description?: string): void; @@ -63,6 +67,10 @@ export function createAppShellSessionSettingsActions(deps: { pendingPermissionModeChangesRef: RefBox>; pendingSessionModelChangesRef: RefBox>; refreshSessions: () => Promise; + applyCommittedSessionSettings: ( + sessionId: string, + patch: CommittedSessionSettingsPatch, + ) => void; saveComposerDefaults: (patch: { model: { llmConnectionSlug: string; model: string }; }) => void; @@ -81,6 +89,7 @@ export function createAppShellSessionSettingsActions(deps: { pendingPermissionModeChangesRef, pendingSessionModelChangesRef, refreshSessions, + applyCommittedSessionSettings, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, @@ -143,6 +152,7 @@ export function createAppShellSessionSettingsActions(deps: { if (sessionId) { const next = await window.maka.sessions.setPermissionMode(sessionId, mode); nextMode = next.permissionMode === 'bypass' ? 'bypass' : 'ask'; + applyCommittedSessionSettings(sessionId, { permissionMode: next.permissionMode }); } else { await setNewTaskPermissionMode(mode); } @@ -179,6 +189,10 @@ export function createAppShellSessionSettingsActions(deps: { })); try { const next = await window.maka.sessions.setModel(sessionId, input); + applyCommittedSessionSettings(sessionId, { + llmConnectionSlug: next.llmConnectionSlug, + model: next.model, + }); if (activeIdRef.current === sessionId) { const connectionChanged = previous?.llmConnectionSlug !== next.llmConnectionSlug; const to = modelEndpointLabel(next.llmConnectionSlug, next.model, connectionChanged); @@ -226,7 +240,8 @@ export function createAppShellSessionSettingsActions(deps: { [sessionId]: true, })); try { - await window.maka.sessions.setThinkingLevel(sessionId, level); + const next = await window.maka.sessions.setThinkingLevel(sessionId, level); + applyCommittedSessionSettings(sessionId, { thinkingLevel: next.thinkingLevel }); if (activeIdRef.current === sessionId) { toastApi.success(copy.thinkingUpdatedTitle, level ? copy.thinkingLabels[level] : copy.thinkingDefault); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e8b51ab186..b21d75ba25 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -357,6 +357,7 @@ function AppShellContent({ sessionsRef, refreshSessions, seedSessions, + applyCommittedSessionSettings, activeId, activeIdRef, bootstrapSelectionLease, @@ -982,6 +983,7 @@ function AppShellContent({ pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, refreshSessions, + applyCommittedSessionSettings, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, @@ -1047,11 +1049,15 @@ function AppShellContent({ // Abandoning the proposal is what leaves Plan: Runtime writes the // Session back to `agent` itself as part of it. await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); + applyCommittedSessionSettings(sessionId, { collaborationMode: 'agent' }); } else { - await window.maka.sessions.setCollaborationMode( + const next = await window.maka.sessions.setCollaborationMode( sessionId, active ? 'plan' : 'agent', ); + applyCommittedSessionSettings(sessionId, { + collaborationMode: next.collaborationMode, + }); } await refreshSessions(); return true; @@ -1108,7 +1114,10 @@ function AppShellContent({ } try { - await window.maka.sessions.setOrchestrationMode(sessionId, mode); + const next = await window.maka.sessions.setOrchestrationMode(sessionId, mode); + applyCommittedSessionSettings(sessionId, { + orchestrationMode: next.orchestrationMode, + }); await refreshSessions(); return true; } catch (error) { diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 74deaa7cb8..1d05ce27bb 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -82,6 +82,27 @@ export function normalizeSessionSummaryForDisplay(sess return { ...rest, status: 'active' } as T; } +type CommittedSessionSettingsPatch = Partial>; + +export function replaceCommittedSessionSettings( + current: T[], + sessionId: string, + patch: CommittedSessionSettingsPatch, +): T[] { + if (!current.some((session) => session.id === sessionId)) return current; + return current.map((session) => ( + session.id === sessionId ? { ...session, ...patch } : session + )); +} + /** * Generalized Chinese phrasing for a failed turn's `errorClass` * Mirrors `describeBlockedReason()` in `@maka/ui`, under the same rule: a UI diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index 546364efb7..f996915b31 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -23,6 +23,7 @@ import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { normalizeSessionSummaryForDisplay, + replaceCommittedSessionSettings, } from './session-status-presentation'; import { createSessionListRefresher, @@ -36,6 +37,15 @@ type ToastApi = { }; type RefBox = { current: T }; +type CommittedSessionSettingsPatch = Partial>; export function useAppShellSessionList( toastApi: ToastApi, @@ -102,7 +112,14 @@ export function useAppShellSessionList( return next; } - + function applyCommittedSessionSettings( + sessionId: string, + patch: CommittedSessionSettingsPatch, + ): void { + const current = sessionsRef.current; + const next = replaceCommittedSessionSettings(current, sessionId, patch); + if (next !== current) commitSessions(next); + } return { sessions, @@ -110,5 +127,6 @@ export function useAppShellSessionList( sessionsRef, refreshSessions, seedSessions, + applyCommittedSessionSettings, }; } From 4be2375d7054d8cf075673689d6ea4a29b50d2e7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:57:08 +0800 Subject: [PATCH 12/16] fix(desktop): keep plan state outside catalog snapshots Generated-by: Maka --- ...app-shell-session-settings-actions.test.ts | 29 -------------- .../app-shell-session-settings-actions.ts | 17 +-------- apps/desktop/src/renderer/app-shell.tsx | 38 +++++++++---------- .../renderer/session-status-presentation.ts | 21 ---------- .../renderer/use-app-shell-session-list.ts | 20 +--------- 5 files changed, 21 insertions(+), 104 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 6ddf25a36e..3b980403cf 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -23,7 +23,6 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import type { StoredMessage } from '@maka/core/session'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; -import { replaceCommittedSessionSettings } from '../../renderer/session-status-presentation.js'; function deferred() { let resolve!: (value: T) => void; @@ -73,10 +72,6 @@ function createHarness(options: { const errors: string[] = []; const errorTargets: Array<{ sessionId: string } | undefined> = []; const successes: Array<{ title: string; description?: string }> = []; - const committedSessionSettings: Array<{ - sessionId: string; - patch: Partial; - }> = []; const newTaskPermissionModes: string[] = []; const modelResult = deferred(); const thinkingResult = deferred(); @@ -114,9 +109,6 @@ function createHarness(options: { pendingPermissionModeChangesRef: { current: new Set() }, pendingSessionModelChangesRef: { current: pending }, refreshSessions: async () => sessions, - applyCommittedSessionSettings: (sessionId, patch) => { - committedSessionSettings.push({ sessionId, patch }); - }, saveComposerDefaults: () => undefined, sessionsRef, setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode), @@ -139,7 +131,6 @@ function createHarness(options: { return { actions, activeIdRef, - committedSessionSettings, errors, errorTargets, modelCalls, @@ -155,22 +146,6 @@ function createHarness(options: { }; } -describe('committed session settings', () => { - it('repaints an existing row without inserting or reordering catalog entries', () => { - const current = [session('session-a'), session('session-b')]; - const next = replaceCommittedSessionSettings(current, 'session-b', { - collaborationMode: 'plan', - }); - - assert.deepEqual(next.map(({ id }) => id), ['session-a', 'session-b']); - assert.equal(next[1]?.collaborationMode, 'plan'); - assert.strictEqual( - replaceCommittedSessionSettings(current, 'missing', { collaborationMode: 'plan' }), - current, - ); - }); -}); - describe('AppShell session settings actions', () => { it('keeps a new-task permission choice in the draft instead of mutating a Host default', async () => { const harness = createHarness(); @@ -206,10 +181,6 @@ describe('AppShell session settings actions', () => { assert.equal(switched, true); assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); - assert.deepEqual(harness.committedSessionSettings, [{ - sessionId: 'session-a', - patch: { permissionMode: 'bypass' }, - }]); }); it('does not report success when the Host returns another permission mode', async () => { diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 70d3c4066b..0877c689dc 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -31,10 +31,6 @@ import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.j type RefBox = { current: T }; type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; -type CommittedSessionSettingsPatch = Partial>; type ToastApi = { success(title: string, description?: string): void; @@ -67,10 +63,6 @@ export function createAppShellSessionSettingsActions(deps: { pendingPermissionModeChangesRef: RefBox>; pendingSessionModelChangesRef: RefBox>; refreshSessions: () => Promise; - applyCommittedSessionSettings: ( - sessionId: string, - patch: CommittedSessionSettingsPatch, - ) => void; saveComposerDefaults: (patch: { model: { llmConnectionSlug: string; model: string }; }) => void; @@ -89,7 +81,6 @@ export function createAppShellSessionSettingsActions(deps: { pendingPermissionModeChangesRef, pendingSessionModelChangesRef, refreshSessions, - applyCommittedSessionSettings, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, @@ -152,7 +143,6 @@ export function createAppShellSessionSettingsActions(deps: { if (sessionId) { const next = await window.maka.sessions.setPermissionMode(sessionId, mode); nextMode = next.permissionMode === 'bypass' ? 'bypass' : 'ask'; - applyCommittedSessionSettings(sessionId, { permissionMode: next.permissionMode }); } else { await setNewTaskPermissionMode(mode); } @@ -189,10 +179,6 @@ export function createAppShellSessionSettingsActions(deps: { })); try { const next = await window.maka.sessions.setModel(sessionId, input); - applyCommittedSessionSettings(sessionId, { - llmConnectionSlug: next.llmConnectionSlug, - model: next.model, - }); if (activeIdRef.current === sessionId) { const connectionChanged = previous?.llmConnectionSlug !== next.llmConnectionSlug; const to = modelEndpointLabel(next.llmConnectionSlug, next.model, connectionChanged); @@ -240,8 +226,7 @@ export function createAppShellSessionSettingsActions(deps: { [sessionId]: true, })); try { - const next = await window.maka.sessions.setThinkingLevel(sessionId, level); - applyCommittedSessionSettings(sessionId, { thinkingLevel: next.thinkingLevel }); + await window.maka.sessions.setThinkingLevel(sessionId, level); if (activeIdRef.current === sessionId) { toastApi.success(copy.thinkingUpdatedTitle, level ? copy.thinkingLabels[level] : copy.thinkingDefault); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b21d75ba25..bba4cfd248 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -357,7 +357,6 @@ function AppShellContent({ sessionsRef, refreshSessions, seedSessions, - applyCommittedSessionSettings, activeId, activeIdRef, bootstrapSelectionLease, @@ -428,11 +427,14 @@ function AppShellContent({ // The rows stay interactive while a commit runs, so a click landing in that // window is the user updating their mind — not noise to drop. Each map holds // only the LATEST ask per session; the in-flight commit's finally block - // applies it if the settled state does not already satisfy it. Nothing - // renders from a mode commit's round trip — painting it (disable/dim, then - // restore) is exactly the + menu blink MatrixA/fix-plan-click-flicker - // removed — so there is no pending state here, only the registry refs. + // applies it if the settled state does not already satisfy it. The Plan + // control separately keeps the last committed value visible while the + // catalog refresh is pending; that transient view never inserts, removes, + // or reorders a catalog row. const queuedCollaborationModeBySession = useRef(new Map()); + const [transientPlanModeBySession, setTransientPlanModeBySession] = useState< + Record + >({}); const queuedOrchestrationModeBySession = useRef(new Map()); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); @@ -911,9 +913,8 @@ function AppShellContent({ // The registry ref is the re-entrancy authority; the setter is only for // actions whose pending state something actually renders (permission, model, - // message retry). Mode toggles pass none — their round trip is deliberately - // not painted, and a write-only state would still schedule a render per - // add/clear. + // message retry). Mode toggles pass none: Plan paints the Host's committed + // value through its separate transient view, not this registry's busy state. function addPendingSessionAction( sessionId: string, pendingRef: { current: Set }, @@ -945,6 +946,7 @@ function AppShellContent({ // in-flight commit's finally would otherwise replay an old ask against a // Session this cleanup has already let go of. queuedCollaborationModeBySession.current.delete(sessionId); + setTransientPlanModeBySession((current) => omitSessionKey(current, sessionId)); queuedOrchestrationModeBySession.current.delete(sessionId); sessionModelChangeRegistry.keysRef.current.delete(sessionId); } @@ -983,7 +985,6 @@ function AppShellContent({ pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, refreshSessions, - applyCommittedSessionSettings, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, @@ -1049,16 +1050,16 @@ function AppShellContent({ // Abandoning the proposal is what leaves Plan: Runtime writes the // Session back to `agent` itself as part of it. await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); - applyCommittedSessionSettings(sessionId, { collaborationMode: 'agent' }); } else { - const next = await window.maka.sessions.setCollaborationMode( + await window.maka.sessions.setCollaborationMode( sessionId, active ? 'plan' : 'agent', ); - applyCommittedSessionSettings(sessionId, { - collaborationMode: next.collaborationMode, - }); } + // The Host has committed this value. Keep that fact visible while the + // catalog refresh reconciles the full row; do not write it into the + // catalog snapshot itself. + setTransientPlanModeBySession((current) => ({ ...current, [sessionId]: active })); await refreshSessions(); return true; } catch (error) { @@ -1071,6 +1072,7 @@ function AppShellContent({ } return false; } finally { + setTransientPlanModeBySession((current) => omitSessionKey(current, sessionId)); clearPendingSessionAction( sessionId, collaborationModeChangeRegistry.keysRef, @@ -1114,10 +1116,7 @@ function AppShellContent({ } try { - const next = await window.maka.sessions.setOrchestrationMode(sessionId, mode); - applyCommittedSessionSettings(sessionId, { - orchestrationMode: next.orchestrationMode, - }); + await window.maka.sessions.setOrchestrationMode(sessionId, mode); await refreshSessions(); return true; } catch (error) { @@ -1312,7 +1311,8 @@ function AppShellContent({ // to keep in sync: a Session in Plan with Swarm as its orchestration default // says both, because it is both. const activePlanMode = activeId - ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' + ? transientPlanModeBySession[activeId] + ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') : newChatPlanModeActive; const activeOrchestrationMode: OrchestrationMode = activeId ? activeSessionForView?.orchestrationMode ?? 'default' diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 1d05ce27bb..74deaa7cb8 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -82,27 +82,6 @@ export function normalizeSessionSummaryForDisplay(sess return { ...rest, status: 'active' } as T; } -type CommittedSessionSettingsPatch = Partial>; - -export function replaceCommittedSessionSettings( - current: T[], - sessionId: string, - patch: CommittedSessionSettingsPatch, -): T[] { - if (!current.some((session) => session.id === sessionId)) return current; - return current.map((session) => ( - session.id === sessionId ? { ...session, ...patch } : session - )); -} - /** * Generalized Chinese phrasing for a failed turn's `errorClass` * Mirrors `describeBlockedReason()` in `@maka/ui`, under the same rule: a UI diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index f996915b31..546364efb7 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -23,7 +23,6 @@ import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { normalizeSessionSummaryForDisplay, - replaceCommittedSessionSettings, } from './session-status-presentation'; import { createSessionListRefresher, @@ -37,15 +36,6 @@ type ToastApi = { }; type RefBox = { current: T }; -type CommittedSessionSettingsPatch = Partial>; export function useAppShellSessionList( toastApi: ToastApi, @@ -112,14 +102,7 @@ export function useAppShellSessionList( return next; } - function applyCommittedSessionSettings( - sessionId: string, - patch: CommittedSessionSettingsPatch, - ): void { - const current = sessionsRef.current; - const next = replaceCommittedSessionSettings(current, sessionId, patch); - if (next !== current) commitSessions(next); - } + return { sessions, @@ -127,6 +110,5 @@ export function useAppShellSessionList( sessionsRef, refreshSessions, seedSessions, - applyCommittedSessionSettings, }; } From 44f3cfadb88edc9231094f061476199f1ca11235 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:57:21 +0800 Subject: [PATCH 13/16] fix(desktop): retain healthy host catalogs Generated-by: Maka --- ...ntime-host-session-catalog-preload.test.ts | 47 +++++++++++++++++++ apps/desktop/src/preload/preload.ts | 9 +--- .../preload/runtime-host-session-catalog.ts | 39 +++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts create mode 100644 apps/desktop/src/preload/runtime-host-session-catalog.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts new file mode 100644 index 0000000000..b90e6bbd86 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; +import { collectRuntimeHostSessionCatalogs } from '../../preload/runtime-host-session-catalog.js'; + +function session(id: string, activityAt: number): DesktopSessionSummary { + return { id, activityAt } as DesktopSessionSummary; +} + +test('keeps healthy Host catalogs when another Host rejects', async () => { + const sessions = await collectRuntimeHostSessionCatalogs([ + Promise.resolve([session('older', 1)]), + Promise.reject(new Error('remote unavailable')), + Promise.resolve([session('newer', 2)]), + ]); + + assert.deepEqual(sessions.map(({ id }) => id), ['newer', 'older']); +}); + +test('fails when every Host catalog rejects', async () => { + await assert.rejects( + collectRuntimeHostSessionCatalogs([ + Promise.reject(new Error('first unavailable')), + Promise.reject(new Error('second unavailable')), + ]), + /Every Runtime Host Session Catalog request failed/, + ); +}); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0f3797dbdc..92f9425fb5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -102,6 +102,7 @@ import type { import type { BotProvider } from '@maka/core/bot-chat-settings'; import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/bot-onboarding'; import type { HealthSnapshot } from '@maka/core/health'; +import { collectRuntimeHostSessionCatalogs } from './runtime-host-session-catalog.js'; import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ActiveInteractionRequestEvent, @@ -775,7 +776,7 @@ async function listDesktopSessions( return sessions.map((session) => projectSessionSummary(parent.scope, session)); } const scopes = await runtimeHostScopeList(); - const groups = await Promise.all( + return collectRuntimeHostSessionCatalogs( scopes.map(async (scope) => { const sessions = await ipcRenderer.invoke( 'sessions:list', @@ -785,12 +786,6 @@ async function listDesktopSessions( return sessions.map((session) => projectSessionSummary(scope, session)); }), ); - return groups.flat().sort((left, right) => { - if (left.activityAt === undefined || right.activityAt === undefined) { - throw new Error('Runtime Host Session Catalog activity is unavailable'); - } - return right.activityAt - left.activityAt || left.id.localeCompare(right.id); - }); } function sendActiveRuntimeHost(channel: string, ...args: unknown[]): void { diff --git a/apps/desktop/src/preload/runtime-host-session-catalog.ts b/apps/desktop/src/preload/runtime-host-session-catalog.ts new file mode 100644 index 0000000000..a23f9a6c37 --- /dev/null +++ b/apps/desktop/src/preload/runtime-host-session-catalog.ts @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DesktopSessionSummary } from './bridge-contract.js'; + +export async function collectRuntimeHostSessionCatalogs( + requests: readonly Promise[], +): Promise { + const results = await Promise.allSettled(requests); + const groups = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []); + if (requests.length > 0 && groups.length === 0) { + throw new AggregateError( + results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []), + 'Every Runtime Host Session Catalog request failed', + ); + } + return groups.flat().sort((left, right) => { + if (left.activityAt === undefined || right.activityAt === undefined) { + throw new Error('Runtime Host Session Catalog activity is unavailable'); + } + return right.activityAt - left.activityAt || left.id.localeCompare(right.id); + }); +} From 77c594b10eb7baf65ba534cd36d20138a56f41ed Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 00:59:34 +0800 Subject: [PATCH 14/16] fix(desktop): reconcile mode intents with catalog snapshots Keep the latest Plan and orchestration intent in one setting owner until the Host commit is observed in a catalog snapshot. Catalog refresh failures no longer roll presentation back or prevent a queued latest intent from reaching the Host. Generated-by: Maka --- .../e2e/composer-plus-menu-stability.spec.ts | 59 +++++ apps/desktop/src/preload/preload.ts | 19 +- apps/desktop/src/renderer/app-shell.tsx | 232 ++++++------------ .../renderer/use-session-setting-intent.ts | 136 ++++++++++ 4 files changed, 283 insertions(+), 163 deletions(-) create mode 100644 apps/desktop/src/renderer/use-session-setting-intent.ts diff --git a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts index 7ea8ed2600..569df394bf 100644 --- a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts +++ b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts @@ -27,6 +27,7 @@ declare global { makaE2eLatch?: { arm(key: LatchKey, options?: { oneShot?: boolean }): void; release(key: LatchKey): void; + reject(key: LatchKey, message: string): void; }; } } @@ -58,6 +59,16 @@ async function releaseBridgeLatch( await page.evaluate((latchKey) => window.makaE2eLatch?.release(latchKey), key); } +async function rejectBridgeLatch( + page: import('@playwright/test').Page, + key: LatchKey, +): Promise { + await page.evaluate( + (latchKey) => window.makaE2eLatch?.reject(latchKey, 'forced E2E bridge failure'), + key, + ); +} + /** * Toggling Plan from the + menu must not move the menu. * @@ -282,6 +293,54 @@ test('two rapid Plan toggles land on the last requested state', async ({ await expect(planRow).toHaveAttribute('aria-checked', 'false'); }); +test('a failed catalog refresh keeps the committed Plan state visible', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('alpha-marker'); + await composer.press('Enter'); + await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + + await page.getByRole('button', { name: '添加上下文' }).click(); + const menu = page.getByRole('menu', { name: '添加上下文' }); + const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); + await expect(planRow).toHaveAttribute('aria-checked', 'false'); + await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); + + await armBridgeLatch(page, 'sessions.list', { oneShot: true }); + await planRow.click(); + await expect(planRow).toHaveAttribute('aria-checked', 'true'); + + await rejectBridgeLatch(page, 'sessions.list'); + await expect(planRow).toHaveAttribute('aria-checked', 'true'); +}); + +test('latest Plan intent still reaches the Host after a catalog refresh fails', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('alpha-marker'); + await composer.press('Enter'); + await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + + await page.getByRole('button', { name: '添加上下文' }).click(); + const planRow = page.getByRole('menu', { name: '添加上下文' }) + .getByRole('menuitemcheckbox', { name: 'Plan' }); + await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); + + await armBridgeLatch(page, 'sessions.list', { oneShot: true }); + await planRow.click(); + await expect(planRow).toHaveAttribute('aria-checked', 'true'); + await planRow.click(); + await rejectBridgeLatch(page, 'sessions.list'); + + await expect.poll(async () => page.evaluate(async () => { + const sessions = await window.maka.sessions.list(); + return sessions[0]?.collaborationMode; + })).toBe('agent'); + await expect(planRow).toHaveAttribute('aria-checked', 'false'); +}); + test('deleting the session while a toggle is pending settles clean', async ({ invocableSkillsWindow: page, }) => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 92f9425fb5..e37717fff7 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3069,7 +3069,7 @@ const makaBridge = { if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list'; const gates = new Map; oneShot: boolean }>(); - const releases = new Map void>(); + const releases = new Map void; reject: (error: Error) => void }>(); const wrapLatched = ( call: (...args: Args) => Promise, key: LatchKey, @@ -3091,15 +3091,22 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { ); contextBridge.exposeInMainWorld('makaE2eLatch', { arm(key: LatchKey, options?: { oneShot?: boolean }) { - let release: () => void = () => {}; - const promise = new Promise((resolve) => { - release = resolve; + let resolve: () => void = () => {}; + let reject: (error: Error) => void = () => {}; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; }); gates.set(key, { promise, oneShot: options?.oneShot === true }); - releases.set(key, release); + releases.set(key, { resolve, reject }); }, release(key: LatchKey) { - releases.get(key)?.(); + releases.get(key)?.resolve(); + releases.delete(key); + gates.delete(key); + }, + reject(key: LatchKey, message: string) { + releases.get(key)?.reject(new Error(message)); releases.delete(key); gates.delete(key); }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index bba4cfd248..2edb3a1395 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -134,6 +134,7 @@ import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useShellSearch } from './use-shell-search'; import { useSessionGoal } from './use-session-goal'; +import { useSessionSettingIntent } from './use-session-setting-intent'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping'; @@ -424,18 +425,6 @@ function AppShellContent({ const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); - // The rows stay interactive while a commit runs, so a click landing in that - // window is the user updating their mind — not noise to drop. Each map holds - // only the LATEST ask per session; the in-flight commit's finally block - // applies it if the settled state does not already satisfy it. The Plan - // control separately keeps the last committed value visible while the - // catalog refresh is pending; that transient view never inserts, removes, - // or reorders a catalog row. - const queuedCollaborationModeBySession = useRef(new Map()); - const [transientPlanModeBySession, setTransientPlanModeBySession] = useState< - Record - >({}); - const queuedOrchestrationModeBySession = useRef(new Map()); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); @@ -897,10 +886,6 @@ function AppShellContent({ const pendingTurnActions = turnActionRegistry.keys; const sessionRowActionRegistry = useKeyedPendingRegistry(); const permissionModeChangeRegistry = useKeyedPendingRegistry(); - // One registry per persisted field. The two controls are independent, so a - // Plan transition in flight is no reason to hold the orchestration choice. - const collaborationModeChangeRegistry = useKeyedPendingRegistry(); - const orchestrationModeChangeRegistry = useKeyedPendingRegistry(); const sessionModelChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => `${sessionId}:${turnId}:${actionId}`; @@ -911,10 +896,6 @@ function AppShellContent({ return next; } - // The registry ref is the re-entrancy authority; the setter is only for - // actions whose pending state something actually renders (permission, model, - // message retry). Mode toggles pass none: Plan paints the Host's committed - // value through its separate transient view, not this registry's busy state. function addPendingSessionAction( sessionId: string, pendingRef: { current: Set }, @@ -940,14 +921,8 @@ function AppShellContent({ clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); permissionModeChangeRegistry.keysRef.current.delete(sessionId); - collaborationModeChangeRegistry.keysRef.current.delete(sessionId); - orchestrationModeChangeRegistry.keysRef.current.delete(sessionId); - // Queued mode intents die with the Session's renderer lifecycle: an - // in-flight commit's finally would otherwise replay an old ask against a - // Session this cleanup has already let go of. - queuedCollaborationModeBySession.current.delete(sessionId); - setTransientPlanModeBySession((current) => omitSessionKey(current, sessionId)); - queuedOrchestrationModeBySession.current.delete(sessionId); + planModeIntent.clear(sessionId); + orchestrationModeIntent.clear(sessionId); sessionModelChangeRegistry.keysRef.current.delete(sessionId); } @@ -993,6 +968,46 @@ function AppShellContent({ toastApi, }); + // Mode writes and catalog reads run on different clocks. These controllers + // own that gap: latest intent wins, and a Host-committed value remains the + // presentation overlay until a later catalog snapshot confirms it. + const planModeIntent = useSessionSettingIntent({ + catalogRevision: sessions, + readCatalogValue: (sessionId) => { + const mode = sessionsRef.current.find((session) => session.id === sessionId)?.collaborationMode; + return mode === undefined ? undefined : mode === 'plan'; + }, + write: commitPlanMode, + refreshCatalog: refreshSessions, + onWriteError: (sessionId, error) => { + if (activeIdRef.current !== sessionId) return; + showSessionError( + sessionId, + shellCopy.planModeFailedTitle, + localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), + ); + }, + }); + const orchestrationModeIntent = useSessionSettingIntent({ + catalogRevision: sessions, + readCatalogValue: (sessionId) => sessionsRef.current.find( + (session) => session.id === sessionId, + )?.orchestrationMode, + write: async (sessionId, mode) => { + await window.maka.sessions.setOrchestrationMode(sessionId, mode); + return true; + }, + refreshCatalog: refreshSessions, + onWriteError: (sessionId, error) => { + if (activeIdRef.current !== sessionId) return; + showSessionError( + sessionId, + shellCopy.orchestrationModeFailedTitle, + localizedShellErrorMessage(error, shellCopy.orchestrationModeFallback, uiLocale), + ); + }, + }); + /** * Enter or leave Plan for one Session — the only path that writes * `collaborationMode`, and it writes nothing else. @@ -1011,137 +1026,38 @@ function AppShellContent({ * approved or abandoned, so clearing the default on the way in would lose * it for the execution the plan was written for. */ - async function applyPlanMode(active: boolean, sessionId: string): Promise { - if (!addPendingSessionAction( - sessionId, - collaborationModeChangeRegistry.keysRef, - )) { - // A commit is in flight and the rows stay interactive (no pending - // repaint), so this click is the user updating their mind, not noise. - // Latest intent wins: remember only the newest value and let the - // in-flight commit's finally block apply it, so a quick "on, then off" - // finishes as "off". - queuedCollaborationModeBySession.current.set(sessionId, active); - return true; - } - - try { - const planState = await window.maka.sessions.getPlanState(sessionId); - if (active && planState.activeExecutionId) { - showSessionError( - sessionId, - shellCopy.planModeExecutionActiveTitle, - shellCopy.planModeExecutionActiveDescription, - ); - return false; - } - const latestProposal = planState.proposals.find( - (proposal) => proposal.proposalId === planState.latestProposalId, - ); - if (!active && latestProposal?.status === 'pending_approval') { - const confirmed = await toastApi.confirm({ - title: shellCopy.planModeExitPendingTitle, - description: shellCopy.planModeExitPendingDescription(latestProposal.title), - confirmLabel: shellCopy.planModeExitConfirm, - cancelLabel: shellCopy.planModeExitCancel, - destructive: true, - }); - if (!confirmed) return false; - // Abandoning the proposal is what leaves Plan: Runtime writes the - // Session back to `agent` itself as part of it. - await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); - } else { - await window.maka.sessions.setCollaborationMode( - sessionId, - active ? 'plan' : 'agent', - ); - } - // The Host has committed this value. Keep that fact visible while the - // catalog refresh reconciles the full row; do not write it into the - // catalog snapshot itself. - setTransientPlanModeBySession((current) => ({ ...current, [sessionId]: active })); - await refreshSessions(); - return true; - } catch (error) { - if (activeIdRef.current === sessionId) { - showSessionError( - sessionId, - shellCopy.planModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), - ); - } - return false; - } finally { - setTransientPlanModeBySession((current) => omitSessionKey(current, sessionId)); - clearPendingSessionAction( + async function commitPlanMode(sessionId: string, active: boolean): Promise { + const planState = await window.maka.sessions.getPlanState(sessionId); + if (active && planState.activeExecutionId) { + showSessionError( sessionId, - collaborationModeChangeRegistry.keysRef, + shellCopy.planModeExecutionActiveTitle, + shellCopy.planModeExecutionActiveDescription, ); - // Whatever the user last asked for while this commit ran is the state - // they expect to land on. Read the settled mode through the ref — the - // closure's projection predates this commit — and only re-apply when - // the intent is not already satisfied. - const queued = queuedCollaborationModeBySession.current.get(sessionId); - queuedCollaborationModeBySession.current.delete(sessionId); - if (queued !== undefined) { - const settledActive = ( - sessionsRef.current.find((session) => session.id === sessionId)?.collaborationMode - ?? 'agent' - ) === 'plan'; - if (queued !== settledActive) void applyPlanMode(queued, sessionId); - } - } - } - - /** - * Set the Session's standing orchestration default — the only path that - * writes `orchestrationMode`, and it writes nothing else. - * - * One field with three values, so there is nothing to sequence and nothing - * to leave half-applied: Swarm, Graph and off are one write each. - */ - async function applyOrchestrationMode( - mode: OrchestrationMode, - sessionId: string, - ): Promise { - if (!addPendingSessionAction( - sessionId, - orchestrationModeChangeRegistry.keysRef, - )) { - // Same latest-intent contract as applyPlanMode: the rows stay - // interactive while a commit runs, so keep the newest ask and apply it - // from the in-flight commit's finally block. - queuedOrchestrationModeBySession.current.set(sessionId, mode); - return true; - } - - try { - await window.maka.sessions.setOrchestrationMode(sessionId, mode); - await refreshSessions(); - return true; - } catch (error) { - if (activeIdRef.current === sessionId) { - showSessionError( - sessionId, - shellCopy.orchestrationModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.orchestrationModeFallback, uiLocale), - ); - } return false; - } finally { - clearPendingSessionAction( + } + const latestProposal = planState.proposals.find( + (proposal) => proposal.proposalId === planState.latestProposalId, + ); + if (!active && latestProposal?.status === 'pending_approval') { + const confirmed = await toastApi.confirm({ + title: shellCopy.planModeExitPendingTitle, + description: shellCopy.planModeExitPendingDescription(latestProposal.title), + confirmLabel: shellCopy.planModeExitConfirm, + cancelLabel: shellCopy.planModeExitCancel, + destructive: true, + }); + if (!confirmed) return false; + // Abandoning the proposal is what leaves Plan: Runtime writes the + // Session back to `agent` itself as part of it. + await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); + } else { + await window.maka.sessions.setCollaborationMode( sessionId, - orchestrationModeChangeRegistry.keysRef, + active ? 'plan' : 'agent', ); - const queued = queuedOrchestrationModeBySession.current.get(sessionId); - queuedOrchestrationModeBySession.current.delete(sessionId); - if (queued !== undefined) { - const settled = sessionsRef.current.find( - (session) => session.id === sessionId, - )?.orchestrationMode ?? 'default'; - if (queued !== settled) void applyOrchestrationMode(queued, sessionId); - } } + return true; } function setPlanMode(active: boolean): Promise { @@ -1151,7 +1067,7 @@ function AppShellContent({ return Promise.resolve(true); } if (active === activePlanMode) return Promise.resolve(true); - return applyPlanMode(active, sessionId); + return planModeIntent.request(sessionId, active); } /** @@ -1168,7 +1084,7 @@ function AppShellContent({ return Promise.resolve(true); } if (mode === activeOrchestrationMode) return Promise.resolve(true); - return applyOrchestrationMode(mode, sessionId); + return orchestrationModeIntent.request(sessionId, mode); } function setOrchestrationModeActive( @@ -1311,11 +1227,13 @@ function AppShellContent({ // to keep in sync: a Session in Plan with Swarm as its orchestration default // says both, because it is both. const activePlanMode = activeId - ? transientPlanModeBySession[activeId] + ? planModeIntent.overlayBySession[activeId] ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') : newChatPlanModeActive; const activeOrchestrationMode: OrchestrationMode = activeId - ? activeSessionForView?.orchestrationMode ?? 'default' + ? orchestrationModeIntent.overlayBySession[activeId] + ?? activeSessionForView?.orchestrationMode + ?? 'default' : newChatOrchestrationMode; /** * Why neither mode can be changed right now, if either cannot. Both controls diff --git a/apps/desktop/src/renderer/use-session-setting-intent.ts b/apps/desktop/src/renderer/use-session-setting-intent.ts new file mode 100644 index 0000000000..743e3ebb5e --- /dev/null +++ b/apps/desktop/src/renderer/use-session-setting-intent.ts @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface SettingIntent { + desired: Value; + committed?: Value; + inFlight: boolean; +} + +interface SessionSettingIntentOptions { + catalogRevision: unknown; + readCatalogValue(sessionId: string): Value | undefined; + write(sessionId: string, value: Value): Promise; + refreshCatalog(): Promise; + onWriteError(sessionId: string, error: unknown): void; +} + +interface SessionSettingIntentController { + overlayBySession: Readonly>; + request(sessionId: string, value: Value): Promise; + clear(sessionId: string): void; +} + +/** + * Owns the gap between a renderer setting intent, its Host commit, and the + * later catalog snapshot that observes that commit. Only the latest desired + * value is written; a committed overlay remains until the catalog confirms it. + */ +export function useSessionSettingIntent( + options: SessionSettingIntentOptions, +): SessionSettingIntentController { + const optionsRef = useRef(options); + optionsRef.current = options; + const intentsRef = useRef(new Map>()); + const [overlayBySession, setOverlayBySession] = useState>({}); + + const setOverlay = useCallback((sessionId: string, value: Value | undefined): void => { + setOverlayBySession((current) => { + if (value !== undefined) { + if (Object.is(current[sessionId], value)) return current; + return { ...current, [sessionId]: value }; + } + if (!(sessionId in current)) return current; + const next = { ...current }; + delete next[sessionId]; + return next; + }); + }, []); + + const reconcile = useCallback((sessionId: string): void => { + const intent = intentsRef.current.get(sessionId); + if (!intent || intent.inFlight || intent.committed === undefined) return; + if (!Object.is(optionsRef.current.readCatalogValue(sessionId), intent.committed)) return; + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + }, [setOverlay]); + + useEffect(() => { + for (const sessionId of intentsRef.current.keys()) reconcile(sessionId); + }, [options.catalogRevision, reconcile]); + + const request = useCallback(async (sessionId: string, value: Value): Promise => { + const existing = intentsRef.current.get(sessionId); + if (existing) { + existing.desired = value; + setOverlay(sessionId, value); + if (existing.inFlight) return true; + } + + const intent = existing ?? { desired: value, inFlight: false }; + intentsRef.current.set(sessionId, intent); + intent.desired = value; + intent.inFlight = true; + setOverlay(sessionId, value); + + let succeeded = true; + while (intentsRef.current.get(sessionId) === intent) { + const attempted = intent.desired; + let committed = false; + try { + committed = await optionsRef.current.write(sessionId, attempted); + } catch (error) { + optionsRef.current.onWriteError(sessionId, error); + } + + if (intentsRef.current.get(sessionId) !== intent) return false; + if (committed) { + intent.committed = attempted; + setOverlay(sessionId, attempted); + // Refresh is only a convergence nudge. A read failure cannot undo a + // Host commit or strand the latest-intent worker. + try { + await optionsRef.current.refreshCatalog(); + } catch {} + } else { + succeeded = false; + if (Object.is(intent.desired, attempted)) { + setOverlay(sessionId, intent.committed); + break; + } + } + if (Object.is(intent.desired, attempted)) break; + } + + if (intentsRef.current.get(sessionId) === intent) { + intent.inFlight = false; + reconcile(sessionId); + } + return succeeded; + }, [reconcile, setOverlay]); + + const clear = useCallback((sessionId: string): void => { + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + }, [setOverlay]); + + return { overlayBySession, request, clear }; +} From 748c2e1167b9368374babaa37145ad1958e63c75 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 02:06:35 +0800 Subject: [PATCH 15/16] fix(desktop): retire settings on newer catalog snapshots Fence committed mode overlays by successful catalog observation revision instead of value equality. Runtime-owned transitions such as Plan approval can now supersede an older renderer commit without letting failed reads discard it. Generated-by: Maka --- .../__tests__/session-setting-intent.test.ts | 110 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 15 +-- .../renderer/use-app-shell-session-list.ts | 12 +- .../renderer/use-session-setting-intent.ts | 22 ++-- 4 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-setting-intent.test.ts diff --git a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts new file mode 100644 index 0000000000..8b5fdebacc --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { useSessionSettingIntent } from '../../renderer/use-session-setting-intent.js'; + +type SessionSettingIntentController = ReturnType< + typeof useSessionSettingIntent +>; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('Runtime leaving Plan after approval supersedes the committed Plan overlay', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + let controller: SessionSettingIntentController | undefined; + const render = async (catalogRevision: number, catalogValue: boolean) => { + await act(async () => { + root.render(createElement(Harness, { + catalogRevision, + catalogValue, + capture: (next) => { + controller = next; + }, + })); + }); + }; + + await render(0, false); + await act(async () => { + await controller?.request('session-1', true); + }); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'true'); + + // Runtime automatically leaves Plan after approval. This is a successful, + // causally newer catalog observation, so its Agent value must win even + // though it differs from the renderer's earlier committed Plan value. + await render(1, false); + + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'false'); +}); + +function Harness({ + catalogRevision, + catalogValue, + capture, +}: { + catalogRevision: number; + catalogValue: boolean; + capture(controller: SessionSettingIntentController): void; +}) { + const controller = useSessionSettingIntent({ + catalogRevision, + write: async () => true, + refreshCatalog: async () => { + throw new Error('catalog unavailable'); + }, + onWriteError: () => {}, + }); + capture(controller); + return createElement('output', { + 'data-value': (controller.overlayBySession['session-1'] ?? catalogValue).toString(), + }); +} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 2edb3a1395..2f6eedfc47 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -354,6 +354,7 @@ function AppShellContent({ const notifiedInstallErrorRef = useRef(null); const { sessions, + catalogRevision, authoritativeSessionIds, sessionsRef, refreshSessions, @@ -970,13 +971,10 @@ function AppShellContent({ // Mode writes and catalog reads run on different clocks. These controllers // own that gap: latest intent wins, and a Host-committed value remains the - // presentation overlay until a later catalog snapshot confirms it. + // presentation overlay until a causally later successful catalog snapshot + // takes over — whether it confirms that value or shows a newer Host change. const planModeIntent = useSessionSettingIntent({ - catalogRevision: sessions, - readCatalogValue: (sessionId) => { - const mode = sessionsRef.current.find((session) => session.id === sessionId)?.collaborationMode; - return mode === undefined ? undefined : mode === 'plan'; - }, + catalogRevision, write: commitPlanMode, refreshCatalog: refreshSessions, onWriteError: (sessionId, error) => { @@ -989,10 +987,7 @@ function AppShellContent({ }, }); const orchestrationModeIntent = useSessionSettingIntent({ - catalogRevision: sessions, - readCatalogValue: (sessionId) => sessionsRef.current.find( - (session) => session.id === sessionId, - )?.orchestrationMode, + catalogRevision, write: async (sessionId, mode) => { await window.maka.sessions.setOrchestrationMode(sessionId, mode); return true; diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index 546364efb7..4c664a49ca 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -51,7 +51,14 @@ export function useAppShellSessionList( const uiLocale = useUiLocale(); const uiLocaleRef = useRef(uiLocale); uiLocaleRef.current = uiLocale; - const [sessions, setSessionsState] = useState([]); + // The list and its observation revision are one committed snapshot. A + // failed refresh changes neither, so consumers can fence transient writes + // against successful catalog observations without a parallel error flag. + const [catalog, setCatalog] = useState<{ + sessions: DesktopSessionSummary[]; + revision: number; + }>({ sessions: [], revision: 0 }); + const { sessions, revision: catalogRevision } = catalog; const authoritativeSessionIds = useMemo( () => new Set(sessions.map(({ id }) => id)), [sessions], @@ -61,7 +68,7 @@ export function useAppShellSessionList( function commitSessions(next: DesktopSessionSummary[]): void { sessionsRef.current = next; - setSessionsState(next); + setCatalog((current) => ({ sessions: next, revision: current.revision + 1 })); } if (!refresherRef.current) { @@ -106,6 +113,7 @@ export function useAppShellSessionList( return { sessions, + catalogRevision, authoritativeSessionIds, sessionsRef, refreshSessions, diff --git a/apps/desktop/src/renderer/use-session-setting-intent.ts b/apps/desktop/src/renderer/use-session-setting-intent.ts index 743e3ebb5e..bcde3ded5b 100644 --- a/apps/desktop/src/renderer/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/use-session-setting-intent.ts @@ -22,12 +22,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; interface SettingIntent { desired: Value; committed?: Value; + committedAtCatalogRevision?: number; inFlight: boolean; } interface SessionSettingIntentOptions { - catalogRevision: unknown; - readCatalogValue(sessionId: string): Value | undefined; + catalogRevision: number; write(sessionId: string, value: Value): Promise; refreshCatalog(): Promise; onWriteError(sessionId: string, error: unknown): void; @@ -41,8 +41,10 @@ interface SessionSettingIntentController { /** * Owns the gap between a renderer setting intent, its Host commit, and the - * later catalog snapshot that observes that commit. Only the latest desired - * value is written; a committed overlay remains until the catalog confirms it. + * next successful catalog observation. Only the latest desired value is + * written. A failed read retains the committed overlay; any causally newer + * successful snapshot retires it, because the Host may legitimately move on + * again (for example, Runtime leaves Plan after approval). */ export function useSessionSettingIntent( options: SessionSettingIntentOptions, @@ -67,8 +69,8 @@ export function useSessionSettingIntent( const reconcile = useCallback((sessionId: string): void => { const intent = intentsRef.current.get(sessionId); - if (!intent || intent.inFlight || intent.committed === undefined) return; - if (!Object.is(optionsRef.current.readCatalogValue(sessionId), intent.committed)) return; + if (!intent || intent.inFlight || intent.committedAtCatalogRevision === undefined) return; + if (optionsRef.current.catalogRevision <= intent.committedAtCatalogRevision) return; intentsRef.current.delete(sessionId); setOverlay(sessionId, undefined); }, [setOverlay]); @@ -104,6 +106,7 @@ export function useSessionSettingIntent( if (intentsRef.current.get(sessionId) !== intent) return false; if (committed) { intent.committed = attempted; + intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; setOverlay(sessionId, attempted); // Refresh is only a convergence nudge. A read failure cannot undo a // Host commit or strand the latest-intent worker. @@ -122,7 +125,12 @@ export function useSessionSettingIntent( if (intentsRef.current.get(sessionId) === intent) { intent.inFlight = false; - reconcile(sessionId); + if (intent.committedAtCatalogRevision === undefined) { + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + } else { + reconcile(sessionId); + } } return succeeded; }, [reconcile, setOverlay]); From 5fde3d4951d5bfbcd91518ebc9e96b33616f4d13 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 02:13:29 +0800 Subject: [PATCH 16/16] fix(runtime-host): advance catalog protocol epoch Current main already owns epoch 43 for shell-run poll correlation. Advance the retired Session timestamp wire change to epoch 44 and pin that compatibility floor. Generated-by: Maka --- packages/runtime-host/src/__tests__/protocol.test.ts | 4 ++++ packages/runtime-host/src/protocol/index.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 9cd6a16514..146c199f07 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -213,6 +213,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 42); }); + test('publishes a new compatibility epoch for the retired Session timestamp', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 43); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index a4435d55a7..eb63d7d0ab 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,9 +91,11 @@ 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 = 43 as const; -// 43: Session continuity and inspection stop carrying the retired Session +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 44 as const; +// 44: Session continuity and inspection stop carrying the retired Session // last-used timestamp. Older peers reject those strict projection shapes. +// 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. +// Older peers reject that added closed-union field. // 42: Turn provider retry progress adds `provider_capacity`. Older peers reject // that strict retry-reason enum value, so mixed versions must fail handshake. // 41: Context compaction returns a typed terminal outcome on both Turn