From b8e56c2a3c9a9a42a11356df79eeaeb0789a995c Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 02:00:00 +0800 Subject: [PATCH 001/168] feat: implement manager replacement functionality with tests - Added `stopServiceManagerForReplacement` function to handle graceful and forced shutdown of the service manager. - Created unit tests for the manager replacement logic to ensure correct behavior under various scenarios. - Introduced `KunOwnerVerificationError` for better error handling during manager replacement. - Updated `startup-failure-content` to include detailed presentation for handoff errors. - Enhanced `showStartupFailureWindow` to support recovery actions during startup failures. - Refactored related functions and types for clarity and maintainability. --- .github/workflows/daily-dev-prerelease.yml | 28 +- .github/workflows/pr-checks.yml | 28 +- .github/workflows/release.yml | 28 +- kun/src/cli/runtime-shutdown-client.ts | 33 + kun/src/cli/shared-runtime.test.ts | 41 ++ kun/src/cli/shared-runtime.ts | 20 +- .../manager/forced-runtime-recovery.test.ts | 82 +++ kun/src/manager/forced-runtime-recovery.ts | 110 +++ kun/src/manager/manager-client.ts | 148 ++-- kun/src/manager/manager-discovery.test.ts | 43 ++ kun/src/manager/manager-discovery.ts | 72 +- kun/src/manager/manager-launch.ts | 58 ++ kun/src/manager/service-manager-state.ts | 72 +- kun/src/manager/service-manager.test.ts | 47 ++ kun/src/manager/service-manager.ts | 1 + .../update-handoff-data-continuity.test.ts | 180 +++++ kun/src/server/runtime-discovery.test.ts | 52 ++ kun/src/server/runtime-discovery.ts | 102 ++- kun/src/server/runtime-server-start.ts | 4 + ...urn-service-manager-reconciliation.test.ts | 98 +++ .../turn-service-runtime-state-operations.ts | 11 + package.json | 3 +- ...check-extension-release-gate-workflows.mjs | 13 + scripts/fixtures/update-handoff-owner.cjs | 83 +++ scripts/release-mac.sh | 4 + scripts/release-win.ps1 | 7 + ...oke-packaged-extension-desktop-runtime.cjs | 2 + .../smoke-packaged-update-handoff-support.cjs | 398 +++++++++++ scripts/smoke-packaged-update-handoff.cjs | 546 +++++++++++++++ .../smoke-packaged-update-handoff.test.cjs | 85 +++ src/main/index.ts | 13 + src/main/kun-process-ports.ts | 30 +- src/main/kun-process-termination.test.ts | 81 +++ src/main/kun-process.ports.test.ts | 1 + src/main/kun-process.ts | 78 ++- src/main/main-migrations.ts | 98 ++- src/main/main-ready-services.ts | 8 + src/main/main-ready.ts | 25 +- .../packaged-update-handoff-smoke.test.ts | 49 ++ src/main/packaged-update-handoff-smoke.ts | 80 +++ src/main/runtime/kun-handoff-logging.test.ts | 79 +++ src/main/runtime/kun-handoff-logging.ts | 37 + .../kun-installed-build-handoff.test.ts | 244 +++++++ .../runtime/kun-installed-build-handoff.ts | 642 ++++++++++++++++++ .../runtime/kun-manager-replacement.test.ts | 176 +++++ src/main/runtime/kun-manager-replacement.ts | 217 ++++++ src/main/runtime/kun-replacement-error.ts | 14 + .../runtime/kun-serve-replacement.test.ts | 14 +- src/main/runtime/kun-serve-replacement.ts | 147 ++-- src/main/startup-failure-content.ts | 60 +- src/main/startup-failure-window.test.ts | 129 ++++ src/main/startup-failure-window.ts | 59 +- 52 files changed, 4429 insertions(+), 251 deletions(-) create mode 100644 kun/src/cli/runtime-shutdown-client.ts create mode 100644 kun/src/manager/forced-runtime-recovery.test.ts create mode 100644 kun/src/manager/forced-runtime-recovery.ts create mode 100644 kun/src/manager/manager-launch.ts create mode 100644 kun/src/manager/update-handoff-data-continuity.test.ts create mode 100644 kun/src/services/turn-service-manager-reconciliation.test.ts create mode 100644 scripts/fixtures/update-handoff-owner.cjs create mode 100644 scripts/smoke-packaged-update-handoff-support.cjs create mode 100644 scripts/smoke-packaged-update-handoff.cjs create mode 100644 scripts/smoke-packaged-update-handoff.test.cjs create mode 100644 src/main/kun-process-termination.test.ts create mode 100644 src/main/packaged-update-handoff-smoke.test.ts create mode 100644 src/main/packaged-update-handoff-smoke.ts create mode 100644 src/main/runtime/kun-handoff-logging.test.ts create mode 100644 src/main/runtime/kun-handoff-logging.ts create mode 100644 src/main/runtime/kun-installed-build-handoff.test.ts create mode 100644 src/main/runtime/kun-installed-build-handoff.ts create mode 100644 src/main/runtime/kun-manager-replacement.test.ts create mode 100644 src/main/runtime/kun-manager-replacement.ts create mode 100644 src/main/runtime/kun-replacement-error.ts diff --git a/.github/workflows/daily-dev-prerelease.yml b/.github/workflows/daily-dev-prerelease.yml index 907e120e9..ee0d74693 100644 --- a/.github/workflows/daily-dev-prerelease.yml +++ b/.github/workflows/daily-dev-prerelease.yml @@ -95,6 +95,18 @@ jobs: - name: Build macOS packages run: npm run dist:mac + - name: Smoke packaged update handoff (host-native macOS) + timeout-minutes: 20 + shell: bash + run: | + set -euo pipefail + if [[ "$(node -p 'process.arch')" == "arm64" ]]; then + resources="dist/mac-arm64/Kun.app/Contents/Resources" + else + resources="dist/mac/Kun.app/Contents/Resources" + fi + npm run smoke:packaged-update-handoff -- --resources "${resources}" + - name: Upload macOS artifacts uses: actions/upload-artifact@v4 with: @@ -141,6 +153,10 @@ jobs: - name: Build Windows installer run: npm run dist:win + - name: Smoke packaged update handoff (Windows) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/win-unpacked/resources + - name: Upload Windows artifacts uses: actions/upload-artifact@v4 with: @@ -183,7 +199,7 @@ jobs: - name: Install Linux packaging dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 xvfb xauth - name: Install dependencies run: npm ci @@ -191,6 +207,10 @@ jobs: - name: Build Linux AppImage run: npm run dist:linux + - name: Smoke packaged update handoff (Linux x64) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/linux-unpacked/resources + - name: Upload Linux artifacts uses: actions/upload-artifact@v4 with: @@ -234,7 +254,7 @@ jobs: - name: Install Linux packaging dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file xvfb xauth - name: Install dependencies run: npm ci @@ -249,6 +269,10 @@ jobs: --arch arm64 --dist dist + - name: Smoke packaged update handoff (Linux ARM64) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/linux-arm64-unpacked/resources + - name: Upload Linux ARM64 artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index bfb1c8baf..a9a0cc821 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -39,7 +39,7 @@ jobs: - name: Install Linux packaging dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 xvfb xauth - name: Install root dependencies run: npm ci @@ -47,6 +47,10 @@ jobs: - name: Build Linux AppImage run: npm run dist:linux + - name: Smoke packaged update handoff (Linux x64) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/linux-unpacked/resources + - name: Upload Linux package uses: actions/upload-artifact@v4 with: @@ -78,7 +82,7 @@ jobs: - name: Install Linux packaging dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file xvfb xauth - name: Install root dependencies run: npm ci @@ -110,6 +114,10 @@ jobs: --arch arm64 --dist dist + - name: Smoke packaged update handoff (Linux ARM64) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/linux-arm64-unpacked/resources + - name: Upload Linux ARM64 PR package uses: actions/upload-artifact@v4 with: @@ -155,6 +163,18 @@ jobs: - name: Build ad-hoc macOS packages (x64 and arm64) run: npm run dist:mac + - name: Smoke packaged update handoff (host-native macOS) + timeout-minutes: 20 + shell: bash + run: | + set -euo pipefail + if [[ "$(node -p 'process.arch')" == "arm64" ]]; then + resources="dist/mac-arm64/Kun.app/Contents/Resources" + else + resources="dist/mac/Kun.app/Contents/Resources" + fi + npm run smoke:packaged-update-handoff -- --resources "${resources}" + - name: Upload ad-hoc macOS PR packages uses: actions/upload-artifact@v4 with: @@ -209,6 +229,10 @@ jobs: - name: Build Windows NSIS installer run: npm run dist:win + - name: Smoke packaged update handoff (Windows) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/win-unpacked/resources + - name: Upload Windows PR package uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbbd9495b..5aa0e0f17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,6 +132,18 @@ jobs: - name: Build signed macOS packages run: npm run dist:mac:signed + - name: Smoke packaged update handoff (host-native macOS) + timeout-minutes: 20 + shell: bash + run: | + set -euo pipefail + if [[ "$(node -p 'process.arch')" == "arm64" ]]; then + resources="dist/mac-arm64/Kun.app/Contents/Resources" + else + resources="dist/mac/Kun.app/Contents/Resources" + fi + npm run smoke:packaged-update-handoff -- --resources "${resources}" + - name: Upload macOS artifacts uses: actions/upload-artifact@v4 with: @@ -176,6 +188,10 @@ jobs: - name: Build Windows installer run: npm run dist:win + - name: Smoke packaged update handoff (Windows) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/win-unpacked/resources + - name: Upload Windows artifacts uses: actions/upload-artifact@v4 with: @@ -218,7 +234,7 @@ jobs: sudo apt-get update # build-essential + python3: node-pty ships no Linux prebuild, so it # must be compiled from source against Electron's ABI during dist. - sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 xvfb xauth - name: Install dependencies run: npm ci @@ -226,6 +242,10 @@ jobs: - name: Build Linux AppImage run: npm run dist:linux + - name: Smoke packaged update handoff (Linux x64) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/linux-unpacked/resources + - name: Upload Linux artifacts uses: actions/upload-artifact@v4 with: @@ -267,7 +287,7 @@ jobs: - name: Install Linux packaging dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file xvfb xauth - name: Install dependencies run: npm ci @@ -282,6 +302,10 @@ jobs: --arch arm64 --dist dist + - name: Smoke packaged update handoff (Linux ARM64) + timeout-minutes: 20 + run: npm run smoke:packaged-update-handoff -- --resources dist/linux-arm64-unpacked/resources + - name: Upload Linux ARM64 artifacts uses: actions/upload-artifact@v4 with: diff --git a/kun/src/cli/runtime-shutdown-client.ts b/kun/src/cli/runtime-shutdown-client.ts new file mode 100644 index 000000000..0f9102e5a --- /dev/null +++ b/kun/src/cli/runtime-shutdown-client.ts @@ -0,0 +1,33 @@ +import { + isSafeRuntimeHandoffDiscovery, + type RuntimeHandoffDiscoveryRecord +} from '../server/runtime-discovery.js' + +const SHUTDOWN_REQUEST_TIMEOUT_MS = 5_000 + +/** + * Ask one exact local Runtime instance to stop. This is intentionally + * independent of the current Runtime info/capability schema: possession of + * the discovery token plus the instance-bound endpoint is the control proof. + */ +export async function requestExactRuntimeShutdown( + target: RuntimeHandoffDiscoveryRecord, + fetchImpl: typeof fetch = fetch +): Promise { + if (!isSafeRuntimeHandoffDiscovery(target)) { + throw new Error('runtime shutdown target is not a safe loopback discovery owner') + } + const response = await fetchImpl( + `${target.baseUrl.replace(/\/$/u, '')}/v1/runtime/shutdown`, + { + method: 'POST', + headers: { + authorization: `Bearer ${target.runtimeToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ instanceId: target.instanceId }), + signal: AbortSignal.timeout(SHUTDOWN_REQUEST_TIMEOUT_MS) + } + ) + if (!response.ok) throw new Error(`runtime shutdown failed with HTTP ${response.status}`) +} diff --git a/kun/src/cli/shared-runtime.test.ts b/kun/src/cli/shared-runtime.test.ts index 2283fc57c..66904ce42 100644 --- a/kun/src/cli/shared-runtime.test.ts +++ b/kun/src/cli/shared-runtime.test.ts @@ -200,6 +200,47 @@ describe('shared runtime discovery validation', () => { } }) + it('gracefully stops an exact owner whose full info schema is incompatible', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-shared-runtime-old-info-')) + const discovery = record({ pid: 2_147_483_640, buildId: 'a'.repeat(64) }) + const originalKill = process.kill.bind(process) + let alive = true + const killSpy = vi.spyOn(process, 'kill').mockImplementation(((pid, signal) => { + if (pid !== discovery.pid) return originalKill(pid, signal) + if (alive) return true + throw Object.assign(new Error('process is gone'), { code: 'ESRCH' }) + }) as typeof process.kill) + const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') { + alive = false + return Response.json({ accepted: true, instanceId: discovery.instanceId }) + } + // Identity is correct, but this intentionally predates the current + // RuntimeInfoResponse capability schema. + return Response.json({ + instanceId: discovery.instanceId, + pid: discovery.pid, + startedAt: discovery.startedAt + }) + }) + const fetchImpl = fetchMock as unknown as typeof fetch + try { + await writeFile( + join(dataDir, 'runtime.json'), + `${JSON.stringify(discovery, null, 2)}\n`, + 'utf8' + ) + + await expect(stopSharedRuntime(dataDir, fetchImpl)).resolves.toBe(true) + expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true) + await expect(readFile(join(dataDir, 'runtime.json'), 'utf8')) + .rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + killSpy.mockRestore() + await rm(dataDir, { recursive: true, force: true }) + } + }) + it('reuses a healthy manager owner when filesystem discovery is missing', async () => { const dataDir = await mkdtemp(join(tmpdir(), 'kun-manager-runtime-owner-')) const buildId = 'a'.repeat(64) diff --git a/kun/src/cli/shared-runtime.ts b/kun/src/cli/shared-runtime.ts index 4b43688ff..efa77c964 100644 --- a/kun/src/cli/shared-runtime.ts +++ b/kun/src/cli/shared-runtime.ts @@ -39,6 +39,7 @@ import { withRuntimeDataDirAncillaryWriter, withRuntimeDataDirConfigWriter } from '../server/runtime-data-dir-lease.js' +import { requestExactRuntimeShutdown } from './runtime-shutdown-client.js' const START_TIMEOUT_MS = 30_000 const STOP_TIMEOUT_MS = 15_000 @@ -542,21 +543,16 @@ async function stopInspectedSharedRuntime( const discoveryDir = runtimeDiscoveryDirectory(dataDir, runtimeFlavor, scope.controlDir) const record = inspected.discovery const live = inspected.connection - if (!live) { + try { + await requestExactRuntimeShutdown(record, fetchImpl) + } catch (error) { + if (live) throw error + const detail = error instanceof Error ? error.message : String(error) throw new Error( - `Kun shared runtime process ${record.pid} is still alive but did not respond to the shutdown probe; its discovery record was preserved` + `Kun shared runtime process ${record.pid} did not accept its authenticated shutdown request; ` + + `its discovery record was preserved: ${detail}` ) } - const response = await fetchImpl(`${record.baseUrl.replace(/\/$/u, '')}/v1/runtime/shutdown`, { - method: 'POST', - headers: { - authorization: `Bearer ${record.runtimeToken}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ instanceId: record.instanceId }), - signal: AbortSignal.timeout(5_000) - }) - if (!response.ok) throw new Error(`runtime shutdown failed with HTTP ${response.status}`) const deadline = Date.now() + STOP_TIMEOUT_MS while (Date.now() < deadline) { if (!processAlive(record.pid)) { diff --git a/kun/src/manager/forced-runtime-recovery.test.ts b/kun/src/manager/forced-runtime-recovery.test.ts new file mode 100644 index 000000000..cd4e8a34e --- /dev/null +++ b/kun/src/manager/forced-runtime-recovery.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + forcedRuntimeRecoveryPath, + readForcedRuntimeRecovery, + recordVerifiedForcedRuntimeOwner, + removeForcedRuntimeRecovery +} from './forced-runtime-recovery.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'kun-forced-runtime-recovery-')) + roots.push(root) + return { controlDir: root, dataDir: join(root, 'data') } +} + +describe('forced Runtime recovery marker', () => { + it('merges exact owners without persisting control tokens or commands', async () => { + const test = await fixture() + const first = await recordVerifiedForcedRuntimeOwner({ + ...test, + owner: { + flavor: 'production', + instanceId: 'production-old', + pid: 4101, + startedAt: '2026-08-21T00:00:00.000Z' + }, + now: new Date('2026-08-21T00:02:00.000Z') + }) + const second = await recordVerifiedForcedRuntimeOwner({ + ...test, + owner: { + flavor: 'development', + instanceId: 'development-old', + pid: 4102, + startedAt: '2026-08-21T00:00:01.000Z' + }, + now: new Date('2026-08-21T00:03:00.000Z') + }) + + expect(second.markerId).toBe(first.markerId) + expect(second.owners.map((owner) => owner.flavor)).toEqual([ + 'production', + 'development' + ]) + const serialized = await readFile(forcedRuntimeRecoveryPath(test.controlDir), 'utf8') + expect(serialized).not.toMatch(/token|command|settings/iu) + if (process.platform !== 'win32') { + expect((await stat(forcedRuntimeRecoveryPath(test.controlDir))).mode & 0o777).toBe(0o600) + } + expect(await removeForcedRuntimeRecovery(test.controlDir, second.markerId)).toBe(true) + expect(await readForcedRuntimeRecovery(test.controlDir)).toBeNull() + }) + + it('rejects malformed, oversized, and cross-data-directory markers', async () => { + const test = await fixture() + const path = forcedRuntimeRecoveryPath(test.controlDir) + await writeFile(path, '{broken', 'utf8') + await expect(readForcedRuntimeRecovery(test.controlDir)).rejects.toThrow() + await writeFile(path, 'x'.repeat(64 * 1024 + 1), 'utf8') + await expect(readForcedRuntimeRecovery(test.controlDir)).rejects.toThrow(/oversized/u) + await rm(path, { force: true }) + await recordVerifiedForcedRuntimeOwner({ + ...test, + owner: { + flavor: 'production', + instanceId: 'production-old', + pid: 4101, + startedAt: '2026-08-21T00:00:00.000Z' + } + }) + await expect(readForcedRuntimeRecovery(test.controlDir, join(test.controlDir, 'other-data'))) + .rejects.toThrow(/different data directory/u) + }) +}) diff --git a/kun/src/manager/forced-runtime-recovery.ts b/kun/src/manager/forced-runtime-recovery.ts new file mode 100644 index 000000000..0feab7bd9 --- /dev/null +++ b/kun/src/manager/forced-runtime-recovery.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto' +import { chmod, readFile, stat, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { atomicWriteFile } from '../adapters/file/atomic-write.js' +import { RuntimeFlavorSchema, type RuntimeFlavor } from '../contracts/runtime-flavor.js' +import { sameCanonicalPath } from './canonical-path.js' + +const FORCED_RUNTIME_RECOVERY_FILE = 'forced-runtime-recovery.json' +const MAX_RECOVERY_FILE_BYTES = 64 * 1024 + +export const VerifiedForcedRuntimeOwnerSchema = z.object({ + flavor: RuntimeFlavorSchema, + instanceId: z.string().min(1).max(256), + pid: z.number().int().positive(), + startedAt: z.string().datetime() +}).strict() + +export type VerifiedForcedRuntimeOwner = z.infer + +export const ForcedRuntimeRecoveryRecordSchema = z.object({ + version: z.literal(1), + markerId: z.string().min(1).max(256), + dataDir: z.string().min(1).max(4_096), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + owners: z.array(VerifiedForcedRuntimeOwnerSchema).min(1).max(8) +}).strict() + +export type ForcedRuntimeRecoveryRecord = z.infer + +export function forcedRuntimeRecoveryPath(controlDir: string): string { + return join(controlDir, FORCED_RUNTIME_RECOVERY_FILE) +} + +export async function readForcedRuntimeRecovery( + controlDir: string, + expectedDataDir?: string +): Promise { + const path = forcedRuntimeRecoveryPath(controlDir) + try { + const metadata = await stat(path) + if (!metadata.isFile() || metadata.size > MAX_RECOVERY_FILE_BYTES) { + throw new Error('Kun forced-runtime recovery marker is invalid or oversized') + } + const record = ForcedRuntimeRecoveryRecordSchema.parse( + JSON.parse(await readFile(path, 'utf8')) + ) + if (expectedDataDir && !sameCanonicalPath(record.dataDir, expectedDataDir)) { + throw new Error('Kun forced-runtime recovery marker owns a different data directory') + } + return record + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return null + throw error + } +} + +export async function recordVerifiedForcedRuntimeOwner(input: { + controlDir: string + dataDir: string + owner: VerifiedForcedRuntimeOwner + now?: Date +}): Promise { + const owner = VerifiedForcedRuntimeOwnerSchema.parse(input.owner) + const existing = await readForcedRuntimeRecovery(input.controlDir, input.dataDir) + const now = (input.now ?? new Date()).toISOString() + const owners = [...(existing?.owners ?? [])] + const index = owners.findIndex((candidate) => + candidate.flavor === owner.flavor && candidate.instanceId === owner.instanceId + ) + if (index >= 0) owners[index] = owner + else owners.push(owner) + const record = ForcedRuntimeRecoveryRecordSchema.parse({ + version: 1, + markerId: existing?.markerId ?? randomUUID(), + dataDir: input.dataDir, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + owners + }) + const path = forcedRuntimeRecoveryPath(input.controlDir) + await atomicWriteFile(path, `${JSON.stringify(record, null, 2)}\n`) + await chmod(path, 0o600).catch((error) => { + if (process.platform !== 'win32') throw error + }) + return record +} + +export async function removeForcedRuntimeRecovery( + controlDir: string, + markerId: string +): Promise { + const current = await readForcedRuntimeRecovery(controlDir) + if (!current || current.markerId !== markerId) return false + try { + await unlink(forcedRuntimeRecoveryPath(controlDir)) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false + throw error + } +} + +export function forcedOwnerKey(owner: { + flavor: RuntimeFlavor + instanceId: string +}): string { + return `${owner.flavor}:${owner.instanceId}` +} diff --git a/kun/src/manager/manager-client.ts b/kun/src/manager/manager-client.ts index 09a4981f6..5f322bbee 100644 --- a/kun/src/manager/manager-client.ts +++ b/kun/src/manager/manager-client.ts @@ -1,10 +1,5 @@ -import { randomBytes, randomUUID } from 'node:crypto' -import { closeSync, openSync } from 'node:fs' -import { mkdir } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' import { z } from 'zod' import { RuntimeFlavorSchema, @@ -39,6 +34,10 @@ import { withRuntimeDataDirAncillaryWriter } from '../server/runtime-data-dir-le import { resolveServiceManager } from './manager-resolution.js' +import { + launchServiceManagerProcess, + type ManagerLaunchOverride +} from './manager-launch.js' export { resolveServiceManager, resolveServiceManagerForMigration @@ -175,7 +174,7 @@ export class ManagerResourceLeaseClient { } } -export async function ensureServiceManager(input: { +export type EnsureServiceManagerInput = { flavor: RuntimeFlavor controlDir?: string fetch?: typeof fetch @@ -184,13 +183,12 @@ export async function ensureServiceManager(input: { buildId?: string dataDir: string settingsPath?: string - launch?: { - command: string - args: string[] - env?: NodeJS.ProcessEnv - runAsNode?: boolean - } -}): Promise { + launch?: ManagerLaunchOverride +} + +export async function ensureServiceManager( + input: EnsureServiceManagerInput +): Promise { const controlDir = input.controlDir ?? defaultKunControlDir() const settingsPath = input.settingsPath ?? defaultProductionSettingsPath() const fetchImpl = input.fetch ?? fetch @@ -203,80 +201,66 @@ export async function ensureServiceManager(input: { } return existing } + assertManagerBootstrapAllowed(input) + return withManagerStartLock( + controlDir, + () => ensureServiceManagerWithStartLockHeld(input) + ) +} + +/** Caller must already hold withManagerStartLock for this control directory. */ +export async function ensureServiceManagerWithStartLockHeld( + input: EnsureServiceManagerInput +): Promise { + const controlDir = input.controlDir ?? defaultKunControlDir() + const settingsPath = input.settingsPath ?? defaultProductionSettingsPath() + const fetchImpl = input.fetch ?? fetch + const elected = await resolveServiceManager(controlDir, fetchImpl) + if (elected) { + if (!managerOwnsPaths(elected.discovery, input.dataDir, settingsPath)) { + throw new Error('Kun Service Manager owns a different canonical data or settings path') + } + return elected + } + assertManagerBootstrapAllowed(input) + const stale = await readManagerDiscovery(controlDir).catch(() => null) + if (stale && !processIsAlive(stale.pid)) { + await removeManagerDiscovery(controlDir, stale.instanceId).catch(() => undefined) + } else if (stale) { + throw new Error(`Kun Service Manager process ${stale.pid} is alive but unavailable`) + } + // The Manager owns the canonical data plane for both flavor slots. Even + // an explicitly allowed source-DV bootstrap must drain a pre-manager + // production writer before opening shared stores; otherwise the DV + // Runtime and legacy production Runtime can concurrently mutate JSONL. + await handoverLegacyProductionRuntime({ + dataDir: input.dataDir, + fetch: fetchImpl, + timeoutMs: Math.max(input.timeoutMs ?? START_TIMEOUT_MS, LEGACY_HANDOVER_TIMEOUT_MS) + }) + const { child, logPath } = await launchServiceManagerProcess({ + controlDir, + dataDir: input.dataDir, + settingsPath, + ...(input.buildId ? { buildId: input.buildId } : {}), + ...(input.launch ? { launch: input.launch } : {}) + }) + const deadline = Date.now() + (input.timeoutMs ?? START_TIMEOUT_MS) + while (Date.now() < deadline) { + const connection = await resolveServiceManager(controlDir, fetchImpl) + if (connection) return connection + if (child.exitCode !== null) break + await delay(POLL_MS) + } + throw new Error(`Kun Service Manager did not become ready; inspect ${logPath}`) +} + +function assertManagerBootstrapAllowed(input: EnsureServiceManagerInput): void { if (input.flavor === 'development' && !input.allowDevelopmentBootstrap) { throw new Error( 'kun-dv requires the compatible Kun Service Manager installed by the production application; start or update Kun first' ) } - return withManagerStartLock(controlDir, async () => { - const elected = await resolveServiceManager(controlDir, fetchImpl) - if (elected) { - if (!managerOwnsPaths(elected.discovery, input.dataDir, settingsPath)) { - throw new Error('Kun Service Manager owns a different canonical data or settings path') - } - return elected - } - const stale = await readManagerDiscovery(controlDir).catch(() => null) - if (stale && !processIsAlive(stale.pid)) { - await removeManagerDiscovery(controlDir, stale.instanceId).catch(() => undefined) - } else if (stale) { - throw new Error(`Kun Service Manager process ${stale.pid} is alive but unavailable`) - } - // The Manager owns the canonical data plane for both flavor slots. Even - // an explicitly allowed source-DV bootstrap must drain a pre-manager - // production writer before opening shared stores; otherwise the DV - // Runtime and legacy production Runtime can concurrently mutate JSONL. - await handoverLegacyProductionRuntime({ - dataDir: input.dataDir, - fetch: fetchImpl, - timeoutMs: Math.max(input.timeoutMs ?? START_TIMEOUT_MS, LEGACY_HANDOVER_TIMEOUT_MS) - }) - await mkdir(controlDir, { recursive: true, mode: 0o700 }) - const logPath = join(controlDir, 'manager.log') - const logFd = openSync(logPath, 'a', 0o600) - const managerToken = randomBytes(32).toString('base64url') - const instanceId = randomUUID() - const entry = fileURLToPath(new URL('./manager-entry.js', import.meta.url)) - const command = input.launch?.command ?? process.execPath - const args = input.launch?.args ?? [entry] - const runAsNode = input.launch?.runAsNode ?? Boolean(process.versions.electron) - let child - try { - child = spawn(command, args, { - detached: true, - windowsHide: true, - stdio: ['ignore', logFd, logFd], - env: { - ...process.env, - ...(input.launch?.env ?? {}), - ...(runAsNode ? { ELECTRON_RUN_AS_NODE: '1' } : {}), - // The spawning runtime may be recovering from a dead manager and - // therefore still carry its old client endpoint. A manager is the - // physical writer and must not proxy AtomicJsonFile operations to a - // predecessor (or recursively to itself). - KUN_MANAGER_BASE_URL: '', - KUN_MANAGER_CONTROL_DIR: controlDir, - KUN_MANAGER_TOKEN: managerToken, - KUN_MANAGER_INSTANCE_ID: instanceId, - ...(input.buildId ? { KUN_RUNTIME_BUILD_ID: input.buildId } : {}), - KUN_MANAGER_DATA_DIR: input.dataDir, - KUN_MANAGER_SETTINGS_PATH: settingsPath, - KUN_MANAGER_LOG_PATH: logPath - } - }) - child.unref() - } finally { - closeSync(logFd) - } - const deadline = Date.now() + (input.timeoutMs ?? START_TIMEOUT_MS) - while (Date.now() < deadline) { - const connection = await resolveServiceManager(controlDir, fetchImpl) - if (connection) return connection - if (child.exitCode !== null) break - await delay(POLL_MS) - } - throw new Error(`Kun Service Manager did not become ready; inspect ${logPath}`) - }) } function managerOwnsPaths( diff --git a/kun/src/manager/manager-discovery.test.ts b/kun/src/manager/manager-discovery.test.ts index af346b3f0..5e48bd9dd 100644 --- a/kun/src/manager/manager-discovery.test.ts +++ b/kun/src/manager/manager-discovery.test.ts @@ -6,6 +6,7 @@ import { createManagerDiscoveryRecord, managerDiscoveryPath, publishManagerDiscovery, + readManagerHandoffDiscovery, readManagerDiscovery, removeManagerDiscovery, withManagerStartLock @@ -65,6 +66,46 @@ describe('manager discovery', () => { expect(legacy).not.toHaveProperty('buildId') }) + it('reads older safe schemas only through the handoff contract', async () => { + const controlDir = await root() + await writeFile(managerDiscoveryPath(controlDir), JSON.stringify({ + ...input(), + version: 7, + protocolVersion: 3, + instanceId: 'older-manager', + futureField: ['ignored', 'for-handoff'] + }), 'utf8') + + expect(await readManagerDiscovery(controlDir)).toBeNull() + expect(await readManagerHandoffDiscovery(controlDir)).toMatchObject({ + instanceId: 'older-manager', + version: 7, + protocolVersion: 3, + futureField: ['ignored', 'for-handoff'] + }) + expect(await removeManagerDiscovery(controlDir, 'older-manager')).toBe(true) + }) + + it('rejects unsafe Manager handoff endpoints and missing identity fields', async () => { + const controlDir = await root() + await writeFile(managerDiscoveryPath(controlDir), JSON.stringify({ + ...input(), + version: 1, + protocolVersion: 1, + instanceId: 'unsafe-manager', + host: 'example.com', + baseUrl: 'http://example.com:18991' + }), 'utf8') + expect(await readManagerHandoffDiscovery(controlDir)).toBeNull() + + await writeFile(managerDiscoveryPath(controlDir), JSON.stringify({ + ...input(), + version: 1, + protocolVersion: 1 + }), 'utf8') + expect(await readManagerHandoffDiscovery(controlDir)).toBeNull() + }) + it('publishes an owner-only discovery record', async () => { const controlDir = await root() const record = await publishManagerDiscovery(controlDir, { ...input(), instanceId: 'manager-a' }) @@ -79,8 +120,10 @@ describe('manager discovery', () => { const controlDir = await root() await writeFile(managerDiscoveryPath(controlDir), '{broken', 'utf8') expect(await readManagerDiscovery(controlDir)).toBeNull() + expect(await readManagerHandoffDiscovery(controlDir)).toBeNull() await writeFile(managerDiscoveryPath(controlDir), 'x'.repeat(65 * 1024), 'utf8') expect(await readManagerDiscovery(controlDir)).toBeNull() + expect(await readManagerHandoffDiscovery(controlDir)).toBeNull() }) it('does not let an old manager remove a replacement record', async () => { diff --git a/kun/src/manager/manager-discovery.ts b/kun/src/manager/manager-discovery.ts index c1d30daac..e64e75c84 100644 --- a/kun/src/manager/manager-discovery.ts +++ b/kun/src/manager/manager-discovery.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { z } from 'zod' import { atomicWriteFile } from '../adapters/file/atomic-write.js' import { RuntimeBuildIdSchema } from '../contracts/runtime-info.js' +import { isLoopbackHost } from '../server/loopback-host.js' export const KUN_MANAGER_PROTOCOL_VERSION = 1 as const export const KUN_MANAGER_DISCOVERY_VERSION = 1 as const @@ -33,7 +34,25 @@ export const ManagerDiscoveryRecordSchema = z.object({ logPath: z.string().min(1).max(4_096).optional() }) +/** Minimal cross-version identity used only to drain an installed owner. */ +export const ManagerHandoffDiscoveryRecordSchema = z.object({ + version: z.number().int().positive().optional(), + protocolVersion: z.number().int().positive().optional(), + instanceId: z.string().min(1).max(256), + pid: z.number().int().positive(), + startedAt: z.string().datetime(), + host: z.string().min(1).max(512), + port: z.number().int().min(1).max(65_535), + baseUrl: z.string().url().max(2_048), + managerToken: z.string().min(1).max(16_384), + buildId: RuntimeBuildIdSchema.optional(), + dataDir: z.string().min(1).max(4_096), + settingsPath: z.string().min(1).max(4_096), + logPath: z.string().min(1).max(4_096).optional() +}).passthrough() + export type ManagerDiscoveryRecord = z.infer +export type ManagerHandoffDiscoveryRecord = z.infer export type PublishManagerDiscoveryInput = Omit< ManagerDiscoveryRecord, 'version' | 'protocolVersion' | 'instanceId' @@ -73,16 +92,20 @@ export function createManagerDiscoveryRecord( export async function readManagerDiscovery( controlDir: string ): Promise { - const path = managerDiscoveryPath(controlDir) - try { - const details = await stat(path) - if (!details.isFile() || details.size > MAX_DISCOVERY_BYTES) return null - const parsed = ManagerDiscoveryRecordSchema.safeParse(JSON.parse(await readFile(path, 'utf8'))) - return parsed.success ? parsed.data : null - } catch (error) { - if (errorCode(error) === 'ENOENT' || error instanceof SyntaxError) return null - throw error - } + const parsed = ManagerDiscoveryRecordSchema.safeParse( + await readManagerDiscoveryValue(controlDir) + ) + return parsed.success ? parsed.data : null +} + +export async function readManagerHandoffDiscovery( + controlDir: string +): Promise { + const parsed = ManagerHandoffDiscoveryRecordSchema.safeParse( + await readManagerDiscoveryValue(controlDir) + ) + if (!parsed.success) return null + return safeHandoffManagerUrl(parsed.data) ? parsed.data : null } export async function publishManagerDiscovery( @@ -106,12 +129,39 @@ export async function removeManagerDiscovery( controlDir: string, instanceId: string ): Promise { - const current = await readManagerDiscovery(controlDir) + const current = await readManagerHandoffDiscovery(controlDir) if (!current || current.instanceId !== instanceId) return false await rm(managerDiscoveryPath(controlDir), { force: true }) return true } +async function readManagerDiscoveryValue(controlDir: string): Promise { + const path = managerDiscoveryPath(controlDir) + try { + const details = await stat(path) + if (!details.isFile() || details.size > MAX_DISCOVERY_BYTES) return null + return JSON.parse(await readFile(path, 'utf8')) as unknown + } catch (error) { + if (errorCode(error) === 'ENOENT' || error instanceof SyntaxError) return null + throw error + } +} + +function safeHandoffManagerUrl(record: ManagerHandoffDiscoveryRecord): boolean { + try { + const url = new URL(record.baseUrl) + return url.protocol === 'http:' && + isLoopbackHost(url.hostname) && + isLoopbackHost(record.host) && + (url.pathname === '/' || url.pathname === '') && + Number(url.port || '80') === record.port && + url.username === '' && + url.password === '' + } catch { + return false + } +} + export async function withManagerStartLock( controlDir: string, action: () => Promise diff --git a/kun/src/manager/manager-launch.ts b/kun/src/manager/manager-launch.ts new file mode 100644 index 000000000..8c47cb2b2 --- /dev/null +++ b/kun/src/manager/manager-launch.ts @@ -0,0 +1,58 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import { closeSync, openSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn, type ChildProcess } from 'node:child_process' + +export type ManagerLaunchOverride = { + command: string + args: string[] + env?: NodeJS.ProcessEnv + runAsNode?: boolean +} + +export async function launchServiceManagerProcess(input: { + controlDir: string + dataDir: string + settingsPath: string + buildId?: string + launch?: ManagerLaunchOverride +}): Promise<{ child: ChildProcess; logPath: string }> { + await mkdir(input.controlDir, { recursive: true, mode: 0o700 }) + const logPath = join(input.controlDir, 'manager.log') + const logFd = openSync(logPath, 'a', 0o600) + const managerToken = randomBytes(32).toString('base64url') + const instanceId = randomUUID() + const entry = fileURLToPath(new URL('./manager-entry.js', import.meta.url)) + const command = input.launch?.command ?? process.execPath + const args = input.launch?.args ?? [entry] + const runAsNode = input.launch?.runAsNode ?? Boolean(process.versions.electron) + let child: ChildProcess + try { + child = spawn(command, args, { + detached: true, + windowsHide: true, + stdio: ['ignore', logFd, logFd], + env: { + ...process.env, + ...(input.launch?.env ?? {}), + ...(runAsNode ? { ELECTRON_RUN_AS_NODE: '1' } : {}), + // A replacement Manager is the physical owner and must never proxy + // its AtomicJsonFile operations to its predecessor (or itself). + KUN_MANAGER_BASE_URL: '', + KUN_MANAGER_CONTROL_DIR: input.controlDir, + KUN_MANAGER_TOKEN: managerToken, + KUN_MANAGER_INSTANCE_ID: instanceId, + ...(input.buildId ? { KUN_RUNTIME_BUILD_ID: input.buildId } : {}), + KUN_MANAGER_DATA_DIR: input.dataDir, + KUN_MANAGER_SETTINGS_PATH: input.settingsPath, + KUN_MANAGER_LOG_PATH: logPath + } + }) + child.unref() + } finally { + closeSync(logFd) + } + return { child, logPath } +} diff --git a/kun/src/manager/service-manager-state.ts b/kun/src/manager/service-manager-state.ts index 6e42bd5da..3ee6dbe1f 100644 --- a/kun/src/manager/service-manager-state.ts +++ b/kun/src/manager/service-manager-state.ts @@ -40,6 +40,12 @@ import { import { buildServiceManagerRouter } from './service-manager-router.js' +import { + forcedOwnerKey, + readForcedRuntimeRecovery, + removeForcedRuntimeRecovery, + type VerifiedForcedRuntimeOwner +} from './forced-runtime-recovery.js' export const KUN_MANAGER_CAPABILITIES = [ 'runtime-slots-v1', @@ -288,6 +294,35 @@ export class ServiceManagerState { return expired } + expireVerifiedRuntimeOwners( + owners: readonly VerifiedForcedRuntimeOwner[] + ): ThreadExecutionLease[] { + const ownerKeys = new Set(owners.map(forcedOwnerKey)) + let changed = false + for (const [flavor, slot] of this.slots) { + if (!ownerKeys.has(forcedOwnerKey({ + flavor, + instanceId: slot.registration.instanceId + }))) continue + this.slots.delete(flavor) + changed = true + } + const expired: ThreadExecutionLease[] = [] + for (const [threadId, lease] of this.leases) { + if (!ownerKeys.has(`${lease.ownerFlavor}:${lease.ownerInstanceId}`)) continue + this.leases.delete(threadId) + expired.push(lease) + changed = true + } + for (const [resource, lease] of this.resourceLeases) { + if (!ownerKeys.has(`${lease.ownerFlavor}:${lease.ownerInstanceId}`)) continue + this.resourceLeases.delete(resource) + changed = true + } + if (changed) this.changed() + return expired + } + acquireResource(input: { resource: string ownerFlavor: RuntimeFlavor @@ -353,6 +388,20 @@ export type ServiceManagerHandle = NodeHttpServerHandle & { shutdownRequested: Promise } +export async function reconcileVerifiedForcedRuntimeRecovery(input: { + controlDir: string + record: NonNullable>> + state: ServiceManagerState + sharedData: Pick + flushState: () => Promise +}): Promise { + const expired = input.state.expireVerifiedRuntimeOwners(input.record.owners) + for (const lease of expired) await input.sharedData.reconcileExpiredLease(lease) + await input.flushState() + await removeForcedRuntimeRecovery(input.controlDir, input.record.markerId) + return expired.length +} + export async function startServiceManager(input: { controlDir: string managerToken: string @@ -374,8 +423,12 @@ export async function startServiceManager(input: { const dataDirLease = await acquireRuntimeDataDirLease(input.dataDir) const managerStatePath = join(input.controlDir, 'manager-state.json') let state: ServiceManagerState + let forcedRecovery: Awaited> try { - state = input.state ?? await readPersistedManagerState(managerStatePath) + ;[state, forcedRecovery] = await Promise.all([ + input.state ?? readPersistedManagerState(managerStatePath), + readForcedRuntimeRecovery(input.controlDir, input.dataDir) + ]) } catch (error) { await dataDirLease.release().catch(() => undefined) throw error @@ -403,6 +456,23 @@ export async function startServiceManager(input: { await dataDirLease.release().catch(() => undefined) throw error } + if (forcedRecovery) { + try { + await reconcileVerifiedForcedRuntimeRecovery({ + controlDir: input.controlDir, + record: forcedRecovery, + state, + sharedData, + flushState: () => statePersistence + }) + } catch (error) { + state.onMutation(undefined) + await statePersistence.catch(() => undefined) + await sharedData.close().catch(() => undefined) + await dataDirLease.release().catch(() => undefined) + throw error + } + } let requestShutdown!: () => void const shutdownRequested = new Promise((resolve) => { requestShutdown = resolve }) let shutdownTimer: ReturnType | undefined diff --git a/kun/src/manager/service-manager.test.ts b/kun/src/manager/service-manager.test.ts index 5d1307280..0bd82b671 100644 --- a/kun/src/manager/service-manager.test.ts +++ b/kun/src/manager/service-manager.test.ts @@ -359,6 +359,53 @@ describe('service manager control plane', () => { expect(state.lease('thread-orphan', new Date('2026-08-01T00:00:21.000Z'))).toBeNull() }) + it('expires only the exact Runtime owner recorded by verified forced handoff', () => { + const state = new ServiceManagerState() + const now = new Date('2026-08-01T00:00:00.000Z') + state.register(registration('production', 'production-forced'), now) + state.register(registration('development', 'development-live'), now) + state.acquireLease({ + threadId: 'thread-forced', + turnId: 'turn-forced', + ownerFlavor: 'production', + ownerInstanceId: 'production-forced' + }, now) + state.acquireLease({ + threadId: 'thread-live', + turnId: 'turn-live', + ownerFlavor: 'development', + ownerInstanceId: 'development-live' + }, now) + state.acquireResource({ + resource: 'forced-resource', + ownerFlavor: 'production', + ownerInstanceId: 'production-forced' + }, now) + + const expired = state.expireVerifiedRuntimeOwners([{ + flavor: 'production', + instanceId: 'production-forced', + pid: 4101, + startedAt: now.toISOString() + }]) + + expect(expired).toMatchObject([{ + threadId: 'thread-forced', + turnId: 'turn-forced' + }]) + expect(state.registration('production')).toBeNull() + expect(state.registration('development')).toMatchObject({ + instanceId: 'development-live' + }) + expect(state.lease('thread-forced', now)).toBeNull() + expect(state.lease('thread-live', now)).toMatchObject({ turnId: 'turn-live' }) + expect(state.acquireResource({ + resource: 'forced-resource', + ownerFlavor: 'development', + ownerInstanceId: 'development-live' + }, now).acquired).toBe(true) + }) + it('gives production preference for singleton desktop resources', () => { const state = new ServiceManagerState() const now = new Date('2026-08-01T00:00:00.000Z') diff --git a/kun/src/manager/service-manager.ts b/kun/src/manager/service-manager.ts index 4cfe8c9f5..860826667 100644 --- a/kun/src/manager/service-manager.ts +++ b/kun/src/manager/service-manager.ts @@ -7,6 +7,7 @@ export { RuntimeSlotBusyError, RuntimeRegistrationRequiredError, ServiceManagerState, + reconcileVerifiedForcedRuntimeRecovery, startServiceManager } from './service-manager-state.js' export type { diff --git a/kun/src/manager/update-handoff-data-continuity.test.ts b/kun/src/manager/update-handoff-data-continuity.test.ts new file mode 100644 index 000000000..4cbcd6e55 --- /dev/null +++ b/kun/src/manager/update-handoff-data-continuity.test.ts @@ -0,0 +1,180 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { DEFAULT_KUN_CAPABILITIES_CONFIG } from '../contracts/capabilities.js' +import { makeInterruptionNoteItem } from '../domain/item.js' +import { createThreadRecord } from '../domain/thread.js' +import { createTurnRecord, finishTurn } from '../domain/turn.js' +import { + readForcedRuntimeRecovery, + recordVerifiedForcedRuntimeOwner +} from './forced-runtime-recovery.js' +import { ManagerSharedDataStore } from './shared-data-store.js' +import { + reconcileVerifiedForcedRuntimeRecovery, + ServiceManagerState +} from './service-manager.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function registration(flavor: 'production' | 'development', instanceId: string) { + return { + flavor, + instanceId, + pid: flavor === 'production' ? 4101 : 4102, + startedAt: '2026-08-21T00:00:00.000Z', + host: '127.0.0.1', + port: flavor === 'production' ? 18899 : 18999, + baseUrl: `http://127.0.0.1:${flavor === 'production' ? 18899 : 18999}`, + runtimeToken: `${flavor}-token` + } +} + +describe('update handoff data continuity', () => { + it('keeps committed settings, history, checkpoints, and attachments readable after forced recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-update-handoff-data-')) + roots.push(root) + const controlDir = join(root, 'control') + const dataDir = join(root, 'data') + const settingsPath = join(root, 'kun-settings.json') + const settingsText = '{"version":1,"theme":"dark","sentinel":"keep-me"}\n' + await writeFile(settingsPath, settingsText, 'utf8') + + const threadId = 'thread-update-continuity' + const committed = finishTurn(createTurnRecord({ + id: 'turn-committed', + threadId, + prompt: 'Keep committed work.', + status: 'running', + createdAt: '2026-08-21T00:00:00.000Z' + }), 'completed', '2026-08-21T00:00:01.000Z') + const active = createTurnRecord({ + id: 'turn-forced', + threadId, + prompt: 'Resume after update.', + status: 'running', + createdAt: '2026-08-21T00:00:02.000Z' + }) + const thread = { + ...createThreadRecord({ + id: threadId, + title: 'Update continuity', + workspace: '/tmp/workspace', + model: 'test-model' + }), + status: 'running' as const, + turns: [committed, active] + } + let store = await ManagerSharedDataStore.create(dataDir) + await store.executeThread('upsert', { thread }) + await store.executeSession('appendItem', { + threadId, + item: makeInterruptionNoteItem({ + id: 'checkpoint-before-update', + threadId, + turnId: committed.id, + sourceTurnId: committed.id, + text: 'Committed checkpoint before update.', + createdAt: '2026-08-21T00:00:01.000Z' + }) + }) + await store.executeSession('appendEvent', { + threadId, + event: { + kind: 'heartbeat', + threadId, + seq: 1, + timestamp: '2026-08-21T00:00:01.000Z' + } + }) + const attachment = await store.executeAttachment('create', { + config: DEFAULT_KUN_CAPABILITIES_CONFIG.attachments, + value: { + name: 'continuity.txt', + mimeType: 'text/plain', + dataBase64: Buffer.from('attachment survives update').toString('base64'), + documentText: 'attachment survives update', + threadId + } + }) as { id: string } + await store.close() + + const state = new ServiceManagerState() + const oldOwner = registration('production', 'production-forced') + state.register(oldOwner, new Date('2026-08-21T00:00:02.000Z')) + state.acquireLease({ + threadId, + turnId: active.id, + ownerFlavor: oldOwner.flavor, + ownerInstanceId: oldOwner.instanceId + }, new Date('2026-08-21T00:00:02.000Z')) + const marker = await recordVerifiedForcedRuntimeOwner({ + controlDir, + dataDir, + owner: { + flavor: oldOwner.flavor, + instanceId: oldOwner.instanceId, + pid: oldOwner.pid, + startedAt: oldOwner.startedAt + } + }) + + store = await ManagerSharedDataStore.create(dataDir) + let stateFlushed = false + await expect(reconcileVerifiedForcedRuntimeRecovery({ + controlDir, + record: marker, + state, + sharedData: store, + flushState: async () => { stateFlushed = true } + })).resolves.toBe(1) + expect(stateFlushed).toBe(true) + expect(await readForcedRuntimeRecovery(controlDir)).toBeNull() + await store.close() + + store = await ManagerSharedDataStore.create(dataDir) + expect(await readFile(settingsPath, 'utf8')).toBe(settingsText) + expect(await store.executeThread('get', { threadId })).toMatchObject({ + turns: [ + { id: committed.id, status: 'completed' }, + { id: active.id, status: 'failed' } + ] + }) + const items = await store.executeSession('loadItems', { threadId }) as Array<{ + id: string + kind: string + code?: string + }> + expect(items).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'checkpoint-before-update', kind: 'interruption_note' }), + expect.objectContaining({ kind: 'error', code: 'owner_lease_expired' }) + ])) + const events = await store.executeSession('loadEventsSince', { + threadId, + sinceSeq: 0 + }) as Array<{ kind: string; code?: string }> + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'heartbeat' }), + expect.objectContaining({ kind: 'turn_failed', code: 'owner_lease_expired' }) + ])) + const resolved = await store.executeAttachment('resolveContent', { + config: DEFAULT_KUN_CAPABILITIES_CONFIG.attachments, + value: { id: attachment.id, scope: { threadId } } + }) as { dataBase64: string } + expect(Buffer.from(resolved.dataBase64, 'base64').toString()) + .toBe('attachment survives update') + + state.register(registration('production', 'production-current')) + state.register(registration('development', 'development-current')) + expect(state.snapshot().map((slot) => slot.registration.instanceId).sort()).toEqual([ + 'development-current', + 'production-current' + ]) + await store.close() + }) +}) diff --git a/kun/src/server/runtime-discovery.test.ts b/kun/src/server/runtime-discovery.test.ts index be037d471..a0067a9d0 100644 --- a/kun/src/server/runtime-discovery.test.ts +++ b/kun/src/server/runtime-discovery.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { createRuntimeDiscoveryRecord, publishRuntimeDiscovery, + readRuntimeHandoffDiscovery, readRuntimeDiscovery, removeRuntimeDiscovery, runtimeDiscoveryPath, @@ -63,6 +64,55 @@ describe('runtime discovery', () => { expect((await readRuntimeDiscovery(root))?.instanceId).toBe('legacy-server') }) + it('reads an older safe record only through the handoff contract', async () => { + const root = await tempRoot() + await writeFile(runtimeDiscoveryPath(root), JSON.stringify({ + version: 1, + instanceId: 'older-runtime', + pid: process.pid, + startedAt: '2026-07-22T00:00:00.000Z', + host: '127.0.0.1', + port: 18899, + baseUrl: 'http://127.0.0.1:18899', + runtimeToken: 'older-secret', + futureField: { supportedByNewerBuilds: true } + }), 'utf8') + + expect(await readRuntimeDiscovery(root)).toBeNull() + expect(await readRuntimeHandoffDiscovery(root)).toMatchObject({ + version: 1, + instanceId: 'older-runtime', + runtimeToken: 'older-secret', + futureField: { supportedByNewerBuilds: true } + }) + expect(await removeRuntimeDiscovery(root, 'older-runtime')).toBe(true) + }) + + it('rejects unsafe or wrong-flavor handoff records', async () => { + const root = await tempRoot() + const older = { + version: 1, + instanceId: 'unsafe-runtime', + pid: process.pid, + startedAt: '2026-07-22T00:00:00.000Z', + host: 'example.com', + port: 18899, + baseUrl: 'http://example.com:18899', + runtimeToken: 'secret' + } + await writeFile(runtimeDiscoveryPath(root), JSON.stringify(older), 'utf8') + expect(await readRuntimeHandoffDiscovery(root)).toBeNull() + + await writeFile(runtimeDiscoveryPath(root, 'development'), JSON.stringify({ + ...older, + instanceId: 'wrong-flavor', + host: '127.0.0.1', + baseUrl: 'http://127.0.0.1:18899', + flavor: 'production' + }), 'utf8') + expect(await readRuntimeHandoffDiscovery(root, 'development')).toBeNull() + }) + it('keeps development discovery separate from the production compatibility record', async () => { const root = await tempRoot() const production = await publishRuntimeDiscovery(root, input({ instanceId: 'production-runtime' })) @@ -95,8 +145,10 @@ describe('runtime discovery', () => { expect(await readRuntimeDiscovery(root)).toBeNull() await writeFile(runtimeDiscoveryPath(root), '{broken', 'utf8') expect(await readRuntimeDiscovery(root)).toBeNull() + expect(await readRuntimeHandoffDiscovery(root)).toBeNull() await writeFile(runtimeDiscoveryPath(root), 'x'.repeat(65 * 1024), 'utf8') expect(await readRuntimeDiscovery(root)).toBeNull() + expect(await readRuntimeHandoffDiscovery(root)).toBeNull() }) it('does not let an older server remove a replacement record', async () => { diff --git a/kun/src/server/runtime-discovery.ts b/kun/src/server/runtime-discovery.ts index 0453da54e..bfe90c4be 100644 --- a/kun/src/server/runtime-discovery.ts +++ b/kun/src/server/runtime-discovery.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { atomicWriteFile } from '../adapters/file/atomic-write.js' import { RuntimeBuildIdSchema } from '../contracts/runtime-info.js' import { RuntimeFlavorSchema, type RuntimeFlavor } from '../contracts/runtime-flavor.js' +import { isLoopbackHost } from './loopback-host.js' import { KUN_VERSION } from '../version.js' export const RUNTIME_DISCOVERY_VERSION = 2 as const @@ -37,7 +38,28 @@ export const RuntimeDiscoveryRecordSchema = z.object({ logPath: z.string().min(1).max(4_096).optional() }) +/** + * Stable, handoff-only view of a discovery record. Normal Runtime attachment + * still requires RuntimeDiscoveryRecordSchema and the current info schema; + * this reader exists solely so a newer binary can identify and stop an older + * local owner without mistaking schema drift for a missing writer. + */ +export const RuntimeHandoffDiscoveryRecordSchema = z.object({ + version: z.number().int().positive(), + instanceId: z.string().min(1).max(256), + pid: z.number().int().positive(), + startedAt: z.string().datetime(), + host: z.string().min(1).max(512), + port: z.number().int().min(1).max(65_535), + baseUrl: z.string().url().max(2_048), + runtimeToken: z.string().max(16_384), + flavor: RuntimeFlavorSchema.optional(), + buildId: RuntimeBuildIdSchema.optional(), + logPath: z.string().min(1).max(4_096).optional() +}).passthrough() + export type RuntimeDiscoveryRecord = z.infer +export type RuntimeHandoffDiscoveryRecord = z.infer export type PublishRuntimeDiscoveryInput = Omit< RuntimeDiscoveryRecord, @@ -74,24 +96,19 @@ export async function readRuntimeDiscovery( dataDir: string, flavor: RuntimeFlavor = 'production' ): Promise { - const path = runtimeDiscoveryPath(dataDir, flavor) - let details - try { - details = await stat(path) - } catch (error) { - if (errorCode(error) === 'ENOENT') return null - throw error - } - if (!details.isFile() || details.size > MAX_DISCOVERY_BYTES) return null - try { - const value = JSON.parse(await readFile(path, 'utf8')) as unknown - const parsed = RuntimeDiscoveryRecordSchema.safeParse(value) - return parsed.success ? parsed.data : null - } catch (error) { - if (errorCode(error) === 'ENOENT') return null - if (error instanceof SyntaxError) return null - throw error - } + const value = await readRuntimeDiscoveryValue(dataDir, flavor) + const parsed = RuntimeDiscoveryRecordSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +export async function readRuntimeHandoffDiscovery( + dataDir: string, + flavor: RuntimeFlavor = 'production' +): Promise { + const value = await readRuntimeDiscoveryValue(dataDir, flavor) + const parsed = RuntimeHandoffDiscoveryRecordSchema.safeParse(value) + if (!parsed.success || !handoffFlavorMatches(parsed.data, flavor)) return null + return isSafeRuntimeHandoffDiscovery(parsed.data) ? parsed.data : null } export async function publishRuntimeDiscovery( @@ -123,13 +140,60 @@ export async function removeRuntimeDiscovery( flavor: RuntimeFlavor = 'production' ): Promise { return withDiscoveryLock(dataDir, instanceId, async () => { - const current = await readRuntimeDiscovery(dataDir, flavor) + const current = await readRuntimeHandoffDiscovery(dataDir, flavor) if (!current || current.instanceId !== instanceId) return false await rm(runtimeDiscoveryPath(dataDir, flavor), { force: true }) return true }) } +async function readRuntimeDiscoveryValue( + dataDir: string, + flavor: RuntimeFlavor +): Promise { + const path = runtimeDiscoveryPath(dataDir, flavor) + let details + try { + details = await stat(path) + } catch (error) { + if (errorCode(error) === 'ENOENT') return null + throw error + } + if (!details.isFile() || details.size > MAX_DISCOVERY_BYTES) return null + try { + return JSON.parse(await readFile(path, 'utf8')) as unknown + } catch (error) { + if (errorCode(error) === 'ENOENT' || error instanceof SyntaxError) return null + throw error + } +} + +function handoffFlavorMatches( + record: RuntimeHandoffDiscoveryRecord, + expected: RuntimeFlavor +): boolean { + return expected === 'production' + ? record.flavor === undefined || record.flavor === 'production' + : record.flavor === expected +} + +export function isSafeRuntimeHandoffDiscovery( + record: RuntimeHandoffDiscoveryRecord +): boolean { + try { + const url = new URL(record.baseUrl) + return url.protocol === 'http:' && + isLoopbackHost(url.hostname) && + isLoopbackHost(record.host) && + (url.pathname === '/' || url.pathname === '') && + Number(url.port || '80') === record.port && + url.username === '' && + url.password === '' + } catch { + return false + } +} + /** Serialize shared-runtime election for one data directory. */ export async function withRuntimeStartLock( dataDir: string, diff --git a/kun/src/server/runtime-server-start.ts b/kun/src/server/runtime-server-start.ts index ee56bdaa5..2fdaf9a93 100644 --- a/kun/src/server/runtime-server-start.ts +++ b/kun/src/server/runtime-server-start.ts @@ -74,6 +74,10 @@ export async function startKunServe( } }) registeredWithManager = true + // Manager startup has already settled leases from a verified forced + // predecessor. Finish orphan/subagent/turn recovery before publishing + // discovery, so clients never attach to a current build with stuck work. + await reconcileRuntimeAfterRestart(runtime) } discovery = await publishRuntimeDiscovery(options.discoveryDir ?? options.dataDir, { pid: process.pid, diff --git a/kun/src/services/turn-service-manager-reconciliation.test.ts b/kun/src/services/turn-service-manager-reconciliation.test.ts new file mode 100644 index 000000000..c70e4714c --- /dev/null +++ b/kun/src/services/turn-service-manager-reconciliation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { InMemoryEventBus } from '../adapters/in-memory-event-bus.js' +import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' +import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' +import { createThreadRecord } from '../domain/thread.js' +import { ContextCompactor } from '../loop/context-compactor.js' +import { InflightTracker } from '../loop/inflight-tracker.js' +import { SteeringQueue } from '../loop/steering-queue.js' +import { SequentialIdGenerator } from '../ports/id-generator.js' +import type { ThreadExecutionLeasePort } from '../ports/thread-execution-lease.js' +import { RuntimeEventRecorder } from './runtime-event-recorder.js' +import { TurnService } from './turn-service.js' + +async function fixture(owner: ThreadExecutionLeasePort['owner']) { + const threadStore = new InMemoryThreadStore() + const sessionStore = new InMemorySessionStore() + const eventBus = new InMemoryEventBus() + const nowIso = () => '2026-08-21T08:00:00.000Z' + const events = new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }) + const base = { + threadStore, + sessionStore, + events, + inflight: new InflightTracker(), + steering: new SteeringQueue(), + compactor: new ContextCompactor(), + ids: new SequentialIdGenerator(), + nowIso + } + const original = new TurnService(base) + const threadId = 'thread-managed-recovery' + await threadStore.upsert(createThreadRecord({ + id: threadId, + title: 'Managed recovery', + workspace: '/tmp/workspace', + model: 'test-model' + })) + const started = await original.startTurn({ + threadId, + request: { prompt: 'Continue safely.' } + }) + const executionLeases: ThreadExecutionLeasePort = { + acquire: vi.fn(), + release: vi.fn(), + owner + } + const recovered = new TurnService({ + ...base, + inflight: new InflightTracker(), + steering: new SteeringQueue(), + executionLeases + }) + return { recovered, started } +} + +describe('managed Runtime restart reconciliation', () => { + it('leaves a sibling Runtime turn untouched while its Manager lease is live', async () => { + const test = await fixture(async (threadId) => ({ + threadId, + turnId: 'turn-owned-by-sibling', + ownerFlavor: 'development', + ownerInstanceId: 'development-live', + acquiredAt: '2026-08-21T07:59:50.000Z', + expiresAt: '2026-08-21T08:00:05.000Z' + })) + + await expect(test.recovered.reconcileOrphanedTurns()).resolves.toEqual([]) + await expect(test.recovered.getTurn(test.started.threadId, test.started.turnId)) + .resolves.toMatchObject({ status: 'running' }) + }) + + it('fails closed when Manager lease ownership cannot be read', async () => { + const test = await fixture(async () => { + throw new Error('Manager unavailable') + }) + + await expect(test.recovered.reconcileOrphanedTurns()).resolves.toEqual([]) + await expect(test.recovered.getTurn(test.started.threadId, test.started.turnId)) + .resolves.toMatchObject({ status: 'running' }) + }) + + it('reconciles and checkpoints an active turn once the Manager has no owner', async () => { + const test = await fixture(async () => null) + + await expect(test.recovered.reconcileOrphanedTurns()) + .resolves.toEqual([test.started.threadId]) + await expect(test.recovered.getTurn(test.started.threadId, test.started.turnId)) + .resolves.toMatchObject({ + status: 'failed', + error: 'Turn was interrupted by a runtime restart.' + }) + }) +}) diff --git a/kun/src/services/turn-service-runtime-state-operations.ts b/kun/src/services/turn-service-runtime-state-operations.ts index b422bfaa9..a4e6bd7aa 100644 --- a/kun/src/services/turn-service-runtime-state-operations.ts +++ b/kun/src/services/turn-service-runtime-state-operations.ts @@ -116,6 +116,17 @@ async reconcileOrphanedTurns(this: TurnService): Promise { if (!metadata?.turns.some((turn) => turn.status === 'running' || turn.status === 'queued')) { continue } + if (this['deps'].executionLeases) { + try { + // A managed sibling Runtime may own this thread. Only the Manager + // can expire that lease; startup recovery must never sweep live + // work merely because it is not inflight in this process. + if (await this['deps'].executionLeases.owner(summary.id)) continue + } catch { + // Losing Manager authority is not proof that the owner is gone. + continue + } + } const store = this['deps'].sessionStore if (store.scheduleItemHistoryCompaction) { store.scheduleItemHistoryCompaction(summary.id) diff --git a/package.json b/package.json index c19eef767..13169d3db 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,11 @@ "check:windows-installer-syntax": "node ./scripts/check-windows-installer-syntax.cjs", "check:package-size:mac:arm64": "node ./scripts/check-package-size.cjs --platform darwin --arch arm64 --enforce", "check:package-size:mac:x64": "node ./scripts/check-package-size.cjs --platform darwin --arch x64", - "check:extension-release-gate": "npm run build:extensions && npm run build:kun && node --test ./scripts/after-pack.test.cjs ./scripts/check-package-size.test.cjs ./scripts/check-packaged-runtime-dependencies.test.cjs ./scripts/check-extension-release-execution.test.mjs ./scripts/ensure-macos-native-dependencies.test.cjs ./scripts/pack-bundled-extensions.test.mjs ./scripts/publish-r2.test.mjs ./scripts/smoke-packaged-cli.test.cjs ./scripts/smoke-packaged-extension-desktop.test.cjs ./scripts/smoke-packaged-extension-appimage.test.cjs ./scripts/smoke-packaged-ocr.test.cjs ./scripts/smoke-packaged-runtime-data-migration.test.cjs ./scripts/verify-extension-native-evidence.test.mjs ./scripts/verify-manual-extension-release.test.mjs ./scripts/verify-packaged-macos-native-architecture.test.cjs ./scripts/write-extension-native-evidence.test.mjs && node ./scripts/check-extension-release-gate.mjs", + "check:extension-release-gate": "npm run build:extensions && npm run build:kun && node --test ./scripts/after-pack.test.cjs ./scripts/check-package-size.test.cjs ./scripts/check-packaged-runtime-dependencies.test.cjs ./scripts/check-extension-release-execution.test.mjs ./scripts/ensure-macos-native-dependencies.test.cjs ./scripts/pack-bundled-extensions.test.mjs ./scripts/publish-r2.test.mjs ./scripts/smoke-packaged-cli.test.cjs ./scripts/smoke-packaged-extension-desktop.test.cjs ./scripts/smoke-packaged-extension-appimage.test.cjs ./scripts/smoke-packaged-ocr.test.cjs ./scripts/smoke-packaged-runtime-data-migration.test.cjs ./scripts/smoke-packaged-update-handoff.test.cjs ./scripts/verify-extension-native-evidence.test.mjs ./scripts/verify-manual-extension-release.test.mjs ./scripts/verify-packaged-macos-native-architecture.test.cjs ./scripts/write-extension-native-evidence.test.mjs && node ./scripts/check-extension-release-gate.mjs", "smoke:packaged-extensions": "node ./scripts/smoke-packaged-extensions.cjs", "smoke:packaged-extension-desktop": "node ./scripts/smoke-packaged-extension-desktop.cjs", "smoke:packaged-runtime-migration": "node ./scripts/smoke-packaged-runtime-data-migration.cjs", + "smoke:packaged-update-handoff": "node ./scripts/smoke-packaged-update-handoff.cjs", "smoke:packaged-extension-appimage": "node ./scripts/smoke-packaged-extension-appimage.cjs", "smoke:packaged-cli": "node ./scripts/smoke-packaged-cli.cjs", "smoke:windows-installer-migration": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/smoke-windows-installer-migration.ps1", diff --git a/scripts/check-extension-release-gate-workflows.mjs b/scripts/check-extension-release-gate-workflows.mjs index 0495ace34..b755500d3 100644 --- a/scripts/check-extension-release-gate-workflows.mjs +++ b/scripts/check-extension-release-gate-workflows.mjs @@ -290,6 +290,15 @@ check( prWorkflow.includes('npm run smoke:packaged-extension-desktop'), 'PR package checks must run the packaged desktop Chromium smoke' ) +for (const [label, source] of [ + ['PR', prWorkflow], + ['Release', releaseWorkflow] +]) { + check( + (source.match(/npm run smoke:packaged-update-handoff/g) ?? []).length >= 4, + `${label} workflow must run packaged update handoff acceptance on macOS, Windows, and both Linux architectures` + ) +} check( releaseWorkflow.includes(appImageDesktopCommand) && prWorkflow.includes(appImageDesktopCommand), 'Release and PR Linux jobs must directly smoke the final AppImage artifact' @@ -448,6 +457,10 @@ for (const marker of [ const dailyWorkflow = await text('.github/workflows/daily-dev-prerelease.yml') const dailyWorkflowDocument = parseYaml(dailyWorkflow) +check( + (dailyWorkflow.match(/npm run smoke:packaged-update-handoff/g) ?? []).length >= 4, + 'Daily workflow must run packaged update handoff acceptance on macOS, Windows, and both Linux architectures' +) requirePublishDependencies(dailyWorkflowDocument, 'Daily prerelease workflow') requireSharedExtensionReleaseGate(dailyWorkflowDocument, 'Daily prerelease', 'validate', [ 'build-macos', diff --git a/scripts/fixtures/update-handoff-owner.cjs b/scripts/fixtures/update-handoff-owner.cjs new file mode 100644 index 000000000..3581a5978 --- /dev/null +++ b/scripts/fixtures/update-handoff-owner.cjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +'use strict' + +const { mkdir, writeFile } = require('node:fs/promises') +const { createServer } = require('node:http') +const { join } = require('node:path') + +function argument(name) { + const index = process.argv.indexOf(name) + const value = index >= 0 ? process.argv[index + 1] : undefined + if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`) + return value +} + +async function main() { + const dataDir = argument('--data-dir') + const scenario = argument('--scenario') + const buildId = argument('--build-id') + const discoveryPath = join(dataDir, 'runtime.json') + const instanceId = `unsafe-${scenario}` + const startedAt = new Date().toISOString() + await mkdir(dataDir, { recursive: true }) + + let record + const server = createServer(async (request, response) => { + if (request.url === '/v1/runtime/info') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + instanceId: record.instanceId, + pid: process.pid, + startedAt, + oldCapabilityShape: { intentionally: 'unknown-to-candidate' } + })) + return + } + if (request.url === '/v1/runtime/shutdown' && request.method === 'POST') { + if (scenario === 'changed-discovery-identity') { + record = { ...record, instanceId: `${instanceId}-changed` } + await writeFile(discoveryPath, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }) + } + response.writeHead(503, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ code: 'fixture_refuses_shutdown' })) + return + } + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ ok: true })) + }) + await new Promise((resolvePromise, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolvePromise) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('fixture did not bind TCP') + record = { + version: 1, + instanceId, + pid: process.pid, + startedAt, + host: '127.0.0.1', + port: address.port, + baseUrl: `http://127.0.0.1:${address.port}`, + runtimeToken: 'unsafe-fixture-token', + flavor: 'production', + buildId, + legacyUnknownField: true + } + await writeFile(discoveryPath, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }) + process.stdout.write(`KUN_UNSAFE_OWNER_READY ${JSON.stringify(record)}\n`) + await new Promise((resolvePromise) => { + const stop = () => server.close(resolvePromise) + process.once('SIGTERM', stop) + process.once('SIGINT', stop) + }) +} + +main().then( + () => process.exit(0), + (error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`) + process.exit(70) + } +) diff --git a/scripts/release-mac.sh b/scripts/release-mac.sh index 0f3924d8b..32de1a7a7 100755 --- a/scripts/release-mac.sh +++ b/scripts/release-mac.sh @@ -156,6 +156,10 @@ smoke_macos_extensions() { npm run smoke:packaged-extension-desktop -- --resources "${host_resources}" \ || die "macOS packaged Extension desktop Chromium smoke failed" + cyan "Smoking packaged old-build update handoff (macOS ${host_arch})..." + npm run smoke:packaged-update-handoff -- --resources "${host_resources}" \ + || die "macOS packaged update handoff smoke failed" + cyan "Smoking host-native FFmpeg broker (macOS ${host_arch})..." KUN_RUN_MEDIA_SMOKE=1 npm run smoke:extension-native-media \ || die "macOS host-native FFmpeg broker smoke failed" diff --git a/scripts/release-win.ps1 b/scripts/release-win.ps1 index 30b6c7946..a661d8241 100644 --- a/scripts/release-win.ps1 +++ b/scripts/release-win.ps1 @@ -232,6 +232,13 @@ if ($LASTEXITCODE -ne 0) { exit 1 } +Write-Info 'Smoking packaged old-build update handoff...' +& npm run smoke:packaged-update-handoff -- --resources dist/win-unpacked/resources +if ($LASTEXITCODE -ne 0) { + Write-Err 'Windows packaged update handoff smoke failed.' + exit 1 +} + Write-Info 'Smoking host-native FFmpeg broker...' $env:KUN_RUN_MEDIA_SMOKE = '1' & npm run smoke:extension-native-media diff --git a/scripts/smoke-packaged-extension-desktop-runtime.cjs b/scripts/smoke-packaged-extension-desktop-runtime.cjs index 6d01d39cc..a776a114a 100644 --- a/scripts/smoke-packaged-extension-desktop-runtime.cjs +++ b/scripts/smoke-packaged-extension-desktop-runtime.cjs @@ -472,6 +472,8 @@ function scrubDesktopEnvironment(environment) { exactOverrides.has(key) || (key.startsWith('KUN_') && key !== 'KUN_PACKAGED_EXTENSION_DESKTOP_SMOKE' && + key !== 'KUN_PACKAGED_UPDATE_HANDOFF_SMOKE' && + key !== 'KUN_PACKAGED_UPDATE_HANDOFF_DENY_INSPECTION' && key !== 'KUN_DISABLE_OS_CREDENTIAL_STORE') || key.startsWith('DEEPSEEK_') ) { diff --git a/scripts/smoke-packaged-update-handoff-support.cjs b/scripts/smoke-packaged-update-handoff-support.cjs new file mode 100644 index 000000000..a682e092f --- /dev/null +++ b/scripts/smoke-packaged-update-handoff-support.cjs @@ -0,0 +1,398 @@ +'use strict' + +const { spawn } = require('node:child_process') +const { createHash, randomBytes } = require('node:crypto') +const { existsSync } = require('node:fs') +const { + cp, + mkdir, + readFile, + symlink, + writeFile +} = require('node:fs/promises') +const { createServer } = require('node:http') +const { dirname, join, resolve } = require('node:path') + +const PROCESS_OUTPUT_LIMIT = 128 * 1024 +const MODEL_NAME = 'packaged-handoff-smoke-model' +const SAVED_THREAD_TITLE = 'saved before packaged update handoff' +const CHAT_MARKER = 'packaged-update-handoff-chat-ok' +const POSITIVE_SCENARIOS = Object.freeze([ + Object.freeze({ name: 'external-auto-on-active', path: 'external', autoStart: true, activeWork: true }), + Object.freeze({ name: 'in-app-auto-on', path: 'in-app', autoStart: true, activeWork: false }), + Object.freeze({ name: 'external-auto-off', path: 'external', autoStart: false, activeWork: false }) +]) +const NEGATIVE_SCENARIOS = Object.freeze([ + 'pid-port-reuse', + 'non-kun-command', + 'changed-discovery-identity', + 'inspection-denied' +]) + +function predecessorBuildId(candidateBuildId) { + return createHash('sha256') + .update(`kun-packaged-update-predecessor\0${candidateBuildId}`, 'utf8') + .digest('hex') +} + +function runtimeBuildIdForFlavor(buildId, flavor) { + if (flavor === 'production') return buildId + return createHash('sha256').update(`kun-dv-runtime\0${buildId}`, 'utf8').digest('hex') +} + +async function readPackagedBuild(resourcesDir) { + const manifestPath = join( + resourcesDir, + 'app.asar.unpacked', + 'kun', + 'dist', + 'runtime-build.json' + ) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + if (!/^[a-f0-9]{64}$/u.test(manifest?.buildId ?? '')) { + throw new Error(`Packaged Runtime manifest has no valid build ID: ${manifestPath}`) + } + return { manifest, manifestPath, buildId: manifest.buildId } +} + +async function preparePredecessorRuntime({ resourcesDir, oldResourcesDir, temporaryRoot }) { + if (oldResourcesDir) { + const old = await readPackagedBuild(oldResourcesDir) + return { + buildId: old.buildId, + kunRoot: join(oldResourcesDir, 'app.asar.unpacked', 'kun'), + synthetic: false + } + } + + const candidate = await readPackagedBuild(resourcesDir) + const sourceRoot = join(resourcesDir, 'app.asar.unpacked') + const sourceKun = join(sourceRoot, 'kun') + const targetParent = join(temporaryRoot, 'synthetic-predecessor') + const targetKun = join(targetParent, 'kun') + await mkdir(targetKun, { recursive: true }) + await Promise.all([ + cp(join(sourceKun, 'dist'), join(targetKun, 'dist'), { recursive: true }), + cp(join(sourceKun, 'package.json'), join(targetKun, 'package.json')) + ]) + await Promise.all([ + linkDirectory(join(sourceKun, 'node_modules'), join(targetKun, 'node_modules')), + linkDirectory(join(sourceRoot, 'node_modules'), join(targetParent, 'node_modules')) + ]) + const buildId = predecessorBuildId(candidate.buildId) + await writeFile(join(targetKun, 'dist', 'runtime-build.json'), `${JSON.stringify({ + ...candidate.manifest, + buildId, + artifactVersion: 'packaged-handoff-predecessor' + }, null, 2)}\n`) + return { buildId, kunRoot: targetKun, synthetic: true } +} + +async function linkDirectory(source, target) { + if (!existsSync(source)) return + await symlink(source, target, process.platform === 'win32' ? 'junction' : 'dir') +} + +function buildSmokeSettings({ dataDir, port, runtimeToken, workspaceRoot, baseUrl, autoStart }) { + return { + version: 1, + workspaceRoot, + agents: { + kun: { + dataDir, + port, + runtimeToken, + autoStart, + providerId: 'deepseek', + model: MODEL_NAME, + apiKey: 'packaged-handoff-smoke-key', + baseUrl, + endpointFormat: 'chat_completions' + } + } + } +} + +async function writeSmokeSettings(paths, settings) { + const text = `${JSON.stringify(settings, null, 2)}\n` + await Promise.all(paths.map(async (path) => { + await mkdir(path, { recursive: true }) + await writeFile(join(path, 'kun-settings.json'), text) + })) +} + +function spawnTracked(command, args, options = {}) { + const child = spawn(command, args, { + detached: process.platform !== 'win32', + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + ...options + }) + let output = '' + const append = (chunk) => { + output = `${output}${String(chunk)}`.slice(-PROCESS_OUTPUT_LIMIT) + } + child.stdout?.on('data', append) + child.stderr?.on('data', append) + child.once('error', (error) => append(`\nlaunch error: ${String(error)}\n`)) + return { child, output: () => output } +} + +async function launchPredecessorOwners(input) { + const managerEntry = join(input.kunRoot, 'dist', 'manager', 'manager-entry.js') + const serveEntry = join(input.kunRoot, 'dist', 'cli', 'serve-entry.js') + const managerEnvironment = { + ...input.environment, + ELECTRON_RUN_AS_NODE: '1', + KUN_MANAGER_CONTROL_DIR: input.controlDir, + KUN_MANAGER_DATA_DIR: input.dataDir, + KUN_MANAGER_SETTINGS_PATH: input.settingsPath, + KUN_MANAGER_TOKEN: `manager-${randomBytes(16).toString('hex')}`, + KUN_MANAGER_INSTANCE_ID: `manager-old-${randomBytes(8).toString('hex')}`, + KUN_RUNTIME_BUILD_ID: input.buildId + } + const manager = spawnTracked(input.runtimeExecutable, [managerEntry], { + cwd: input.workspaceRoot, + env: managerEnvironment + }) + const managerDiscovery = await waitForJson( + join(input.controlDir, 'manager.json'), + (value) => value?.pid === manager.child.pid && value?.buildId === input.buildId, + input.timeoutMs, + () => childState(manager.child, manager.output()) + ) + + const runtimes = [] + for (const flavor of ['production', 'development']) { + const port = flavor === 'production' ? input.productionPort : input.developmentPort + const token = `${flavor}-${randomBytes(16).toString('hex')}` + const environment = { + ...input.environment, + ELECTRON_RUN_AS_NODE: '1', + KUN_RUNTIME_LAUNCH_MODE: 'shared', + KUN_RUNTIME_FLAVOR: flavor, + KUN_MANAGER_CONTROL_DIR: input.controlDir, + KUN_MANAGER_SETTINGS_PATH: input.settingsPath, + KUN_DISABLE_OS_CREDENTIAL_STORE: '1' + } + const args = [ + serveEntry, + 'serve', + '--host', '127.0.0.1', + '--port', String(port), + '--data-dir', input.dataDir, + '--runtime-token', token, + '--api-key', 'packaged-handoff-smoke-key', + '--base-url', input.baseUrl, + '--endpoint-format', 'chat_completions', + '--model', MODEL_NAME, + '--approval-policy', 'auto', + '--sandbox-mode', 'workspace-write' + ] + const process = spawnTracked(input.runtimeExecutable, args, { + cwd: input.workspaceRoot, + env: environment + }) + const discoveryPath = flavor === 'production' + ? join(input.dataDir, 'runtime.json') + : join(input.controlDir, 'runtime.development.json') + const expectedBuildId = runtimeBuildIdForFlavor(input.buildId, flavor) + const discovery = await waitForJson( + discoveryPath, + (value) => value?.pid === process.child.pid && value?.buildId === expectedBuildId, + input.timeoutMs, + () => childState(process.child, process.output()) + ) + runtimes.push({ flavor, discovery, discoveryPath, process }) + } + return { manager: { discovery: managerDiscovery, process: manager }, runtimes } +} + +async function startModelFixture() { + const pending = new Set() + const state = { mode: 'complete', requests: 0 } + const server = createServer(async (request, response) => { + if (request.method !== 'POST') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ object: 'list', data: [{ id: MODEL_NAME }] })) + return + } + state.requests += 1 + for await (const _chunk of request) { /* consume bounded local request */ } + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive' + }) + response.write(`data: ${JSON.stringify({ + id: 'chatcmpl-smoke', + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: { role: 'assistant', content: CHAT_MARKER }, finish_reason: null }] + })}\n\n`) + if (state.mode === 'hang') { + pending.add(response) + response.once('close', () => pending.delete(response)) + return + } + response.write(`data: ${JSON.stringify({ + id: 'chatcmpl-smoke', + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] + })}\n\n`) + response.end('data: [DONE]\n\n') + }) + await new Promise((resolvePromise, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolvePromise) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Model fixture has no TCP port') + return { + baseUrl: `http://127.0.0.1:${address.port}`, + state, + close: async () => { + for (const response of pending) response.destroy() + await new Promise((resolvePromise) => server.close(resolvePromise)) + } + } +} + +async function runtimeJson(discovery, path, init = {}) { + const headers = new Headers(init.headers) + headers.set('authorization', `Bearer ${discovery.runtimeToken}`) + if (init.body !== undefined) headers.set('content-type', 'application/json') + const response = await fetch(`${discovery.baseUrl}${path}`, { + ...init, + headers, + signal: AbortSignal.timeout(init.timeoutMs ?? 10_000) + }) + const body = await response.text() + if (!response.ok) throw new Error(`${init.method ?? 'GET'} ${path} failed (${response.status}): ${body}`) + return body ? JSON.parse(body) : undefined +} + +async function createSmokeThread(discovery, workspaceRoot, title = SAVED_THREAD_TITLE) { + return runtimeJson(discovery, '/v1/threads', { + method: 'POST', + body: JSON.stringify({ + title, + workspace: workspaceRoot, + model: MODEL_NAME, + mode: 'agent', + approvalPolicy: 'auto', + sandboxMode: 'workspace-write' + }) + }) +} + +async function startSmokeTurn(discovery, threadId, prompt) { + return runtimeJson(discovery, `/v1/threads/${encodeURIComponent(threadId)}/turns`, { + method: 'POST', + body: JSON.stringify({ + prompt, + model: MODEL_NAME, + approvalPolicy: 'auto', + sandboxMode: 'workspace-write', + disableUserInput: true + }) + }) +} + +async function waitForTurn(discovery, threadId, turnId, predicate, timeoutMs) { + return poll(async () => { + const turn = await runtimeJson( + discovery, + `/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}` + ) + return predicate(turn) ? turn : undefined + }, timeoutMs, `turn ${turnId}`) +} + +async function waitForJson(path, predicate, timeoutMs, state = () => '') { + return poll(async () => { + try { + const value = JSON.parse(await readFile(path, 'utf8')) + return predicate(value) ? value : undefined + } catch (error) { + if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined + throw error + } + }, timeoutMs, `${path}; ${state()}`) +} + +async function poll(operation, timeoutMs, description) { + const deadline = Date.now() + timeoutMs + let lastError + while (Date.now() < deadline) { + try { + const value = await operation() + if (value !== undefined && value !== false) return value + } catch (error) { + lastError = error + } + await delay(100) + } + throw new Error(`Timed out waiting for ${description}${lastError ? `: ${lastError.message}` : ''}`) +} + +function childState(child, output = '') { + const state = child.exitCode === null && child.signalCode === null + ? 'running' + : child.signalCode ?? `exit-${child.exitCode}` + return `${state}${output.trim() ? `\n${output.trim()}` : ''}` +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error?.code === 'EPERM' + } +} + +async function waitForProcessExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (!processIsAlive(pid)) return true + await delay(100) + } + return !processIsAlive(pid) +} + +function parseSmokeMarker(output, prefix) { + const line = String(output).split(/\r?\n/u).find((candidate) => candidate.startsWith(prefix)) + if (!line) return undefined + return JSON.parse(line.slice(prefix.length)) +} + +function delay(ms) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)) +} + +module.exports = { + CHAT_MARKER, + MODEL_NAME, + NEGATIVE_SCENARIOS, + POSITIVE_SCENARIOS, + SAVED_THREAD_TITLE, + buildSmokeSettings, + childState, + createSmokeThread, + launchPredecessorOwners, + parseSmokeMarker, + poll, + predecessorBuildId, + preparePredecessorRuntime, + processIsAlive, + readPackagedBuild, + runtimeBuildIdForFlavor, + runtimeJson, + spawnTracked, + startModelFixture, + startSmokeTurn, + waitForJson, + waitForProcessExit, + waitForTurn, + writeSmokeSettings +} diff --git a/scripts/smoke-packaged-update-handoff.cjs b/scripts/smoke-packaged-update-handoff.cjs new file mode 100644 index 000000000..8bf8d2ca2 --- /dev/null +++ b/scripts/smoke-packaged-update-handoff.cjs @@ -0,0 +1,546 @@ +#!/usr/bin/env node + +'use strict' + +const { mkdir, mkdtemp, readFile, rm } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join, resolve } = require('node:path') +const { + createDesktopLaunchPlan, + createIsolatedEnvironment, + desktopUserDataCandidates, + platformDesktopArguments, + resolveDesktopLaunchSelection, + terminateProcessTree +} = require('./smoke-packaged-extension-desktop.cjs') +const { availablePort } = require('./smoke-packaged-extension-desktop-process.cjs') +const { + makeTreeWritable, + resolvePackagedRuntimeExecutable +} = require('./smoke-packaged-extensions.cjs') +const { + CHAT_MARKER, + NEGATIVE_SCENARIOS, + POSITIVE_SCENARIOS, + SAVED_THREAD_TITLE, + buildSmokeSettings, + childState, + createSmokeThread, + launchPredecessorOwners, + parseSmokeMarker, + poll, + preparePredecessorRuntime, + processIsAlive, + readPackagedBuild, + runtimeBuildIdForFlavor, + runtimeJson, + spawnTracked, + startModelFixture, + startSmokeTurn, + waitForJson, + waitForProcessExit, + waitForTurn, + writeSmokeSettings +} = require('./smoke-packaged-update-handoff-support.cjs') + +const DEFAULT_TIMEOUT_MS = 120_000 +const READY_PREFIX = 'KUN_UPDATE_HANDOFF_SMOKE_READY ' +const FAILED_PREFIX = 'KUN_UPDATE_HANDOFF_SMOKE_FAILED ' + +async function main() { + const resourcesDir = requiredPath('--resources') + const oldResourcesDir = optionalPath('--old-resources') + const timeoutMs = positiveIntegerArgument('--timeout-ms', DEFAULT_TIMEOUT_MS) + const candidateRuntimeExecutable = resolvePackagedRuntimeExecutable( + resourcesDir, + argumentValue('--runtime-executable') + ) + if (!candidateRuntimeExecutable) { + throw new Error(`Candidate package is not executable on ${process.platform}/${process.arch}`) + } + const desktop = resolveDesktopLaunchSelection({ + resourcesDir, + runtimeExecutable: candidateRuntimeExecutable, + packagedRuntimeExecutable: candidateRuntimeExecutable, + desktopExecutable: argumentValue('--desktop-executable') + }) + const candidate = await readPackagedBuild(resourcesDir) + const selection = argumentValue('--cases') ?? 'all' + const runPositive = selection === 'all' || selection === 'positive' + const runNegative = selection === 'all' || selection === 'negative' + if (!runPositive && !runNegative) throw new Error('--cases must be all, positive, or negative') + + for (const scenario of runPositive ? POSITIVE_SCENARIOS : []) { + await runPositiveScenario({ + scenario, + resourcesDir, + oldResourcesDir, + candidateRuntimeExecutable, + desktop, + candidateBuildId: candidate.buildId, + timeoutMs + }) + } + for (const scenario of runNegative ? NEGATIVE_SCENARIOS : []) { + await runNegativeScenario({ + scenario, + candidateBuildId: candidate.buildId, + desktop, + timeoutMs + }) + } + process.stdout.write( + `Packaged update handoff smoke OK (${process.platform}/${process.arch}): ` + + `${runPositive ? POSITIVE_SCENARIOS.length : 0} update paths and ` + + `${runNegative ? NEGATIVE_SCENARIOS.length : 0} fail-closed owner cases passed.\n` + ) +} + +async function runPositiveScenario(input) { + const root = await createProfileRoot(`kun-packaged-handoff-${input.scenario.name}-`) + const modelFixture = await startModelFixture() + const tracked = [] + let primaryError + let cleanupErrors = [] + try { + const profile = await initializeProfile(root, modelFixture.baseUrl, input.scenario.autoStart) + const predecessor = await preparePredecessorRuntime({ + resourcesDir: input.resourcesDir, + oldResourcesDir: input.oldResourcesDir, + temporaryRoot: root.temporaryRoot + }) + if (predecessor.buildId === input.candidateBuildId) { + throw new Error('Old and candidate packaged Runtime build IDs must differ') + } + const owners = await launchPredecessorOwners({ + runtimeExecutable: input.candidateRuntimeExecutable, + kunRoot: predecessor.kunRoot, + buildId: predecessor.buildId, + environment: profile.environment, + controlDir: profile.controlDir, + dataDir: profile.dataDir, + settingsPath: profile.settingsPath, + workspaceRoot: profile.workspaceRoot, + productionPort: profile.productionPort, + developmentPort: profile.developmentPort, + baseUrl: modelFixture.baseUrl, + timeoutMs: input.timeoutMs + }) + tracked.push(owners.manager.process, ...owners.runtimes.map((entry) => entry.process)) + const production = owners.runtimes.find((entry) => entry.flavor === 'production').discovery + const saved = await createSmokeThread(production, profile.workspaceRoot) + let activeTurn + if (input.scenario.activeWork) { + modelFixture.state.mode = 'hang' + activeTurn = await startSmokeTurn(production, saved.id, 'remain active until the update handoff') + await waitForTurn( + production, + saved.id, + activeTurn.turnId, + (turn) => turn.status === 'running', + input.timeoutMs + ) + await poll( + () => modelFixture.state.requests > 0, + input.timeoutMs, + 'the predecessor model request to become active' + ) + } + + if (input.scenario.path === 'in-app') { + const preflight = launchCandidate(input.desktop, profile, { + preflight: true, + timeoutMs: input.timeoutMs + }) + tracked.push(preflight) + const result = await waitForChild(preflight, input.timeoutMs) + tracked.splice(tracked.indexOf(preflight), 1) + if (result.code !== 0) { + throw new Error(`Packaged in-app handoff preflight failed: ${result.output}`) + } + const marker = parseSmokeMarker(result.output, READY_PREFIX) + if (marker?.postcondition !== 'drained' || marker?.targetBuildId !== input.candidateBuildId) { + throw new Error(`Packaged in-app handoff omitted its drained acceptance marker: ${result.output}`) + } + } + + const candidateDesktop = launchCandidate(input.desktop, profile, { timeoutMs: input.timeoutMs }) + tracked.push(candidateDesktop) + const current = await waitForCurrentOwners({ + profile, + candidateBuildId: input.candidateBuildId, + autoStart: input.scenario.autoStart, + oldOwners: owners, + desktop: candidateDesktop, + timeoutMs: input.timeoutMs + }) + + for (const owner of [owners.manager, ...owners.runtimes]) { + const pid = owner.discovery.pid + if (processIsAlive(pid)) throw new Error(`Candidate left predecessor PID ${pid} alive`) + } + if (input.scenario.autoStart) { + modelFixture.state.mode = 'complete' + const listed = await runtimeJson(current.runtime, '/v1/threads?include_archived=true&include=side') + if (!listed.threads?.some((thread) => thread.id === saved.id && thread.title === SAVED_THREAD_TITLE)) { + throw new Error('Candidate Runtime could not read the conversation saved by the predecessor') + } + if (activeTurn) { + const settled = await runtimeJson( + current.runtime, + `/v1/threads/${encodeURIComponent(saved.id)}/turns/${encodeURIComponent(activeTurn.turnId)}` + ) + if (settled.status === 'running' || settled.status === 'queued') { + throw new Error(`Predecessor active turn remained ${settled.status} after handoff`) + } + } + await assertChatRoundTrip(current.runtime, profile.workspaceRoot, input.timeoutMs) + } else { + await assertNoRuntimeDiscovery(profile) + const savedMetadata = await readFile( + join(profile.dataDir, 'threads', saved.id, 'metadata.jsonl'), + 'utf8' + ) + if (!savedMetadata.includes(saved.id)) { + throw new Error('autoStart=false handoff lost the saved conversation metadata') + } + } + + await terminateProcessTree(candidateDesktop.child, process.platform, { timeoutMs: 15_000 }) + tracked.splice(tracked.indexOf(candidateDesktop), 1) + if (!processIsAlive(current.manager.pid)) { + throw new Error('Ordinary GUI quit unexpectedly stopped the current Service Manager') + } + if (current.runtime && !processIsAlive(current.runtime.pid)) { + throw new Error('Ordinary GUI quit unexpectedly stopped the shared Runtime') + } + await stopCurrentOwners(current, input.timeoutMs) + } catch (error) { + primaryError = error + } finally { + await modelFixture.close().catch(() => undefined) + cleanupErrors = await cleanupTracked(tracked) + await cleanupProfile(root).catch((error) => cleanupErrors.push(error.message ?? String(error))) + } + if (primaryError) { + const detail = tracked.map((entry) => entry.output?.() ?? '').filter(Boolean).join('\n') + throw new Error(`${primaryError.stack ?? primaryError}${detail ? `\nProcess output:\n${detail}` : ''}`) + } + if (cleanupErrors.length > 0) { + throw new Error(`Packaged handoff cleanup failed: ${cleanupErrors.join('; ')}`) + } +} + +async function runNegativeScenario(input) { + const root = await createProfileRoot(`kun-packaged-handoff-negative-${input.scenario}-`) + const tracked = [] + let primaryError + let cleanupErrors = [] + try { + const profile = await initializeProfile(root, 'http://127.0.0.1:9', false) + const fixture = spawnTracked(process.execPath, [ + join(__dirname, 'fixtures', 'update-handoff-owner.cjs'), + '--data-dir', profile.dataDir, + '--scenario', input.scenario, + '--build-id', 'a'.repeat(64) + ], { cwd: profile.workspaceRoot, env: profile.environment }) + tracked.push(fixture) + const owner = await waitForJson( + join(profile.dataDir, 'runtime.json'), + (value) => value?.pid === fixture.child.pid, + input.timeoutMs, + () => childState(fixture.child, fixture.output()) + ) + const preflight = launchCandidate(input.desktop, profile, { + preflight: true, + denyInspection: input.scenario === 'inspection-denied', + timeoutMs: input.timeoutMs + }) + tracked.push(preflight) + const result = await waitForChild(preflight, input.timeoutMs) + tracked.splice(tracked.indexOf(preflight), 1) + if (result.code === 0) throw new Error(`Unsafe ${input.scenario} owner was accepted`) + const failure = parseSmokeMarker(result.output, FAILED_PREFIX) + if (!failure || failure.retryable !== false || failure.owner?.pid !== owner.pid) { + throw new Error(`Unsafe ${input.scenario} did not expose actionable fail-closed metadata: ${result.output}`) + } + if (!processIsAlive(owner.pid)) { + throw new Error(`Candidate terminated unsafe ${input.scenario} PID ${owner.pid}`) + } + const preserved = JSON.parse(await readFile(join(profile.dataDir, 'runtime.json'), 'utf8')) + if (input.scenario === 'changed-discovery-identity') { + if (preserved.instanceId === owner.instanceId) { + throw new Error('Changed-identity fixture did not publish its replacement identity') + } + } else if (preserved.pid !== owner.pid) { + throw new Error(`Candidate rewrote unsafe ${input.scenario} discovery ownership`) + } + } catch (error) { + primaryError = error + } finally { + cleanupErrors = await cleanupTracked(tracked) + await cleanupProfile(root).catch((error) => cleanupErrors.push(error.message ?? String(error))) + } + if (primaryError) throw primaryError + if (cleanupErrors.length > 0) { + throw new Error(`Negative handoff cleanup failed: ${cleanupErrors.join('; ')}`) + } +} + +async function createProfileRoot(prefix) { + const temporaryRoot = await mkdtemp(join(tmpdir(), prefix)) + const home = join(temporaryRoot, 'home') + const explicitUserData = join(temporaryRoot, 'electron-user-data') + const appData = join(temporaryRoot, 'app-data') + const localAppData = join(temporaryRoot, 'local-app-data') + const temporaryDirectory = join(temporaryRoot, 'tmp') + const workspaceRoot = join(temporaryRoot, 'workspace') + await Promise.all([ + home, + explicitUserData, + appData, + localAppData, + temporaryDirectory, + workspaceRoot + ].map((path) => mkdir(path, { recursive: true }))) + return { + temporaryRoot, + home, + explicitUserData, + appData, + localAppData, + temporaryDirectory, + workspaceRoot + } +} + +async function initializeProfile(root, baseUrl, autoStart) { + const dataDir = join(root.home, '.kun', 'data') + const controlDir = join(root.home, '.kun', 'control') + const productionPort = await availablePort() + let developmentPort = await availablePort() + while (developmentPort === productionPort) developmentPort = await availablePort() + const environment = createIsolatedEnvironment(process.env, root) + const userDataPaths = desktopUserDataCandidates({ + platform: process.platform, + home: root.home, + appData: root.appData, + explicitUserData: root.explicitUserData + }) + const settings = buildSmokeSettings({ + dataDir, + port: productionPort, + runtimeToken: 'candidate-packaged-handoff-token', + workspaceRoot: root.workspaceRoot, + baseUrl, + autoStart + }) + await Promise.all([mkdir(dataDir, { recursive: true }), mkdir(controlDir, { recursive: true })]) + await writeSmokeSettings(userDataPaths, settings) + return { + ...root, + dataDir, + controlDir, + productionPort, + developmentPort, + environment, + settingsPath: join(root.appData, 'Kun', 'kun-settings.json') + } +} + +function launchCandidate(desktop, profile, options = {}) { + const applicationArguments = [ + ...(desktop.applicationEntry ? [desktop.applicationEntry] : []), + ...(options.preflight ? ['--kun-packaged-update-handoff-smoke'] : []), + `--user-data-dir=${profile.explicitUserData}`, + '--no-first-run', + '--disable-background-networking', + '--disable-component-update', + '--disable-default-apps', + ...platformDesktopArguments(process.platform) + ] + const environment = { + ...profile.environment, + ...(options.preflight ? { KUN_PACKAGED_UPDATE_HANDOFF_SMOKE: '1' } : {}), + ...(options.denyInspection ? { KUN_PACKAGED_UPDATE_HANDOFF_DENY_INSPECTION: '1' } : {}) + } + const plan = createDesktopLaunchPlan({ + executable: desktop.desktopExecutable, + applicationArguments, + environment, + platform: process.platform, + hasDisplay: Boolean(environment.DISPLAY), + xvfbExecutable: argumentValue('--xvfb-run') ?? 'xvfb-run' + }) + return spawnTracked(plan.command, plan.args, { + cwd: profile.workspaceRoot, + env: plan.env + }) +} + +async function waitForCurrentOwners(input) { + const manager = await waitForJson( + join(input.profile.controlDir, 'manager.json'), + (value) => value?.buildId === input.candidateBuildId && + value?.pid !== input.oldOwners.manager.discovery.pid, + input.timeoutMs, + () => childState(input.desktop.child, input.desktop.output()) + ) + let runtime + if (input.autoStart) { + runtime = await waitForJson( + join(input.profile.dataDir, 'runtime.json'), + (value) => value?.buildId === runtimeBuildIdForFlavor(input.candidateBuildId, 'production'), + input.timeoutMs, + () => childState(input.desktop.child, input.desktop.output()) + ) + await runtimeJson(runtime, '/v1/runtime/info') + } else { + await poll( + () => input.oldOwners.runtimes.every((entry) => !processIsAlive(entry.discovery.pid)), + input.timeoutMs, + 'all predecessor Runtimes to exit with autoStart disabled' + ) + } + await poll( + () => !processIsAlive(input.oldOwners.manager.discovery.pid), + input.timeoutMs, + 'the predecessor Manager to exit' + ) + return { manager, runtime } +} + +async function assertChatRoundTrip(runtime, workspaceRoot, timeoutMs) { + const thread = await createSmokeThread(runtime, workspaceRoot, 'candidate chat round-trip') + const turn = await startSmokeTurn(runtime, thread.id, 'return the deterministic fixture response') + await waitForTurn( + runtime, + thread.id, + turn.turnId, + (value) => ['completed', 'failed', 'aborted'].includes(value.status), + timeoutMs + ) + const snapshot = await runtimeJson(runtime, `/v1/threads/${encodeURIComponent(thread.id)}`) + if (!JSON.stringify(snapshot).includes(CHAT_MARKER)) { + throw new Error('Candidate Runtime health passed but its chat round-trip did not complete') + } +} + +async function assertNoRuntimeDiscovery(profile) { + for (const path of [ + join(profile.dataDir, 'runtime.json'), + join(profile.controlDir, 'runtime.development.json') + ]) { + try { + const record = JSON.parse(await readFile(path, 'utf8')) + if (record && processIsAlive(record.pid)) { + throw new Error(`autoStart=false left Runtime PID ${record.pid} alive`) + } + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error + } + } +} + +async function stopCurrentOwners(current, timeoutMs) { + if (current.runtime) { + await fetch(`${current.runtime.baseUrl}/v1/runtime/shutdown`, { + method: 'POST', + headers: { + authorization: `Bearer ${current.runtime.runtimeToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ instanceId: current.runtime.instanceId }), + signal: AbortSignal.timeout(10_000) + }).catch(() => undefined) + if (!await waitForProcessExit(current.runtime.pid, Math.min(timeoutMs, 20_000))) { + throw new Error(`Current Runtime PID ${current.runtime.pid} did not stop through its authenticated API`) + } + } + await fetch(`${current.manager.baseUrl}/v1/manager/shutdown`, { + method: 'POST', + headers: { + authorization: `Bearer ${current.manager.managerToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ instanceId: current.manager.instanceId }), + signal: AbortSignal.timeout(10_000) + }).catch(() => undefined) + if (!await waitForProcessExit(current.manager.pid, Math.min(timeoutMs, 20_000))) { + throw new Error(`Current Manager PID ${current.manager.pid} did not stop through its authenticated API`) + } +} + +async function waitForChild(tracked, timeoutMs) { + let timer + const result = await Promise.race([ + new Promise((resolvePromise) => { + tracked.child.once('exit', (code, signal) => resolvePromise({ code, signal })) + }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Timed out waiting for packaged handoff process')), timeoutMs) + }) + ]).finally(() => clearTimeout(timer)) + return { ...result, output: tracked.output() } +} + +async function cleanupTracked(tracked) { + const errors = [] + for (const entry of [...tracked].reverse()) { + if (!entry?.child || entry.child.exitCode !== null || entry.child.signalCode !== null) continue + await terminateProcessTree(entry.child, process.platform, { timeoutMs: 10_000 }) + .catch((error) => errors.push(error.message ?? String(error))) + } + return errors +} + +async function cleanupProfile(root) { + if (process.env.KUN_KEEP_PACKAGED_UPDATE_HANDOFF_SMOKE === '1') { + process.stderr.write(`Preserved packaged update handoff profile: ${root.temporaryRoot}\n`) + return + } + await makeTreeWritable(root.temporaryRoot).catch(() => undefined) + await rm(root.temporaryRoot, { recursive: true, force: true }) +} + +function argumentValue(name) { + const index = process.argv.indexOf(name) + if (index < 0) return undefined + const value = process.argv[index + 1] + if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`) + return value +} + +function requiredPath(name) { + const value = argumentValue(name) + if (!value) throw new Error(`${name} is required`) + return resolve(value) +} + +function optionalPath(name) { + const value = argumentValue(name) + return value ? resolve(value) : undefined +} + +function positiveIntegerArgument(name, fallback) { + const value = argumentValue(name) + if (value === undefined) return fallback + const number = Number(value) + if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`${name} must be a positive integer`) + return number +} + +module.exports = { + FAILED_PREFIX, + READY_PREFIX, + assertNoRuntimeDiscovery, + positiveIntegerArgument, + waitForCurrentOwners +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/smoke-packaged-update-handoff.test.cjs b/scripts/smoke-packaged-update-handoff.test.cjs new file mode 100644 index 000000000..fea4e7708 --- /dev/null +++ b/scripts/smoke-packaged-update-handoff.test.cjs @@ -0,0 +1,85 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') +const { + NEGATIVE_SCENARIOS, + POSITIVE_SCENARIOS, + buildSmokeSettings, + parseSmokeMarker, + predecessorBuildId, + runtimeBuildIdForFlavor +} = require('./smoke-packaged-update-handoff-support.cjs') +const { + FAILED_PREFIX, + READY_PREFIX, + positiveIntegerArgument +} = require('./smoke-packaged-update-handoff.cjs') + +test('release matrix covers both update paths, active work, and auto-start off', () => { + assert.deepEqual(POSITIVE_SCENARIOS.map((scenario) => scenario.name), [ + 'external-auto-on-active', + 'in-app-auto-on', + 'external-auto-off' + ]) + assert(POSITIVE_SCENARIOS.some((scenario) => scenario.path === 'external')) + assert(POSITIVE_SCENARIOS.some((scenario) => scenario.path === 'in-app')) + assert(POSITIVE_SCENARIOS.some((scenario) => scenario.activeWork)) + assert(POSITIVE_SCENARIOS.some((scenario) => scenario.autoStart === false)) +}) + +test('negative release matrix names every fail-closed ownership case', () => { + assert.deepEqual(NEGATIVE_SCENARIOS, [ + 'pid-port-reuse', + 'non-kun-command', + 'changed-discovery-identity', + 'inspection-denied' + ]) +}) + +test('synthetic predecessor and development flavor use distinct stable build IDs', () => { + const candidate = 'b'.repeat(64) + const predecessor = predecessorBuildId(candidate) + assert.match(predecessor, /^[a-f0-9]{64}$/u) + assert.notEqual(predecessor, candidate) + assert.equal(runtimeBuildIdForFlavor(predecessor, 'production'), predecessor) + assert.match(runtimeBuildIdForFlavor(predecessor, 'development'), /^[a-f0-9]{64}$/u) + assert.notEqual(runtimeBuildIdForFlavor(predecessor, 'development'), predecessor) +}) + +test('profile settings preserve explicit auto-start policy and canonical data scope', () => { + const settings = buildSmokeSettings({ + dataDir: '/profile/data', + port: 18899, + runtimeToken: 'token', + workspaceRoot: '/workspace', + baseUrl: 'http://127.0.0.1:4000', + autoStart: false + }) + assert.equal(settings.agents.kun.autoStart, false) + assert.equal(settings.agents.kun.dataDir, '/profile/data') + assert.equal(settings.agents.kun.port, 18899) +}) + +test('acceptance and recovery markers are machine-readable', () => { + assert.deepEqual(parseSmokeMarker( + `noise\n${READY_PREFIX}{"postcondition":"drained"}\n`, + READY_PREFIX + ), { postcondition: 'drained' }) + assert.deepEqual(parseSmokeMarker( + `${FAILED_PREFIX}{"retryable":false,"phase":"stop-runtimes"}\n`, + FAILED_PREFIX + ), { retryable: false, phase: 'stop-runtimes' }) +}) + +test('timeout parser rejects invalid release gate values', () => { + const original = process.argv + try { + process.argv = ['node', 'smoke', '--timeout-ms', '0'] + assert.throws(() => positiveIntegerArgument('--timeout-ms', 100), /positive integer/) + process.argv = ['node', 'smoke'] + assert.equal(positiveIntegerArgument('--timeout-ms', 100), 100) + } finally { + process.argv = original + } +}) diff --git a/src/main/index.ts b/src/main/index.ts index 7c3b5977e..282f2d864 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,12 +17,25 @@ import { } from './main-lifecycle' import { stopRuntimeWatchdog } from './main-runtime-health' import { startMainApp } from './main-ready' +import { + packagedUpdateHandoffSmokeFailure, + packagedUpdateHandoffSmokeRequested, + runPackagedUpdateHandoffSmoke +} from './packaged-update-handoff-smoke' if (runningClawScheduleMcpServer) { void runClawScheduleMcpServerFromArgv(process.argv).catch((error) => { console.error('[claw-schedule-mcp] server failed:', error) process.exit(1) }) +} else if (packagedUpdateHandoffSmokeRequested()) { + void runPackagedUpdateHandoffSmoke().then( + () => app.exit(0), + (error) => { + process.stderr.write(`${packagedUpdateHandoffSmokeFailure(error)}\n`) + app.exit(70) + } + ) } else { startMainApp() } diff --git a/src/main/kun-process-ports.ts b/src/main/kun-process-ports.ts index a30b9a202..930e9bb82 100644 --- a/src/main/kun-process-ports.ts +++ b/src/main/kun-process-ports.ts @@ -94,6 +94,7 @@ export async function killStaleKunOnPort(port: number): Promise { * macOS/Linux and `netstat -ano` on Windows. */ export async function listListeningPidsOnPort(port: number): Promise { + if (packagedUpdateHandoffInspectionDenied()) return [] if (process.platform === 'win32') { try { const { stdout } = await execFileAsync('netstat', ['-ano'], { @@ -138,6 +139,9 @@ export function parseListeningPidsFromNetstat(stdout: string, port: number): num /** Read a process's full command line (best effort, platform-specific). */ export async function processCommandLine(pid: number): Promise { + if (packagedUpdateHandoffInspectionDenied()) { + throw Object.assign(new Error('packaged smoke denied process inspection'), { code: 'EPERM' }) + } if (process.platform === 'win32') { const { stdout } = await execFileAsync( 'powershell', @@ -155,6 +159,14 @@ export async function processCommandLine(pid: number): Promise { return stdout.trim() } +export function packagedUpdateHandoffInspectionDenied( + env: NodeJS.ProcessEnv = process.env +): boolean { + return env.KUN_PACKAGED_EXTENSION_DESKTOP_SMOKE === '1' && + env.KUN_PACKAGED_UPDATE_HANDOFF_SMOKE === '1' && + env.KUN_PACKAGED_UPDATE_HANDOFF_DENY_INSPECTION === '1' +} + /** Terminate a positively-identified stale kun process. */ export async function terminateStalePid(pid: number): Promise { if (process.platform === 'win32') { @@ -196,12 +208,20 @@ export async function terminateStalePid(pid: number): Promise { export async function terminateVerifiedPid( pid: number, verifyTarget: () => Promise, - waitForExit: (pid: number, timeoutMs: number) => Promise = waitForPidExit + waitForExit: (pid: number, timeoutMs: number) => Promise = waitForPidExit, + system: { + platform?: NodeJS.Platform + kill?: typeof process.kill + execFile?: typeof execFileAsync + } = {} ): Promise { + const platform = system.platform ?? process.platform + const kill = system.kill ?? process.kill.bind(process) + const execFile = system.execFile ?? execFileAsync if (!(await verifyTarget())) return false - if (process.platform === 'win32') { + if (platform === 'win32') { try { - await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], { + await execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 5_000 }) @@ -212,7 +232,7 @@ export async function terminateVerifiedPid( } try { - process.kill(pid, 'SIGTERM') + kill(pid, 'SIGTERM') } catch { return waitForExit(pid, 0) } @@ -220,7 +240,7 @@ export async function terminateVerifiedPid( // Do not escalate after PID reuse or an identity change. if (!(await verifyTarget())) return false try { - process.kill(pid, 'SIGKILL') + kill(pid, 'SIGKILL') } catch { return waitForExit(pid, 0) } diff --git a/src/main/kun-process-termination.test.ts b/src/main/kun-process-termination.test.ts new file mode 100644 index 000000000..9be1df2b5 --- /dev/null +++ b/src/main/kun-process-termination.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { + packagedUpdateHandoffInspectionDenied, + terminateVerifiedPid +} from './kun-process-ports' + +describe('terminateVerifiedPid platform safety', () => { + it('denies inspection only inside the doubly opted-in packaged smoke', () => { + expect(packagedUpdateHandoffInspectionDenied({ + KUN_PACKAGED_EXTENSION_DESKTOP_SMOKE: '1', + KUN_PACKAGED_UPDATE_HANDOFF_SMOKE: '1', + KUN_PACKAGED_UPDATE_HANDOFF_DENY_INSPECTION: '1' + })).toBe(true) + expect(packagedUpdateHandoffInspectionDenied({ + KUN_PACKAGED_UPDATE_HANDOFF_DENY_INSPECTION: '1' + })).toBe(false) + }) + it('uses TERM then KILL on Unix only while the exact identity remains verified', async () => { + const kill = vi.fn(() => true) + const verifyTarget = vi.fn(async () => true) + const waitForExit = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + + await expect(terminateVerifiedPid(8123, verifyTarget, waitForExit, { + platform: 'darwin', + kill + })).resolves.toBe(true) + + expect(verifyTarget).toHaveBeenCalledTimes(2) + expect(kill.mock.calls).toEqual([ + [8123, 'SIGTERM'], + [8123, 'SIGKILL'] + ]) + }) + + it('does not escalate after TERM when the PID identity changed', async () => { + const kill = vi.fn(() => true) + const verifyTarget = vi.fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + await expect(terminateVerifiedPid(8124, verifyTarget, async () => false, { + platform: 'linux', + kill + })).resolves.toBe(false) + + expect(kill).toHaveBeenCalledOnce() + expect(kill).toHaveBeenCalledWith(8124, 'SIGTERM') + }) + + it('fails closed when Unix signal permission is denied and the PID remains live', async () => { + const kill = vi.fn(() => { + throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + }) + + await expect(terminateVerifiedPid(8125, async () => true, async () => false, { + platform: 'linux', + kill + })).resolves.toBe(false) + + expect(kill).toHaveBeenCalledWith(8125, 'SIGTERM') + }) + + it('uses the Windows process-tree taskkill path and confirms exit', async () => { + const execFile = vi.fn(async () => ({ stdout: '', stderr: '' })) + const waitForExit = vi.fn(async () => true) + + await expect(terminateVerifiedPid(8126, async () => true, waitForExit, { + platform: 'win32', + execFile: execFile as never + })).resolves.toBe(true) + + expect(execFile).toHaveBeenCalledWith( + 'taskkill', + ['/PID', '8126', '/T', '/F'], + { windowsHide: true, timeout: 5_000 } + ) + expect(waitForExit).toHaveBeenCalledWith(8126, 2_000) + }) +}) diff --git a/src/main/kun-process.ports.test.ts b/src/main/kun-process.ports.test.ts index 3ef9db6e0..7cd94e7b0 100644 --- a/src/main/kun-process.ports.test.ts +++ b/src/main/kun-process.ports.test.ts @@ -322,4 +322,5 @@ describe('terminateVerifiedPid', () => { expect(kill).not.toHaveBeenCalled() }) + }) diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index fe0ebbb86..4de334d3f 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -113,11 +113,21 @@ import { } from '../../kun/src/cli/runtime-flavor.js' import { ensureServiceManager, - resolveServiceManager, + ensureServiceManagerWithStartLockHeld, type ServiceManagerConnection } from '../../kun/src/manager/manager-client.js' +import { + defaultKunControlDir, + withManagerStartLock +} from '../../kun/src/manager/manager-discovery.js' import { configureManagerAtomicJsonClient } from '../../kun/src/extensions/atomic-json.js' import { handoffExistingKunServiceManagerForDataDir } from './runtime/service-manager-build-handoff' +import { + drainKunOwnersForHandoff, + drainKunOwnersForHandoffWithLock, + requiresInstalledBuildHandoff +} from './runtime/kun-installed-build-handoff' +import { logKunHandoffEvent } from './runtime/kun-handoff-logging' import { appendTail, @@ -161,18 +171,6 @@ export async function resolveKunManagerDataDirFromSettings( } } -async function handoffMismatchedKunServiceManager( - dataDir: string, - settingsPath: string, - expectedBuildId: string | undefined -): Promise { - const existing = await resolveServiceManager() - if (!existing) return - await handoffExistingKunServiceManagerForDataDir(existing, dataDir, settingsPath, { - force: Boolean(expectedBuildId) && existing.discovery.buildId !== expectedBuildId - }) -} - export async function ensureKunServiceManager(input: { dataDir?: string settingsPath: string @@ -187,11 +185,12 @@ export async function ensureKunServiceManager(input: { ) } const buildId = await resolveKunRuntimeBuildId(resolution) - await handoffMismatchedKunServiceManager(dataDir, input.settingsPath, buildId) const managerEntry = join(dirname(serveEntry), '..', 'manager', 'manager-entry.js') const flavor = resolveCliRuntimeFlavor({ env: process.env }) - const manager = await ensureServiceManager({ + const controlDir = defaultKunControlDir() + const managerInput = { flavor, + controlDir, allowDevelopmentBootstrap: allowsDevelopmentManagerBootstrap({ flavor, env: process.env, @@ -205,10 +204,53 @@ export async function ensureKunServiceManager(input: { args: [managerEntry], runAsNode: true } - }) + } + let manager: ServiceManagerConnection + const handoffInput = { + reason: 'installed-build-change' as const, + dataDirs: [dataDir], + settingsPath: input.settingsPath, + controlDir, + onEvent: logKunHandoffEvent, + ...(buildId ? { targetBuildId: buildId } : {}) + } + if (app.isPackaged && flavor === 'production' && + await requiresInstalledBuildHandoff(handoffInput)) { + manager = await withManagerStartLock(controlDir, async () => { + // Recheck after acquiring the election lock so a replacement that won + // the race before us is not interrupted unnecessarily. + if (await requiresInstalledBuildHandoff(handoffInput)) { + await drainKunOwnersForHandoffWithLock(handoffInput) + } + return ensureServiceManagerWithStartLockHeld(managerInput) + }) + } else { + manager = await ensureServiceManager(managerInput) + } return configureKunManagerDataPlaneForCurrentProcess(manager) } +export async function preparePackagedKunBuildHandoff(input: { + dataDir: string + settingsPath: string +}): Promise { + const flavor = resolveCliRuntimeFlavor({ env: process.env }) + if (!app.isPackaged || flavor !== 'production') return false + const buildId = await resolveKunRuntimeBuildId(resolveKunExecutable(appRoot(), '')) + if (!buildId) return false + const handoffInput = { + reason: 'installed-build-change' as const, + dataDirs: [input.dataDir], + settingsPath: input.settingsPath, + controlDir: defaultKunControlDir(), + targetBuildId: buildId, + onEvent: logKunHandoffEvent + } + if (!(await requiresInstalledBuildHandoff(handoffInput))) return false + await drainKunOwnersForHandoff(handoffInput) + return true +} + /** * Makes Main-process AtomicJson consumers join the Manager-owned data plane. * This must run before constructing a Main Registry or credential store. @@ -248,6 +290,10 @@ function appRoot(): string { : app.getAppPath() } +export function resolveKunExecutableForCurrentApp(): ReturnType { + return resolveKunExecutable(appRoot(), '') +} + function resolveNodeScriptCommand(command: string): string { if (command !== process.execPath) return command if (process.platform !== 'darwin') return command diff --git a/src/main/main-migrations.ts b/src/main/main-migrations.ts index eb0d505c4..512ebb8b5 100644 --- a/src/main/main-migrations.ts +++ b/src/main/main-migrations.ts @@ -29,20 +29,28 @@ import { kunRuntimeAdapter, runtimeAuthHeaders } from './runtime/kun-adapter' -import { ensureKunServiceManager } from './kun-process' +import { + ensureKunServiceManager, + resolveKunManagerDataDirFromSettings +} from './kun-process' import { ManagerRevisionedDocumentClient, readManagerRuntime, requestManagerJson, - resolveServiceManagerForMigration, type ServiceManagerConnection } from '../../kun/src/manager/manager-client.js' import { defaultKunControlDir, readManagerDiscovery } from '../../kun/src/manager/manager-discovery.js' -import { stopSharedRuntime } from '../../kun/src/cli/shared-runtime.js' import { listServiceManagerRuntimeActiveWork } from './runtime/service-manager-runtime-active-work' +import { + drainKunOwnersForHandoff, + KunHandoffError, + withDrainedKunOwners +} from './runtime/kun-installed-build-handoff' +import { logKunHandoffEvent } from './runtime/kun-handoff-logging' +import { SETTINGS_FILE_NAME } from './settings-file-paths' import { StorageRelocationController } from './storage-relocation/controller' import { StorageRelocationEngine } from './storage-relocation/engine' import type { @@ -145,10 +153,43 @@ export async function shutdownServiceManagerAndWait(manager: ServiceManagerConne export async function shutdownActiveServiceManagerForUpdate(): Promise { const manager = mainState.activeServiceManager if (!manager) return - await shutdownServiceManagerAndWait(manager) + await drainKunOwnersForHandoff({ + reason: 'in-app-update', + dataDirs: [manager.discovery.dataDir], + settingsPath: manager.discovery.settingsPath, + controlDir: defaultKunControlDir(), + fetch, + onEvent: logKunHandoffEvent + }) if (mainState.activeServiceManager === manager) mainState.activeServiceManager = null } +export function createStartupKunHandoffRecovery( + error: unknown +): (() => Promise) | undefined { + if (!(error instanceof KunHandoffError) || !error.retryable) return undefined + + return async () => { + const userDataPath = app.getPath('userData') + const settingsPath = join(userDataPath, SETTINGS_FILE_NAME) + const dataDirs = error.reason === 'exclusive-data-migration' + ? [ + canonicalLegacyKunDataDir(homedir(), process.platform), + canonicalCurrentKunDataDir(homedir(), process.platform) + ] + : [await resolveKunManagerDataDirFromSettings(settingsPath)] + + await drainKunOwnersForHandoff({ + reason: error.reason, + dataDirs, + settingsPath, + controlDir: defaultKunControlDir(), + fetch, + onEvent: logKunHandoffEvent + }) + } +} + function managerProcessIsAlive(pid: number): boolean { try { process.kill(pid, 0) @@ -163,34 +204,21 @@ function managerProcessIsAlive(pid: number): boolean { } } -async function drainCanonicalRuntimeMigrationWriters(): Promise { - const controlDir = defaultKunControlDir() - const manager = await resolveServiceManagerForMigration(controlDir, fetch) - if (manager) { - await interruptStorageRelocationWork(manager) - await Promise.all((['production', 'development'] as const).map((runtimeFlavor) => - stopSharedRuntime(manager.discovery.dataDir, fetch, { runtimeFlavor, manager }) - )) - await shutdownServiceManagerAndWait(manager) - } else { - const unresolved = await readManagerDiscovery(controlDir).catch(() => null) - if (unresolved && managerProcessIsAlive(unresolved.pid)) { - throw new Error( - `active_writer: Kun Service Manager ${unresolved.pid} is alive but could not be ` + - 'authenticated for a safe shutdown.' - ) - } - } - +async function withCanonicalRuntimeMigrationWritersDrained( + afterDrain: () => T | Promise +): Promise { const canonicalDirs = [ canonicalLegacyKunDataDir(homedir(), process.platform), canonicalCurrentKunDataDir(homedir(), process.platform) ] - for (const dataDir of canonicalDirs) { - for (const runtimeFlavor of ['production', 'development'] as const) { - await stopSharedRuntime(dataDir, fetch, { runtimeFlavor }) - } - } + const { value } = await withDrainedKunOwners({ + reason: 'exclusive-data-migration', + dataDirs: canonicalDirs, + controlDir: defaultKunControlDir(), + fetch, + onEvent: logKunHandoffEvent + }, afterDrain) + return value } async function assertCanonicalRuntimeMigrationWritersStopped(dataDir: string): Promise { @@ -223,8 +251,9 @@ export async function runStartupLegacyMigrations(): Promise | undefined try { if (requiresExclusiveAccess) { - await drainCanonicalRuntimeMigrationWriters() - lock = acquireCanonicalRuntimeMigrationLock([sourcePath, targetPath]) + lock = await withCanonicalRuntimeMigrationWritersDrained(() => + acquireCanonicalRuntimeMigrationLock([sourcePath, targetPath]) + ) await assertCanonicalRuntimeMigrationWritersStopped(sourcePath) await assertCanonicalRuntimeMigrationWritersStopped(targetPath) } @@ -335,11 +364,10 @@ export async function runRuntimeDataRecoveryMaintenance(): Promise { const userDataPath = app.getPath('userData') const sourcePath = canonicalLegacyKunDataDir(homeDir, process.platform) const targetPath = canonicalCurrentKunDataDir(homeDir, process.platform) - await drainCanonicalRuntimeMigrationWriters() - mainState.runtimeDataRecoveryMigrationLock = acquireCanonicalRuntimeMigrationLock([ - sourcePath, - targetPath - ]) + mainState.runtimeDataRecoveryMigrationLock = + await withCanonicalRuntimeMigrationWritersDrained(() => + acquireCanonicalRuntimeMigrationLock([sourcePath, targetPath]) + ) try { await assertCanonicalRuntimeMigrationWritersStopped(sourcePath) await assertCanonicalRuntimeMigrationWritersStopped(targetPath) diff --git a/src/main/main-ready-services.ts b/src/main/main-ready-services.ts index 23ec01a15..0d6fb2d12 100644 --- a/src/main/main-ready-services.ts +++ b/src/main/main-ready-services.ts @@ -30,6 +30,7 @@ import { import { configureKunManagerDataPlaneForCurrentProcess, ensureKunServiceManager, + preparePackagedKunBuildHandoff, resolveKunManagerDataDirFromSettings, setKunUnexpectedExitHandler } from './kun-process' @@ -175,6 +176,13 @@ export async function initializeMainServices(): Promise { return null } if (appIdentity.flavor === 'production') { + const preMigrationDataDir = await resolveKunManagerDataDirFromSettings(productionSettingsPath) + if (await preparePackagedKunBuildHandoff({ + dataDir: preMigrationDataDir, + settingsPath: productionSettingsPath + })) { + traceStartup('installed Runtime build handoff:done') + } traceStartup('runtime data migration:start') const migrationResult = await runStartupLegacyMigrations() traceStartup('runtime data migration:done', { diff --git a/src/main/main-ready.ts b/src/main/main-ready.ts index 3e424fae0..2a17307dc 100644 --- a/src/main/main-ready.ts +++ b/src/main/main-ready.ts @@ -14,6 +14,7 @@ import { } from './main-lifecycle' import { assertCanonicalRuntimeMigrationReady, + createStartupKunHandoffRecovery, shutdownActiveServiceManagerForUpdate } from './main-migrations' import { @@ -80,18 +81,20 @@ export function startMainApp(): void { console.warn('[kun-gui] CLI install prompt failed:', error) }) traceStartup('createWindow:returned') - void loadGuiUpdaterModule() - .then((module) => module.showPostUpdateReleaseNotes()) - .catch((error) => { - console.warn('[kun-gui updater] failed to show post-update release notes:', error) - }) + const updaterModule = loadGuiUpdaterModule().catch((error) => { + console.warn('[kun-gui updater] failed to initialize updater:', error) + return null + }) void pruneOnStartup().catch((err) => { console.warn('[kun-gui] prune logs:', err) }) - setTimeout(() => { - void reconcileBundledRuntimeAfterInstall(initial) + void reconcileBundledRuntimeAfterInstall(initial) + .then(async () => { + const module = await updaterModule + await module?.showPostUpdateReleaseNotes() + }) .then(() => resolveManagedRuntimeStartupTarget( initial, managedKunHostCanAutoStart(initial), @@ -120,7 +123,6 @@ export function startMainApp(): void { .catch((err) => { console.warn('[kun-gui] failed to start, attach, or configure the shared Kun runtime:', err) }) - }, 1500) app.on('activate', () => { if (!mainState.mainWindow || mainState.mainWindow.isDestroyed()) createWindow() @@ -134,7 +136,12 @@ export function startMainApp(): void { packaged: app.isPackaged, message }) - const recoveryWindow = showStartupFailureWindow(error, mainState.logDir) + const recoverHandoff = createStartupKunHandoffRecovery(error) + const recoveryWindow = showStartupFailureWindow( + error, + mainState.logDir, + recoverHandoff ? { recoverHandoff } : {} + ) if (recoveryWindow) { mainState.mainWindow = recoveryWindow recoveryWindow.on('closed', () => { diff --git a/src/main/packaged-update-handoff-smoke.test.ts b/src/main/packaged-update-handoff-smoke.test.ts new file mode 100644 index 000000000..b92225629 --- /dev/null +++ b/src/main/packaged-update-handoff-smoke.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + PACKAGED_UPDATE_HANDOFF_SMOKE_ARG, + PACKAGED_UPDATE_HANDOFF_SMOKE_FAILED, + packagedUpdateHandoffSmokeFailure, + packagedUpdateHandoffSmokeRequested +} from './packaged-update-handoff-smoke' +import { KunHandoffError } from './runtime/kun-installed-build-handoff' + +describe('packaged update handoff smoke entry', () => { + it('requires a packaged app, the isolated desktop marker, the opt-in marker, and the flag', () => { + const argv = ['Kun', PACKAGED_UPDATE_HANDOFF_SMOKE_ARG] + const env = { + KUN_PACKAGED_EXTENSION_DESKTOP_SMOKE: '1', + KUN_PACKAGED_UPDATE_HANDOFF_SMOKE: '1' + } + expect(packagedUpdateHandoffSmokeRequested(argv, env, true)).toBe(true) + expect(packagedUpdateHandoffSmokeRequested(argv, env, false)).toBe(false) + expect(packagedUpdateHandoffSmokeRequested(['Kun'], env, true)).toBe(false) + expect(packagedUpdateHandoffSmokeRequested(argv, { + KUN_PACKAGED_UPDATE_HANDOFF_SMOKE: '1' + }, true)).toBe(false) + }) + + it('emits only sanitized typed failure fields', () => { + const error = new KunHandoffError( + 'runtime_stop_failed', + 'stop-runtimes', + 'in-app-update', + false, + { + kind: 'runtime', + flavor: 'production', + instanceId: 'runtime-secret-instance', + pid: 4321, + port: 18899, + buildId: 'a'.repeat(64) + }, + 'secret token and full command must not escape' + ) + const line = packagedUpdateHandoffSmokeFailure(error) + expect(line.startsWith(PACKAGED_UPDATE_HANDOFF_SMOKE_FAILED)).toBe(true) + expect(line).toContain('runtime_stop_failed') + expect(line).toContain('"buildId":"aaaaaaaaaaaa"') + expect(line).not.toContain('runtime-secret-instance') + expect(line).not.toContain('secret token') + expect(line).not.toContain('full command') + }) +}) diff --git a/src/main/packaged-update-handoff-smoke.ts b/src/main/packaged-update-handoff-smoke.ts new file mode 100644 index 000000000..8a6578213 --- /dev/null +++ b/src/main/packaged-update-handoff-smoke.ts @@ -0,0 +1,80 @@ +import { app } from 'electron' +import { join } from 'node:path' +import { defaultKunControlDir } from '../../kun/src/manager/manager-discovery.js' +import { resolveKunRuntimeBuildId } from './resolve-kun-binary' +import { + resolveKunExecutableForCurrentApp, + resolveKunManagerDataDirFromSettings +} from './kun-process' +import { + drainKunOwnersForHandoff, + KunHandoffError, + type KunHandoffOwnerReport +} from './runtime/kun-installed-build-handoff' +import { logKunHandoffEvent } from './runtime/kun-handoff-logging' +import { SETTINGS_FILE_NAME } from './settings-file-paths' + +export const PACKAGED_UPDATE_HANDOFF_SMOKE_ARG = '--kun-packaged-update-handoff-smoke' +export const PACKAGED_UPDATE_HANDOFF_SMOKE_READY = 'KUN_UPDATE_HANDOFF_SMOKE_READY ' +export const PACKAGED_UPDATE_HANDOFF_SMOKE_FAILED = 'KUN_UPDATE_HANDOFF_SMOKE_FAILED ' + +export function packagedUpdateHandoffSmokeRequested( + argv: readonly string[] = process.argv, + env: NodeJS.ProcessEnv = process.env, + isPackaged: boolean = app.isPackaged +): boolean { + return isPackaged && + env.KUN_PACKAGED_EXTENSION_DESKTOP_SMOKE === '1' && + env.KUN_PACKAGED_UPDATE_HANDOFF_SMOKE === '1' && + argv.includes(PACKAGED_UPDATE_HANDOFF_SMOKE_ARG) +} + +export async function runPackagedUpdateHandoffSmoke(): Promise { + await app.whenReady() + const settingsPath = join(app.getPath('userData'), SETTINGS_FILE_NAME) + const dataDir = await resolveKunManagerDataDirFromSettings(settingsPath) + const targetBuildId = await resolveKunRuntimeBuildId(resolveKunExecutableForCurrentApp()) + if (!targetBuildId) throw new Error('The packaged Kun Runtime build identity is missing') + + const report = await drainKunOwnersForHandoff({ + reason: 'in-app-update', + dataDirs: [dataDir], + settingsPath, + controlDir: defaultKunControlDir(), + targetBuildId, + fetch, + onEvent: logKunHandoffEvent + }) + process.stdout.write(`${PACKAGED_UPDATE_HANDOFF_SMOKE_READY}${JSON.stringify({ + targetBuildId, + postcondition: 'drained', + owners: report.owners.map(safeOwner) + })}\n`) +} + +export function packagedUpdateHandoffSmokeFailure(error: unknown): string { + const payload = error instanceof KunHandoffError + ? { + code: error.code, + phase: error.phase, + retryable: error.retryable, + ...(error.owner ? { owner: safeOwner(error.owner) } : {}) + } + : { + code: 'unexpected', + phase: 'startup', + retryable: false + } + return `${PACKAGED_UPDATE_HANDOFF_SMOKE_FAILED}${JSON.stringify(payload)}` +} + +function safeOwner(owner: Omit | KunHandoffOwnerReport): object { + return { + kind: owner.kind, + ...(owner.flavor ? { flavor: owner.flavor } : {}), + ...(owner.pid ? { pid: owner.pid } : {}), + ...(owner.port ? { port: owner.port } : {}), + ...(owner.buildId ? { buildId: owner.buildId.slice(0, 12) } : {}), + ...('result' in owner ? { result: owner.result } : {}) + } +} diff --git a/src/main/runtime/kun-handoff-logging.test.ts b/src/main/runtime/kun-handoff-logging.test.ts new file mode 100644 index 000000000..9a460616b --- /dev/null +++ b/src/main/runtime/kun-handoff-logging.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { KunHandoffEvent } from './kun-installed-build-handoff' + +const logger = vi.hoisted(() => ({ + logInfo: vi.fn(), + logWarn: vi.fn() +})) + +vi.mock('../logger', () => logger) + +import { kunHandoffLogDetail, logKunHandoffEvent } from './kun-handoff-logging' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('Kun handoff logging', () => { + it('records only allow-listed, abbreviated lifecycle diagnostics', () => { + const event = { + reason: 'installed-build-change', + phase: 'stop-runtimes', + elapsedMs: 412, + targetBuildId: 'b'.repeat(64), + probeClassification: 'runtime-discovery-compatible', + postcondition: 'drained', + result: 'forced', + owner: { + kind: 'runtime', + flavor: 'production', + instanceId: 'runtime-1', + pid: 4312, + port: 18899, + buildId: 'a'.repeat(64) + }, + runtimeToken: 'runtime-secret', + managerToken: 'manager-secret', + settings: '{"apiKey":"settings-secret"}', + command: '/Applications/Old Kun.app/Contents/MacOS/Kun --secret' + } as unknown as KunHandoffEvent + + const detail = kunHandoffLogDetail(event) + const serialized = JSON.stringify(detail) + + expect(detail).toMatchObject({ + reason: 'installed-build-change', + phase: 'stop-runtimes', + elapsedMs: 412, + targetBuildId: 'b'.repeat(12), + probeClassification: 'runtime-discovery-compatible', + postcondition: 'drained', + result: 'forced', + ownerKind: 'runtime', + flavor: 'production', + pid: 4312, + buildId: 'a'.repeat(12) + }) + expect(serialized).not.toContain('runtime-secret') + expect(serialized).not.toContain('manager-secret') + expect(serialized).not.toContain('settings-secret') + expect(serialized).not.toContain('/Applications/Old Kun.app') + expect(serialized).not.toContain('a'.repeat(64)) + expect(serialized).not.toContain('b'.repeat(64)) + }) + + it('uses warning severity only for failed handoff events', () => { + const failed: KunHandoffEvent = { + reason: 'in-app-update', + phase: 'verify-drained', + elapsedMs: 40, + result: 'failed', + code: 'postcondition_failed' + } + logKunHandoffEvent(failed) + logKunHandoffEvent({ ...failed, result: 'graceful' }) + + expect(logger.logWarn).toHaveBeenCalledOnce() + expect(logger.logInfo).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/kun-handoff-logging.ts b/src/main/runtime/kun-handoff-logging.ts new file mode 100644 index 000000000..b2b955431 --- /dev/null +++ b/src/main/runtime/kun-handoff-logging.ts @@ -0,0 +1,37 @@ +import { logInfo, logWarn } from '../logger' +import type { KunHandoffEvent } from './kun-installed-build-handoff' + +export function kunHandoffLogDetail(event: KunHandoffEvent): Record { + const owner = event.owner + return { + reason: event.reason, + phase: event.phase, + elapsedMs: event.elapsedMs, + ...(event.targetBuildId ? { targetBuildId: abbreviateBuildId(event.targetBuildId) } : {}), + ...(event.result ? { result: event.result } : {}), + ...(event.code ? { code: event.code } : {}), + ...(event.probeClassification ? { probeClassification: event.probeClassification } : {}), + ...(event.postcondition ? { postcondition: event.postcondition } : {}), + ...(owner + ? { + ownerKind: owner.kind, + ...(owner.flavor ? { flavor: owner.flavor } : {}), + ...(owner.pid ? { pid: owner.pid } : {}), + ...(owner.instanceId ? { instanceId: owner.instanceId } : {}), + ...(owner.port ? { port: owner.port } : {}), + ...(owner.buildId ? { buildId: abbreviateBuildId(owner.buildId) } : {}) + } + : {}) + } +} + +export function logKunHandoffEvent(event: KunHandoffEvent): void { + const message = `Kun owner handoff ${event.phase}${event.result ? `: ${event.result}` : ''}` + const detail = kunHandoffLogDetail(event) + if (event.result === 'failed') logWarn('update-handoff', message, detail) + else logInfo('update-handoff', message, detail) +} + +function abbreviateBuildId(buildId: string): string { + return buildId.length > 12 ? buildId.slice(0, 12) : buildId +} diff --git a/src/main/runtime/kun-installed-build-handoff.test.ts b/src/main/runtime/kun-installed-build-handoff.test.ts new file mode 100644 index 000000000..665804384 --- /dev/null +++ b/src/main/runtime/kun-installed-build-handoff.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeHandoffDiscoveryRecord } from '../../../kun/src/server/runtime-discovery.js' +import type { ManagerHandoffDiscoveryRecord } from '../../../kun/src/manager/manager-discovery.js' +import { + drainKunOwnersForHandoff, + KunHandoffError, + withDrainedKunOwners +} from './kun-installed-build-handoff' + +const controlDir = '/tmp/kun-control' +const dataDir = '/tmp/kun-data' +const settingsPath = '/tmp/Kun/kun-settings.json' + +function manager( + overrides: Partial = {} +): ManagerHandoffDiscoveryRecord { + return { + version: 7, + protocolVersion: 3, + instanceId: 'manager-old', + pid: 900, + startedAt: '2026-08-21T00:00:00.000Z', + host: '127.0.0.1', + port: 43000, + baseUrl: 'http://127.0.0.1:43000', + managerToken: 'manager-secret', + dataDir, + settingsPath, + ...overrides + } +} + +function runtime( + flavor: 'production' | 'development', + overrides: Partial = {} +): RuntimeHandoffDiscoveryRecord { + const development = flavor === 'development' + return { + version: 1, + instanceId: `${flavor}-old`, + pid: development ? 902 : 901, + startedAt: '2026-08-21T00:00:00.000Z', + host: '127.0.0.1', + port: development ? 43002 : 43001, + baseUrl: `http://127.0.0.1:${development ? 43002 : 43001}`, + runtimeToken: `${flavor}-secret`, + ...(development ? { flavor } : {}), + ...overrides + } +} + +function input() { + return { + reason: 'installed-build-change' as const, + dataDirs: [dataDir], + settingsPath, + controlDir, + targetBuildId: 'b'.repeat(64) + } +} + +describe('installed build handoff coordinator', () => { + it('drains both Runtime flavors and an older-schema Manager under one lock', async () => { + const currentManager = manager() + const currentRuntimes = new Map([ + ['production', runtime('production')], + ['development', runtime('development')] + ] as const) + let managerAlive = true + let lockHeld = false + const order: string[] = [] + const stopRuntime = vi.fn(async ( + _dataDir: string, + target: { discovery: RuntimeHandoffDiscoveryRecord } + ) => { + expect(lockHeld).toBe(true) + const flavor = target.discovery.flavor ?? 'production' + order.push(`runtime:${flavor}`) + currentRuntimes.delete(flavor) + return { stopped: true, forced: flavor === 'development' } + }) + const stopManager = vi.fn(async () => { + expect(lockHeld).toBe(true) + order.push('manager') + managerAlive = false + return { stopped: true, forced: false } + }) + const fetchMock = vi.fn(async () => Response.json({ + instanceId: currentManager.instanceId, + pid: currentManager.pid, + startedAt: currentManager.startedAt, + slots: [...currentRuntimes.values()].map((registration) => ({ registration: { + ...registration, + flavor: registration.flavor ?? 'production' + } })) + })) + + const report = await drainKunOwnersForHandoff({ ...input(), fetch: fetchMock as unknown as typeof fetch }, { + withManagerLock: async (_dir: string, action: () => Promise) => { + lockHeld = true + try { return await action() } finally { lockHeld = false } + }, + readManager: async () => managerAlive ? currentManager : null, + readRuntime: async (_dir, flavor) => currentRuntimes.get(flavor ?? 'production') ?? null, + processAlive: (pid) => managerAlive && pid === currentManager.pid || + [...currentRuntimes.values()].some((record) => record.pid === pid), + recordForcedOwner: vi.fn(async () => ({ markerId: 'marker' })) as never, + stopRuntime: stopRuntime as never, + stopManager: stopManager as never, + now: (() => { let value = 100; return () => value += 5 })() + }) + + expect(order).toEqual(['runtime:production', 'runtime:development', 'manager']) + expect(report.owners).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'runtime', flavor: 'production', result: 'graceful' }), + expect.objectContaining({ kind: 'runtime', flavor: 'development', result: 'forced' }), + expect.objectContaining({ kind: 'manager', result: 'graceful' }) + ])) + }) + + it('uses minimally parsed Manager slots when filesystem discovery is absent', async () => { + const currentManager = manager() + const slot = runtime('production') + let runtimeAlive = true + let managerAlive = true + const stopRuntime = vi.fn(async () => { + runtimeAlive = false + return { stopped: true, forced: false } + }) + const fetchMock = vi.fn(async () => Response.json({ + instanceId: currentManager.instanceId, + pid: currentManager.pid, + startedAt: currentManager.startedAt, + futureStatusField: true, + slots: [{ registration: { ...slot, flavor: 'production', futureSlotField: true } }] + })) + + await expect(drainKunOwnersForHandoff({ + ...input(), + fetch: fetchMock as unknown as typeof fetch + }, { + withManagerLock: async (_dir: string, action: () => Promise) => action(), + readManager: async () => managerAlive ? currentManager : null, + readRuntime: async () => null, + processAlive: (pid) => pid === slot.pid ? runtimeAlive : managerAlive, + stopRuntime: stopRuntime as never, + stopManager: (async () => { + managerAlive = false + return { stopped: true, forced: false } + }) as never + })).resolves.toMatchObject({ reason: 'installed-build-change' }) + + expect(stopRuntime).toHaveBeenCalledOnce() + }) + + it('re-discovers and drains a replacement Runtime that races the first pass', async () => { + const first = runtime('production') + const second = runtime('production', { + instanceId: 'production-raced', + pid: 903, + startedAt: '2026-08-21T00:01:00.000Z', + port: 43003, + baseUrl: 'http://127.0.0.1:43003' + }) + let current: RuntimeHandoffDiscoveryRecord | null = first + const stopped: string[] = [] + + await drainKunOwnersForHandoff(input(), { + withManagerLock: async (_dir: string, action: () => Promise) => action(), + readManager: async () => null, + readRuntime: async (_dir, flavor) => flavor === 'production' ? current : null, + processAlive: (pid) => current?.pid === pid, + stopRuntime: (async (_dir: string, target: { discovery: RuntimeHandoffDiscoveryRecord }) => { + stopped.push(target.discovery.instanceId) + current = target.discovery.instanceId === first.instanceId ? second : null + return { stopped: true, forced: false } + }) as never, + stopManager: vi.fn() as never + }) + + expect(stopped).toEqual([first.instanceId, second.instanceId]) + }) + + it('fails closed before stopping anything when Manager settings scope differs', async () => { + const stopRuntime = vi.fn() + const stopManager = vi.fn() + const failure = await drainKunOwnersForHandoff(input(), { + withManagerLock: async (_dir: string, action: () => Promise) => action(), + readManager: async () => manager({ settingsPath: '/tmp/Other/settings.json' }), + readRuntime: async () => null, + processAlive: () => true, + stopRuntime: stopRuntime as never, + stopManager: stopManager as never + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(KunHandoffError) + expect(failure).toMatchObject({ code: 'unsafe_scope', retryable: false }) + expect(stopRuntime).not.toHaveBeenCalled() + expect(stopManager).not.toHaveBeenCalled() + }) + + it('wraps an ambiguous Runtime failure and preserves the Manager', async () => { + const target = runtime('production') + const stopManager = vi.fn() + const failure = await drainKunOwnersForHandoff(input(), { + withManagerLock: async (_dir: string, action: () => Promise) => action(), + readManager: async () => manager(), + readRuntime: async (_dir, flavor) => flavor === 'production' ? target : null, + processAlive: () => true, + stopRuntime: (async () => { throw new Error('identity proof failed') }) as never, + stopManager: stopManager as never + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(KunHandoffError) + expect(failure).toMatchObject({ + code: 'runtime_stop_failed', + phase: 'stop-runtimes', + owner: { kind: 'runtime', flavor: 'production', pid: target.pid } + }) + expect(String((failure as Error).message)).not.toContain(target.runtimeToken) + expect(stopManager).not.toHaveBeenCalled() + }) + + it('runs the post-drain action before releasing the Manager election lock', async () => { + let lockHeld = false + const result = await withDrainedKunOwners(input(), async () => { + expect(lockHeld).toBe(true) + return 'manager-started' + }, { + withManagerLock: async (_dir: string, action: () => Promise) => { + lockHeld = true + try { return await action() } finally { lockHeld = false } + }, + readManager: async () => null, + readRuntime: async () => null, + processAlive: () => false, + stopRuntime: vi.fn() as never, + stopManager: vi.fn() as never + }) + + expect(lockHeld).toBe(false) + expect(result.value).toBe('manager-started') + }) +}) diff --git a/src/main/runtime/kun-installed-build-handoff.ts b/src/main/runtime/kun-installed-build-handoff.ts new file mode 100644 index 000000000..4e08405c9 --- /dev/null +++ b/src/main/runtime/kun-installed-build-handoff.ts @@ -0,0 +1,642 @@ +import { resolve } from 'node:path' +import { z } from 'zod' +import type { RuntimeFlavor } from '../../../kun/src/contracts/runtime-flavor.js' +import { + isSafeRuntimeHandoffDiscovery, + readRuntimeHandoffDiscovery, + type RuntimeHandoffDiscoveryRecord +} from '../../../kun/src/server/runtime-discovery.js' +import { + defaultKunControlDir, + readManagerHandoffDiscovery, + withManagerStartLock, + type ManagerHandoffDiscoveryRecord +} from '../../../kun/src/manager/manager-discovery.js' +import { sameCanonicalPath } from '../../../kun/src/manager/canonical-path.js' +import { processAlive, runtimeDiscoveryDirectory } from '../../../kun/src/cli/shared-runtime-support.js' +import { runtimeBuildIdForFlavor } from '../../../kun/src/cli/runtime-flavor.js' +import { + stopExactSharedRuntimeForReplacement, + type KunServeReplacementReport, + type SharedRuntimeReplacementInspection +} from './kun-serve-replacement' +import { + stopServiceManagerForReplacement, + type KunManagerReplacementReport +} from './kun-manager-replacement' +import { KunOwnerVerificationError } from './kun-replacement-error' +import { recordVerifiedForcedRuntimeOwner } from '../../../kun/src/manager/forced-runtime-recovery.js' + +const MANAGED_RUNTIME_FLAVORS = ['production', 'development'] as const +const MAX_RUNTIME_DRAIN_PASSES = 3 +const STATUS_TIMEOUT_MS = 2_000 + +export type KunHandoffReason = + | 'in-app-update' + | 'installed-build-change' + | 'exclusive-data-migration' + +export type KunHandoffPhase = + | 'discover' + | 'quiesce-runtimes' + | 'stop-runtimes' + | 'stop-manager' + | 'verify-drained' + | 'start-and-verify' + +export type KunHandoffErrorCode = + | 'unsafe_scope' + | 'runtime_stop_failed' + | 'manager_stop_failed' + | 'postcondition_failed' + +export type KunHandoffProbeClassification = + | 'no-live-owner' + | 'runtime-discovery-compatible' + | 'manager-discovery-compatible' + | 'manager-status-compatible' + | 'manager-status-unavailable' + +export type KunHandoffOwnerReport = { + kind: 'runtime' | 'manager' + flavor?: RuntimeFlavor + instanceId?: string + pid?: number + port?: number + buildId?: string + result: 'not-found' | 'graceful' | 'forced' +} + +export type KunHandoffReport = { + reason: KunHandoffReason + targetBuildId?: string + owners: KunHandoffOwnerReport[] + elapsedMs: number +} + +export type KunHandoffEvent = { + reason: KunHandoffReason + phase: KunHandoffPhase + elapsedMs: number + targetBuildId?: string + owner?: Omit + result?: KunHandoffOwnerReport['result'] | 'failed' + code?: KunHandoffErrorCode + probeClassification?: KunHandoffProbeClassification + postcondition?: 'drained' +} + +export class KunHandoffError extends Error { + readonly name = 'KunHandoffError' + + constructor( + readonly code: KunHandoffErrorCode, + readonly phase: KunHandoffPhase, + readonly reason: KunHandoffReason, + readonly retryable: boolean, + readonly owner: Omit | undefined, + message: string, + options: { cause?: unknown } = {} + ) { + super(message, options) + } +} + +export type KunInstalledBuildHandoffInput = { + reason: KunHandoffReason + dataDirs: readonly string[] + settingsPath?: string + controlDir?: string + targetBuildId?: string + fetch?: typeof fetch + onEvent?: (event: KunHandoffEvent) => void +} + +type RuntimeOwner = { + dataDir: string + flavor: RuntimeFlavor + inspection: SharedRuntimeReplacementInspection +} + +type HandoffDependencies = { + readManager: typeof readManagerHandoffDiscovery + readRuntime: typeof readRuntimeHandoffDiscovery + withManagerLock: (controlDir: string, action: () => Promise) => Promise + stopRuntime: typeof stopExactSharedRuntimeForReplacement + stopManager: typeof stopServiceManagerForReplacement + processAlive: typeof processAlive + recordForcedOwner: typeof recordVerifiedForcedRuntimeOwner + now: () => number +} + +const defaultDependencies: HandoffDependencies = { + readManager: readManagerHandoffDiscovery, + readRuntime: readRuntimeHandoffDiscovery, + withManagerLock: withManagerStartLock, + stopRuntime: stopExactSharedRuntimeForReplacement, + stopManager: stopServiceManagerForReplacement, + processAlive, + recordForcedOwner: recordVerifiedForcedRuntimeOwner, + now: Date.now +} + +export async function drainKunOwnersForHandoff( + input: KunInstalledBuildHandoffInput, + overrides: Partial = {} +): Promise { + return (await withDrainedKunOwners(input, async () => undefined, overrides)).report +} + +export async function requiresInstalledBuildHandoff( + input: KunInstalledBuildHandoffInput, + overrides: Partial = {} +): Promise { + if (!input.targetBuildId) return false + const deps = { ...defaultDependencies, ...overrides } + const controlDir = input.controlDir ?? defaultKunControlDir() + const manager = await deps.readManager(controlDir) + if (manager && deps.processAlive(manager.pid) && manager.buildId !== input.targetBuildId) { + return true + } + const dataDirs = canonicalDataDirs([ + ...input.dataDirs, + ...(manager ? [manager.dataDir] : []) + ]) + for (const dataDir of dataDirs) { + const runtime = await deps.readRuntime(dataDir, 'production') + if (runtime && deps.processAlive(runtime.pid) && + runtime.buildId !== runtimeBuildIdForFlavor(input.targetBuildId, 'production')) { + return true + } + } + const development = await deps.readRuntime(controlDir, 'development') + return Boolean(development && deps.processAlive(development.pid) && + development.buildId !== runtimeBuildIdForFlavor(input.targetBuildId, 'development')) +} + +export async function withDrainedKunOwners( + input: KunInstalledBuildHandoffInput, + afterDrain: (report: KunHandoffReport) => Promise | T, + overrides: Partial = {} +): Promise<{ report: KunHandoffReport; value: T }> { + const deps = { ...defaultDependencies, ...overrides } + const controlDir = input.controlDir ?? defaultKunControlDir() + return deps.withManagerLock(controlDir, async () => { + const report = await drainKunOwnersForHandoffWithLock(input, deps) + return { report, value: await afterDrain(report) } + }) +} + +/** Caller must hold the Manager start lock for input.controlDir. */ +export async function drainKunOwnersForHandoffWithLock( + input: KunInstalledBuildHandoffInput, + overrides: Partial = {} +): Promise { + const deps = { ...defaultDependencies, ...overrides } + const startedAt = deps.now() + const controlDir = input.controlDir ?? defaultKunControlDir() + const fetchImpl = input.fetch ?? fetch + const owners: KunHandoffOwnerReport[] = MANAGED_RUNTIME_FLAVORS.map((flavor) => ({ + kind: 'runtime' as const, + flavor, + result: 'not-found' as const + })) + owners.push({ kind: 'manager', result: 'not-found' }) + + emit(input, startedAt, deps, { phase: 'discover' }) + let discovered: Awaited> + try { + discovered = await discoverHandoffOwners(input, deps) + } catch (error) { + if (error instanceof KunHandoffError) { + emit(input, startedAt, deps, { + phase: error.phase, + ...(error.owner ? { owner: error.owner } : {}), + result: 'failed', + code: error.code + }) + } + throw error + } + for (const probeClassification of discovered.probeClassifications) { + emit(input, startedAt, deps, { phase: 'discover', probeClassification }) + } + for (let pass = 0; pass < MAX_RUNTIME_DRAIN_PASSES; pass += 1) { + if (discovered.runtimes.length === 0) break + emit(input, startedAt, deps, { phase: 'quiesce-runtimes' }) + for (const runtime of discovered.runtimes) { + const owner = runtimeOwnerReport(runtime) + try { + const result = await deps.stopRuntime( + runtime.dataDir, + runtime.inspection, + fetchImpl, + { runtimeFlavor: runtime.flavor, controlDir }, + { + inspect: async () => { + const latest = await discoverHandoffOwners(input, deps) + return latest.runtimes.find((candidate) => + sameRuntimeIdentity(candidate, runtime) + )?.inspection ?? null + } + } + ) + if (result.forced) { + await deps.recordForcedOwner({ + controlDir, + dataDir: runtime.dataDir, + owner: { + flavor: runtime.flavor, + instanceId: runtime.inspection.discovery.instanceId, + pid: runtime.inspection.discovery.pid, + startedAt: runtime.inspection.discovery.startedAt + } + }) + } + mergeOwnerReport(owners, owner, result) + emit(input, startedAt, deps, { + phase: 'stop-runtimes', + owner, + result: replacementResult(result) + }) + } catch (error) { + const failure = handoffFailure( + input, + 'runtime_stop_failed', + 'stop-runtimes', + owner, + error + ) + emit(input, startedAt, deps, { + phase: failure.phase, + owner, + result: 'failed', + code: failure.code + }) + throw failure + } + } + discovered = await discoverHandoffOwners(input, deps) + } + + if (discovered.manager) { + const managerOwner = managerOwnerReport(discovered.manager) + try { + const result = await deps.stopManager( + controlDir, + { + dataDir: discovered.manager.dataDir, + settingsPath: discovered.manager.settingsPath + }, + fetchImpl + ) + mergeOwnerReport(owners, managerOwner, result) + emit(input, startedAt, deps, { + phase: 'stop-manager', + owner: managerOwner, + result: replacementResult(result) + }) + } catch (error) { + const failure = handoffFailure( + input, + 'manager_stop_failed', + 'stop-manager', + managerOwner, + error + ) + emit(input, startedAt, deps, { + phase: failure.phase, + owner: managerOwner, + result: 'failed', + code: failure.code + }) + throw failure + } + } + + // Once the Manager is down, a Runtime heartbeat cannot elect a replacement + // while this process holds the same start lock. Drain any owner that raced + // with the first pass, then prove the scope is stable. + discovered = await discoverHandoffOwners(input, deps) + for (const runtime of discovered.runtimes) { + const owner = runtimeOwnerReport(runtime) + try { + const result = await deps.stopRuntime( + runtime.dataDir, + runtime.inspection, + fetchImpl, + { runtimeFlavor: runtime.flavor, controlDir }, + { + inspect: async () => { + const latest = await discoverHandoffOwners(input, deps) + return latest.runtimes.find((candidate) => + sameRuntimeIdentity(candidate, runtime) + )?.inspection ?? null + } + } + ) + if (result.forced) { + await deps.recordForcedOwner({ + controlDir, + dataDir: runtime.dataDir, + owner: { + flavor: runtime.flavor, + instanceId: runtime.inspection.discovery.instanceId, + pid: runtime.inspection.discovery.pid, + startedAt: runtime.inspection.discovery.startedAt + } + }) + } + mergeOwnerReport(owners, owner, result) + } catch (error) { + const failure = handoffFailure( + input, + 'runtime_stop_failed', + 'stop-runtimes', + owner, + error + ) + emit(input, startedAt, deps, { + phase: failure.phase, + owner, + result: 'failed', + code: failure.code + }) + throw failure + } + } + + const remaining = await discoverHandoffOwners(input, deps) + if (remaining.manager || remaining.runtimes.length > 0) { + const owner = remaining.manager + ? managerOwnerReport(remaining.manager) + : runtimeOwnerReport(remaining.runtimes[0]!) + const failure = new KunHandoffError( + 'postcondition_failed', + 'verify-drained', + input.reason, + true, + owner, + `Kun update handoff could not prove that ${ownerLabel(owner)} exited` + ) + emit(input, startedAt, deps, { + phase: failure.phase, + owner, + result: 'failed', + code: failure.code + }) + throw failure + } + + emit(input, startedAt, deps, { + phase: 'verify-drained', + postcondition: 'drained' + }) + return { + reason: input.reason, + ...(input.targetBuildId ? { targetBuildId: input.targetBuildId } : {}), + owners, + elapsedMs: deps.now() - startedAt + } +} + +async function discoverHandoffOwners( + input: KunInstalledBuildHandoffInput, + deps: HandoffDependencies +): Promise<{ + manager: ManagerHandoffDiscoveryRecord | null + runtimes: RuntimeOwner[] + probeClassifications: KunHandoffProbeClassification[] +}> { + const controlDir = input.controlDir ?? defaultKunControlDir() + const manager = await deps.readManager(controlDir) + if (manager && input.settingsPath && + !sameCanonicalPath(manager.settingsPath, input.settingsPath)) { + throw new KunHandoffError( + 'unsafe_scope', + 'discover', + input.reason, + false, + managerOwnerReport(manager), + 'Kun Service Manager owns a different canonical settings scope' + ) + } + const dataDirs = canonicalDataDirs([ + ...input.dataDirs, + ...(manager ? [manager.dataDir] : []) + ]) + const runtimes: RuntimeOwner[] = [] + for (const dataDir of dataDirs) { + const record = await deps.readRuntime(dataDir, 'production') + if (record && deps.processAlive(record.pid)) { + runtimes.push(runtimeOwner(dataDir, 'production', record)) + } + } + const developmentDir = manager?.dataDir ?? dataDirs[0] + if (developmentDir) { + const record = await deps.readRuntime(controlDir, 'development') + if (record && deps.processAlive(record.pid)) { + runtimes.push(runtimeOwner(developmentDir, 'development', record)) + } + } + const probeClassifications: KunHandoffProbeClassification[] = [] + if (runtimes.length > 0) probeClassifications.push('runtime-discovery-compatible') + if (manager && deps.processAlive(manager.pid)) { + probeClassifications.push('manager-discovery-compatible') + } + if (manager && deps.processAlive(manager.pid)) { + const managerStatus = await readCompatibleManagerSlots(manager, input.fetch ?? fetch) + probeClassifications.push(managerStatus.classification) + for (const slot of managerStatus.records) { + if (!deps.processAlive(slot.pid)) continue + runtimes.push(runtimeOwner(manager.dataDir, slot.flavor, slot)) + } + } + if (probeClassifications.length === 0) probeClassifications.push('no-live-owner') + return { + manager: manager && deps.processAlive(manager.pid) ? manager : null, + runtimes: deduplicateRuntimeOwners(runtimes), + probeClassifications + } +} + +const RuntimeSlotSchema = z.object({ + flavor: z.enum(MANAGED_RUNTIME_FLAVORS), + instanceId: z.string().min(1).max(256), + pid: z.number().int().positive(), + startedAt: z.string().datetime(), + host: z.string().min(1).max(512), + port: z.number().int().min(1).max(65_535), + baseUrl: z.string().url().max(2_048), + runtimeToken: z.string().max(16_384), + buildId: z.string().regex(/^[a-f0-9]{64}$/).optional(), + logPath: z.string().min(1).max(4_096).optional() +}).passthrough() + +async function readCompatibleManagerSlots( + manager: ManagerHandoffDiscoveryRecord, + fetchImpl: typeof fetch +): Promise<{ + records: Array + classification: Extract< + KunHandoffProbeClassification, + 'manager-status-compatible' | 'manager-status-unavailable' + > +}> { + try { + const response = await fetchImpl(`${manager.baseUrl.replace(/\/$/u, '')}/v1/manager/status`, { + headers: { authorization: `Bearer ${manager.managerToken}` }, + signal: AbortSignal.timeout(STATUS_TIMEOUT_MS) + }) + if (!response.ok) return { records: [], classification: 'manager-status-unavailable' } + const body = z.object({ + instanceId: z.string(), + pid: z.number().int().positive().optional(), + startedAt: z.string(), + slots: z.array(z.unknown()) + }).passthrough().safeParse(await response.json()) + if (!body.success || + body.data.instanceId !== manager.instanceId || + body.data.startedAt !== manager.startedAt || + (body.data.pid !== undefined && body.data.pid !== manager.pid)) { + return { records: [], classification: 'manager-status-unavailable' } + } + const records: Array = [] + for (const value of body.data.slots) { + const envelope = z.object({ registration: z.unknown() }).passthrough().safeParse(value) + const parsed = RuntimeSlotSchema.safeParse(envelope.success ? envelope.data.registration : value) + if (!parsed.success) continue + const record: RuntimeHandoffDiscoveryRecord & { flavor: RuntimeFlavor } = { + version: 1, + ...parsed.data, + flavor: parsed.data.flavor + } + if (isSafeRuntimeHandoffDiscovery(record)) records.push(record) + } + return { records, classification: 'manager-status-compatible' } + } catch { + return { records: [], classification: 'manager-status-unavailable' } + } +} + +function runtimeOwner( + dataDir: string, + flavor: RuntimeFlavor, + record: RuntimeHandoffDiscoveryRecord +): RuntimeOwner { + return { + dataDir, + flavor, + inspection: { discovery: record, connection: null } + } +} + +function deduplicateRuntimeOwners(owners: RuntimeOwner[]): RuntimeOwner[] { + const seen = new Set() + return owners.filter((owner) => { + const record = owner.inspection.discovery + const key = `${record.instanceId}:${record.pid}:${record.startedAt}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function canonicalDataDirs(values: readonly string[]): string[] { + const result: string[] = [] + for (const value of values) { + if (!value.trim() || result.some((current) => sameCanonicalPath(current, value))) continue + result.push(resolve(value)) + } + return result +} + +function sameRuntimeIdentity(left: RuntimeOwner, right: RuntimeOwner): boolean { + const a = left.inspection.discovery + const b = right.inspection.discovery + return left.flavor === right.flavor && + a.instanceId === b.instanceId && + a.pid === b.pid && + a.startedAt === b.startedAt +} + +function runtimeOwnerReport(runtime: RuntimeOwner): Omit { + const record = runtime.inspection.discovery + return { + kind: 'runtime', + flavor: runtime.flavor, + instanceId: record.instanceId, + pid: record.pid, + port: record.port, + ...(record.buildId ? { buildId: record.buildId } : {}) + } +} + +function managerOwnerReport( + manager: ManagerHandoffDiscoveryRecord +): Omit { + return { + kind: 'manager', + instanceId: manager.instanceId, + pid: manager.pid, + port: manager.port, + ...(manager.buildId ? { buildId: manager.buildId } : {}) + } +} + +function mergeOwnerReport( + reports: KunHandoffOwnerReport[], + owner: Omit, + replacement: KunServeReplacementReport | KunManagerReplacementReport +): void { + const result = replacementResult(replacement) + const existing = reports.find((candidate) => + candidate.kind === owner.kind && candidate.flavor === owner.flavor + ) + if (existing) Object.assign(existing, owner, { result }) + else reports.push({ ...owner, result }) +} + +function replacementResult( + report: KunServeReplacementReport | KunManagerReplacementReport +): KunHandoffOwnerReport['result'] { + return report.forced ? 'forced' : report.stopped ? 'graceful' : 'not-found' +} + +function handoffFailure( + input: KunInstalledBuildHandoffInput, + code: KunHandoffErrorCode, + phase: KunHandoffPhase, + owner: Omit, + cause: unknown +): KunHandoffError { + return new KunHandoffError( + code, + phase, + input.reason, + !(cause instanceof KunOwnerVerificationError), + owner, + `Kun update handoff could not safely stop ${ownerLabel(owner)}`, + { cause } + ) +} + +function ownerLabel(owner: Omit): string { + return owner.kind === 'runtime' + ? `${owner.flavor ?? 'unknown'} Runtime${owner.pid ? ` ${owner.pid}` : ''}` + : `Service Manager${owner.pid ? ` ${owner.pid}` : ''}` +} + +function emit( + input: KunInstalledBuildHandoffInput, + startedAt: number, + deps: HandoffDependencies, + event: Omit +): void { + input.onEvent?.({ + reason: input.reason, + elapsedMs: deps.now() - startedAt, + ...(input.targetBuildId ? { targetBuildId: input.targetBuildId } : {}), + ...event + }) +} diff --git a/src/main/runtime/kun-manager-replacement.test.ts b/src/main/runtime/kun-manager-replacement.test.ts new file mode 100644 index 000000000..283dd7470 --- /dev/null +++ b/src/main/runtime/kun-manager-replacement.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ManagerHandoffDiscoveryRecord } from '../../../kun/src/manager/manager-discovery.js' +import { stopServiceManagerForReplacement } from './kun-manager-replacement' + +const controlDir = '/tmp/kun-control' +const scope = { + dataDir: '/tmp/kun-data', + settingsPath: '/tmp/Kun/kun-settings.json' +} + +function manager( + overrides: Partial = {} +): ManagerHandoffDiscoveryRecord { + return { + version: 1, + protocolVersion: 1, + instanceId: 'manager-old', + pid: 901, + startedAt: '2026-08-21T00:00:00.000Z', + host: '127.0.0.1', + port: 43100, + baseUrl: 'http://127.0.0.1:43100', + managerToken: 'manager-secret', + serviceVersion: '0.1.0', + dataDir: scope.dataDir, + settingsPath: scope.settingsPath, + ...overrides + } +} + +describe('stopServiceManagerForReplacement', () => { + it('gracefully stops an exact authenticated Manager without full health parsing', async () => { + const target = manager({ version: 7, protocolVersion: 3 }) + const fetchMock = vi.fn(async () => Response.json({ accepted: true })) + const removeDiscovery = vi.fn(async () => true) + let waitCalls = 0 + + await expect(stopServiceManagerForReplacement( + controlDir, + scope, + fetchMock as unknown as typeof fetch, + { + readDiscovery: vi.fn(async () => target), + waitForExit: vi.fn(async () => ++waitCalls > 1), + commandLine: vi.fn(), + listenerPids: vi.fn(), + terminate: vi.fn(), + removeDiscovery + } + )).resolves.toEqual({ stopped: true, forced: false }) + + expect(fetchMock).toHaveBeenCalledWith( + `${target.baseUrl}/v1/manager/shutdown`, + expect.objectContaining({ + method: 'POST', + headers: { + authorization: `Bearer ${target.managerToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ instanceId: target.instanceId }) + }) + ) + expect(removeDiscovery).toHaveBeenCalledWith(controlDir, target.instanceId) + }) + + it('forces only an unchanged Manager with matching command, scope, and listener', async () => { + const target = manager() + let current: ManagerHandoffDiscoveryRecord | null = target + const terminate = vi.fn(async (_pid: number, verify: () => Promise) => { + expect(await verify()).toBe(true) + current = null + return true + }) + const removeDiscovery = vi.fn(async () => true) + + await expect(stopServiceManagerForReplacement(controlDir, scope, fetch, { + readDiscovery: vi.fn(async () => current), + requestShutdown: vi.fn(async () => { throw new Error('shutdown timed out') }), + waitForExit: vi.fn(async () => current === null), + commandLine: vi.fn(async () => '/Applications/Kun.app/manager-entry.js'), + listenerPids: vi.fn(async () => [target.pid]), + terminate, + removeDiscovery + })).resolves.toEqual({ stopped: true, forced: true }) + + expect(terminate).toHaveBeenCalledTimes(1) + expect(removeDiscovery).not.toHaveBeenCalled() + }) + + it.each([ + ['command mismatch', 'node unrelated.js', [901]], + ['listener mismatch', 'kun-service-manager', [902]], + ['process inspection denied', '', []] + ])('refuses force replacement on %s', async (_label, command, listeners) => { + const target = manager() + let signalSent = false + const terminate = vi.fn(async (_pid: number, verify: () => Promise) => { + if (!(await verify())) return false + signalSent = true + return true + }) + + await expect(stopServiceManagerForReplacement(controlDir, scope, fetch, { + readDiscovery: vi.fn(async () => target), + requestShutdown: vi.fn(async () => { throw new Error('shutdown unavailable') }), + waitForExit: vi.fn(async () => false), + commandLine: vi.fn(async () => command), + listenerPids: vi.fn(async () => listeners), + terminate, + removeDiscovery: vi.fn(async () => true) + })).rejects.toThrow(/could not be safely replaced/) + + expect(signalSent).toBe(false) + }) + + it('does not signal a changed live owner or erase its record', async () => { + const target = manager() + const replacement = manager({ + instanceId: 'manager-new', + pid: 902, + startedAt: '2026-08-21T00:01:00.000Z', + port: 43101, + baseUrl: 'http://127.0.0.1:43101', + managerToken: 'new-secret' + }) + let reads = 0 + const removeDiscovery = vi.fn(async () => true) + const requestShutdown = vi.fn() + const terminate = vi.fn() + + await expect(stopServiceManagerForReplacement(controlDir, scope, fetch, { + readDiscovery: vi.fn(async () => ++reads === 1 ? target : replacement), + requestShutdown, + waitForExit: vi.fn(async () => false), + commandLine: vi.fn(), + listenerPids: vi.fn(), + terminate, + removeDiscovery + })).rejects.toThrow(/ownership changed before shutdown/) + + expect(requestShutdown).not.toHaveBeenCalled() + expect(terminate).not.toHaveBeenCalled() + expect(removeDiscovery).not.toHaveBeenCalled() + }) + + it('treats an already-exited changed target as settled without touching replacement', async () => { + const target = manager() + const replacement = manager({ + instanceId: 'manager-new', + pid: 902, + startedAt: '2026-08-21T00:01:00.000Z' + }) + let reads = 0 + let waits = 0 + const removeDiscovery = vi.fn(async () => false) + + await expect(stopServiceManagerForReplacement(controlDir, scope, fetch, { + readDiscovery: vi.fn(async () => ++reads === 1 ? target : replacement), + requestShutdown: vi.fn(), + waitForExit: vi.fn(async () => ++waits > 1), + commandLine: vi.fn(), + listenerPids: vi.fn(), + terminate: vi.fn(), + removeDiscovery + })).resolves.toEqual({ stopped: true, forced: false }) + + expect(removeDiscovery).toHaveBeenCalledWith(controlDir, target.instanceId) + }) + + it('rejects a Manager outside the selected canonical scope', async () => { + const target = manager({ dataDir: '/tmp/other-data' }) + await expect(stopServiceManagerForReplacement(controlDir, scope, fetch, { + readDiscovery: vi.fn(async () => target) + })).rejects.toThrow(/different canonical scope/) + }) +}) diff --git a/src/main/runtime/kun-manager-replacement.ts b/src/main/runtime/kun-manager-replacement.ts new file mode 100644 index 000000000..9220af36f --- /dev/null +++ b/src/main/runtime/kun-manager-replacement.ts @@ -0,0 +1,217 @@ +import { + readManagerHandoffDiscovery, + removeManagerDiscovery, + type ManagerHandoffDiscoveryRecord +} from '../../../kun/src/manager/manager-discovery.js' +import { sameCanonicalPath } from '../../../kun/src/manager/canonical-path.js' +import { + listListeningPidsOnPort, + processCommandLine, + terminateVerifiedPid, + waitForPidExit +} from '../kun-process-ports' +import { KunOwnerVerificationError } from './kun-replacement-error' + +const GRACEFUL_EXIT_TIMEOUT_MS = 15_000 +const SHUTDOWN_REQUEST_TIMEOUT_MS = 5_000 + +export type KunManagerReplacementReport = { + stopped: boolean + forced: boolean +} + +export type KunManagerReplacementScope = { + dataDir: string + settingsPath: string +} + +export type KunManagerReplacementDependencies = { + readDiscovery: typeof readManagerHandoffDiscovery + requestShutdown: ( + target: ManagerHandoffDiscoveryRecord, + fetchImpl: typeof fetch + ) => Promise + waitForExit: typeof waitForPidExit + commandLine: typeof processCommandLine + listenerPids: typeof listListeningPidsOnPort + terminate: typeof terminateVerifiedPid + removeDiscovery: typeof removeManagerDiscovery +} + +const defaultDependencies: KunManagerReplacementDependencies = { + readDiscovery: readManagerHandoffDiscovery, + requestShutdown: requestExactManagerShutdown, + waitForExit: waitForPidExit, + commandLine: processCommandLine, + listenerPids: listListeningPidsOnPort, + terminate: terminateVerifiedPid, + removeDiscovery: removeManagerDiscovery +} + +/** Stop one exact Manager during an explicit replacement or migration. */ +export async function stopServiceManagerForReplacement( + controlDir: string, + scope: KunManagerReplacementScope, + fetchImpl: typeof fetch = fetch, + overrides: Partial = {} +): Promise { + const deps = { ...defaultDependencies, ...overrides } + const target = await deps.readDiscovery(controlDir) + if (!target) return { stopped: false, forced: false } + assertManagerScope(target, scope) + + if (await deps.waitForExit(target.pid, 0)) { + await deps.removeDiscovery(controlDir, target.instanceId) + return { stopped: false, forced: false } + } + + try { + const current = await readTarget(controlDir, deps) + if (!current.ok || !sameManagerOwner(target, current.value)) { + return settleChangedOwner(controlDir, target, deps) + } + await deps.requestShutdown(target, fetchImpl) + if (await deps.waitForExit(target.pid, GRACEFUL_EXIT_TIMEOUT_MS)) { + await deps.removeDiscovery(controlDir, target.instanceId) + return { stopped: true, forced: false } + } + return forceVerifiedManager( + controlDir, + scope, + target, + deps, + new Error(`timed out waiting for Kun Service Manager ${target.pid} to exit`) + ) + } catch (error) { + return forceVerifiedManager(controlDir, scope, target, deps, error) + } +} + +async function requestExactManagerShutdown( + target: ManagerHandoffDiscoveryRecord, + fetchImpl: typeof fetch +): Promise { + const response = await fetchImpl(`${target.baseUrl.replace(/\/$/u, '')}/v1/manager/shutdown`, { + method: 'POST', + headers: { + authorization: `Bearer ${target.managerToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ instanceId: target.instanceId }), + signal: AbortSignal.timeout(SHUTDOWN_REQUEST_TIMEOUT_MS) + }) + if (!response.ok) throw new Error(`manager shutdown failed with HTTP ${response.status}`) +} + +async function forceVerifiedManager( + controlDir: string, + scope: KunManagerReplacementScope, + target: ManagerHandoffDiscoveryRecord, + deps: KunManagerReplacementDependencies, + originalError: unknown +): Promise { + const current = await readTarget(controlDir, deps) + if (!current.ok || !sameManagerOwner(target, current.value)) { + return settleChangedOwner(controlDir, target, deps, originalError) + } + const terminated = await deps.terminate(target.pid, () => + targetStillMatches(controlDir, scope, target, deps) + ) + if (!terminated || !(await deps.waitForExit(target.pid, 0))) { + throw replacementFailure(target.pid, originalError) + } + + const remaining = await readTarget(controlDir, deps) + if (!remaining.ok) throw replacementFailure(target.pid, originalError) + if (sameManagerOwner(target, remaining.value)) { + await deps.removeDiscovery(controlDir, target.instanceId) + } + return { stopped: true, forced: true } +} + +async function settleChangedOwner( + controlDir: string, + target: ManagerHandoffDiscoveryRecord, + deps: KunManagerReplacementDependencies, + originalError: unknown = new Error('Manager ownership changed before shutdown') +): Promise { + if (!(await deps.waitForExit(target.pid, 0))) { + throw replacementFailure(target.pid, originalError) + } + await deps.removeDiscovery(controlDir, target.instanceId) + return { stopped: true, forced: false } +} + +async function targetStillMatches( + controlDir: string, + scope: KunManagerReplacementScope, + target: ManagerHandoffDiscoveryRecord, + deps: KunManagerReplacementDependencies +): Promise { + const current = await readTarget(controlDir, deps) + if (!current.ok || !current.value || + !sameManagerOwner(target, current.value) || target.pid === process.pid) { + return false + } + try { + assertManagerScope(current.value, scope) + } catch { + return false + } + const [command, listeners] = await Promise.all([ + deps.commandLine(target.pid).catch(() => ''), + deps.listenerPids(target.port).catch((): number[] => []) + ]) + return commandLooksLikeManager(command) && listeners.includes(target.pid) +} + +async function readTarget( + controlDir: string, + deps: KunManagerReplacementDependencies +): Promise< + { ok: true; value: ManagerHandoffDiscoveryRecord | null } | + { ok: false } +> { + try { + return { ok: true, value: await deps.readDiscovery(controlDir) } + } catch { + return { ok: false } + } +} + +function sameManagerOwner( + expected: ManagerHandoffDiscoveryRecord, + current: ManagerHandoffDiscoveryRecord | null +): boolean { + return current !== null && + current.instanceId === expected.instanceId && + current.pid === expected.pid && + current.startedAt === expected.startedAt && + current.baseUrl === expected.baseUrl && + current.port === expected.port && + current.managerToken === expected.managerToken && + sameCanonicalPath(current.dataDir, expected.dataDir) && + sameCanonicalPath(current.settingsPath, expected.settingsPath) +} + +function assertManagerScope( + target: ManagerHandoffDiscoveryRecord, + scope: KunManagerReplacementScope +): void { + if (!sameCanonicalPath(target.dataDir, scope.dataDir) || + !sameCanonicalPath(target.settingsPath, scope.settingsPath)) { + throw new Error('Kun Service Manager replacement target owns a different canonical scope') + } +} + +function commandLooksLikeManager(command: string): boolean { + const normalized = command.trim().replace(/\\/gu, '/').toLowerCase() + return normalized === 'kun-service-manager' || + normalized.startsWith('kun-service-manager ') || + normalized.includes('manager-entry.js') +} + +function replacementFailure(pid: number, error: unknown): Error { + const detail = error instanceof Error ? error.message : String(error) + return new KunOwnerVerificationError('manager', pid, detail) +} diff --git a/src/main/runtime/kun-replacement-error.ts b/src/main/runtime/kun-replacement-error.ts new file mode 100644 index 000000000..01fbfc930 --- /dev/null +++ b/src/main/runtime/kun-replacement-error.ts @@ -0,0 +1,14 @@ +export class KunOwnerVerificationError extends Error { + readonly name = 'KunOwnerVerificationError' + + constructor( + readonly ownerKind: 'runtime' | 'manager', + readonly pid: number, + detail: string + ) { + super( + `Kun ${ownerKind === 'manager' ? 'Service Manager' : 'Runtime'} ${pid} ` + + `could not be safely replaced after graceful shutdown failed: ${detail}` + ) + } +} diff --git a/src/main/runtime/kun-serve-replacement.test.ts b/src/main/runtime/kun-serve-replacement.test.ts index 53c0b3980..a959a2c3a 100644 --- a/src/main/runtime/kun-serve-replacement.test.ts +++ b/src/main/runtime/kun-serve-replacement.test.ts @@ -136,7 +136,7 @@ describe('stopSharedRuntimeForReplacement', () => { }, { inspect: vi.fn(async () => current), requestShutdown: vi.fn(async () => { throw new Error('shutdown probe timed out') }), - waitForExit: vi.fn(async () => false), + waitForExit: vi.fn(async (_pid, timeoutMs) => timeoutMs === 0), commandLine: vi.fn(async () => 'kun-runtime'), listenerPids: vi.fn(async () => [target.discovery.pid]), terminate, @@ -159,7 +159,11 @@ describe('stopSharedRuntimeForReplacement', () => { }) }) - it('does not signal a PID when the discovered process no longer looks like the recorded serve', async () => { + it.each([ + ['command mismatch', 'node unrelated-service.js', [101]], + ['listener mismatch', 'node serve-entry.js --runtime-flavor production --data-dir /tmp/kun-replacement-data', [202]], + ['process inspection denied', '', []] + ])('does not signal a PID on %s', async (_label, command, listeners) => { const target = inspection() let signalSent = false const terminate = vi.fn(async (_pid: number, verify: () => Promise) => { @@ -176,8 +180,8 @@ describe('stopSharedRuntimeForReplacement', () => { inspect: vi.fn(async () => target), requestShutdown: vi.fn(async () => { throw new Error('shutdown unavailable') }), waitForExit: vi.fn(async () => false), - commandLine: vi.fn(async () => 'node unrelated-service.js'), - listenerPids: vi.fn(async () => [target.discovery.pid]), + commandLine: vi.fn(async () => command), + listenerPids: vi.fn(async () => listeners), terminate, removeDiscovery, withAncillaryWriter: async (_dataDir, action) => action(), @@ -209,7 +213,7 @@ describe('stopSharedRuntimeForReplacement', () => { }, { inspect: vi.fn(async () => ++reads === 1 ? target : replacement), requestShutdown, - waitForExit: vi.fn(async () => false), + waitForExit: vi.fn(async (_pid, timeoutMs) => timeoutMs === 0), commandLine: vi.fn(async () => 'kun-runtime'), listenerPids: vi.fn(async () => [target.discovery.pid]), terminate, diff --git a/src/main/runtime/kun-serve-replacement.ts b/src/main/runtime/kun-serve-replacement.ts index 5b5d4f860..39db2b6fb 100644 --- a/src/main/runtime/kun-serve-replacement.ts +++ b/src/main/runtime/kun-serve-replacement.ts @@ -1,11 +1,17 @@ import { resolve } from 'node:path' import type { RuntimeFlavor } from '../../../kun/src/contracts/runtime-flavor.js' +import type { RuntimeHandoffDiscoveryRecord } from '../../../kun/src/server/runtime-discovery.js' +import { readRuntimeHandoffDiscovery } from '../../../kun/src/server/runtime-discovery.js' import { inspectSharedRuntime, - type SharedRuntimeInspection, + type SharedRuntimeConnection, type SharedRuntimeScope } from '../../../kun/src/cli/shared-runtime.js' -import { runtimeDiscoveryDirectory } from '../../../kun/src/cli/shared-runtime-support.js' +import { requestExactRuntimeShutdown } from '../../../kun/src/cli/runtime-shutdown-client.js' +import { + processAlive, + runtimeDiscoveryDirectory +} from '../../../kun/src/cli/shared-runtime-support.js' import { removeRuntimeDiscovery } from '../../../kun/src/server/runtime-discovery.js' import { withRuntimeDataDirAncillaryWriter } from '../../../kun/src/server/runtime-data-dir-lease.js' import { unregisterRuntimeWithManager } from '../../../kun/src/manager/manager-client.js' @@ -15,15 +21,28 @@ import { terminateVerifiedPid, waitForPidExit } from '../kun-process-ports' +import { KunOwnerVerificationError } from './kun-replacement-error' export type KunServeReplacementReport = { stopped: boolean forced: boolean } +export type SharedRuntimeReplacementInspection = { + discovery: RuntimeHandoffDiscoveryRecord + connection: SharedRuntimeConnection | null +} + export type KunServeReplacementDependencies = { - inspect: typeof inspectSharedRuntime - requestShutdown: (target: SharedRuntimeInspection, fetchImpl: typeof fetch) => Promise + inspect: ( + dataDir: string, + fetchImpl: typeof fetch, + scope: SharedRuntimeScope + ) => Promise + requestShutdown: ( + target: SharedRuntimeReplacementInspection, + fetchImpl: typeof fetch + ) => Promise waitForExit: typeof waitForPidExit commandLine: typeof processCommandLine listenerPids: typeof listListeningPidsOnPort @@ -34,8 +53,9 @@ export type KunServeReplacementDependencies = { } const defaultDependencies: KunServeReplacementDependencies = { - inspect: inspectSharedRuntime, - requestShutdown: requestExactRuntimeShutdown, + inspect: inspectSharedRuntimeForReplacement, + requestShutdown: (target, fetchImpl) => + requestExactRuntimeShutdown(target.discovery, fetchImpl), waitForExit: waitForPidExit, commandLine: processCommandLine, listenerPids: listListeningPidsOnPort, @@ -60,15 +80,45 @@ export async function stopSharedRuntimeForReplacement( const deps = { ...defaultDependencies, ...overrides } const target = await deps.inspect(dataDir, fetchImpl, scope) if (!target) return { stopped: false, forced: false } + return stopExactSharedRuntimeForReplacementWithDependencies( + dataDir, + target, + fetchImpl, + scope, + deps + ) +} +export async function stopExactSharedRuntimeForReplacement( + dataDir: string, + target: SharedRuntimeReplacementInspection, + fetchImpl: typeof fetch = fetch, + scope: SharedRuntimeScope = {}, + overrides: Partial = {} +): Promise { + return stopExactSharedRuntimeForReplacementWithDependencies( + dataDir, + target, + fetchImpl, + scope, + { ...defaultDependencies, ...overrides } + ) +} + +async function stopExactSharedRuntimeForReplacementWithDependencies( + dataDir: string, + target: SharedRuntimeReplacementInspection, + fetchImpl: typeof fetch, + scope: SharedRuntimeScope, + deps: KunServeReplacementDependencies +): Promise { try { const currentBeforeShutdown = await inspectTarget(dataDir, fetchImpl, scope, deps) if (!currentBeforeShutdown.ok) { throw new Error('could not re-verify the recorded runtime owner before shutdown') } if (!sameRuntimeOwner(target, currentBeforeShutdown.value)) { - await removeExactOwnership(dataDir, target, scope, deps) - return { stopped: true, forced: false } + return settleChangedRuntimeOwner(dataDir, target, scope, deps) } await deps.requestShutdown(target, fetchImpl) if (await deps.waitForExit(target.discovery.pid, 15_000)) { @@ -90,7 +140,7 @@ export async function stopSharedRuntimeForReplacement( async function forceVerifiedReplacement( dataDir: string, - target: SharedRuntimeInspection, + target: SharedRuntimeReplacementInspection, fetchImpl: typeof fetch, scope: SharedRuntimeScope, deps: KunServeReplacementDependencies, @@ -102,8 +152,7 @@ async function forceVerifiedReplacement( // it is no longer safe or necessary to signal the old PID. if (!current.ok) throw replacementFailure(runtimeFlavorFor(target, scope), target.discovery.pid, originalError) if (!sameRuntimeOwner(target, current.value)) { - await removeExactOwnership(dataDir, target, scope, deps) - return { stopped: true, forced: false } + return settleChangedRuntimeOwner(dataDir, target, scope, deps, originalError) } const flavor = runtimeFlavorFor(target, scope) @@ -120,28 +169,15 @@ async function forceVerifiedReplacement( return { stopped: true, forced: true } } -async function requestExactRuntimeShutdown( - target: SharedRuntimeInspection, - fetchImpl: typeof fetch -): Promise { - const response = await fetchImpl(`${target.discovery.baseUrl.replace(/\/$/u, '')}/v1/runtime/shutdown`, { - method: 'POST', - headers: { - authorization: `Bearer ${target.discovery.runtimeToken}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ instanceId: target.discovery.instanceId }), - signal: AbortSignal.timeout(5_000) - }) - if (!response.ok) throw new Error(`runtime shutdown failed with HTTP ${response.status}`) -} - async function inspectTarget( dataDir: string, fetchImpl: typeof fetch, scope: SharedRuntimeScope, deps: KunServeReplacementDependencies -): Promise<{ ok: true; value: SharedRuntimeInspection | null } | { ok: false }> { +): Promise< + { ok: true; value: SharedRuntimeReplacementInspection | null } | + { ok: false } +> { try { return { ok: true, value: await deps.inspect(dataDir, fetchImpl, scope) } } catch { @@ -153,17 +189,17 @@ async function inspectTarget( async function targetStillMatches( dataDir: string, - target: SharedRuntimeInspection, + target: SharedRuntimeReplacementInspection, fetchImpl: typeof fetch, scope: SharedRuntimeScope, deps: KunServeReplacementDependencies ): Promise { const current = await inspectTarget(dataDir, fetchImpl, scope, deps) - if (!current.ok || !sameRuntimeOwner(target, current.value)) return false + if (!current.ok || !current.value || !sameRuntimeOwner(target, current.value)) return false if (target.discovery.pid === process.pid) return false const [command, listeners] = await Promise.all([ deps.commandLine(target.discovery.pid).catch(() => ''), - deps.listenerPids(target.discovery.port) + deps.listenerPids(target.discovery.port).catch((): number[] => []) ]) return commandLooksLikeExpectedServe( command, @@ -173,17 +209,38 @@ async function targetStillMatches( } function sameRuntimeOwner( - expected: SharedRuntimeInspection, - current: SharedRuntimeInspection | null + expected: SharedRuntimeReplacementInspection, + current: SharedRuntimeReplacementInspection | null ): boolean { if (!current) return false return current.discovery.instanceId === expected.discovery.instanceId && current.discovery.pid === expected.discovery.pid && - current.discovery.startedAt === expected.discovery.startedAt + current.discovery.startedAt === expected.discovery.startedAt && + current.discovery.baseUrl === expected.discovery.baseUrl && + current.discovery.port === expected.discovery.port && + current.discovery.runtimeToken === expected.discovery.runtimeToken +} + +async function settleChangedRuntimeOwner( + dataDir: string, + target: SharedRuntimeReplacementInspection, + scope: SharedRuntimeScope, + deps: KunServeReplacementDependencies, + originalError: unknown = new Error('Runtime ownership changed before shutdown') +): Promise { + if (!(await deps.waitForExit(target.discovery.pid, 0))) { + throw replacementFailure( + runtimeFlavorFor(target, scope), + target.discovery.pid, + originalError + ) + } + await removeExactOwnership(dataDir, target, scope, deps) + return { stopped: true, forced: false } } function runtimeFlavorFor( - target: SharedRuntimeInspection, + target: SharedRuntimeReplacementInspection, scope: SharedRuntimeScope ): RuntimeFlavor { return scope.runtimeFlavor ?? target.discovery.flavor ?? 'production' @@ -211,7 +268,7 @@ function normalizeCommandPath(value: string): string { async function removeExactOwnership( dataDir: string, - target: SharedRuntimeInspection, + target: SharedRuntimeReplacementInspection, scope: SharedRuntimeScope, deps: KunServeReplacementDependencies ): Promise { @@ -235,7 +292,19 @@ async function removeExactOwnership( function replacementFailure(flavor: RuntimeFlavor, pid: number, error: unknown): Error { const detail = error instanceof Error ? error.message : String(error) - return new Error( - `Kun ${flavor} serve ${pid} could not be safely replaced after graceful shutdown failed: ${detail}` - ) + return new KunOwnerVerificationError('runtime', pid, `${flavor}: ${detail}`) +} + +async function inspectSharedRuntimeForReplacement( + dataDir: string, + fetchImpl: typeof fetch, + scope: SharedRuntimeScope +): Promise { + const strict = await inspectSharedRuntime(dataDir, fetchImpl, scope) + if (strict) return strict + const flavor = scope.runtimeFlavor ?? 'production' + const discoveryDir = runtimeDiscoveryDirectory(dataDir, flavor, scope.controlDir) + const compatible = await readRuntimeHandoffDiscovery(discoveryDir, flavor) + if (!compatible || !processAlive(compatible.pid)) return null + return { discovery: compatible, connection: null } } diff --git a/src/main/startup-failure-content.ts b/src/main/startup-failure-content.ts index d410b738a..5b4a47c8b 100644 --- a/src/main/startup-failure-content.ts +++ b/src/main/startup-failure-content.ts @@ -1,7 +1,14 @@ +import { KunHandoffError } from './runtime/kun-installed-build-handoff' + const STARTUP_ACTION_PROTOCOL = 'kun-startup-action:' const MAX_FAILURE_MESSAGE_LENGTH = 1_200 export type StartupFailureAction = 'retry' | 'open-logs' | 'quit' +export type StartupFailurePresentation = { + message: string + handoff: boolean + retryable: boolean +} function escapeHtml(value: string): string { return value @@ -18,10 +25,34 @@ export function sanitizeStartupFailureMessage(error: unknown): string { .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') .replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, '$1[redacted]@') .replace(/([?&](?:access_token|refresh_token|id_token|code|client_secret)=)[^&#\s]+/gi, '$1[redacted]') - .replace(/("(?:access_token|refresh_token|id_token|client_secret|password)"\s*:\s*")[^"]+/gi, '$1[redacted]') + .replace(/("(?:access_token|refresh_token|id_token|client_secret|password|runtimeToken|managerToken|apiKey)"\s*:\s*")[^"]+/gi, '$1[redacted]') + .replace(/\b(runtimeToken|managerToken|apiKey)=\S+/gi, '$1=[redacted]') .slice(0, MAX_FAILURE_MESSAGE_LENGTH) } +export function startupFailurePresentation(error: unknown): StartupFailurePresentation { + if (!(error instanceof KunHandoffError)) { + return { + message: sanitizeStartupFailureMessage(error), + handoff: false, + retryable: true + } + } + const owner = error.owner + const detail = [ + error.message, + `Phase: ${error.phase}`, + ...(owner?.kind ? [`Owner: ${owner.kind}${owner.flavor ? `/${owner.flavor}` : ''}`] : []), + ...(owner?.pid ? [`PID: ${owner.pid}`] : []), + ...(owner?.buildId ? [`Build: ${owner.buildId.slice(0, 12)}`] : []) + ].join('\n') + return { + message: sanitizeStartupFailureMessage(detail), + handoff: true, + retryable: error.retryable + } +} + export function parseStartupFailureAction(targetUrl: string): StartupFailureAction | null { if (!targetUrl.startsWith(STARTUP_ACTION_PROTOCOL)) return null const action = targetUrl.slice(STARTUP_ACTION_PROTOCOL.length).replace(/^\/+/, '') @@ -30,9 +61,27 @@ export function parseStartupFailureAction(targetUrl: string): StartupFailureActi : null } -export function startupFailureHtml(message: string, logDir: string): string { +export function startupFailureHtml( + message: string, + logDir: string, + options: { handoff?: boolean; retryable?: boolean; busy?: boolean } = {} +): string { const safeMessage = escapeHtml(message || 'Unknown startup error') const safeLogDir = escapeHtml(logDir || 'Log directory is unavailable') + const handoff = options.handoff === true + const busy = options.busy === true + const retryable = options.retryable !== false + const heading = handoff ? 'Kun could not complete the update handoff' : 'Kun could not finish starting' + const explanation = handoff + ? retryable + ? 'Kun identified the previous local owner. It will pause and checkpoint active work before retrying the safe handoff, without deleting your saved conversations.' + : 'Kun could not safely verify the previous local owner, so it left the process, active work, and saved data untouched.' + : 'The application is still running so you can inspect the failure or retry. The diagnostic detail is:' + const primaryAction = busy + ? 'Safely stopping old Kun…' + : retryable + ? `${handoff ? 'Safely stop old Kun and retry' : 'Retry Kun'}` + : '' return ` @@ -51,17 +100,18 @@ export function startupFailureHtml(message: string, logDir: string): string { .actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; } a { padding: 10px 16px; border-radius: 8px; color: #f8fafc; background: #303746; text-decoration: none; } a.primary { background: #5b5ce2; } + .working { padding: 10px 16px; border-radius: 8px; color: #d7d9ff; background: #34355f; }
-

Kun could not finish starting

-

The application is still running so you can inspect the failure or retry. The diagnostic detail is:

+

${heading}

+

${explanation}

${safeMessage}

Log directory:

${safeLogDir}
- Retry Kun + ${primaryAction} Open log folder Quit
diff --git a/src/main/startup-failure-window.test.ts b/src/main/startup-failure-window.test.ts index 08c19c0f5..cc2509a6d 100644 --- a/src/main/startup-failure-window.test.ts +++ b/src/main/startup-failure-window.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { parseStartupFailureAction, sanitizeStartupFailureMessage, + startupFailurePresentation, startupFailureHtml } from './startup-failure-content' +import { KunHandoffError } from './runtime/kun-installed-build-handoff' const electron = vi.hoisted(() => { const webHandlers = new Map void>() @@ -66,6 +68,30 @@ beforeEach(() => { electron.shell.openPath.mockResolvedValue('') }) +function runtimeHandoffError(retryable = true): KunHandoffError { + return new KunHandoffError( + 'runtime_stop_failed', + 'stop-runtimes', + 'installed-build-change', + retryable, + { + kind: 'runtime', + flavor: 'production', + instanceId: 'runtime-instance', + pid: 4312, + port: 18899, + buildId: 'a'.repeat(64) + }, + 'The previous Runtime did not exit; runtimeToken=do-not-render' + ) +} + +function lastRenderedHtml(): string { + const value = electron.window.loadURL.mock.calls.at(-1)?.[0] + if (typeof value !== 'string') return '' + return decodeURIComponent(value.slice(value.indexOf(',') + 1)) +} + describe('startup failure recovery helpers', () => { it('redacts credentials and OAuth secrets from startup diagnostics', () => { const message = sanitizeStartupFailureMessage( @@ -80,6 +106,36 @@ describe('startup failure recovery helpers', () => { expect(message).toContain('[redacted]') }) + it('presents a typed handoff failure with safe owner details and task continuity', () => { + const presentation = startupFailurePresentation(runtimeHandoffError()) + const html = startupFailureHtml(presentation.message, '/tmp/logs', { + handoff: presentation.handoff, + retryable: presentation.retryable + }) + + expect(presentation.message).toContain('Phase: stop-runtimes') + expect(presentation.message).toContain('Owner: runtime/production') + expect(presentation.message).toContain('PID: 4312') + expect(presentation.message).toContain(`Build: ${'a'.repeat(12)}`) + expect(presentation.message).not.toContain('do-not-render') + expect(html).toContain('pause and checkpoint active work') + expect(html).toContain('Safely stop old Kun and retry') + }) + + it('does not render retry or force actions for an unverified owner', () => { + const presentation = startupFailurePresentation(runtimeHandoffError(false)) + const html = startupFailureHtml(presentation.message, '/tmp/logs', { + handoff: presentation.handoff, + retryable: presentation.retryable + }) + + expect(html).not.toContain('kun-startup-action:retry') + expect(html).not.toContain('force') + expect(html).toContain('left the process, active work, and saved data untouched') + expect(html).toContain('kun-startup-action:open-logs') + expect(html).toContain('kun-startup-action:quit') + }) + it('escapes diagnostic content before rendering static recovery HTML', () => { const html = startupFailureHtml('', 'C:\\Users\\') @@ -125,4 +181,77 @@ describe('showStartupFailureWindow', () => { expect(electron.app.relaunch).toHaveBeenCalledOnce() expect(electron.app.quit).toHaveBeenCalledOnce() }) + + it('runs handoff recovery once and relaunches only after it succeeds', async () => { + let finishRecovery!: () => void + const recovery = new Promise((resolve) => { + finishRecovery = resolve + }) + const recoverHandoff = vi.fn(() => recovery) + showStartupFailureWindow(runtimeHandoffError(), '/tmp/kun-logs', { recoverHandoff }) + const navigate = electron.webHandlers.get('will-navigate') + const preventDefault = vi.fn() + + navigate?.({ preventDefault }, 'kun-startup-action:retry') + navigate?.({ preventDefault }, 'kun-startup-action:retry') + + expect(recoverHandoff).toHaveBeenCalledOnce() + expect(electron.app.relaunch).not.toHaveBeenCalled() + expect(electron.app.quit).not.toHaveBeenCalled() + expect(lastRenderedHtml()).toContain('Safely stopping old Kun') + + finishRecovery() + await vi.waitFor(() => expect(electron.app.relaunch).toHaveBeenCalledOnce()) + expect(electron.app.quit).toHaveBeenCalledOnce() + }) + + it('keeps the recovery window open and sanitized when a safe retry fails', async () => { + const recoverHandoff = vi.fn().mockRejectedValue( + new Error('shutdown rejected runtimeToken=secret-value') + ) + showStartupFailureWindow(runtimeHandoffError(), '/tmp/kun-logs', { recoverHandoff }) + + electron.webHandlers.get('will-navigate')?.( + { preventDefault: vi.fn() }, + 'kun-startup-action:retry' + ) + + await vi.waitFor(() => expect(lastRenderedHtml()).toContain('Retry failed')) + expect(lastRenderedHtml()).toContain('runtimeToken=[redacted]') + expect(lastRenderedHtml()).not.toContain('secret-value') + expect(lastRenderedHtml()).toContain('kun-startup-action:retry') + expect(electron.app.relaunch).not.toHaveBeenCalled() + expect(electron.app.quit).not.toHaveBeenCalled() + }) + + it('finishes a successful handoff even if the recovery window was closed', async () => { + let finishRecovery!: () => void + const recoverHandoff = vi.fn(() => new Promise((resolve) => { + finishRecovery = resolve + })) + showStartupFailureWindow(runtimeHandoffError(), '/tmp/kun-logs', { recoverHandoff }) + electron.webHandlers.get('will-navigate')?.( + { preventDefault: vi.fn() }, + 'kun-startup-action:retry' + ) + electron.window.isDestroyed.mockReturnValue(true) + + finishRecovery() + await vi.waitFor(() => expect(electron.app.relaunch).toHaveBeenCalledOnce()) + expect(electron.app.quit).toHaveBeenCalledOnce() + }) + + it('ignores a forged retry navigation when owner verification failed', () => { + const recoverHandoff = vi.fn().mockResolvedValue(undefined) + showStartupFailureWindow(runtimeHandoffError(false), '/tmp/kun-logs', { recoverHandoff }) + + expect(lastRenderedHtml()).not.toContain('kun-startup-action:retry') + electron.webHandlers.get('will-navigate')?.( + { preventDefault: vi.fn() }, + 'kun-startup-action:retry' + ) + + expect(recoverHandoff).not.toHaveBeenCalled() + expect(electron.app.relaunch).not.toHaveBeenCalled() + }) }) diff --git a/src/main/startup-failure-window.ts b/src/main/startup-failure-window.ts index 098edef63..1fe0bd248 100644 --- a/src/main/startup-failure-window.ts +++ b/src/main/startup-failure-window.ts @@ -4,11 +4,20 @@ import { logError, logWarn } from './logger' import { parseStartupFailureAction, sanitizeStartupFailureMessage, + startupFailurePresentation, startupFailureHtml } from './startup-failure-content' -export function showStartupFailureWindow(error: unknown, logDir: string): BrowserWindow | null { - const message = sanitizeStartupFailureMessage(error) +export function showStartupFailureWindow( + error: unknown, + logDir: string, + options: { recoverHandoff?: () => Promise } = {} +): BrowserWindow | null { + const presentation = startupFailurePresentation(error) + const message = presentation.message + const canRecoverHandoff = presentation.handoff && + presentation.retryable && + Boolean(options.recoverHandoff) logError('startup', 'Kun failed before main-window creation.', { platform: process.platform, packaged: app.isPackaged, @@ -32,6 +41,23 @@ export function showStartupFailureWindow(error: unknown, logDir: string): Browse } }) window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + let recoveryInFlight = false + const render = (detail: string, busy = false): void => { + if (window.isDestroyed()) return + void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupFailureHtml( + detail, + logDir, + { + handoff: presentation.handoff, + retryable: presentation.handoff ? canRecoverHandoff : true, + busy + } + ))}`).catch((loadError) => { + logError('startup', 'Failed to render startup recovery window.', { + message: sanitizeStartupFailureMessage(loadError) + }) + }) + } window.webContents.on('will-navigate', (event, targetUrl) => { const action = parseStartupFailureAction(targetUrl) if (!action) { @@ -40,8 +66,24 @@ export function showStartupFailureWindow(error: unknown, logDir: string): Browse } event.preventDefault() if (action === 'retry') { - app.relaunch() - app.quit() + if (recoveryInFlight) return + if (!presentation.handoff) { + app.relaunch() + app.quit() + return + } + if (!canRecoverHandoff || !options.recoverHandoff) return + recoveryInFlight = true + render(message, true) + void options.recoverHandoff().then(() => { + app.relaunch() + app.quit() + }).catch((recoveryError) => { + recoveryInFlight = false + const detail = sanitizeStartupFailureMessage(recoveryError) + logWarn('startup', 'Safe Kun handoff retry failed.', { message: detail }) + render(`${message}\n\nRetry failed: ${detail}`) + }) } else if (action === 'quit') { app.quit() } else { @@ -53,7 +95,14 @@ export function showStartupFailureWindow(error: unknown, logDir: string): Browse } }) window.once('ready-to-show', () => window.show()) - void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupFailureHtml(message, logDir))}`) + void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startupFailureHtml( + message, + logDir, + { + handoff: presentation.handoff, + retryable: presentation.handoff ? canRecoverHandoff : true + } + ))}`) .catch((loadError) => { logError('startup', 'Failed to render startup recovery window.', { message: sanitizeStartupFailureMessage(loadError) From 61b3306b6860f84d95b06fe4b5008e166ca10d2e Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 02:40:00 +0800 Subject: [PATCH 002/168] feat(providers): add OpenCore Free provider --- src/main/models-dev-catalog.test.ts | 33 ++++++++++ src/main/models-dev-catalog.ts | 5 ++ .../settings-section-providers-view.tsx | 4 +- .../use-provider-lifecycle-actions.test.ts | 48 ++++++++++++++ .../use-provider-lifecycle-actions.ts | 43 ++++++++---- src/shared/app-settings-provider-core.ts | 12 +++- src/shared/app-settings-provider-profiles.ts | 17 ++++- .../app-settings-provider.presets.test.ts | 43 ++++++++++++ src/shared/kun-gui-api-contracts.ts | 2 + .../model-provider-preset-catalog-core.ts | 66 +++++++++++++++++++ .../model-provider-preset-operations-core.ts | 10 +-- src/shared/model-provider-preset-types.ts | 42 ++++++++++++ src/shared/model-provider-presets.ts | 3 + 13 files changed, 305 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/use-provider-lifecycle-actions.test.ts diff --git a/src/main/models-dev-catalog.test.ts b/src/main/models-dev-catalog.test.ts index 771ebd216..2a0bc576c 100644 --- a/src/main/models-dev-catalog.test.ts +++ b/src/main/models-dev-catalog.test.ts @@ -67,6 +67,25 @@ function catalogBody(): string { } } }, + opencode: { + id: 'opencode', + name: 'OpenCode Zen', + api: 'https://opencode.ai/zen/v1', + models: { + 'free-model': { + id: 'free-model', + cost: { input: 0, output: 0 }, + modalities: { input: ['text'], output: ['text'] }, + limit: { context: 128_000, output: 16_000 } + }, + 'paid-model': { + id: 'paid-model', + cost: { input: 0, output: 1 }, + modalities: { input: ['text'], output: ['text'] }, + limit: { context: 128_000, output: 16_000 } + } + } + }, openai: { id: 'openai', name: 'OpenAI', @@ -193,6 +212,7 @@ describe('resolveModelsDevProvider', () => { ['zai-coding-plan', 'https://example.invalid/custom', 'zai-coding-plan', 'catalog'], ['kimi-code', 'https://api.kimi.com/coding/v1', 'kimi-for-coding', 'catalog'], ['opencode-go', 'https://opencode.ai/zen/go/v1', 'opencode-go', 'catalog'], + ['opencode-free', 'https://opencode.ai/zen/v1', 'opencode', 'catalog'], ['moonshot-cn', 'https://api.moonshot.cn/v1', 'moonshotai-cn', 'catalog'], ['moonshot-global', 'https://api.moonshot.ai/v1', 'moonshotai', 'catalog'], ['xiaomi', 'https://api.xiaomimimo.com/v1', 'xiaomi', 'catalog'], @@ -269,6 +289,19 @@ describe('ModelsDevCatalogService', () => { }) }) + it('marks only zero-cost OpenCode Zen models as free', async () => { + const fetcher = vi.fn(async () => new Response(catalogBody(), { status: 200 })) + const service = new ModelsDevCatalogService(fetcher) + await expect(service.fetch({ + providerId: 'opencode-free', + baseUrl: 'https://opencode.ai/zen/v1' + })).resolves.toMatchObject({ + status: 'ok', + providerKey: 'opencode', + models: [{ id: 'free-model', free: true }, { id: 'paid-model' }] + }) + }) + it('keeps OpenCode Go output limits in the catalog result', async () => { const fetcher = vi.fn(async () => new Response(catalogBody(), { status: 200 })) const service = new ModelsDevCatalogService(fetcher) diff --git a/src/main/models-dev-catalog.ts b/src/main/models-dev-catalog.ts index 2fb78f234..4fa06e175 100644 --- a/src/main/models-dev-catalog.ts +++ b/src/main/models-dev-catalog.ts @@ -81,6 +81,7 @@ const PROFILE_MATCHES: Record = { 'zai-coding-plan': catalogMatch('zai-coding-plan'), 'kimi-code': catalogMatch('kimi-for-coding'), 'opencode-go': catalogMatch('opencode-go'), + 'opencode-free': catalogMatch('opencode'), 'moonshot-cn': catalogMatch('moonshotai-cn'), 'moonshot-global': catalogMatch('moonshotai'), xiaomi: catalogMatch('xiaomi'), @@ -160,6 +161,7 @@ const UNAMBIGUOUS_URL_MATCHES = urlMatchMap({ 'https://api.z.ai/api/coding/paas/v4/chat/completions': 'zai-coding-plan', 'https://api.kimi.com/coding/v1': 'kimi-for-coding', 'https://opencode.ai/zen/go/v1': 'opencode-go', + 'https://opencode.ai/zen/v1': 'opencode', 'https://api.moonshot.cn/v1': 'moonshotai-cn', 'https://api.moonshot.ai/v1': 'moonshotai', 'https://api.xiaomimimo.com/v1': 'xiaomi', @@ -518,6 +520,8 @@ function sanitizeModel(fallbackId: string, value: unknown): ModelsDevCatalogMode const description = boundedString(value.description, MAX_MODEL_DESCRIPTION_LENGTH) const modalities = isRecord(value.modalities) ? value.modalities : {} const limit = isRecord(value.limit) ? value.limit : {} + const cost = isRecord(value.cost) ? value.cost : {} + const free = cost.input === 0 && cost.output === 0 const reasoning = typeof value.reasoning === 'boolean' ? value.reasoning : undefined const toolCalling = typeof value.tool_call === 'boolean' ? value.tool_call : undefined const metadataIssues: ModelsDevCatalogMetadataIssue[] = [] @@ -541,6 +545,7 @@ function sanitizeModel(fallbackId: string, value: unknown): ModelsDevCatalogMode outputModalities: sanitizeModalities(modalities.output), ...(reasoning !== undefined ? { reasoning } : {}), ...(toolCalling !== undefined ? { toolCalling } : {}), + ...(free ? { free } : {}), ...(contextWindowTokens ? { contextWindowTokens } : {}), ...(maxOutputTokens ? { maxOutputTokens } : {}), ...(metadataIssues.length ? { metadataIssues } : {}) diff --git a/src/renderer/src/components/settings-section-providers-view.tsx b/src/renderer/src/components/settings-section-providers-view.tsx index 3f83742a3..997e1a65a 100644 --- a/src/renderer/src/components/settings-section-providers-view.tsx +++ b/src/renderer/src/components/settings-section-providers-view.tsx @@ -1,5 +1,6 @@ import { DEFAULT_MODEL_PROVIDER_ID, + OPENCODE_FREE_PROVIDER_ID, isLocalModelProxyPort, localModelProxyPort, localModelProxyUrl, @@ -290,7 +291,8 @@ export function ProvidersSettingsView({ view }: { view: Record }): {!isDraftActive && activeTab === 'advanced' && - activeProvider.id !== DEFAULT_MODEL_PROVIDER_ID ? ( + activeProvider.id !== DEFAULT_MODEL_PROVIDER_ID && + activeProvider.id !== OPENCODE_FREE_PROVIDER_ID ? (
+ {freeAddEntries.length > 0 ? ( +
+
+

{t('modelProviderGroupFree')}

+ {freeAddEntries.length} +
+
{freeAddEntries.map(renderAddEntry)}
+
+ ) : null} {showPlanAddGroup ? (
@@ -520,7 +536,7 @@ export function ProvidersSettingsView({ view }: { view: Record }):
{apiAddEntries.map(renderAddEntry)}
) : null} - {planAddEntries.length === 0 && apiAddEntries.length === 0 ? ( + {freeAddEntries.length === 0 && planAddEntries.length === 0 && apiAddEntries.length === 0 ? (

{t('modelProviderAddDialogEmpty', { query: addProviderQuery.trim() })}

diff --git a/src/renderer/src/locales/en/settings/provider-media-mcp.json b/src/renderer/src/locales/en/settings/provider-media-mcp.json index 1880facfb..6698f993d 100644 --- a/src/renderer/src/locales/en/settings/provider-media-mcp.json +++ b/src/renderer/src/locales/en/settings/provider-media-mcp.json @@ -129,6 +129,8 @@ "modelProviderCustomBadge": "Custom", "modelProviderTokenPlanBadge": "Token Plan", "modelProviderPlanBadge": "Plan", + "modelProviderFreeBadge": "Free", + "modelProviderGroupFree": "Free", "modelProviderGroupPlans": "Subscription plans", "modelProviderSubscriptionRegions": "Subscription plan regions", "modelProviderSubscriptionRegionAll": "All", diff --git a/src/renderer/src/locales/hi/settings/provider-media-mcp.json b/src/renderer/src/locales/hi/settings/provider-media-mcp.json index d8baea4d5..50303e5e6 100644 --- a/src/renderer/src/locales/hi/settings/provider-media-mcp.json +++ b/src/renderer/src/locales/hi/settings/provider-media-mcp.json @@ -126,6 +126,8 @@ "modelProviderCustomBadge": "कस्टम", "modelProviderTokenPlanBadge": "टोकन योजना", "modelProviderPlanBadge": "योजना", + "modelProviderFreeBadge": "निःशुल्क", + "modelProviderGroupFree": "निःशुल्क", "modelProviderGroupPlans": "सदस्यता योजनाएँ", "modelProviderSubscriptionRegions": "सदस्यता योजना क्षेत्र", "modelProviderSubscriptionRegionAll": "सभी", diff --git a/src/renderer/src/locales/ja/settings/provider-media-mcp.json b/src/renderer/src/locales/ja/settings/provider-media-mcp.json index 8a5feb74c..7f46f0a8c 100644 --- a/src/renderer/src/locales/ja/settings/provider-media-mcp.json +++ b/src/renderer/src/locales/ja/settings/provider-media-mcp.json @@ -126,6 +126,8 @@ "modelProviderCustomBadge": "カスタム", "modelProviderTokenPlanBadge": "トークンプラン", "modelProviderPlanBadge": "計画", + "modelProviderFreeBadge": "無料", + "modelProviderGroupFree": "無料", "modelProviderGroupPlans": "サブスクリプションプラン", "modelProviderSubscriptionRegions": "サブスクリプションプランの地域", "modelProviderSubscriptionRegionAll": "すべて", diff --git a/src/renderer/src/locales/ko/settings/provider-media-mcp.json b/src/renderer/src/locales/ko/settings/provider-media-mcp.json index 5b78d28c9..5406e0508 100644 --- a/src/renderer/src/locales/ko/settings/provider-media-mcp.json +++ b/src/renderer/src/locales/ko/settings/provider-media-mcp.json @@ -126,6 +126,8 @@ "modelProviderCustomBadge": "맞춤", "modelProviderTokenPlanBadge": "토큰 계획", "modelProviderPlanBadge": "계획", + "modelProviderFreeBadge": "무료", + "modelProviderGroupFree": "무료", "modelProviderGroupPlans": "구독 계획", "modelProviderSubscriptionRegions": "구독 요금제 지역", "modelProviderSubscriptionRegionAll": "전체", diff --git a/src/renderer/src/locales/ru/settings/provider-media-mcp.json b/src/renderer/src/locales/ru/settings/provider-media-mcp.json index 8cbe4e67d..6e7987818 100644 --- a/src/renderer/src/locales/ru/settings/provider-media-mcp.json +++ b/src/renderer/src/locales/ru/settings/provider-media-mcp.json @@ -126,6 +126,8 @@ "modelProviderCustomBadge": "Пользовательский", "modelProviderTokenPlanBadge": "План токенов", "modelProviderPlanBadge": "Тариф", + "modelProviderFreeBadge": "Бесплатно", + "modelProviderGroupFree": "Бесплатно", "modelProviderGroupPlans": "Тарифы подписки", "modelProviderSubscriptionRegions": "Регионы подписки", "modelProviderSubscriptionRegionAll": "Все", diff --git a/src/renderer/src/locales/th/settings/provider-media-mcp.json b/src/renderer/src/locales/th/settings/provider-media-mcp.json index fddb82087..57d46745d 100644 --- a/src/renderer/src/locales/th/settings/provider-media-mcp.json +++ b/src/renderer/src/locales/th/settings/provider-media-mcp.json @@ -126,6 +126,8 @@ "modelProviderCustomBadge": "กำหนดเอง", "modelProviderTokenPlanBadge": "แผนโทเค็น", "modelProviderPlanBadge": "แผน", + "modelProviderFreeBadge": "ฟรี", + "modelProviderGroupFree": "ฟรี", "modelProviderGroupPlans": "แผนการสมัครสมาชิก", "modelProviderSubscriptionRegions": "ภูมิภาคแพ็กเกจสมัครสมาชิก", "modelProviderSubscriptionRegionAll": "ทั้งหมด", diff --git a/src/renderer/src/locales/zh/settings/provider-media-mcp.json b/src/renderer/src/locales/zh/settings/provider-media-mcp.json index 57a03f558..4a3ca069d 100644 --- a/src/renderer/src/locales/zh/settings/provider-media-mcp.json +++ b/src/renderer/src/locales/zh/settings/provider-media-mcp.json @@ -129,6 +129,8 @@ "modelProviderCustomBadge": "自定义", "modelProviderTokenPlanBadge": "Token Plan", "modelProviderPlanBadge": "套餐", + "modelProviderFreeBadge": "免费", + "modelProviderGroupFree": "免费", "modelProviderGroupPlans": "套餐订阅", "modelProviderSubscriptionRegions": "套餐订阅地区", "modelProviderSubscriptionRegionAll": "全部", diff --git a/src/shared/app-settings-provider-runtime.ts b/src/shared/app-settings-provider-runtime.ts index 5b59ee112..dcb71b130 100644 --- a/src/shared/app-settings-provider-runtime.ts +++ b/src/shared/app-settings-provider-runtime.ts @@ -72,6 +72,8 @@ import { CHATGPT_SUBSCRIPTION_NAME, CHATGPT_SUBSCRIPTION_PROVIDER_ID, GEMINI_SUBSCRIPTION_MODEL_IDS, + OPENCODE_ANONYMOUS_API_KEY, + OPENCODE_FREE_PROVIDER_ID, TOKEN_PLAN_PROVIDER_ID_SUFFIX, getModelProviderPreset, modelProviderPresetProfile, @@ -202,17 +204,20 @@ export function resolveKunRuntimeSettings(settings: AppSettingsV1): KunRuntimeSe const runtimeBaseUrl = runtime.baseUrl?.trim() ?? '' const providerBaseUrl = provider.baseUrl.trim() || DEFAULT_DEEPSEEK_BASE_URL const useProviderCredentials = Boolean(providerId) + const useOpenCodeAnonymousAccess = + resolveModelProviderPresetSource(provider)?.preset.id === OPENCODE_FREE_PROVIDER_ID && + !provider.apiKey.trim() return { ...runtime, - // When a provider is selected we prefer that profile's key, but fall back - // to the agent's own runtime.apiKey if the profile happens to be keyless. - // A providerId pointing at a keyless profile must NOT resolve to an empty - // key (issue #329) — that briefly reads as "no API key" and the - // settings-apply gate then stops a perfectly healthy Kun runtime. - apiKey: useProviderCredentials - ? provider.apiKey.trim() || runtimeApiKey - : runtimeApiKey || provider.apiKey.trim(), + // OpenCode Zen treats this literal as an anonymous request. It is derived + // only for the live transport: never persist it or fall back to a stale + // runtime key, which would unexpectedly leave the anonymous free tier. + apiKey: useOpenCodeAnonymousAccess + ? OPENCODE_ANONYMOUS_API_KEY + : useProviderCredentials + ? provider.apiKey.trim() || runtimeApiKey + : runtimeApiKey || provider.apiKey.trim(), baseUrl: !useProviderCredentials && runtimeBaseUrl && runtimeBaseUrl !== DEFAULT_DEEPSEEK_BASE_URL ? normalizeDeepseekBaseUrl(runtimeBaseUrl) diff --git a/src/shared/app-settings-provider.runtime.test.ts b/src/shared/app-settings-provider.runtime.test.ts index 5be9f5ee4..c3a7d0f52 100644 --- a/src/shared/app-settings-provider.runtime.test.ts +++ b/src/shared/app-settings-provider.runtime.test.ts @@ -28,6 +28,8 @@ import { CHATGPT_SUBSCRIPTION_MODEL_IDS, GROK_SUBSCRIPTION_PROVIDER_ID, OLLAMA_CLOUD_MODEL_IDS, + OPENCODE_ANONYMOUS_API_KEY, + OPENCODE_FREE_PROVIDER_ID, listMusicGenerationProviderProfiles, listSpeechToTextProviderProfiles, listTextToSpeechProviderProfiles, @@ -56,6 +58,28 @@ import { import { settings } from './app-settings-provider.test-support' describe('model provider settings', () => { + it('uses the transient public key for a keyless OpenCore Free provider', () => { + const state = settings() + const openCodeFree = state.provider.providers.find((provider) => provider.id === OPENCODE_FREE_PROVIDER_ID)! + state.agents.kun.providerId = OPENCODE_FREE_PROVIDER_ID + state.agents.kun.apiKey = 'sk-stale-runtime' + + const runtime = resolveKunRuntimeSettings(state) + + expect(openCodeFree.apiKey).toBe('') + expect(runtime.apiKey).toBe(OPENCODE_ANONYMOUS_API_KEY) + }) + + it('uses a configured OpenCore Free key instead of the anonymous public key', () => { + const state = settings() + state.provider.providers = state.provider.providers.map((provider) => + provider.id === OPENCODE_FREE_PROVIDER_ID ? { ...provider, apiKey: 'sk-zen' } : provider + ) + state.agents.kun.providerId = OPENCODE_FREE_PROVIDER_ID + + expect(resolveKunRuntimeSettings(state).apiKey).toBe('sk-zen') + }) + it('resolves Kun runtime credentials from the selected provider', () => { const state = settings() state.agents.kun.apiKey = 'sk-stale-runtime' diff --git a/src/shared/model-provider-preset-catalog-core.ts b/src/shared/model-provider-preset-catalog-core.ts index 79812c451..7d8a3313b 100644 --- a/src/shared/model-provider-preset-catalog-core.ts +++ b/src/shared/model-provider-preset-catalog-core.ts @@ -328,13 +328,15 @@ export const MODEL_PROVIDER_PRESETS_CORE: ModelProviderPreset[] = [ { id: OPENCODE_FREE_PROVIDER_ID, name: OPENCODE_FREE_PROVIDER_NAME, - // OpenCode Zen's public free tier is OpenAI-compatible and intentionally - // accepts requests without an Authorization header. + category: 'free', + // Anonymous requests use Bearer public; the gateway treats it as no + // account key and permits only allowAnonymous models. baseUrl: 'https://opencode.ai/zen/v1', endpointFormat: 'chat_completions', defaultRetryMaxAttempts: 10, models: [...OPENCODE_FREE_MODEL_IDS], modelProfiles: { + 'gpt-5-nano': openCodeFreeProfile(128_000, 16_000), 'ling-3.0-flash-free': openCodeFreeProfile(262_144, 32_768), 'laguna-s-2.1-free': openCodeFreeProfile(256_000, 32_000), 'nemotron-3.5-lightning-free': openCodeFreeProfile(262_144, 262_144), diff --git a/src/shared/model-provider-preset-types.ts b/src/shared/model-provider-preset-types.ts index f181ce161..413e4ed10 100644 --- a/src/shared/model-provider-preset-types.ts +++ b/src/shared/model-provider-preset-types.ts @@ -82,10 +82,14 @@ export const OPENCODE_FREE_PROVIDER_ID = 'opencode-free' export const OPENCODE_FREE_PROVIDER_NAME = 'OpenCore Free' +/** Transient OpenCode Zen credential for anonymous free-tier requests. */ +export const OPENCODE_ANONYMOUS_API_KEY = 'public' + // Bootstrap snapshot from the OpenCode Zen catalog's zero-cost models. The // models.dev catalog remains authoritative and Settings imports newly added // free models without admitting paid ones. export const OPENCODE_FREE_MODEL_IDS = [ + 'gpt-5-nano', 'ling-3.0-flash-free', 'laguna-s-2.1-free', 'nemotron-3.5-lightning-free', @@ -244,10 +248,10 @@ export type ModelProviderPreset = { id: ModelProviderPresetId name: string /** - * 计费/接入大类。'subscription' = 固定费用套餐(Coding Plan、Token Plan 这类), - * 'api'(默认) = 按量付费。仅用于设置页把套餐类供应商收拢成一组,不写入存储的 profile。 + * 'free' = 内置免 Key 供应商,'subscription' = 固定费用套餐, + * 'api'(默认) = 按量付费。仅用于设置页分组,不写入存储的 profile。 */ - category?: 'api' | 'subscription' + category?: 'api' | 'free' | 'subscription' /** * 套餐订阅筛选所使用的供应商归属地区。仅用于预设选择器展示,不写入 provider profile。 * 同一个预设的 Token Plan 入口沿用这里的地区。 diff --git a/src/shared/model-provider-presets.ts b/src/shared/model-provider-presets.ts index 63efa8065..a602f44e6 100644 --- a/src/shared/model-provider-presets.ts +++ b/src/shared/model-provider-presets.ts @@ -20,6 +20,7 @@ export { OLLAMA_CLOUD_MODEL_IDS, OLLAMA_CLOUD_PROVIDER_ID, OLLAMA_CLOUD_PROVIDER_NAME, + OPENCODE_ANONYMOUS_API_KEY, OPENCODE_FREE_MODEL_IDS, OPENCODE_FREE_PROVIDER_ID, OPENCODE_FREE_PROVIDER_NAME, From bcd912300401fad94699140c018218039794e12f Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 04:40:00 +0800 Subject: [PATCH 005/168] fix(providers): show OpenCore Free in free group without API key prompt --- ...s-section-agents-provider-controls.test.ts | 35 +++++++++++++++++++ .../settings-section-agents.test-support.tsx | 1 + .../settings-section-providers-view-model.tsx | 3 +- .../components/settings-section-providers.tsx | 4 +-- src/shared/app-settings-provider-core.ts | 6 +++- src/shared/app-settings-provider-profiles.ts | 5 ++- src/shared/app-settings-provider-runtime.ts | 5 ++- .../app-settings-provider.presets.test.ts | 21 +++++++++++ 8 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/settings-section-agents-provider-controls.test.ts b/src/renderer/src/components/settings-section-agents-provider-controls.test.ts index 3e02e006a..248b485b5 100644 --- a/src/renderer/src/components/settings-section-agents-provider-controls.test.ts +++ b/src/renderer/src/components/settings-section-agents-provider-controls.test.ts @@ -228,6 +228,41 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { return runtimeRequest } + it('places a stored OpenCore Free profile in the free group without an API key field', async () => { + const provider = defaultModelProviderSettings() + const storedFreeProvider = { + ...provider.providers.find((item) => item.id === 'opencode-free')!, + name: 'opencode-free', + presetSource: undefined + } satisfies ModelProviderProfileV1 + const customProvider = { + id: 'custom-provider-2', + name: 'Custom Provider', + apiKey: 'sk-custom', + baseUrl: 'https://api.example.com/v1', + endpointFormat: 'messages', + models: ['custom-model-1'], + modelProfiles: {} + } satisfies ModelProviderProfileV1 + const renderer = await mountProviders({ + ...baseCtx(), + provider: { + ...provider, + providers: [storedFreeProvider, customProvider] + }, + kun: { + ...defaultKunRuntimeSettings(), + providerId: storedFreeProvider.id + } + }) + + expect(rendererText(renderer)).toContain('Free') + expect(activePanelText(renderer)).toContain('Provider connection') + expect(rendererText(renderer)).not.toContain('Enter provider API key') + expect(rendererText(renderer)).not.toContain('未找到可用凭据') + expect(rendererText(renderer)).not.toContain('No usable credential is stored') + }) + it('renders task tabs and keeps the selected task while switching providers', async () => { const provider = defaultModelProviderSettings() const customProvider = { diff --git a/src/renderer/src/components/settings-section-agents.test-support.tsx b/src/renderer/src/components/settings-section-agents.test-support.tsx index d17cc15c6..479946d74 100644 --- a/src/renderer/src/components/settings-section-agents.test-support.tsx +++ b/src/renderer/src/components/settings-section-agents.test-support.tsx @@ -116,6 +116,7 @@ const labels: Record = { modelProviderSearchPlaceholder: 'Search configured providers…', modelProviderSearchEmpty: 'No providers match "{{query}}".', modelProviderGroupPlans: 'Subscription plans', + modelProviderGroupFree: 'Free', modelProviderSubscriptionRegions: 'Subscription plan regions', modelProviderSubscriptionRegionAll: 'All', modelProviderSubscriptionRegionChina: 'China', diff --git a/src/renderer/src/components/settings-section-providers-view-model.tsx b/src/renderer/src/components/settings-section-providers-view-model.tsx index 055386275..336ba3fd5 100644 --- a/src/renderer/src/components/settings-section-providers-view-model.tsx +++ b/src/renderer/src/components/settings-section-providers-view-model.tsx @@ -57,7 +57,8 @@ export { sharedModelConnectionHasUsableCredential } from '../lib/provider-creden export function isOpenCodeFreeProvider(provider: Pick): boolean { - return resolveModelProviderPresetSource(provider)?.preset.id === OPENCODE_FREE_PROVIDER_ID + return provider.id === OPENCODE_FREE_PROVIDER_ID || + resolveModelProviderPresetSource(provider)?.preset.id === OPENCODE_FREE_PROVIDER_ID } export function buildProvidersViewModel(scope: Record): Record { diff --git a/src/renderer/src/components/settings-section-providers.tsx b/src/renderer/src/components/settings-section-providers.tsx index b1b71533d..15b66b10e 100644 --- a/src/renderer/src/components/settings-section-providers.tsx +++ b/src/renderer/src/components/settings-section-providers.tsx @@ -303,7 +303,7 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }): setCursorAccounts, sharedConnectionFor, patchProviderProfile, fetchModelsDevCatalogFor, openModelImport, flushSharedProviderCatalog }) - const { activeProbe, probeBusy, probeNotice, activeBaseUrlInvalid, activeImageBaseUrlInvalid, activeSpeechBaseUrlInvalid, activeSpeechToggleDisabled, activeTextToSpeechBaseUrlInvalid, activeMusicBaseUrlInvalid, activeVideoBaseUrlInvalid, activeMissingCredential, providerSetupNeedsApiKey, activeProbeBlocked, activeCursorAccount, activeCursorAccountFresh, activeCursorApiKeyUrl, activeSharedConnection, activeCredentialNeedsReplacement, activeApiKeyPlaceholder, activeApiKeyValue, activeCredentialRevealBusy, activeTokenPlanRegions, filteredProviders, planProviders, apiProviders, grouped, renderProviderButton, planAddEntries, apiAddEntries, showPlanAddGroup, renderAddEntry, pendingImportProvider } = buildProvidersViewModel({ t, showApiKey, modelProviders, + const { activeProbe, probeBusy, probeNotice, activeBaseUrlInvalid, activeImageBaseUrlInvalid, activeSpeechBaseUrlInvalid, activeSpeechToggleDisabled, activeTextToSpeechBaseUrlInvalid, activeMusicBaseUrlInvalid, activeVideoBaseUrlInvalid, activeMissingCredential, providerSetupNeedsApiKey, activeProbeBlocked, activeCursorAccount, activeCursorAccountFresh, activeCursorApiKeyUrl, activeSharedConnection, activeCredentialNeedsReplacement, activeApiKeyPlaceholder, activeApiKeyValue, activeCredentialRevealBusy, activeTokenPlanRegions, filteredProviders, freeProviders, planProviders, apiProviders, grouped, renderProviderButton, freeAddEntries, planAddEntries, apiAddEntries, showPlanAddGroup, renderAddEntry, pendingImportProvider } = buildProvidersViewModel({ t, showApiKey, modelProviders, sharedConnections, revealedCredential, credentialRevealPendingProviderId, setSelectedProviderId, addProviderQuery, subscriptionRegion, providerListQuery, probeStates, cursorAccounts, pendingImport, draftProvider, displayProviders, activeProvider, sharedConnectionFor, @@ -316,6 +316,6 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }): if (!result.ok) setSettingsConfigOpenError(result.message ?? t('modelProviderConfigOpenFailed')) } - const view = { t, kun, update, showApiKey, selectControlClass, saveStatus, saveError: providerSaveError, saveIssue, retrySave, zh, provider, sharedConnections, sharedConnectionsError, settingsConfigOpenError, openSettingsConfigFile, credentialRevealError, setSelectedProviderId, addMenuOpen, addProviderQuery, setAddProviderQuery, subscriptionRegion, setSubscriptionRegion, providerListQuery, setProviderListQuery, activeTab, setActiveTab, workspaceMode, setWorkspaceMode, globalNetworkOpen, setGlobalNetworkOpen, expandedCapabilities, addProviderButtonRef, addProviderDialogRef, pendingImport, setPendingImport, displayProviders, activeProvider, activeRetry, isDraftActive, canEditActiveProviderId, activeKunProviderId, providerProxy, selectSharedModel, updateProviderProxy, setCapabilityExpanded, openAddProviderDialog, closeAddProviderDialog, handleAddProviderDialogKeyDown, handleSubscriptionRegionTabKeyDown, patchProviderProfile, updateModelProvider, updateActiveProviderCredential, toggleActiveProviderCredentialVisibility, updateModelProviderImage, removeModelProviderImage, updateModelProviderSpeech, removeModelProviderSpeech, updateModelProviderTextToSpeech, removeModelProviderTextToSpeech, updateModelProviderMusic, removeModelProviderMusic, updateModelProviderVideo, removeModelProviderVideo, updateModelProviderId, commitProviderDraft, cancelProviderDraft, addModelProvider, removeModelProvider, runProbe, importPickedModels, activeProbe, probeBusy, probeNotice, activeBaseUrlInvalid, activeImageBaseUrlInvalid, activeSpeechBaseUrlInvalid, activeSpeechToggleDisabled, activeTextToSpeechBaseUrlInvalid, activeMusicBaseUrlInvalid, activeVideoBaseUrlInvalid, activeMissingCredential, providerSetupNeedsApiKey, activeProbeBlocked, activeCursorAccount, activeCursorAccountFresh, activeCursorApiKeyUrl, activeSharedConnection, activeCredentialNeedsReplacement, activeApiKeyPlaceholder, activeApiKeyValue, activeCredentialRevealBusy, activeTokenPlanRegions, filteredProviders, planProviders, apiProviders, grouped, renderProviderButton, planAddEntries, apiAddEntries, showPlanAddGroup, renderAddEntry, pendingImportProvider } + const view = { t, kun, update, showApiKey, selectControlClass, saveStatus, saveError: providerSaveError, saveIssue, retrySave, zh, provider, sharedConnections, sharedConnectionsError, settingsConfigOpenError, openSettingsConfigFile, credentialRevealError, setSelectedProviderId, addMenuOpen, addProviderQuery, setAddProviderQuery, subscriptionRegion, setSubscriptionRegion, providerListQuery, setProviderListQuery, activeTab, setActiveTab, workspaceMode, setWorkspaceMode, globalNetworkOpen, setGlobalNetworkOpen, expandedCapabilities, addProviderButtonRef, addProviderDialogRef, pendingImport, setPendingImport, displayProviders, activeProvider, activeRetry, isDraftActive, canEditActiveProviderId, activeKunProviderId, providerProxy, selectSharedModel, updateProviderProxy, setCapabilityExpanded, openAddProviderDialog, closeAddProviderDialog, handleAddProviderDialogKeyDown, handleSubscriptionRegionTabKeyDown, patchProviderProfile, updateModelProvider, updateActiveProviderCredential, toggleActiveProviderCredentialVisibility, updateModelProviderImage, removeModelProviderImage, updateModelProviderSpeech, removeModelProviderSpeech, updateModelProviderTextToSpeech, removeModelProviderTextToSpeech, updateModelProviderMusic, removeModelProviderMusic, updateModelProviderVideo, removeModelProviderVideo, updateModelProviderId, commitProviderDraft, cancelProviderDraft, addModelProvider, removeModelProvider, runProbe, importPickedModels, activeProbe, probeBusy, probeNotice, activeBaseUrlInvalid, activeImageBaseUrlInvalid, activeSpeechBaseUrlInvalid, activeSpeechToggleDisabled, activeTextToSpeechBaseUrlInvalid, activeMusicBaseUrlInvalid, activeVideoBaseUrlInvalid, activeMissingCredential, providerSetupNeedsApiKey, activeProbeBlocked, activeCursorAccount, activeCursorAccountFresh, activeCursorApiKeyUrl, activeSharedConnection, activeCredentialNeedsReplacement, activeApiKeyPlaceholder, activeApiKeyValue, activeCredentialRevealBusy, activeTokenPlanRegions, filteredProviders, freeProviders, planProviders, apiProviders, grouped, renderProviderButton, freeAddEntries, planAddEntries, apiAddEntries, showPlanAddGroup, renderAddEntry, pendingImportProvider } return } diff --git a/src/shared/app-settings-provider-core.ts b/src/shared/app-settings-provider-core.ts index 0b58ae5bd..30a8fd7ba 100644 --- a/src/shared/app-settings-provider-core.ts +++ b/src/shared/app-settings-provider-core.ts @@ -381,7 +381,11 @@ export function modelProviderRequiresApiKey( } const source = resolveModelProviderPresetSource(provider) - if (source?.preset.id === 'litellm' || source?.preset.id === OPENCODE_FREE_PROVIDER_ID) return false + if ( + provider.id === OPENCODE_FREE_PROVIDER_ID || + source?.preset.id === 'litellm' || + source?.preset.id === OPENCODE_FREE_PROVIDER_ID + ) return false if (provider.id === DEFAULT_MODEL_PROVIDER_ID) return true return Boolean(source) } diff --git a/src/shared/app-settings-provider-profiles.ts b/src/shared/app-settings-provider-profiles.ts index 9f6be5179..a01c3e988 100644 --- a/src/shared/app-settings-provider-profiles.ts +++ b/src/shared/app-settings-provider-profiles.ts @@ -73,6 +73,7 @@ import { CHATGPT_SUBSCRIPTION_NAME, CHATGPT_SUBSCRIPTION_PROVIDER_ID, GEMINI_SUBSCRIPTION_MODEL_IDS, + OPENCODE_FREE_PROVIDER_ID, TOKEN_PLAN_PROVIDER_ID_SUFFIX, getModelProviderPreset, modelProviderPresetProfile, @@ -130,7 +131,9 @@ export function normalizeModelProviderProfile( ): ModelProviderProfileV1 | null { const id = normalizeModelProviderId(input?.id) if (!id) return null - const presetSource = normalizeModelProviderPresetSource(input, id) + const presetSource = id === OPENCODE_FREE_PROVIDER_ID + ? { presetId: OPENCODE_FREE_PROVIDER_ID, mode: 'api' as const } + : normalizeModelProviderPresetSource(input, id) const resolvedPresetSource = presetSource ? resolveModelProviderPresetSource({ id, presetSource }) : null diff --git a/src/shared/app-settings-provider-runtime.ts b/src/shared/app-settings-provider-runtime.ts index dcb71b130..28e8bdacc 100644 --- a/src/shared/app-settings-provider-runtime.ts +++ b/src/shared/app-settings-provider-runtime.ts @@ -205,7 +205,10 @@ export function resolveKunRuntimeSettings(settings: AppSettingsV1): KunRuntimeSe const providerBaseUrl = provider.baseUrl.trim() || DEFAULT_DEEPSEEK_BASE_URL const useProviderCredentials = Boolean(providerId) const useOpenCodeAnonymousAccess = - resolveModelProviderPresetSource(provider)?.preset.id === OPENCODE_FREE_PROVIDER_ID && + ( + provider.id === OPENCODE_FREE_PROVIDER_ID || + resolveModelProviderPresetSource(provider)?.preset.id === OPENCODE_FREE_PROVIDER_ID + ) && !provider.apiKey.trim() return { diff --git a/src/shared/app-settings-provider.presets.test.ts b/src/shared/app-settings-provider.presets.test.ts index 733a4e320..a97d8c043 100644 --- a/src/shared/app-settings-provider.presets.test.ts +++ b/src/shared/app-settings-provider.presets.test.ts @@ -284,6 +284,27 @@ describe('provider presets', () => { .toMatchObject({ retry: { maxAttempts: 10 } }) }) + it('repairs a stored OpenCore Free profile to the built-in free preset', () => { + const normalized = normalizeModelProviderSettings({ + providers: [{ + id: OPENCODE_FREE_PROVIDER_ID, + name: 'opencode-free', + apiKey: '', + baseUrl: 'https://opencode.ai/zen/v1', + endpointFormat: 'chat_completions', + models: ['gpt-5-nano'], + modelProfiles: {} + }] + }).providers.find((provider) => provider.id === OPENCODE_FREE_PROVIDER_ID) + + expect(normalized).toMatchObject({ + presetSource: { presetId: OPENCODE_FREE_PROVIDER_ID, mode: 'api' }, + name: 'opencode-free', + retry: { maxAttempts: 10 } + }) + expect(normalized && modelProviderRequiresApiKey(normalized)).toBe(false) + }) + it('preserves explicit OpenCore Free retry settings during normalization', () => { const profile = modelProviderPresetProfile(getModelProviderPreset(OPENCODE_FREE_PROVIDER_ID)!) const normalized = normalizeModelProviderSettings({ From d847a5c0034f27adc2ecb2cef6ade1558ea80070 Mon Sep 17 00:00:00 2001 From: Kun Date: Sat, 22 Aug 2026 05:20:00 +0800 Subject: [PATCH 006/168] feat(agent): add user_input timeout self-decision and sidebar awaiting-input hint - user_input/request_user_input accept timeoutSeconds (5-3600); on elapse the gate self-resolves with status timeout and the tool result instructs the model to proceed with its own best judgment instead of re-asking - timeout wiring shared by the main bridge and agent-sdk/cursor factories; contract events, turn items, and replay reducer carry timeoutSeconds - GUI shows a countdown chip on the ask-user panel, a timeout terminal state on the timeline bubble, and a distinct amber pulse indicator on sidebar rows whose thread is awaiting the user's answer (sorted first) - awaiting-input registry hydrates from pendingUserInputIds on thread switch/restart and clears on resolution or turn completion --- kun/src/adapters/tool/local-tool-host.ts | 33 +++++- ...local-tool-host.user-input-timeout.test.ts | 63 +++++++++++ kun/src/contracts/events.ts | 5 +- kun/src/contracts/items.ts | 3 +- kun/src/domain/item.ts | 4 +- kun/src/domain/runtime-event-reducer.ts | 1 + kun/src/loop/interactive-tool-bridge.test.ts | 98 ++++++++++++++++ kun/src/loop/interactive-tool-bridge.ts | 32 ++++-- kun/src/ports/user-input-gate.ts | 3 + .../agent-sdk-runtime-factory-context.ts | 18 ++- .../cursor/cursor-sdk-runtime-factory.ts | 18 ++- kun/src/services/interactive-gate.ts | 21 ++++ .../src/agent/kun-contract-runtime.ts | 2 + .../src/agent/kun-event-normalizer.ts | 7 +- src/renderer/src/agent/kun-mapper-events.ts | 1 + .../src/agent/kun-mapper-interactions.test.ts | 43 +++++++ .../src/agent/kun-mapper-projection.ts | 15 ++- src/renderer/src/agent/types.ts | 14 +-- .../chat/FloatingComposerUserInputPanel.tsx | 8 +- src/renderer/src/components/chat/Sidebar.tsx | 3 + .../chat/SidebarConversationsSection.tsx | 7 +- .../components/chat/SidebarProjectRows.tsx | 24 +++- .../chat/SidebarProjectsContent.tsx | 1 + .../chat/SidebarProjectsSection.tsx | 5 +- .../floating-composer-user-input-timeout.tsx | 59 ++++++++++ .../chat/message-timeline-bubble-support.tsx | 14 ++- ...r-project-selectors.awaiting-input.test.ts | 59 ++++++++++ .../chat/sidebar-project-selectors.ts | 14 ++- .../src/locales/en/common/commands-sdd.json | 4 + .../src/locales/hi/common/commands-sdd.json | 4 + .../src/locales/ja/common/commands-sdd.json | 4 + .../src/locales/ko/common/commands-sdd.json | 4 + .../src/locales/ru/common/commands-sdd.json | 4 + .../src/locales/th/common/commands-sdd.json | 4 + .../src/locales/zh/common/commands-sdd.json | 4 + .../src/store/awaiting-user-input-registry.ts | 47 ++++++++ .../src/store/chat-projection-reducer.ts | 1 + ...jection-reducer.user-input-timeout.test.ts | 105 ++++++++++++++++++ .../src/store/chat-store-initial-state.ts | 1 + .../src/store/chat-store-runtime-reconcile.ts | 81 ++++++++++++++ src/renderer/src/store/chat-store-runtime.ts | 71 ++---------- .../src/store/chat-store-side-runtime.ts | 1 + .../chat-store-thread-selection-actions.ts | 12 ++ src/renderer/src/store/chat-store-types.ts | 2 + 44 files changed, 814 insertions(+), 110 deletions(-) create mode 100644 kun/src/adapters/tool/local-tool-host.user-input-timeout.test.ts create mode 100644 src/renderer/src/components/chat/floating-composer-user-input-timeout.tsx create mode 100644 src/renderer/src/components/chat/sidebar-project-selectors.awaiting-input.test.ts create mode 100644 src/renderer/src/store/awaiting-user-input-registry.ts create mode 100644 src/renderer/src/store/chat-projection-reducer.user-input-timeout.test.ts create mode 100644 src/renderer/src/store/chat-store-runtime-reconcile.ts diff --git a/kun/src/adapters/tool/local-tool-host.ts b/kun/src/adapters/tool/local-tool-host.ts index 332a366a6..fda73edfd 100644 --- a/kun/src/adapters/tool/local-tool-host.ts +++ b/kun/src/adapters/tool/local-tool-host.ts @@ -69,6 +69,13 @@ function createUserInputTool(name: string): LocalTool { minimum: 1, description: 'Maximum allowed selections for a multiple-choice question.' }, + timeoutSeconds: { + type: 'integer', + minimum: 5, + maximum: 3600, + description: + 'Optional. If the user does not answer within this many seconds, the request auto-resolves with status "timeout"; you must then proceed with your own best judgment instead of waiting or asking again.' + }, questions: { type: 'array', description: 'One to three structured questions. Each question may include answer options.', @@ -130,7 +137,24 @@ function createUserInputTool(name: string): LocalTool { } } const prompt = explicitPrompt ?? questions[0]!.question - const resolution = await context.awaitUserInput({ id: inputId, itemId, prompt, questions }) + const timeoutSeconds = normalizeTimeoutSeconds(args.timeoutSeconds) + const resolution = await context.awaitUserInput({ + id: inputId, + itemId, + prompt, + questions, + ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}) + }) + if (resolution.status === 'timeout') { + return { + output: { + ...resolution, + message: + 'No answer within the timeout. Do NOT call user_input again for the same question; proceed with your own best judgment based on the conversation so far.' + }, + isError: false + } + } return { output: resolution, isError: resolution.status === 'cancelled' @@ -149,6 +173,13 @@ export const defaultLocalTools: LocalTool[] = [ requestUserInputTool ] +function normalizeTimeoutSeconds(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + const normalized = Math.floor(value) + if (normalized < 5 || normalized > 3600) return undefined + return normalized +} + function normalizeUserInputQuestions( args: Record, fallbackId: string, diff --git a/kun/src/adapters/tool/local-tool-host.user-input-timeout.test.ts b/kun/src/adapters/tool/local-tool-host.user-input-timeout.test.ts new file mode 100644 index 000000000..5e86d693e --- /dev/null +++ b/kun/src/adapters/tool/local-tool-host.user-input-timeout.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { requestUserInputTool } from './local-tool-host.js' + +type CapturedRequest = { + id: string + itemId: string + prompt: string + questions: unknown[] + timeoutSeconds?: number +} + +function executeWithAwaitUserInput( + args: Record, + awaitUserInput: (request: CapturedRequest) => Promise +): Promise<{ output: unknown; isError?: boolean }> { + const tool = requestUserInputTool + if (!tool.execute) throw new Error('tool has no execute') + return Promise.resolve( + tool.execute(args, { awaitUserInput } as never) as Promise<{ output: unknown; isError?: boolean }> + ) +} + +describe('user_input timeoutSeconds', () => { + it('passes a normalized timeoutSeconds through awaitUserInput', async () => { + const captured: CapturedRequest[] = [] + const result = await executeWithAwaitUserInput( + { prompt: 'Continue?', timeoutSeconds: 30.7 }, + async (request) => { + captured.push(request) + return { status: 'submitted', answers: [] } + } + ) + expect(captured).toHaveLength(1) + expect(captured[0]!.timeoutSeconds).toBe(30) + expect(result.isError).toBeFalsy() + }) + + it('drops out-of-range or non-numeric timeoutSeconds values', async () => { + for (const raw of [1, 9999, '30', Number.NaN, null]) { + const captured: CapturedRequest[] = [] + await executeWithAwaitUserInput( + { prompt: 'Continue?', timeoutSeconds: raw }, + async (request) => { + captured.push(request) + return { status: 'submitted', answers: [] } + } + ) + expect(captured[0]!.timeoutSeconds).toBeUndefined() + } + }) + + it('returns a non-error self-decision payload on timeout resolution', async () => { + const result = await executeWithAwaitUserInput( + { prompt: 'Continue?', timeoutSeconds: 20 }, + async () => ({ status: 'timeout' }) + ) + expect(result.isError).toBe(false) + expect(result.output).toMatchObject({ + status: 'timeout', + message: expect.stringContaining('proceed with your own best judgment') + }) + }) +}) diff --git a/kun/src/contracts/events.ts b/kun/src/contracts/events.ts index 4c6e9f48d..ce6f3183e 100644 --- a/kun/src/contracts/events.ts +++ b/kun/src/contracts/events.ts @@ -311,10 +311,11 @@ export type ApprovalReviewCompletedEvent = z.infer diff --git a/kun/src/contracts/items.ts b/kun/src/contracts/items.ts index a8d03f98e..092aa9bae 100644 --- a/kun/src/contracts/items.ts +++ b/kun/src/contracts/items.ts @@ -273,7 +273,8 @@ export const UserInputTurnItem = TurnItemBase.extend({ prompt: z.string(), questions: z.array(UserInputQuestionSchema).default([]), answers: z.array(UserInputAnswerSchema).optional(), - status: z.enum(['pending', 'submitted', 'cancelled']) + status: z.enum(['pending', 'submitted', 'cancelled', 'timeout']), + timeoutSeconds: z.number().int().positive().optional() }) export type UserInputTurnItem = z.infer diff --git a/kun/src/domain/item.ts b/kun/src/domain/item.ts index 6b86d827d..884a47a14 100644 --- a/kun/src/domain/item.ts +++ b/kun/src/domain/item.ts @@ -279,6 +279,7 @@ export function makeUserInputItem(input: { inputId: string prompt: string questions?: UserInputQuestion[] + timeoutSeconds?: number }): TurnItem { return { id: input.id, @@ -290,7 +291,8 @@ export function makeUserInputItem(input: { inputId: input.inputId, prompt: input.prompt, questions: input.questions ?? [], - status: 'pending' + status: 'pending', + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) } } diff --git a/kun/src/domain/runtime-event-reducer.ts b/kun/src/domain/runtime-event-reducer.ts index d31e59dab..fa8369563 100644 --- a/kun/src/domain/runtime-event-reducer.ts +++ b/kun/src/domain/runtime-event-reducer.ts @@ -448,6 +448,7 @@ function upsertUserInputFromEvent( if (item.kind === 'user_input') { if (event.questions) item.questions = event.questions if (event.answers) item.answers = event.answers + if (event.timeoutSeconds !== undefined) item.timeoutSeconds = event.timeoutSeconds } upsertItem(projection, item, 'replace') } diff --git a/kun/src/loop/interactive-tool-bridge.test.ts b/kun/src/loop/interactive-tool-bridge.test.ts index 0694741b6..ceb19d0dc 100644 --- a/kun/src/loop/interactive-tool-bridge.test.ts +++ b/kun/src/loop/interactive-tool-bridge.test.ts @@ -231,4 +231,102 @@ describe('InteractiveToolBridge', () => { 'user_input_resolved' ]) }) + + it('auto-resolves with status timeout when timeoutSeconds elapses', async () => { + vi.useFakeTimers() + try { + const userInputGate = new InMemoryUserInputGate() + const turns = { + applyItem: vi.fn(async () => undefined), + updateItem: vi.fn(async () => undefined) + } as unknown as TurnService + const recorded: Array> = [] + const events = { + record: vi.fn(async (event: Record) => { + recorded.push(event) + }) + } as unknown as RuntimeEventRecorder + const bridge = new InteractiveToolBridge({ + approvalGate: new InMemoryApprovalGate(), + userInputGate, + events, + turns, + sessionStore: { loadEventsSince: async () => [] } as unknown as SessionStore, + nowIso: () => '2026-07-10T00:00:00.000Z' + }) + + const pending = bridge.awaitUserInput({ + threadId: 'thread_1', + turnId: 'turn_1', + input: { + id: 'input_timeout', + itemId: 'item_input_timeout', + prompt: 'Continue?', + questions: [], + timeoutSeconds: 30 + }, + signal: new AbortController().signal + }) + await vi.advanceTimersByTimeAsync(29_999) + expect(userInputGate.get('input_timeout')).toBeDefined() + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toEqual({ status: 'timeout' }) + + const requested = recorded.find((event) => event.kind === 'user_input_requested') + expect(requested).toMatchObject({ timeoutSeconds: 30 }) + const resolved = recorded.find((event) => event.kind === 'user_input_resolved') + expect(resolved).toMatchObject({ status: 'timeout' }) + expect(turns.updateItem).toHaveBeenCalledWith( + 'thread_1', + 'item_input_timeout', + expect.objectContaining({ status: 'timeout' }) + ) + + // A late user submission cannot revive the settled gate. + expect(userInputGate.resolve('input_timeout', { status: 'submitted', answers: [] })).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('disarms the timeout when the user answers first', async () => { + vi.useFakeTimers() + try { + const userInputGate = new InMemoryUserInputGate() + const turns = { + applyItem: vi.fn(async () => undefined), + updateItem: vi.fn(async () => undefined) + } as unknown as TurnService + const events = { + record: vi.fn(async () => undefined) + } as unknown as RuntimeEventRecorder + const bridge = new InteractiveToolBridge({ + approvalGate: new InMemoryApprovalGate(), + userInputGate, + events, + turns, + sessionStore: { loadEventsSince: async () => [] } as unknown as SessionStore, + nowIso: () => '2026-07-10T00:00:00.000Z' + }) + + const pending = bridge.awaitUserInput({ + threadId: 'thread_1', + turnId: 'turn_1', + input: { + id: 'input_answered', + itemId: 'item_input_answered', + prompt: 'Continue?', + questions: [], + timeoutSeconds: 10 + }, + signal: new AbortController().signal + }) + userInputGate.resolve('input_answered', { status: 'submitted', answers: [] }) + await expect(pending).resolves.toEqual({ status: 'submitted', answers: [] }) + await vi.advanceTimersByTimeAsync(60_000) + expect(userInputGate.get('input_answered')).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/kun/src/loop/interactive-tool-bridge.ts b/kun/src/loop/interactive-tool-bridge.ts index 56daca490..6921963e5 100644 --- a/kun/src/loop/interactive-tool-bridge.ts +++ b/kun/src/loop/interactive-tool-bridge.ts @@ -12,7 +12,7 @@ import type { } from '../ports/user-input-gate.js' import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' import type { TurnService } from '../services/turn-service.js' -import { awaitAbortableGate } from '../services/interactive-gate.js' +import { armUserInputTimeout, awaitAbortableGate } from '../services/interactive-gate.js' import { sessionEventExists } from '../adapters/session-event-query.js' export type InteractiveToolBridgeDeps = { @@ -182,7 +182,10 @@ export class InteractiveToolBridge { turnId: input.turnId, inputId: input.input.id, prompt: input.input.prompt, - questions: input.input.questions + questions: input.input.questions, + ...(input.input.timeoutSeconds !== undefined + ? { timeoutSeconds: input.input.timeoutSeconds } + : {}) }) try { await this.deps.turns.applyItem(input.threadId, item) @@ -194,7 +197,10 @@ export class InteractiveToolBridge { inputId: input.input.id, status: 'pending', prompt: input.input.prompt, - questions: input.input.questions + questions: input.input.questions, + ...(input.input.timeoutSeconds !== undefined + ? { timeoutSeconds: input.input.timeoutSeconds } + : {}) }) } catch (error) { this.deps.userInputGate.resolve(input.input.id, { status: 'cancelled' }) @@ -202,12 +208,22 @@ export class InteractiveToolBridge { throw error } - const resolution = await awaitAbortableGate( - pending, - input.signal, - () => { this.deps.userInputGate.resolve(input.input.id, { status: 'cancelled' }) }, - 'cancelled while awaiting user input' + const disarmTimeout = armUserInputTimeout( + (resolution) => this.deps.userInputGate.resolve(input.input.id, resolution), + input.input.id, + input.input.timeoutSeconds ) + let resolution: UserInputResolution + try { + resolution = await awaitAbortableGate( + pending, + input.signal, + () => { this.deps.userInputGate.resolve(input.input.id, { status: 'cancelled' }) }, + 'cancelled while awaiting user input' + ) + } finally { + disarmTimeout() + } await this.deps.turns.updateItem(input.threadId, item.id, { status: resolution.status, finishedAt: this.deps.nowIso(), diff --git a/kun/src/ports/user-input-gate.ts b/kun/src/ports/user-input-gate.ts index 90a8c00e2..33143d169 100644 --- a/kun/src/ports/user-input-gate.ts +++ b/kun/src/ports/user-input-gate.ts @@ -28,11 +28,14 @@ export type UserInputRequest = { itemId: string prompt: string questions: UserInputQuestion[] + /** Optional wall-clock budget; when it elapses the gate self-resolves. */ + timeoutSeconds?: number } export type UserInputResolution = | { status: 'submitted'; answers: UserInputAnswer[] } | { status: 'cancelled'; answers?: UserInputAnswer[] } + | { status: 'timeout'; answers?: UserInputAnswer[] } /** * Exclusive reservation used by the HTTP route to persist a resolution event diff --git a/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory-context.ts b/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory-context.ts index 6c733c269..97bf714c3 100644 --- a/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory-context.ts +++ b/kun/src/runtime/agent-sdk/agent-sdk-runtime-factory-context.ts @@ -77,7 +77,7 @@ import { import type { ApprovalReviewPort } from '../../ports/approval-review.js' import type { ActingTurnModelRoute } from '../../contracts/turns.js' import { makeUserInputItem } from '../../domain/item.js' -import { awaitAbortableGate } from '../../services/interactive-gate.js' +import { armUserInputTimeout, awaitAbortableGate } from '../../services/interactive-gate.js' import { buildHistoryTranscript, DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES @@ -188,7 +188,8 @@ export function createAgentSdkFactoryContext(deps: AgentSdkRuntimeFactoryDeps) { turnId, itemId: input.itemId, prompt: input.prompt, - questions: input.questions + questions: input.questions, + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) } // Arm first so an event subscriber can immediately submit a response. const pending = gate.request(request) @@ -198,7 +199,8 @@ export function createAgentSdkFactoryContext(deps: AgentSdkRuntimeFactoryDeps) { turnId, inputId: input.id, prompt: input.prompt, - questions: input.questions + questions: input.questions, + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) }) try { await deps.turns.applyItem(threadId, item) @@ -210,18 +212,26 @@ export function createAgentSdkFactoryContext(deps: AgentSdkRuntimeFactoryDeps) { inputId: input.id, status: 'pending', prompt: input.prompt, - questions: input.questions + questions: input.questions, + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) }) } catch (error) { gate.resolve(input.id, { status: 'cancelled' }) void pending.catch(() => undefined) throw error } + const disarmTimeout = armUserInputTimeout( + (resolution) => gate.resolve(input.id, resolution), + input.id, + input.timeoutSeconds + ) let resolution: UserInputResolution try { resolution = await waitForGate(gate, request, signal, pending) } catch { resolution = { status: 'cancelled' } + } finally { + disarmTimeout() } await deps.turns.updateItem(threadId, item.id, { status: resolution.status, diff --git a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts index 5fe3a3cef..84d9a1732 100644 --- a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts +++ b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts @@ -35,7 +35,7 @@ import type { UserInputRequest, UserInputResolution } from '../../ports/user-input-gate.js' -import { awaitAbortableGate } from '../../services/interactive-gate.js' +import { armUserInputTimeout, awaitAbortableGate } from '../../services/interactive-gate.js' import { sessionEventExists } from '../../adapters/session-event-query.js' import type { SkillRuntime } from '../../skills/skill-runtime.js' import { @@ -153,7 +153,8 @@ export function createCursorSdkRuntime( turnId, itemId: input.itemId, prompt: input.prompt, - questions: input.questions + questions: input.questions, + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) } const pending = userInputGate.request(request) const item = makeUserInputItem({ @@ -162,7 +163,8 @@ export function createCursorSdkRuntime( turnId, inputId: input.id, prompt: input.prompt, - questions: input.questions + questions: input.questions, + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) }) try { await deps.turns.applyItem(threadId, item) @@ -174,13 +176,19 @@ export function createCursorSdkRuntime( inputId: input.id, status: 'pending', prompt: input.prompt, - questions: input.questions + questions: input.questions, + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) }) } catch (error) { userInputGate.resolve(input.id, { status: 'cancelled' }) void pending.catch(() => undefined) throw error } + const disarmTimeout = armUserInputTimeout( + (resolution) => userInputGate.resolve(input.id, resolution), + input.id, + input.timeoutSeconds + ) let resolution: UserInputResolution try { resolution = await awaitAbortableGate( @@ -191,6 +199,8 @@ export function createCursorSdkRuntime( ) } catch { resolution = { status: 'cancelled' } + } finally { + disarmTimeout() } await deps.turns.updateItem(threadId, item.id, { status: resolution.status, diff --git a/kun/src/services/interactive-gate.ts b/kun/src/services/interactive-gate.ts index 1aae6fa4d..4fd7481da 100644 --- a/kun/src/services/interactive-gate.ts +++ b/kun/src/services/interactive-gate.ts @@ -33,3 +33,24 @@ export function awaitAbortableGate( ) }) } + +/** + * Arm the optional self-resolution timer for a pending user-input request. + * When the budget elapses, the gate resolves with status "timeout" so the + * model can proceed on its own instead of blocking the turn forever. Duplicate + * resolution is a no-op; the gate already settles exclusively by input id. + */ +export function armUserInputTimeout( + resolve: (resolution: { status: 'timeout' }) => boolean, + inputId: string, + timeoutSeconds: number | undefined +): () => void { + if (timeoutSeconds === undefined || !(timeoutSeconds > 0)) return () => undefined + const timer = setTimeout(() => { + // A false return means the request already settled (user answered or the + // turn aborted first); duplicate resolution is safely ignored. + resolve({ status: 'timeout' }) + }, timeoutSeconds * 1000) + timer.unref?.() + return () => clearTimeout(timer) +} diff --git a/src/renderer/src/agent/kun-contract-runtime.ts b/src/renderer/src/agent/kun-contract-runtime.ts index 8e1a7a529..3cb31775b 100644 --- a/src/renderer/src/agent/kun-contract-runtime.ts +++ b/src/renderer/src/agent/kun-contract-runtime.ts @@ -273,6 +273,7 @@ export type CoreTurnItemJson = { decisionSource?: 'user' | 'agent' inputId?: string prompt?: string + timeoutSeconds?: number questions?: Array<{ header?: string id: string @@ -573,6 +574,7 @@ export type CoreRuntimeEventJson = { rationale?: string prompt?: string inputId?: string + timeoutSeconds?: number questions?: Array<{ header?: string id: string diff --git a/src/renderer/src/agent/kun-event-normalizer.ts b/src/renderer/src/agent/kun-event-normalizer.ts index 0c88a8c87..0c05e89ae 100644 --- a/src/renderer/src/agent/kun-event-normalizer.ts +++ b/src/renderer/src/agent/kun-event-normalizer.ts @@ -172,11 +172,16 @@ function normalizeKunRuntimeEventPayload( } case 'user_input_resolved': { const answers = deps.userInputAnswers(event.answers) + const status = event.status === 'cancelled' + ? 'cancelled' + : event.status === 'timeout' + ? 'timeout' + : 'submitted' return [{ type: 'user_input_status_changed', payload: { itemId: event.itemId ?? event.inputId ?? `input_${event.seq ?? 'unknown'}`, - status: event.status === 'cancelled' ? 'cancelled' : 'submitted', + status, ...(answers ? { answers } : {}) } }] diff --git a/src/renderer/src/agent/kun-mapper-events.ts b/src/renderer/src/agent/kun-mapper-events.ts index 658916278..36040c9c8 100644 --- a/src/renderer/src/agent/kun-mapper-events.ts +++ b/src/renderer/src/agent/kun-mapper-events.ts @@ -362,6 +362,7 @@ export const kunEventNormalizerDeps: KunEventNormalizerDeps = { createdAt: event.timestamp, prompt: event.prompt, questions: event.questions, + timeoutSeconds: event.timeoutSeconds, seq: event.seq }), userInputAnswers: userInputAnswersFromCore, diff --git a/src/renderer/src/agent/kun-mapper-interactions.test.ts b/src/renderer/src/agent/kun-mapper-interactions.test.ts index e19eeecc1..787f20305 100644 --- a/src/renderer/src/agent/kun-mapper-interactions.test.ts +++ b/src/renderer/src/agent/kun-mapper-interactions.test.ts @@ -297,6 +297,49 @@ describe('user input mapping', () => { }) }) + it('maps timeoutSeconds from runtime events and timeout resolutions', async () => { + let request: unknown = null + let status: unknown = null + const sink: ThreadEventSink = { + ...makeSink(), + onUserInput: (payload) => { + request = payload + }, + onUserInputStatus: (payload) => { + status = payload + } + } + await dispatchKunRuntimeEvent( + { + kind: 'user_input_requested', + seq: 21, + itemId: 'item_input_timeout', + inputId: 'input_timeout', + prompt: 'Choose', + timeoutSeconds: 45, + questions: [ + { header: 'Mode', id: 'mode', question: 'Choose', options: [] } + ] + }, + sink, + async () => undefined + ) + expect(request).toMatchObject({ timeoutSeconds: 45 }) + + await dispatchKunRuntimeEvent( + { + kind: 'user_input_resolved', + seq: 22, + itemId: 'item_input_timeout', + inputId: 'input_timeout', + status: 'timeout' + }, + sink, + async () => undefined + ) + expect(status).toMatchObject({ itemId: 'item_input_timeout', status: 'timeout' }) + }) + it('maps prompt/message aliases on user-input questions', async () => { let request: unknown = null const sink: ThreadEventSink = { diff --git a/src/renderer/src/agent/kun-mapper-projection.ts b/src/renderer/src/agent/kun-mapper-projection.ts index 28520b9a4..6c5f9b88e 100644 --- a/src/renderer/src/agent/kun-mapper-projection.ts +++ b/src/renderer/src/agent/kun-mapper-projection.ts @@ -448,13 +448,16 @@ export function userInputBlockFromItem( requestId: item.inputId ?? item.id, questions: userInputQuestionsFromItem(item), ...(answers ? { answers } : {}), + ...(item.timeoutSeconds !== undefined ? { timeoutSeconds: item.timeoutSeconds } : {}), status: item.status === 'failed' ? 'error' - : item.status === 'submitted' || item.status === 'completed' - ? 'submitted' - : item.status === 'cancelled' || item.status === 'aborted' - ? 'cancelled' + : item.status === 'timeout' + ? 'timeout' + : item.status === 'submitted' || item.status === 'completed' + ? 'submitted' + : item.status === 'cancelled' || item.status === 'aborted' + ? 'cancelled' : 'pending' } } @@ -466,6 +469,7 @@ export function userInputRequestFromCore(input: { createdAt?: string prompt?: string questions?: CoreTurnItemJson['questions'] | CoreRuntimeEventJson['questions'] + timeoutSeconds?: number seq?: number }): UserInputRequestPayload { const fallbackId = input.inputId ?? input.itemId ?? `input_${input.seq ?? Date.now()}` @@ -474,7 +478,8 @@ export function userInputRequestFromCore(input: { ...(input.turnId ? { turnId: input.turnId } : {}), ...(input.createdAt ? { createdAt: input.createdAt } : {}), requestId: input.inputId ?? fallbackId, - questions: questionsFromCore(input.questions, input.prompt, input.inputId ?? fallbackId) + questions: questionsFromCore(input.questions, input.prompt, input.inputId ?? fallbackId), + ...(input.timeoutSeconds !== undefined ? { timeoutSeconds: input.timeoutSeconds } : {}) } } diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index f9b20d02a..97930f032 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -452,15 +452,12 @@ export type ChatBlock = createdAt?: string requestId: string questions: UserInputQuestion[] - status: 'pending' | 'submitted' | 'cancelled' | 'error' + status: 'pending' | 'submitted' | 'cancelled' | 'timeout' | 'error' answers?: UserInputAnswer[] errorMessage?: string - /** - * True only for a request the live runtime is currently awaiting (set by - * the `onUserInput` stream event). Historical blocks rehydrated from a - * finished thread never carry it, so a stale `pending` request reopened - * from history is not re-surfaced as an actionable prompt (issue #606). - */ + /** Auto-resolve budget; the model proceeds on its own when it elapses. */ + timeoutSeconds?: number + /** True only while the live runtime awaits this request (see #606). */ live?: boolean } @@ -576,11 +573,12 @@ export type UserInputRequestPayload = { createdAt?: string requestId: string questions: UserInputQuestion[] + timeoutSeconds?: number } export type UserInputStatusPayload = { itemId: string - status: 'submitted' | 'cancelled' | 'error' + status: 'submitted' | 'cancelled' | 'timeout' | 'error' answers?: UserInputAnswer[] errorMessage?: string } diff --git a/src/renderer/src/components/chat/FloatingComposerUserInputPanel.tsx b/src/renderer/src/components/chat/FloatingComposerUserInputPanel.tsx index d12264f0f..40d34333e 100644 --- a/src/renderer/src/components/chat/FloatingComposerUserInputPanel.tsx +++ b/src/renderer/src/components/chat/FloatingComposerUserInputPanel.tsx @@ -9,6 +9,7 @@ import { } from 'lucide-react' import type { UserInputOption } from '../../agent/types' import type { ComposerUserInputController } from './use-composer-user-input' +import { UserInputTimeoutCountdownChip } from './floating-composer-user-input-timeout' import { isMultipleChoiceQuestion, shouldShowQuestionHeader @@ -66,8 +67,11 @@ export function FloatingComposerUserInputPanel({

- - {index + 1} / {total} + + + + {index + 1} / {total} + diff --git a/src/renderer/src/components/chat/Sidebar.tsx b/src/renderer/src/components/chat/Sidebar.tsx index ac4ceb83d..d8a5fb749 100644 --- a/src/renderer/src/components/chat/Sidebar.tsx +++ b/src/renderer/src/components/chat/Sidebar.tsx @@ -130,6 +130,7 @@ export function Sidebar({ const watchTurnCompletion = useChatStore((s) => s.watchTurnCompletion) const unreadThreadIds = useChatStore((s) => s.unreadThreadIds) const scheduledThreadActivities = useChatStore((s) => s.scheduledThreadActivities) + const awaitingUserInputThreadIds = useChatStore((s) => s.awaitingUserInputThreadIds) const clawChannels = useChatStore((s) => s.clawChannels) const activeClawChannelId = useChatStore((s) => s.activeClawChannelId) const selectClawChannel = useChatStore((s) => s.selectClawChannel) @@ -278,6 +279,7 @@ export function Sidebar({ watchTurnCompletion={watchTurnCompletion} unreadThreadIds={unreadThreadIds} scheduledThreadActivities={scheduledThreadActivities} + awaitingUserInputThreadIds={awaitingUserInputThreadIds} locale={i18n.language} onPickWorkspace={() => void chooseWorkspace()} onRemoveWorkspace={deleteWorkspace} @@ -312,6 +314,7 @@ export function Sidebar({ watchTurnCompletion={watchTurnCompletion} unreadThreadIds={unreadThreadIds} scheduledThreadActivities={scheduledThreadActivities} + awaitingUserInputThreadIds={awaitingUserInputThreadIds} locale={i18n.language} onPickWorkspace={() => void chooseWorkspace()} onRemoveWorkspace={deleteWorkspace} diff --git a/src/renderer/src/components/chat/SidebarConversationsSection.tsx b/src/renderer/src/components/chat/SidebarConversationsSection.tsx index 320736881..8afa0bfe5 100644 --- a/src/renderer/src/components/chat/SidebarConversationsSection.tsx +++ b/src/renderer/src/components/chat/SidebarConversationsSection.tsx @@ -76,6 +76,7 @@ export function SidebarConversationsSection({ const watchTurnCompletion = useChatStore((s) => s.watchTurnCompletion) const unreadThreadIds = useChatStore((s) => s.unreadThreadIds) const scheduledThreadActivities = useChatStore((s) => s.scheduledThreadActivities) + const awaitingUserInputThreadIds = useChatStore((s) => s.awaitingUserInputThreadIds) const [collapsed, setCollapsed] = useState(true) const [searchOpen, setSearchOpen] = useState(false) @@ -94,8 +95,9 @@ export function SidebarConversationsSection({ busy, watchTurnCompletion, unreadThreadIds, - scheduledThreadActivities - }), [activeThreadId, busy, scheduledThreadActivities, unreadThreadIds, watchTurnCompletion]) + scheduledThreadActivities, + awaitingUserInputThreadIds + }), [activeThreadId, awaitingUserInputThreadIds, busy, scheduledThreadActivities, unreadThreadIds, watchTurnCompletion]) const allConversationThreads = useMemo(() => sortSidebarThreads(threads.filter((thread) => isConversationWorkspacePath(thread.workspace, conversationRoot) && thread.archived !== true @@ -328,6 +330,7 @@ export function SidebarConversationsSection({ showRunning={activity === 'running'} showFailed={activity === 'failed'} showUnread={activity === 'unread'} + showAwaitingInput={activity === 'awaiting-input'} scheduledActivity={activity === 'scheduled' ? scheduledThreadActivities[thread.id] : undefined} diff --git a/src/renderer/src/components/chat/SidebarProjectRows.tsx b/src/renderer/src/components/chat/SidebarProjectRows.tsx index caf29acbc..32271cc49 100644 --- a/src/renderer/src/components/chat/SidebarProjectRows.tsx +++ b/src/renderer/src/components/chat/SidebarProjectRows.tsx @@ -12,6 +12,7 @@ import { ChevronDown, ChevronRight, CircleAlert, + CircleHelp, ClipboardList, FolderPlus, GitBranch, @@ -156,6 +157,7 @@ type ThreadRowProps = { showRunning: boolean showUnread: boolean showFailed?: boolean + showAwaitingInput?: boolean scheduledActivity?: ScheduledThreadActivity onSelect: () => void onContextMenu: (event: ReactMouseEvent) => void @@ -188,6 +190,7 @@ export function ThreadRow({ showRunning, showUnread, showFailed = false, + showAwaitingInput = false, scheduledActivity, onSelect, onContextMenu, @@ -239,6 +242,7 @@ export function ThreadRow({ thread.title, updatedLabel, pinned ? t('sidebarThreadPinned') : '', + showAwaitingInput ? t('sidebarThreadAwaitingInput') : '', showRunning ? t('sidebarThreadRunning') : '', showFailed ? t('sidebarThreadFailed') : '', showUnreadDot ? t('sidebarThreadUnread') : '', @@ -345,10 +349,12 @@ export function ThreadRow({ running={showRunning} failed={showFailed} unread={showUnreadDot} + awaitingInput={showAwaitingInput} scheduled={!showRunning && !showFailed && !showUnreadDot ? scheduledActivity : undefined} unreadLabel={t(showRunning ? 'sidebarThreadRunning' : 'sidebarThreadUnread')} failedLabel={t('sidebarThreadFailed')} scheduledLabel={scheduledLabel} + awaitingInputLabel={t('sidebarThreadAwaitingInput')} /> @@ -378,18 +384,34 @@ function ThreadActivityIndicator({ failed, unread, scheduled, + awaitingInput, unreadLabel, failedLabel, - scheduledLabel + scheduledLabel, + awaitingInputLabel }: { running: boolean failed: boolean unread: boolean scheduled?: ScheduledThreadActivity + awaitingInput: boolean unreadLabel: string failedLabel: string scheduledLabel: string + awaitingInputLabel: string }): ReactElement | null { + if (awaitingInput) { + return ( + + + + ) + } if (running) return if (failed) { return ( diff --git a/src/renderer/src/components/chat/SidebarProjectsContent.tsx b/src/renderer/src/components/chat/SidebarProjectsContent.tsx index 3bca0bd9c..8a4390fae 100644 --- a/src/renderer/src/components/chat/SidebarProjectsContent.tsx +++ b/src/renderer/src/components/chat/SidebarProjectsContent.tsx @@ -196,6 +196,7 @@ export function SidebarProjectsContent(props: SidebarProjectsContentProps): Reac showRunning={activity === 'running'} showFailed={activity === 'failed'} showUnread={activity === 'unread'} + showAwaitingInput={activity === 'awaiting-input'} scheduledActivity={activity === 'scheduled' ? sidebarThreadActivityContext.scheduledThreadActivities?.[thread.id] : undefined} diff --git a/src/renderer/src/components/chat/SidebarProjectsSection.tsx b/src/renderer/src/components/chat/SidebarProjectsSection.tsx index 5b436c14d..e1f026c6d 100644 --- a/src/renderer/src/components/chat/SidebarProjectsSection.tsx +++ b/src/renderer/src/components/chat/SidebarProjectsSection.tsx @@ -165,6 +165,7 @@ type SidebarProjectsSectionProps = { watchTurnCompletion: Record unreadThreadIds: Parameters[1]['unreadThreadIds'] scheduledThreadActivities?: Parameters[1]['scheduledThreadActivities'] + awaitingUserInputThreadIds?: Parameters[1]['awaitingUserInputThreadIds'] locale: string onPickWorkspace: () => void onRemoveWorkspace: (workspacePath: string) => Promise @@ -207,6 +208,7 @@ export function SidebarProjectsSection({ watchTurnCompletion, unreadThreadIds, scheduledThreadActivities = {}, + awaitingUserInputThreadIds, locale, onPickWorkspace, onRemoveWorkspace, @@ -293,7 +295,8 @@ export function SidebarProjectsSection({ busy, watchTurnCompletion, unreadThreadIds, - scheduledThreadActivities + scheduledThreadActivities, + awaitingUserInputThreadIds } const groups = useMemo(() => { diff --git a/src/renderer/src/components/chat/floating-composer-user-input-timeout.tsx b/src/renderer/src/components/chat/floating-composer-user-input-timeout.tsx new file mode 100644 index 000000000..334809ed2 --- /dev/null +++ b/src/renderer/src/components/chat/floating-composer-user-input-timeout.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState, type ReactElement } from 'react' +import { Clock } from 'lucide-react' +import type { ChatBlock } from '../../agent/types' + +type Translate = (key: string, options?: Record) => string + +type UserInputBlock = Extract + +/** + * Countdown chip for a live `user_input` request carrying `timeoutSeconds`. + * Rendered while the runtime is still awaiting the answer; once the deadline + * passes it switches to an "elapsed" label until the resolution event lands. + */ +export function UserInputTimeoutCountdownChip({ + block, + t +}: { + block: UserInputBlock | null + t: Translate +}): ReactElement | null { + const timeoutSeconds = block?.timeoutSeconds + const createdAt = block?.createdAt + const deadline = timeoutSeconds !== undefined && createdAt + ? Date.parse(createdAt) + timeoutSeconds * 1000 + : NaN + + const [remaining, setRemaining] = useState(() => + Number.isFinite(deadline) ? Math.max(0, Math.ceil((deadline - Date.now()) / 1000)) : 0 + ) + + useEffect(() => { + if (!Number.isFinite(deadline)) return + const tick = (): void => setRemaining(Math.max(0, Math.ceil((deadline - Date.now()) / 1000))) + tick() + const timer = setInterval(tick, 1000) + return () => clearInterval(timer) + }, [deadline]) + + if (!Number.isFinite(deadline)) return null + + const elapsed = remaining <= 0 + return ( + + + ) +} diff --git a/src/renderer/src/components/chat/message-timeline-bubble-support.tsx b/src/renderer/src/components/chat/message-timeline-bubble-support.tsx index 65930e728..7e79a6f3e 100644 --- a/src/renderer/src/components/chat/message-timeline-bubble-support.tsx +++ b/src/renderer/src/components/chat/message-timeline-bubble-support.tsx @@ -365,17 +365,19 @@ export function UserInputBubble({ ? t('userInputSubmitted') : block.status === 'cancelled' ? t('userInputCancelled') - : block.status === 'error' - ? t('userInputFailed') - : pending - ? t('userInputPending') - : t('userInputCancelled') + : block.status === 'timeout' + ? t('userInputTimedOut') + : block.status === 'error' + ? t('userInputFailed') + : pending + ? t('userInputPending') + : t('userInputCancelled') const tone = block.status === 'error' ? 'error' : block.status === 'submitted' ? 'success' - : block.status === 'cancelled' + : block.status === 'cancelled' || block.status === 'timeout' ? 'muted' : pending ? 'active' diff --git a/src/renderer/src/components/chat/sidebar-project-selectors.awaiting-input.test.ts b/src/renderer/src/components/chat/sidebar-project-selectors.awaiting-input.test.ts new file mode 100644 index 000000000..886f27e28 --- /dev/null +++ b/src/renderer/src/components/chat/sidebar-project-selectors.awaiting-input.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { NormalizedThread } from '../../agent/types' +import { + prioritizeSidebarThreadActivity, + sidebarThreadActivity, + type SidebarThreadActivityContext +} from './sidebar-project-selectors' + +function thread(id: string, overrides: Partial = {}): NormalizedThread { + return { + id, + title: id, + updatedAt: '2026-08-01T00:00:00.000Z', + workspace: '/tmp/project', + ...overrides + } as NormalizedThread +} + +const baseContext: SidebarThreadActivityContext = { + activeThreadId: null, + busy: false, + watchTurnCompletion: {}, + unreadThreadIds: {} +} + +describe('awaiting-input sidebar activity', () => { + it('outranks running and unread classifications', () => { + const context: SidebarThreadActivityContext = { + ...baseContext, + awaitingUserInputThreadIds: { thr_waiting: true }, + watchTurnCompletion: { thr_waiting: true, thr_running: true }, + unreadThreadIds: { thr_unread: true } + } + expect(sidebarThreadActivity(thread('thr_waiting'), context)).toBe('awaiting-input') + expect(sidebarThreadActivity(thread('thr_running'), context)).toBe('running') + expect(sidebarThreadActivity(thread('thr_unread'), context)).toBe('unread') + }) + + it('sorts awaiting-input threads before running and read threads', () => { + const context: SidebarThreadActivityContext = { + ...baseContext, + awaitingUserInputThreadIds: { thr_waiting: true }, + watchTurnCompletion: { thr_running: true } + } + const ordered = prioritizeSidebarThreadActivity( + [thread('thr_read'), thread('thr_running'), thread('thr_waiting')], + context + ) + expect(ordered.map((item) => item.id)).toEqual(['thr_waiting', 'thr_running', 'thr_read']) + }) + + it('falls back to running when the thread is not awaiting input', () => { + const context: SidebarThreadActivityContext = { + ...baseContext, + watchTurnCompletion: { thr_running: true } + } + expect(sidebarThreadActivity(thread('thr_running'), context)).toBe('running') + }) +}) diff --git a/src/renderer/src/components/chat/sidebar-project-selectors.ts b/src/renderer/src/components/chat/sidebar-project-selectors.ts index 58db62094..f687f7b61 100644 --- a/src/renderer/src/components/chat/sidebar-project-selectors.ts +++ b/src/renderer/src/components/chat/sidebar-project-selectors.ts @@ -33,7 +33,7 @@ const THREAD_PREVIEW_MAX_HEIGHT = 220 const THREAD_PREVIEW_GAP = 10 const THREAD_PREVIEW_VIEWPORT_MARGIN = 12 -export type SidebarThreadActivity = 'failed' | 'unread' | 'running' | 'scheduled' | 'read' +export type SidebarThreadActivity = 'awaiting-input' | 'failed' | 'unread' | 'running' | 'scheduled' | 'read' export type SidebarThreadActivityContext = { activeThreadId: string | null @@ -41,18 +41,22 @@ export type SidebarThreadActivityContext = { watchTurnCompletion: Record unreadThreadIds: CompletionAttentionRegistry scheduledThreadActivities?: Record + awaitingUserInputThreadIds?: Record } /** * Classifies a sidebar row from transient renderer state without mutating the - * durable thread record. Running wins over unread during refresh races, so a - * live turn never appears as a completed notification. + * durable thread record. A thread waiting on the user's answer outranks + * everything else (only user action can move it forward); running wins over + * unread during refresh races, so a live turn never appears as a completed + * notification. */ export function sidebarThreadActivity( thread: NormalizedThread, context: SidebarThreadActivityContext ): SidebarThreadActivity { const id = thread.id.trim() + if (context.awaitingUserInputThreadIds?.[id] === true) return 'awaiting-input' const running = threadLooksRunning(thread) || context.watchTurnCompletion[id] === true || @@ -73,19 +77,21 @@ export function prioritizeSidebarThreadActivity( threads: readonly NormalizedThread[], context: SidebarThreadActivityContext ): NormalizedThread[] { + const awaitingInput: NormalizedThread[] = [] const running: NormalizedThread[] = [] const failed: NormalizedThread[] = [] const unread: NormalizedThread[] = [] const read: NormalizedThread[] = [] for (const thread of threads) { switch (sidebarThreadActivity(thread, context)) { + case 'awaiting-input': awaitingInput.push(thread); break case 'running': running.push(thread); break case 'failed': failed.push(thread); break case 'unread': unread.push(thread); break default: read.push(thread) } } - return [...running, ...failed, ...unread, ...read] + return [...awaitingInput, ...running, ...failed, ...unread, ...read] } export function sidebarThreadsHaveRunningActivity( diff --git a/src/renderer/src/locales/en/common/commands-sdd.json b/src/renderer/src/locales/en/common/commands-sdd.json index 2f9f6b160..9e512d9df 100644 --- a/src/renderer/src/locales/en/common/commands-sdd.json +++ b/src/renderer/src/locales/en/common/commands-sdd.json @@ -128,6 +128,9 @@ "userInputPending": "Waiting for your answer…", "userInputSubmitted": "Submitted", "userInputCancelled": "Cancelled", + "userInputTimedOut": "Timed out - the model decided on its own", + "userInputTimeoutCountdown": "Times out in {{seconds}}s - the model will decide on its own", + "userInputTimeoutElapsed": "Timed out - the model is deciding on its own...", "userInputFailed": "Submit failed", "userInputQuestionProgress": "Question {{current}} / {{total}}", "userInputOther": "Other", @@ -509,6 +512,7 @@ "sidebarThreadPreviewUpdated": "Updated {{time}}", "sidebarThreadPreviewEmpty": "No preview text yet.", "sidebarThreadRunning": "Running", + "sidebarThreadAwaitingInput": "Awaiting your input", "sidebarThreadUnread": "New reply", "sidebarThreadFailed": "Task failed — open to review", "sidebarThreadScheduled": "Scheduled for {{time}}", diff --git a/src/renderer/src/locales/hi/common/commands-sdd.json b/src/renderer/src/locales/hi/common/commands-sdd.json index 41c586467..0c618ff0c 100644 --- a/src/renderer/src/locales/hi/common/commands-sdd.json +++ b/src/renderer/src/locales/hi/common/commands-sdd.json @@ -126,6 +126,9 @@ "userInputPending": "आपके उत्तर की प्रतीक्षा में...", "userInputSubmitted": "प्रस्तुत किया गया", "userInputCancelled": "रद्द कर दिया गया", + "userInputTimedOut": "समय समाप्त - मॉडल ने स्वयं निर्णय लिया", + "userInputTimeoutCountdown": "{{seconds}} सेकंड में समय समाप्त - मॉडल स्वयं निर्णय लेगा", + "userInputTimeoutElapsed": "समय समाप्त - मॉडल स्वयं निर्णय ले रहा है...", "userInputFailed": "सबमिट विफल", "userInputQuestionProgress": "Question {{current}} / {{total}}", "userInputOther": "अन्य", @@ -498,6 +501,7 @@ "sidebarThreadPreviewUpdated": "Updated {{time}}", "sidebarThreadPreviewEmpty": "अभी तक कोई पूर्वावलोकन पाठ नहीं.", "sidebarThreadRunning": "चल रहा है", + "sidebarThreadAwaitingInput": "आपके इनपुट की प्रतीक्षा", "sidebarThreadUnread": "नया उत्तर", "sidebarThreadFailed": "कार्य विफल — समीक्षा के लिए खोलें", "sidebarThreadScheduled": "{{time}} के लिए निर्धारित", diff --git a/src/renderer/src/locales/ja/common/commands-sdd.json b/src/renderer/src/locales/ja/common/commands-sdd.json index f7c882c2e..03c34a0b0 100644 --- a/src/renderer/src/locales/ja/common/commands-sdd.json +++ b/src/renderer/src/locales/ja/common/commands-sdd.json @@ -126,6 +126,9 @@ "userInputPending": "あなたの答えを待っています…", "userInputSubmitted": "提出済み", "userInputCancelled": "キャンセルされました", + "userInputTimedOut": "タイムアウト - モデルが独自に判断しました", + "userInputTimeoutCountdown": "あと{{seconds}}秒でタイムアウト - モデルが独自に判断します", + "userInputTimeoutElapsed": "タイムアウト - モデルが独自に判断しています...", "userInputFailed": "送信に失敗しました", "userInputQuestionProgress": "Question {{current}} / {{total}}", "userInputOther": "その他", @@ -498,6 +501,7 @@ "sidebarThreadPreviewUpdated": "{{time}}を更新しました", "sidebarThreadPreviewEmpty": "プレビュー テキストはまだありません。", "sidebarThreadRunning": "ランニング", + "sidebarThreadAwaitingInput": "入力を待っています", "sidebarThreadUnread": "新しい返信", "sidebarThreadFailed": "タスクに失敗しました。開いて確認してください", "sidebarThreadScheduled": "{{time}} に実行予定", diff --git a/src/renderer/src/locales/ko/common/commands-sdd.json b/src/renderer/src/locales/ko/common/commands-sdd.json index b50f02ee8..f53b7c16b 100644 --- a/src/renderer/src/locales/ko/common/commands-sdd.json +++ b/src/renderer/src/locales/ko/common/commands-sdd.json @@ -126,6 +126,9 @@ "userInputPending": "귀하의 답변을 기다리고 있습니다…", "userInputSubmitted": "제출됨", "userInputCancelled": "취소됨", + "userInputTimedOut": "시간 초과 - 모델이 스스로 결정했습니다", + "userInputTimeoutCountdown": "{{seconds}}초 후 시간 초과 - 모델이 스스로 결정합니다", + "userInputTimeoutElapsed": "시간 초과 - 모델이 스스로 결정하고 있습니다...", "userInputFailed": "제출 실패", "userInputQuestionProgress": "Question {{current}} / {{total}}", "userInputOther": "기타", @@ -498,6 +501,7 @@ "sidebarThreadPreviewUpdated": "{{time}} 업데이트됨", "sidebarThreadPreviewEmpty": "아직 미리보기 텍스트가 없습니다.", "sidebarThreadRunning": "달리기", + "sidebarThreadAwaitingInput": "입력을 기다리는 중", "sidebarThreadUnread": "새 답변", "sidebarThreadFailed": "작업 실패 — 열어서 확인", "sidebarThreadScheduled": "{{time}}에 실행 예정", diff --git a/src/renderer/src/locales/ru/common/commands-sdd.json b/src/renderer/src/locales/ru/common/commands-sdd.json index 740d6c6f0..fac1e4f3f 100644 --- a/src/renderer/src/locales/ru/common/commands-sdd.json +++ b/src/renderer/src/locales/ru/common/commands-sdd.json @@ -126,6 +126,9 @@ "userInputPending": "Жду вашего ответа…", "userInputSubmitted": "Отправлено", "userInputCancelled": "Отменено", + "userInputTimedOut": "Время истекло - модель решила самостоятельно", + "userInputTimeoutCountdown": "Истекает через {{seconds}} с - модель решит самостоятельно", + "userInputTimeoutElapsed": "Время истекло - модель решает самостоятельно...", "userInputFailed": "Отправить не удалось", "userInputQuestionProgress": "Question {{current}} / {{total}}", "userInputOther": "Другое", @@ -498,6 +501,7 @@ "sidebarThreadPreviewUpdated": "Обновлено {{time}}", "sidebarThreadPreviewEmpty": "Текста предварительного просмотра пока нет.", "sidebarThreadRunning": "Бег", + "sidebarThreadAwaitingInput": "Ожидает вашего ввода", "sidebarThreadUnread": "Новый ответ", "sidebarThreadFailed": "Задача завершилась с ошибкой — откройте для просмотра", "sidebarThreadScheduled": "Запланировано на {{time}}", diff --git a/src/renderer/src/locales/th/common/commands-sdd.json b/src/renderer/src/locales/th/common/commands-sdd.json index 224714600..8dae6de24 100644 --- a/src/renderer/src/locales/th/common/commands-sdd.json +++ b/src/renderer/src/locales/th/common/commands-sdd.json @@ -126,6 +126,9 @@ "userInputPending": "กำลังรอคำตอบของคุณ...", "userInputSubmitted": "ส่งแล้ว", "userInputCancelled": "ยกเลิกแล้ว", + "userInputTimedOut": "หมดเวลา - โมเดลตัดสินใจด้วยตัวเองแล้ว", + "userInputTimeoutCountdown": "หมดเวลาใน {{seconds}} วินาที - โมเดลจะตัดสินใจด้วยตัวเอง", + "userInputTimeoutElapsed": "หมดเวลาแล้ว - โมเดลกำลังตัดสินใจด้วยตัวเอง...", "userInputFailed": "ส่งล้มเหลว", "userInputQuestionProgress": "คำถาม {{current}} / {{total}}", "userInputOther": "อื่นๆ", @@ -498,6 +501,7 @@ "sidebarThreadPreviewUpdated": "Updated {{time}}", "sidebarThreadPreviewEmpty": "ยังไม่มีข้อความแสดงตัวอย่าง", "sidebarThreadRunning": "วิ่ง", + "sidebarThreadAwaitingInput": "กำลังรอการป้อนข้อมูลของคุณ", "sidebarThreadUnread": "ตอบกลับใหม่", "sidebarThreadFailed": "งานล้มเหลว — เปิดเพื่อตรวจสอบ", "sidebarThreadScheduled": "กำหนดไว้เวลา {{time}}", diff --git a/src/renderer/src/locales/zh/common/commands-sdd.json b/src/renderer/src/locales/zh/common/commands-sdd.json index 2e06b6ebb..42c400847 100644 --- a/src/renderer/src/locales/zh/common/commands-sdd.json +++ b/src/renderer/src/locales/zh/common/commands-sdd.json @@ -128,6 +128,9 @@ "userInputPending": "等待你选择…", "userInputSubmitted": "已提交", "userInputCancelled": "已取消", + "userInputTimedOut": "超时未回答,模型已自行决策", + "userInputTimeoutCountdown": "{{seconds}} 秒后超时,模型将自行决策", + "userInputTimeoutElapsed": "已超时,模型正在自行决策…", "userInputFailed": "提交失败", "userInputQuestionProgress": "问题 {{current}} / {{total}}", "userInputOther": "其他", @@ -508,6 +511,7 @@ "sidebarThreadPreviewUpdated": "{{time}} 更新", "sidebarThreadPreviewEmpty": "暂无预览内容。", "sidebarThreadRunning": "运行中", + "sidebarThreadAwaitingInput": "等待你的输入", "sidebarThreadUnread": "有新回复", "sidebarThreadFailed": "任务运行失败,打开查看", "sidebarThreadScheduled": "计划于 {{time}} 执行", diff --git a/src/renderer/src/store/awaiting-user-input-registry.ts b/src/renderer/src/store/awaiting-user-input-registry.ts new file mode 100644 index 000000000..796dd4714 --- /dev/null +++ b/src/renderer/src/store/awaiting-user-input-registry.ts @@ -0,0 +1,47 @@ +import type { ChatState } from './chat-store-types' + +type SetState = (partial: Partial | ((state: ChatState) => Partial)) => void +type GetState = () => ChatState + +/** + * Registry helpers for threads whose live runtime is currently awaiting a + * `user_input` answer. The sidebar uses this set to show an explicit + * "awaiting your input" marker instead of a generic running spinner. + */ + +export function markThreadAwaitingUserInput( + set: SetState, + get: GetState, + threadId: string | null | undefined +): void { + const id = threadId?.trim() + if (!id) return + set((state) => ({ + awaitingUserInputThreadIds: { ...state.awaitingUserInputThreadIds, [id]: true } + })) +} + +export function clearThreadAwaitingUserInput( + set: SetState, + get: GetState, + threadId: string | null | undefined +): void { + const id = threadId?.trim() + if (!id || !get().awaitingUserInputThreadIds[id]) return + set((state) => { + const next = { ...state.awaitingUserInputThreadIds } + delete next[id] + return { awaitingUserInputThreadIds: next } + }) +} + +/** Removes the awaiting marker from a state patch's thread id, if present. */ +export function withoutAwaitingUserInput( + awaiting: Record | undefined, + threadId: string +): Record { + if (!awaiting || !awaiting[threadId]) return awaiting ?? {} + const next = { ...awaiting } + delete next[threadId] + return next +} diff --git a/src/renderer/src/store/chat-projection-reducer.ts b/src/renderer/src/store/chat-projection-reducer.ts index 4155a4ce8..eaf9b71d5 100644 --- a/src/renderer/src/store/chat-projection-reducer.ts +++ b/src/renderer/src/store/chat-projection-reducer.ts @@ -423,6 +423,7 @@ export function reduceChatProjection( createdAt: req.createdAt ?? new Date(context.now).toISOString(), requestId: req.requestId, questions: req.questions, + ...(req.timeoutSeconds !== undefined ? { timeoutSeconds: req.timeoutSeconds } : {}), status: 'pending', live: true }], diff --git a/src/renderer/src/store/chat-projection-reducer.user-input-timeout.test.ts b/src/renderer/src/store/chat-projection-reducer.user-input-timeout.test.ts new file mode 100644 index 000000000..359ca1a08 --- /dev/null +++ b/src/renderer/src/store/chat-projection-reducer.user-input-timeout.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeProjectionAction } from '../agent/runtime-projection-actions' +import type { ChatState } from './chat-store-types' +import { reduceChatProjection } from './chat-projection-reducer' + +const NOW = Date.parse('2026-07-11T00:00:00.000Z') +const context = { + now: NOW, + clearRecoveringError: (error: string | null) => error === 'recovering' ? null : error, + goalTimelineText: (goal: ChatState['activeThreadGoal'], cleared?: boolean) => + cleared || !goal ? 'Goal cleared' : `Goal ${goal.status}: ${goal.objective}`, + runtimeStatusText: () => 'Runtime status', + runtimeErrorView: (event: { message: string; code?: string }) => ({ + summary: `Summary: ${event.message}`, + message: event.message, + ...(event.code ? { code: event.code } : {}) + }), + upsertRuntimeError: (blocks: ChatState['blocks'], block: ChatState['blocks'][number]) => { + const index = blocks.findIndex((candidate) => candidate.id === block.id) + if (index < 0) return [...blocks, block] + const next = [...blocks] + next[index] = block + return next + }, + formatRuntimeError: (error: unknown) => error instanceof Error ? error.message : String(error), + runtimeErrorDetail: () => '', + isInterruptSettledError: () => false, + settlePendingRuntimeWork: (blocks: ChatState['blocks']) => blocks, + threadSnapshotLooksRunning: () => false +} + +function state(): ChatState { + return { + activeThreadId: 'thread_1', + blocks: [], + liveReasoning: '', + liveAssistant: '', + threads: [{ + id: 'thread_1', title: 'Thread', updatedAt: '2026-07-10T00:00:00.000Z', model: 'model', mode: 'agent' + }], + usageRefreshKey: 0, + error: null + } as unknown as ChatState +} + +function project( + initial: ChatState, + actions: RuntimeProjectionAction[] +): ChatState { + return actions.reduce( + (current, action) => ({ ...current, ...reduceChatProjection(current, action, context) }), + initial + ) +} + +describe('user_input timeout projection', () => { + it('carries timeoutSeconds onto the pending user-input block and settles timeout status', () => { + const projected = project(state(), [ + { + type: 'user_input_requested', + payload: { + itemId: 'input_item_timeout', + requestId: 'input_timeout', + createdAt: '2026-07-11T00:00:00.000Z', + timeoutSeconds: 30, + questions: [{ header: 'Input', id: 'input_timeout', question: 'Continue?', options: [] }] + } + } + ]) + expect(projected.blocks).toContainEqual(expect.objectContaining({ + kind: 'user_input', + id: 'input_item_timeout', + status: 'pending', + live: true, + timeoutSeconds: 30 + })) + + const settled = project(projected, [{ + type: 'user_input_status_changed', + payload: { itemId: 'input_item_timeout', status: 'timeout' } + }]) + expect(settled.blocks).toContainEqual(expect.objectContaining({ + kind: 'user_input', + id: 'input_item_timeout', + status: 'timeout' + })) + }) + + it('keeps timeoutSeconds when replaying a request over an existing live block', () => { + const request: RuntimeProjectionAction = { + type: 'user_input_requested', + payload: { + itemId: 'input_item_replay', + requestId: 'input_replay', + createdAt: '2026-07-11T00:00:00.000Z', + timeoutSeconds: 45, + questions: [{ header: 'Input', id: 'input_replay', question: 'Continue?', options: [] }] + } + } + const once = project(state(), [request]) + const twice = project(once, [request]) + expect(twice.blocks).toHaveLength(1) + expect(twice.blocks[0]).toMatchObject({ timeoutSeconds: 45, live: true }) + }) +}) diff --git a/src/renderer/src/store/chat-store-initial-state.ts b/src/renderer/src/store/chat-store-initial-state.ts index b322b3e92..337feee8d 100644 --- a/src/renderer/src/store/chat-store-initial-state.ts +++ b/src/renderer/src/store/chat-store-initial-state.ts @@ -78,6 +78,7 @@ export function createInitialChatStoreState(workingDirectoryLabel: string) { queuedMessages: [], extensionComposerContexts: [], watchTurnCompletion: {}, + awaitingUserInputThreadIds: {}, unreadThreadIds: readUnreadCompletions(), scheduledThreadActivities: {}, sideConversations: {}, diff --git a/src/renderer/src/store/chat-store-runtime-reconcile.ts b/src/renderer/src/store/chat-store-runtime-reconcile.ts new file mode 100644 index 000000000..fc53b7695 --- /dev/null +++ b/src/renderer/src/store/chat-store-runtime-reconcile.ts @@ -0,0 +1,81 @@ +import type { AgentProvider } from '../agent/provider-types' +import type { ChatState } from './chat-store-types' +import { reduceChatProjection } from './chat-projection-reducer' +import { hydrateBlockModelLabels } from './chat-store-helpers' +import { settlePendingRuntimeWorkAfterInterrupt, threadSnapshotLooksRunning } from './chat-store-runtime-helpers' +import { + clearRuntimeStreamRecoveringError, + isInterruptSettledError, + runtimeErrorDetail +} from './chat-store-runtime-notifications' +import { describeRuntimeError, formatRuntimeError } from '../lib/format-runtime-error' +import { + goalTimelineText, + runtimeErrorPayloadToError, + runtimeStatusText, + upsertRuntimeErrorBlock +} from './chat-store-runtime-projection-support' + +/** + * Re-fetch the settled thread detail after a terminal stream event so the + * projection matches the runtime's durable record (blocks, statuses, goal, + * todos) even when SSE delivery of the tail was interrupted. + */ +export async function reconcileCompletedTurnFromThreadDetail(input: { + threadId: string | null | undefined + turnId: string | null | undefined + userBlockId: string | null | undefined + loadThreadDetail: AgentProvider['getThreadDetail'] + set: (partial: Partial | ((state: ChatState) => Partial)) => void + get: () => ChatState +}): Promise { + const threadId = input.threadId?.trim() + if (!threadId) return + + try { + const { + blocks: rawBlocks, + latestSeq, + threadStatus, + latestTurnId, + latestTurnStatus, + goal, + todos + } = await input.loadThreadDetail(threadId) + const loaded = hydrateBlockModelLabels(threadId, rawBlocks) + + input.set((state) => reduceChatProjection(state, { + type: 'thread_snapshot_reconciled', + payload: { + threadId, + blocks: loaded, + latestSeq, + threadStatus, + latestTurnId, + latestTurnStatus, + goal, + todos, + turnId: input.turnId, + userBlockId: input.userBlockId + } + }, { + now: Date.now(), + clearRecoveringError: clearRuntimeStreamRecoveringError, + goalTimelineText, + runtimeStatusText, + runtimeErrorView: (event) => describeRuntimeError(runtimeErrorPayloadToError(event)), + upsertRuntimeError: upsertRuntimeErrorBlock, + formatRuntimeError, + runtimeErrorDetail, + isInterruptSettledError, + settlePendingRuntimeWork: settlePendingRuntimeWorkAfterInterrupt, + threadSnapshotLooksRunning + })) + } catch (error) { + if (typeof window === 'undefined') return + void window.kunGui?.logError?.('turn-completion-reconcile', 'Failed to reconcile completed turn', { + message: error instanceof Error ? error.message : String(error), + threadId + }).catch(() => undefined) + } +} diff --git a/src/renderer/src/store/chat-store-runtime.ts b/src/renderer/src/store/chat-store-runtime.ts index 96bb8adfd..a0fba75c8 100644 --- a/src/renderer/src/store/chat-store-runtime.ts +++ b/src/renderer/src/store/chat-store-runtime.ts @@ -27,6 +27,12 @@ import { isBackgroundShellNoticeUserMessage } from '@shared/background-shell-not import type { ChatState } from './chat-store-types' import { drainBackgroundQueuedMessage } from './chat-store-background-queue' import { isPendingQueuedMessage } from './queued-message-persistence' +import { + clearThreadAwaitingUserInput, + markThreadAwaitingUserInput, + withoutAwaitingUserInput +} from './awaiting-user-input-registry' +import { reconcileCompletedTurnFromThreadDetail } from './chat-store-runtime-reconcile' import { hydrateBlockModelLabels, isClawThread } from './chat-store-helpers' import { collectAssistantTextForTurn, @@ -292,65 +298,6 @@ export type ThreadEventSinkBinding = { getThreadDetail?: AgentProvider['getThreadDetail'] } -async function reconcileCompletedTurnFromThreadDetail(input: { - threadId: string | null | undefined - turnId: string | null | undefined - userBlockId: string | null | undefined - loadThreadDetail: AgentProvider['getThreadDetail'] - set: (partial: Partial | ((state: ChatState) => Partial)) => void - get: () => ChatState -}): Promise { - const threadId = input.threadId?.trim() - if (!threadId) return - - try { - const { - blocks: rawBlocks, - latestSeq, - threadStatus, - latestTurnId, - latestTurnStatus, - goal, - todos - } = await input.loadThreadDetail(threadId) - const loaded = hydrateBlockModelLabels(threadId, rawBlocks) - - input.set((state) => reduceChatProjection(state, { - type: 'thread_snapshot_reconciled', - payload: { - threadId, - blocks: loaded, - latestSeq, - threadStatus, - latestTurnId, - latestTurnStatus, - goal, - todos, - turnId: input.turnId, - userBlockId: input.userBlockId - } - }, { - now: Date.now(), - clearRecoveringError: clearRuntimeStreamRecoveringError, - goalTimelineText, - runtimeStatusText, - runtimeErrorView: (event) => describeRuntimeError(runtimeErrorPayloadToError(event)), - upsertRuntimeError: upsertRuntimeErrorBlock, - formatRuntimeError, - runtimeErrorDetail, - isInterruptSettledError, - settlePendingRuntimeWork: settlePendingRuntimeWorkAfterInterrupt, - threadSnapshotLooksRunning - })) - } catch (error) { - if (typeof window === 'undefined') return - void window.kunGui?.logError?.('turn-completion-reconcile', 'Failed to reconcile completed turn', { - message: error instanceof Error ? error.message : String(error), - threadId - }).catch(() => undefined) - } -} - export function buildThreadEventSink( set: (partial: Partial | ((state: ChatState) => Partial)) => void, get: () => ChatState, @@ -550,11 +497,13 @@ export function buildThreadEventSink( if (!isCurrentStream()) return resetBusyRecoveryAttempts() clearBusyWatchdog() + markThreadAwaitingUserInput(set, get, boundThreadId || get().activeThreadId) set((state) => reduce(state, { type: 'user_input_requested', payload: request })) }, onUserInputStatus: (event) => { if (!isCurrentStream()) return resetBusyRecoveryAttempts() + clearThreadAwaitingUserInput(set, get, boundThreadId || get().activeThreadId) if (event.status === 'submitted' && get().busy) armBusyWatchdog(set, get) set((state) => reduce(state, { type: 'user_input_status_changed', payload: event })) }, @@ -615,6 +564,10 @@ export function buildThreadEventSink( if (!completedThreadId) return patch return { ...patch, + awaitingUserInputThreadIds: withoutAwaitingUserInput( + state.awaitingUserInputThreadIds, + completedThreadId + ), unreadThreadIds: status === 'aborted' || completionIsCurrentlyVisible(state, completedThreadId) ? clearUnreadCompletion(state.unreadThreadIds, completedThreadId) : markUnreadCompletion(state.unreadThreadIds, completedThreadId) diff --git a/src/renderer/src/store/chat-store-side-runtime.ts b/src/renderer/src/store/chat-store-side-runtime.ts index 2c726495d..79cc0709a 100644 --- a/src/renderer/src/store/chat-store-side-runtime.ts +++ b/src/renderer/src/store/chat-store-side-runtime.ts @@ -467,6 +467,7 @@ function buildSideSink(sideId: string, ctx: SideContext, sinceSeq = 0): ThreadEv createdAt: req.createdAt ?? new Date().toISOString(), requestId: req.requestId, questions: req.questions, + ...(req.timeoutSeconds !== undefined ? { timeoutSeconds: req.timeoutSeconds } : {}), status: 'pending', live: true } diff --git a/src/renderer/src/store/chat-store-thread-selection-actions.ts b/src/renderer/src/store/chat-store-thread-selection-actions.ts index 3a82a1360..fcf417d1f 100644 --- a/src/renderer/src/store/chat-store-thread-selection-actions.ts +++ b/src/renderer/src/store/chat-store-thread-selection-actions.ts @@ -374,9 +374,21 @@ export function createThreadSelectionActions( turnId: latestTurnId, blocks }) + // Re-derive the awaiting-input marker from the runtime's pending gate so + // switching threads (or restarting) keeps the sidebar hint accurate. + const hasLivePendingUserInput = blocks.some( + (block) => block.kind === 'user_input' && block.status === 'pending' && block.live === true + ) set({ watchTurnCompletion: nextWatch, unreadThreadIds: nextUnread, + awaitingUserInputThreadIds: hasLivePendingUserInput + ? { ...get().awaitingUserInputThreadIds, [id]: true } + : (() => { + const next = { ...get().awaitingUserInputThreadIds } + delete next[id] + return next + })(), activeThreadId: id, threadLoadingId: null, threadHistoryCursor: historyCursor ?? null, diff --git a/src/renderer/src/store/chat-store-types.ts b/src/renderer/src/store/chat-store-types.ts index d7aaf920c..ae38aac52 100644 --- a/src/renderer/src/store/chat-store-types.ts +++ b/src/renderer/src/store/chat-store-types.ts @@ -425,6 +425,8 @@ export type ChatState = { /** Source-neutral, host-fenced context awaiting one main-chat turn. Legacy field name is persisted for compatibility. */ extensionComposerContexts: PendingComposerContextEvent[] watchTurnCompletion: Record + /** Threads whose live runtime is currently awaiting a user_input answer. */ + awaitingUserInputThreadIds: Record /** Completion attention keyed by thread. Legacy boolean true reads as completed. */ unreadThreadIds: CompletionAttentionRegistry scheduledThreadActivities: Record From c7a6c26fd43749e384c1b71910a9cf4e7bd138e2 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Fri, 21 Aug 2026 22:49:04 +0800 Subject: [PATCH 007/168] fix(composer): keep user model selection when switching threads A conversation's stored model selection could be silently reverted to the model used for the first message via three paths: - loadComposerModels overwrote a source:'user' stored selection with a fallback whenever the refreshed catalog temporarily lacked the model - draining a queued message rewrote the selection with the stale model captured at enqueue time, clobbering a newer picker change - composerSelectionForThread fell back to the first-sent thread model before the model catalog had loaded, flashing the wrong selection Now a user selection survives catalog refreshes and queue drains, and thread switching keeps it visible until the catalog is actually ready. --- ...-store-app-actions-model-switching.test.ts | 56 +++++ .../src/store/chat-store-app-actions.ts | 8 + .../chat-store-thread-action-helpers.test.ts | 65 +++++- .../store/chat-store-thread-action-helpers.ts | 24 ++- ...-store-thread-actions-model-memory.test.ts | 202 ++++++++++++++++++ .../store/chat-store-thread-actions.test.ts | 1 + .../store/chat-store-thread-send-direct.ts | 26 ++- 7 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/store/chat-store-thread-actions-model-memory.test.ts diff --git a/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts b/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts index e4a8ef392..627b31b6d 100644 --- a/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts +++ b/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts @@ -392,6 +392,62 @@ describe('chat-store app actions composer model loading', () => { expect(localStorage.getItem(COMPOSER_MODEL_STORAGE_KEY)).toBe('MiniMax-M2') }) + it('does not downgrade a user-selected per-thread model when the catalog refresh lacks it', async () => { + // The user explicitly picked k3 on thread-a; a catalog refresh that + // temporarily lacks k3 (partial load / upstream hiccup) must show the + // fallback transiently without overwriting the stored user selection. + localStorage.setItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY, JSON.stringify({ + 'thread-a': { model: 'k3', providerId: 'test-provider', source: 'user' } + })) + const { actions, state } = buildHarness({ + ok: true, + modelIds: ['terra'], + defaultModelId: 'terra', + modelGroups: [] + }) + state.route = 'chat' + state.activeThreadId = 'thread-a' + state.threads = [{ + id: 'thread-a', + title: 'Thread A', + workspace: '/tmp/project', + model: 'terra', + status: 'idle', + mode: 'agent', + updatedAt: '2026-06-01T00:00:00.000Z' + }] + state.blocks = [{ kind: 'user', id: 'user-1', text: 'first message on terra' }] as ChatState['blocks'] + + await actions.loadComposerModels() + + // Fallback shows transiently in state... + expect(state.composerModel).toBe('terra') + // ...but the stored user selection survives untouched. + expect(JSON.parse(localStorage.getItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) ?? '{}')).toEqual({ + 'thread-a': { model: 'k3', providerId: 'test-provider', source: 'user' } + }) + + // Once the catalog recovers k3, the user selection wins again. + const second = buildHarness({ + ok: true, + modelIds: ['terra', 'k3'], + defaultModelId: 'terra', + modelGroups: [{ + providerId: 'test-provider', + label: 'Test', + modelIds: ['terra', 'k3'] + }] + }) + second.state.route = 'chat' + second.state.activeThreadId = 'thread-a' + second.state.threads = [state.threads[0]] + second.state.blocks = [{ kind: 'user', id: 'user-1', text: 'first message on terra' }] as ChatState['blocks'] + + await second.actions.loadComposerModels() + + expect(second.state.composerModel).toBe('k3') + }) + it('records the return route only on first entry into settings', () => { const { actions, state } = buildHarness({ ok: true, diff --git a/src/renderer/src/store/chat-store-app-actions.ts b/src/renderer/src/store/chat-store-app-actions.ts index 5b851fd2e..c8f83786f 100644 --- a/src/renderer/src/store/chat-store-app-actions.ts +++ b/src/renderer/src/store/chat-store-app-actions.ts @@ -315,8 +315,16 @@ export function createAppActions(options: CreateAppActionsOptions): Pick< storedProviderId || providerIdForComposerModel(groups, model) if (!activeThread && providerId !== state.composerProviderId) persistComposerProviderId(providerId) + // A catalog refresh must never downgrade an explicit user + // selection to a fallback (e.g. the first-sent thread model) while + // the stored model is merely missing from a partially loaded list. + // The fallback may show transiently in state but stays unpersisted. + const downgradeOfUserSelection = + threadSelection?.source === 'user' && + threadSelection.model.trim().toLowerCase() !== model.trim().toLowerCase() if ( activeThread && + !downgradeOfUserSelection && (!threadSelection || threadSelection.model !== model || threadSelection.providerId !== providerId) && composerModelSelectable(pick, groups, model) ) { diff --git a/src/renderer/src/store/chat-store-thread-action-helpers.test.ts b/src/renderer/src/store/chat-store-thread-action-helpers.test.ts index c202649b2..25a9ab47a 100644 --- a/src/renderer/src/store/chat-store-thread-action-helpers.test.ts +++ b/src/renderer/src/store/chat-store-thread-action-helpers.test.ts @@ -1,7 +1,68 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentProvider, ThreadEventSink } from '../agent/types' import type { ChatState } from './chat-store-types' -import { subscribeThreadEventsWithRecovery } from './chat-store-thread-action-helpers' +import { + composerSelectionForThread, + subscribeThreadEventsWithRecovery +} from './chat-store-thread-action-helpers' +import { rememberThreadComposerSelection } from './chat-store-helpers' + +class MemoryStorage { + private readonly values = new Map() + getItem(key: string): string | null { + return this.values.get(key) ?? null + } + setItem(key: string, value: string): void { + this.values.set(key, value) + } +} + +describe('composerSelectionForThread', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', new MemoryStorage()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps a user-selected model before the model catalog has loaded', () => { + rememberThreadComposerSelection('thread-a', 'k3', 'test-provider', 'user') + const state = { + composerPickList: [], + composerModelGroups: [] + } as unknown as ChatState + + const selection = composerSelectionForThread(state, { id: 'thread-a', model: 'terra' }, { + hasUserMessages: true, + runtimeModel: 'terra' + }) + + // Catalog not ready yet: the explicit user selection wins instead of + // flashing back to the first-sent thread model. + expect(selection).toEqual({ model: 'k3', providerId: 'test-provider' }) + }) + + it('falls back to the thread model once the loaded catalog excludes the stored selection', () => { + rememberThreadComposerSelection('thread-b', 'k3', 'test-provider', 'user') + const state = { + composerPickList: ['terra'], + composerModelGroups: [{ + providerId: 'test-provider', + label: 'Test', + modelIds: ['terra'] + }] + } as unknown as ChatState + + const selection = composerSelectionForThread(state, { id: 'thread-b', model: 'terra' }, { + hasUserMessages: true, + runtimeModel: 'terra' + }) + + // Catalog is ready and genuinely lacks k3: existing fallback behavior. + expect(selection).toEqual({ model: 'terra', providerId: 'test-provider' }) + }) +}) describe('subscribeThreadEventsWithRecovery', () => { afterEach(() => { diff --git a/src/renderer/src/store/chat-store-thread-action-helpers.ts b/src/renderer/src/store/chat-store-thread-action-helpers.ts index b25dcf40f..1b93d85f7 100644 --- a/src/renderer/src/store/chat-store-thread-action-helpers.ts +++ b/src/renderer/src/store/chat-store-thread-action-helpers.ts @@ -50,18 +50,30 @@ export function composerSelectionForThread( stored?.source === 'user' || stored?.source === 'default' ) + // Before the catalog has loaded (empty pick list/groups), an explicit user + // selection is still trustworthy: returning it keeps the composer from + // flashing back to the first-sent thread model on every switch. Once the + // catalog is ready and really excludes the model, fall back as before. + const catalogLoaded = + pickList.length > 0 || state.composerModelGroups.length > 0 const model = storedShouldWin ? storedModel - : composerModelSelectable(pickList, state.composerModelGroups, threadModel) - ? threadModel - : storedSelectable - ? storedModel - : '' + : storedModel && stored?.source === 'user' && !catalogLoaded + ? storedModel + : composerModelSelectable(pickList, state.composerModelGroups, threadModel) + ? threadModel + : storedSelectable + ? storedModel + : '' if (!model) return null const usesStoredModel = storedModel.toLowerCase() === model.toLowerCase() + // With an unloaded catalog there is no group data to validate the stored + // providerId against; trusting it keeps the selection stable across the + // catalog load instead of clearing it to ''. const storedProviderId = stored && usesStoredModel && - providerIdMatchesComposerModel(state.composerModelGroups, stored.providerId, model) + (!catalogLoaded || + providerIdMatchesComposerModel(state.composerModelGroups, stored.providerId, model)) ? stored.providerId : '' return { diff --git a/src/renderer/src/store/chat-store-thread-actions-model-memory.test.ts b/src/renderer/src/store/chat-store-thread-actions-model-memory.test.ts new file mode 100644 index 000000000..aceabafed --- /dev/null +++ b/src/renderer/src/store/chat-store-thread-actions-model-memory.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NormalizedThread } from '../agent/types' +import type { ChatState, ChatStoreGet, ChatStoreSet } from './chat-store-types' +import { rendererRuntimeClient } from '../agent/runtime-client' +import { useGraphStore } from '../graph/graph-store' +import type { BrowserStorageLike } from '../lib/browser-storage' +import { clearThreadSnapshotCache } from './thread-snapshot-cache' +import { rememberThreadComposerSelection } from './chat-store-helpers' + +const registryMock = vi.hoisted(() => ({ getProvider: vi.fn() })) + +vi.mock('../agent/registry', () => ({ + getProvider: registryMock.getProvider +})) + +import { createThreadActions } from './chat-store-thread-actions' + +const THREAD_COMPOSER_SELECTION_STORAGE_KEY = 'kun.threadComposerSelection.v1' + +class MemoryStorage implements BrowserStorageLike { + private readonly values = new Map() + + getItem(key: string): string | null { + return this.values.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.values.set(key, value) + } +} + +function thread(id: string): NormalizedThread { + return { + id, + title: id, + updatedAt: '2026-06-09T00:00:00.000Z', + model: 'deepseek-v4-pro', + mode: 'agent', + workspace: '/workspace/deepseek-gui', + status: 'running' + } +} + +function buildHarness(): { + actions: ReturnType + state: ChatState +} { + let state: ChatState + state = { + activeThreadId: 'thr_existing', + blocks: [], + busy: true, + clawChannels: [], + codeWorkspaceRoots: [], + composerModel: '', + composerMode: 'agent', + composerOrchestration: 'direct', + composerProviderId: '', + currentTurnId: null, + currentTurnOrchestration: null, + currentTurnUserId: null, + error: 'previous error', + extensionComposerContexts: [], + lastSeq: 0, + loadComposerModels: vi.fn(async () => undefined), + queuedMessages: [], + recoverActiveTurn: vi.fn(async () => true), + refreshThreads: vi.fn(async () => undefined), + route: 'chat', + runtimeConnection: 'ready', + turnDurationByUserId: {}, + turnReasoningFirstAtByUserId: {}, + turnReasoningLastAtByUserId: {}, + turnStartedAtByUserId: {}, + threads: [thread('thr_existing')] + } as unknown as ChatState + + const set: ChatStoreSet = (partial) => { + const update = typeof partial === 'function' ? partial(state) : partial + Object.assign(state, update) + } + const get: ChatStoreGet = () => state + const actions = createThreadActions({ + set, + get, + sseAbortRef: { current: null } + }) + state.sendMessage = actions.sendMessage + return { actions, state } +} + +function stubRuntimeWindow(): void { + vi.stubGlobal('window', { + kunGui: { + getSettings: vi.fn(async () => ({ + agents: { kun: { providerId: 'deepseek', model: 'terra' } }, + codePromptPrefix: '', + chatWelcomeMessage: '' + })), + workspaceDirectoryExists: vi.fn(async () => true), + logError: vi.fn(async () => undefined) + } + }) +} + +function readStoredSelection(): Record { + return JSON.parse(localStorage.getItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) ?? '{}') +} + +describe('thread send model memory', () => { + beforeEach(() => { + clearThreadSnapshotCache() + vi.stubGlobal('localStorage', new MemoryStorage()) + rendererRuntimeClient.invalidateSettings() + registryMock.getProvider.mockReset() + registryMock.getProvider.mockReturnValue({}) + useGraphStore.setState({ + threadId: null, + runs: [], + selectedRunId: null, + selectedNodeId: null, + error: null + }) + }) + + afterEach(() => { + rendererRuntimeClient.invalidateSettings() + vi.unstubAllGlobals() + }) + + it('does not let a drained queued message overwrite a newer user model selection', async () => { + // A message enqueued while terra was selected keeps sending with terra, + // but draining it after the user switched to k3 must not rewrite the + // stored per-thread selection back to terra. + rememberThreadComposerSelection('thr_existing', 'k3', 'test-provider', 'user') + const sendUserMessage = vi.fn(async () => ({ + threadId: 'thr_existing', + turnId: 'turn_drain', + userMessageItemId: 'item_drain' + })) + registryMock.getProvider.mockReturnValue({ + sendUserMessage, + subscribeThreadEvents: vi.fn(async () => undefined) + }) + stubRuntimeWindow() + const { actions, state } = buildHarness() + state.busy = false + state.route = 'chat' + state.composerModel = 'k3' + state.composerProviderId = 'test-provider' + + const queued = { + id: 'q-drain-1', + text: 'queued while terra was selected', + mode: 'agent' as const, + deliveryState: 'starting' as const, + model: 'terra', + providerId: 'test-provider' + } + + await expect(actions.sendMessage(queued.text, queued.mode, { queued })) + .resolves.toBe(true) + + expect(sendUserMessage).toHaveBeenCalledWith( + 'thr_existing', + queued.text, + expect.objectContaining({ model: 'terra' }) + ) + expect(readStoredSelection()).toEqual({ + thr_existing: { model: 'k3', providerId: 'test-provider', source: 'user' } + }) + }) + + it('still records the sending model for a non-queued send', async () => { + const sendUserMessage = vi.fn(async () => ({ + threadId: 'thr_existing', + turnId: 'turn_direct', + userMessageItemId: 'item_direct' + })) + registryMock.getProvider.mockReturnValue({ + sendUserMessage, + subscribeThreadEvents: vi.fn(async () => undefined) + }) + stubRuntimeWindow() + const { actions, state } = buildHarness() + state.busy = false + state.route = 'chat' + state.composerModel = 'k3' + state.composerProviderId = 'test-provider' + + await expect(actions.sendMessage('direct send on k3', 'agent')).resolves.toBe(true) + + expect(sendUserMessage).toHaveBeenCalledWith( + 'thr_existing', + 'direct send on k3', + expect.objectContaining({ model: 'k3' }) + ) + expect(readStoredSelection()).toEqual({ + thr_existing: { model: 'k3', providerId: 'test-provider', source: 'user' } + }) + }) +}) diff --git a/src/renderer/src/store/chat-store-thread-actions.test.ts b/src/renderer/src/store/chat-store-thread-actions.test.ts index 03a970451..2c095f660 100644 --- a/src/renderer/src/store/chat-store-thread-actions.test.ts +++ b/src/renderer/src/store/chat-store-thread-actions.test.ts @@ -110,6 +110,7 @@ function expectSink(sink: ThreadEventSink | null): ThreadEventSink { describe('chat-store-thread-actions queued messages', () => { beforeEach(() => { clearThreadSnapshotCache() + vi.stubGlobal('localStorage', new MemoryStorage()) rendererRuntimeClient.invalidateSettings() registryMock.getProvider.mockReset() registryMock.getProvider.mockReturnValue({}) diff --git a/src/renderer/src/store/chat-store-thread-send-direct.ts b/src/renderer/src/store/chat-store-thread-send-direct.ts index 8ff4ba976..61cf16d46 100644 --- a/src/renderer/src/store/chat-store-thread-send-direct.ts +++ b/src/renderer/src/store/chat-store-thread-send-direct.ts @@ -8,6 +8,7 @@ import { runtimePromptForSurface } from './chat-store-send-prompt' import { currentTurnStartGeneration } from './turn-start-fence' import { activeClawChannel, + readThreadComposerSelection, rememberCodeWorkspaceRoots, rememberThreadComposerSelection, rememberTurnModel @@ -44,6 +45,23 @@ import { } from './chat-store-thread-actions-support' import type { PreparedThreadSend } from './chat-store-thread-send-direct-types' +/** + * A queued message freezes the model captured when it was enqueued. Draining + * that queue after the user already switched models must not write the stale + * capture back over the newer explicit user selection; only the user's own + * picker actions (or a non-queued send of the current selection) update it. + */ +function queuedModelWouldOverwriteUserSelection( + queued: PreparedThreadSend['queued'], + threadId: string, + sendingModel: string +): boolean { + if (!queued?.model?.trim()) return false + const stored = readThreadComposerSelection(threadId) + return stored?.source === 'user' && + stored.model.trim().toLowerCase() !== sendingModel.trim().toLowerCase() +} + export async function performPreparedThreadSend(input: PreparedThreadSend): Promise { let { activeThreadId, @@ -207,7 +225,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom throw new Error('Failed to resolve target thread id.') } activeThreadId = threadId - if (composerModel) { + if (composerModel && !queuedModelWouldOverwriteUserSelection(queued, threadId, composerModel)) { rememberThreadComposerSelection(threadId, composerModel, composerProviderId) } set((s) => ({ @@ -258,7 +276,11 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom try { const seqAtSend = get().lastSeq const channel = get().route === 'claw' ? activeClawChannel(get()) : null - if (!channel && composerModel) { + if ( + !channel && + composerModel && + !queuedModelWouldOverwriteUserSelection(queued, activeThreadId, composerModel) + ) { rememberThreadComposerSelection(activeThreadId, composerModel, composerProviderId) } await ensureRuntimeProviderForSend({ From b7fbdce27332153cb1ca442d9a1eea1a51cbdd07 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 00:06:25 +0800 Subject: [PATCH 008/168] fix(providers): send truly anonymous requests for OpenCore Free --- kun/src/adapters/model/compat-http-diagnostics.test.ts | 10 ++++++---- src/shared/app-settings-provider-runtime.ts | 10 +++++----- src/shared/app-settings-provider.runtime.test.ts | 8 ++++---- src/shared/model-provider-preset-types.ts | 3 --- src/shared/model-provider-presets.ts | 1 - 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/kun/src/adapters/model/compat-http-diagnostics.test.ts b/kun/src/adapters/model/compat-http-diagnostics.test.ts index c9adde360..71c33f0d0 100644 --- a/kun/src/adapters/model/compat-http-diagnostics.test.ts +++ b/kun/src/adapters/model/compat-http-diagnostics.test.ts @@ -16,10 +16,12 @@ describe('compat HTTP diagnostics', () => { }) }) - it('sends the OpenCode anonymous public credential as a bearer token', () => { - expect(buildCompatRequestHeaders({ - apiKey: 'public', stream: true, endpointFormat: 'chat_completions' - })).toMatchObject({ Authorization: 'Bearer public' }) + it('omits every auth header for an anonymous (empty-key) request', () => { + const headers = buildCompatRequestHeaders({ + apiKey: '', stream: true, endpointFormat: 'chat_completions' + }) + expect(headers).not.toHaveProperty('Authorization') + expect(headers).not.toHaveProperty('x-api-key') }) it('keeps provider guidance on 404 errors', async () => { diff --git a/src/shared/app-settings-provider-runtime.ts b/src/shared/app-settings-provider-runtime.ts index 28e8bdacc..935debdf3 100644 --- a/src/shared/app-settings-provider-runtime.ts +++ b/src/shared/app-settings-provider-runtime.ts @@ -72,7 +72,6 @@ import { CHATGPT_SUBSCRIPTION_NAME, CHATGPT_SUBSCRIPTION_PROVIDER_ID, GEMINI_SUBSCRIPTION_MODEL_IDS, - OPENCODE_ANONYMOUS_API_KEY, OPENCODE_FREE_PROVIDER_ID, TOKEN_PLAN_PROVIDER_ID_SUFFIX, getModelProviderPreset, @@ -213,11 +212,12 @@ export function resolveKunRuntimeSettings(settings: AppSettingsV1): KunRuntimeSe return { ...runtime, - // OpenCode Zen treats this literal as an anonymous request. It is derived - // only for the live transport: never persist it or fall back to a stale - // runtime key, which would unexpectedly leave the anonymous free tier. + // OpenCode Zen free models authenticate by omitting the credential header + // entirely; a placeholder bearer would be treated as a real (unknown) key + // and rejected with 401. Never fall back to a stale runtime key here — that + // would silently attach an unrelated DeepSeek credential to the free tier. apiKey: useOpenCodeAnonymousAccess - ? OPENCODE_ANONYMOUS_API_KEY + ? '' : useProviderCredentials ? provider.apiKey.trim() || runtimeApiKey : runtimeApiKey || provider.apiKey.trim(), diff --git a/src/shared/app-settings-provider.runtime.test.ts b/src/shared/app-settings-provider.runtime.test.ts index c3a7d0f52..e0148d5c2 100644 --- a/src/shared/app-settings-provider.runtime.test.ts +++ b/src/shared/app-settings-provider.runtime.test.ts @@ -28,7 +28,6 @@ import { CHATGPT_SUBSCRIPTION_MODEL_IDS, GROK_SUBSCRIPTION_PROVIDER_ID, OLLAMA_CLOUD_MODEL_IDS, - OPENCODE_ANONYMOUS_API_KEY, OPENCODE_FREE_PROVIDER_ID, listMusicGenerationProviderProfiles, listSpeechToTextProviderProfiles, @@ -58,7 +57,7 @@ import { import { settings } from './app-settings-provider.test-support' describe('model provider settings', () => { - it('uses the transient public key for a keyless OpenCore Free provider', () => { + it('resolves an empty key so keyless OpenCore Free requests stay anonymous', () => { const state = settings() const openCodeFree = state.provider.providers.find((provider) => provider.id === OPENCODE_FREE_PROVIDER_ID)! state.agents.kun.providerId = OPENCODE_FREE_PROVIDER_ID @@ -67,10 +66,11 @@ describe('model provider settings', () => { const runtime = resolveKunRuntimeSettings(state) expect(openCodeFree.apiKey).toBe('') - expect(runtime.apiKey).toBe(OPENCODE_ANONYMOUS_API_KEY) + // A stale runtime key must not leak into the anonymous free tier either. + expect(runtime.apiKey).toBe('') }) - it('uses a configured OpenCore Free key instead of the anonymous public key', () => { + it('uses a configured OpenCore Free key instead of staying anonymous', () => { const state = settings() state.provider.providers = state.provider.providers.map((provider) => provider.id === OPENCODE_FREE_PROVIDER_ID ? { ...provider, apiKey: 'sk-zen' } : provider diff --git a/src/shared/model-provider-preset-types.ts b/src/shared/model-provider-preset-types.ts index 413e4ed10..b18338616 100644 --- a/src/shared/model-provider-preset-types.ts +++ b/src/shared/model-provider-preset-types.ts @@ -82,9 +82,6 @@ export const OPENCODE_FREE_PROVIDER_ID = 'opencode-free' export const OPENCODE_FREE_PROVIDER_NAME = 'OpenCore Free' -/** Transient OpenCode Zen credential for anonymous free-tier requests. */ -export const OPENCODE_ANONYMOUS_API_KEY = 'public' - // Bootstrap snapshot from the OpenCode Zen catalog's zero-cost models. The // models.dev catalog remains authoritative and Settings imports newly added // free models without admitting paid ones. diff --git a/src/shared/model-provider-presets.ts b/src/shared/model-provider-presets.ts index a602f44e6..63efa8065 100644 --- a/src/shared/model-provider-presets.ts +++ b/src/shared/model-provider-presets.ts @@ -20,7 +20,6 @@ export { OLLAMA_CLOUD_MODEL_IDS, OLLAMA_CLOUD_PROVIDER_ID, OLLAMA_CLOUD_PROVIDER_NAME, - OPENCODE_ANONYMOUS_API_KEY, OPENCODE_FREE_MODEL_IDS, OPENCODE_FREE_PROVIDER_ID, OPENCODE_FREE_PROVIDER_NAME, From 20e72561823da04e66a5a2b3dd456bd4e5507a57 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 00:15:09 +0800 Subject: [PATCH 009/168] fix(UserMessageBubble): improve visibility of user message actions on hover --- .../chat/message-timeline-user-bubbles.tsx | 19 ++++++++++--------- .../styles/write-editor/markdown-preview.css | 4 ---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx b/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx index 7b9940cf5..ac0161d40 100644 --- a/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx +++ b/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx @@ -386,14 +386,16 @@ export function UserMessageBubble({ )} -
-
- -
-
+
+
+
+ + {block.modelLabel ? ( +
diff --git a/src/renderer/src/styles/write-editor/markdown-preview.css b/src/renderer/src/styles/write-editor/markdown-preview.css index cf5a76c06..44c3533e7 100644 --- a/src/renderer/src/styles/write-editor/markdown-preview.css +++ b/src/renderer/src/styles/write-editor/markdown-preview.css @@ -266,10 +266,6 @@ html[data-theme='dark'] .cm-write-md-code-block-html > pre span { max-width: min(94%, 520px); } -.write-assistant-panel .ds-user-message-footer { - min-height: 1.75rem; -} - .write-assistant-panel [data-work-meta-row='true'] { padding-block: 0.45rem; font-size: 12.5px; From ea9121421cd01056825c4316c38c41d19859eca8 Mon Sep 17 00:00:00 2001 From: Kun Date: Sat, 22 Aug 2026 00:33:41 +0800 Subject: [PATCH 010/168] fix(agent-ui): surface awaiting-input state across the chat UI A turn parked on a user_input gate kept showing generic running indicators, so users did not realize the agent was waiting for their answer. - shared hasLivePendingUserInput(blocks) helper (live pending only, not stale history records) - timeline live progress row swaps the thinking label/work logo for an amber pulsing awaiting label - SessionHeader and WorkbenchChatStage badges switch from Running to an amber awaiting badge with icon while awaiting - background threads fire one OS notification (deduped by input id) via the existing turn-complete notification channel - i18n keys awaitingYourInput / userInputNotificationTitle/Body in 7 locales --- src/renderer/src/components/SessionHeader.tsx | 16 +++- .../ConversationTurn.awaiting-input.test.ts | 92 +++++++++++++++++++ .../message-timeline-conversation-turn.tsx | 36 ++++++-- .../workbench/WorkbenchChatStage.tsx | 15 ++- .../src/locales/en/common/commands-sdd.json | 3 + .../src/locales/hi/common/commands-sdd.json | 3 + .../src/locales/ja/common/commands-sdd.json | 3 + .../src/locales/ko/common/commands-sdd.json | 3 + .../src/locales/ru/common/commands-sdd.json | 3 + .../src/locales/th/common/commands-sdd.json | 3 + .../src/locales/zh/common/commands-sdd.json | 3 + .../src/store/chat-store-runtime-helpers.ts | 12 +++ ...t-store-runtime-helpers.user-input.test.ts | 36 ++++++++ .../store/chat-store-runtime-notifications.ts | 40 ++++++++ src/renderer/src/store/chat-store-runtime.ts | 9 +- 15 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/chat/ConversationTurn.awaiting-input.test.ts create mode 100644 src/renderer/src/store/chat-store-runtime-helpers.user-input.test.ts diff --git a/src/renderer/src/components/SessionHeader.tsx b/src/renderer/src/components/SessionHeader.tsx index 789ef081d..a98a8ebbe 100644 --- a/src/renderer/src/components/SessionHeader.tsx +++ b/src/renderer/src/components/SessionHeader.tsx @@ -1,8 +1,9 @@ import type { ReactElement } from 'react' import { useCallback, useEffect, useState } from 'react' -import { ChevronRight, FileText, Folder, GitBranch, GitFork } from 'lucide-react' +import { ChevronRight, CircleHelp, FileText, Folder, GitBranch, GitFork } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useChatStore } from '../store/chat-store' +import { hasLivePendingUserInput } from '../store/chat-store-runtime-helpers' import { formatRelativeTime } from '../lib/format-relative-time' import { GIT_BRANCH_STATUS_CHANGED_EVENT } from '../lib/git-branch-status-event' import { middleEllipsize } from '../lib/middle-ellipsize' @@ -335,9 +336,16 @@ export function SessionHeader({ )} {busy ? ( - - {t('running')} - + hasLivePendingUserInput(blocks) ? ( + + + ) : ( + + {t('running')} + + ) ) : null} ) diff --git a/src/renderer/src/components/chat/ConversationTurn.awaiting-input.test.ts b/src/renderer/src/components/chat/ConversationTurn.awaiting-input.test.ts new file mode 100644 index 000000000..4029d3609 --- /dev/null +++ b/src/renderer/src/components/chat/ConversationTurn.awaiting-input.test.ts @@ -0,0 +1,92 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it } from 'vitest' +import type { ChatBlock } from '../../agent/types' +import i18n from '../../i18n' +import { useChatStore } from '../../store/chat-store' +import { ConversationTurn } from './MessageTimeline' +import type { Turn } from './message-timeline-turns' + +function renderTurn(turn: Turn, isProcessing: boolean): string { + return renderToStaticMarkup(createElement(ConversationTurn, { + turn, + isProcessing, + liveReasoning: '', + live: '', + filePreviewWorkspaceRoot: '/tmp/project', + viewportRef: { current: null } + })) +} + +describe('ConversationTurn awaiting-input progress row', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + useChatStore.setState({ + route: 'chat', + workspaceRoot: '/tmp/project', + activeThreadId: 'thr_1', + threads: [{ + id: 'thr_1', + title: 'Thread', + updatedAt: '2026-08-21T00:00:00.000Z', + model: 'deepseek-chat', + mode: 'code', + workspace: '/tmp/project' + }], + busy: false, + currentTurnUserId: null, + turnStartedAtByUserId: {}, + turnDurationByUserId: {}, + turnReasoningFirstAtByUserId: {}, + turnReasoningLastAtByUserId: {}, + clawChannels: [], + activeClawChannelId: '' + }) + }) + + it('shows the awaiting label instead of thinking while a live user_input is pending', () => { + const html = renderTurn( + { + turnId: 'turn_1', + user: { kind: 'user', id: 'user_1', text: 'Continue' }, + blocks: [ + { + kind: 'user_input', + id: 'ui_1', + requestId: 'input_1', + status: 'pending', + live: true, + questions: [ + { header: 'Input', id: 'q1', question: 'Pick one', options: [] } + ] + } as ChatBlock + ] + }, + true + ) + expect(html).toContain('Awaiting your input') + expect(html).not.toContain('Thinking') + }) + + it('keeps the generic progress label for a stale pending request from history', () => { + const html = renderTurn( + { + turnId: 'turn_2', + user: { kind: 'user', id: 'user_2', text: 'Old thread' }, + blocks: [ + { + kind: 'user_input', + id: 'ui_2', + requestId: 'input_2', + status: 'pending', + questions: [ + { header: 'Input', id: 'q2', question: 'Pick one', options: [] } + ] + } as ChatBlock + ] + }, + true + ) + expect(html).not.toContain('Awaiting your input') + }) +}) diff --git a/src/renderer/src/components/chat/message-timeline-conversation-turn.tsx b/src/renderer/src/components/chat/message-timeline-conversation-turn.tsx index 0f2d183f8..c8382c71f 100644 --- a/src/renderer/src/components/chat/message-timeline-conversation-turn.tsx +++ b/src/renderer/src/components/chat/message-timeline-conversation-turn.tsx @@ -30,6 +30,8 @@ import type { PlanBuildOrchestration } from '../../plan/plan-build' import { TimelineRuntimeError, liveTurnProgressClass } from './message-timeline-jump-preview' import type { TurnUsageSummary } from '../../hooks/use-turn-usage' import { TurnUsageRow } from './TurnUsageRow' +import { hasLivePendingUserInput } from '../../store/chat-store-runtime-helpers' +import { CircleHelp } from 'lucide-react' export type ConversationTurnProps = { turn: Turn @@ -212,6 +214,9 @@ export function ConversationTurn({ updatedAt: activity.updatedAt ?? '' }) }, [liveToolBlock]) + // A live user_input gate means the turn is parked waiting for the user, not + // computing. Surface that instead of the generic "thinking" label. + const awaitingUserInput = isProcessing && hasLivePendingUserInput(turn.blocks) const showLiveThinking = Boolean(liveProcessText.trim()) && !liveChildActivityLabel && !liveToolBlock const forkFromTurn = async (): Promise => { if (!allowMainThreadActions || !forkTurnId || forking) return @@ -397,6 +402,7 @@ export function ConversationTurn({ tool={liveToolBlock} thinking={showLiveThinking} activityLabel={liveChildActivityLabel} + awaitingUserInput={awaitingUserInput} /> ) : null} @@ -406,11 +412,13 @@ export function ConversationTurn({ function LiveTurnProgressRow({ tool, thinking, - activityLabel + activityLabel, + awaitingUserInput = false }: { tool?: Extract thinking: boolean activityLabel?: string + awaitingUserInput?: boolean }): ReactElement { const { t, i18n } = useTranslation('common') const swimMode = useWorkLogoSwimMode(true) @@ -427,7 +435,9 @@ function LiveTurnProgressRow({ swimLabelKey as UiPluginLabelKey, i18n.language ?? 'zh' ) - const label = activityLabel + const label = awaitingUserInput + ? t('awaitingYourInput') + : activityLabel ? t('workingToolAction', { action: activityLabel }) : thinking ? t('thinkingNow') @@ -442,6 +452,7 @@ function LiveTurnProgressRow({ label={label} ikunVariant={ikunVariant} swimMode={swimMode} + awaitingUserInput={awaitingUserInput} /> ) } @@ -449,18 +460,31 @@ function LiveTurnProgressRow({ function LiveTurnActivityRow({ label, ikunVariant, - swimMode + swimMode, + awaitingUserInput = false }: { label: string ikunVariant?: IkunWorkLogoVariant swimMode?: WorkLogoSwimMode + awaitingUserInput?: boolean }): ReactElement { return (
- - + {awaitingUserInput ? ( + + ) : ( + + + + )} + + {label} - {label}
) } diff --git a/src/renderer/src/components/workbench/WorkbenchChatStage.tsx b/src/renderer/src/components/workbench/WorkbenchChatStage.tsx index bff4fc4aa..e80b9feeb 100644 --- a/src/renderer/src/components/workbench/WorkbenchChatStage.tsx +++ b/src/renderer/src/components/workbench/WorkbenchChatStage.tsx @@ -27,7 +27,9 @@ import type { RegisteredContribution } from '../../extensions/contribution-regis import { DeclarativeActionBar } from '../../extensions/ControlledContributionSurfaces' import type { PlanBuildOrchestration } from '../../plan/plan-build' import { useChatStore } from '../../store/chat-store' +import { hasLivePendingUserInput } from '../../store/chat-store-runtime-helpers' import { shouldUseEmptyTaskLayout } from './workbench-chat-layout' +import { CircleHelp } from 'lucide-react' const TerminalPanel = lazy(() => import('../terminal/TerminalPanel').then((module) => ({ default: module.TerminalPanel })) @@ -222,9 +224,16 @@ export function WorkbenchChatStage({ /> ) : null} {busy ? ( - - {t('running')} - + hasLivePendingUserInput(blocks) ? ( + + + ) : ( + + {t('running')} + + ) ) : null} block.kind === 'user_input' && block.status === 'pending' && block.live === true + ) +} + export function isDetachedSubagentToolBlock(block: ChatBlock): boolean { if (block.kind !== 'tool') return false const child = block.meta?.child diff --git a/src/renderer/src/store/chat-store-runtime-helpers.user-input.test.ts b/src/renderer/src/store/chat-store-runtime-helpers.user-input.test.ts new file mode 100644 index 000000000..06950f820 --- /dev/null +++ b/src/renderer/src/store/chat-store-runtime-helpers.user-input.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { ChatBlock } from '../agent/types' +import { hasLivePendingUserInput } from './chat-store-runtime-helpers' + +function userInputBlock(overrides: Partial>): ChatBlock { + return { + kind: 'user_input', + id: 'ui_1', + requestId: 'input_1', + status: 'pending', + live: true, + questions: [], + ...overrides + } +} + +describe('hasLivePendingUserInput', () => { + it('is true for a live pending user_input block', () => { + expect(hasLivePendingUserInput([userInputBlock({})])).toBe(true) + }) + + it('is false for a stale pending record rehydrated from history', () => { + expect(hasLivePendingUserInput([userInputBlock({ live: false })])).toBe(false) + expect(hasLivePendingUserInput([userInputBlock({ live: undefined })])).toBe(false) + }) + + it('is false once the request settles or for unrelated blocks', () => { + expect(hasLivePendingUserInput([userInputBlock({ status: 'submitted' })])).toBe(false) + expect( + hasLivePendingUserInput([ + { kind: 'assistant', id: 'a_1', text: 'hi', status: 'success' } as ChatBlock + ]) + ).toBe(false) + expect(hasLivePendingUserInput([])).toBe(false) + }) +}) diff --git a/src/renderer/src/store/chat-store-runtime-notifications.ts b/src/renderer/src/store/chat-store-runtime-notifications.ts index f406c4450..0f7919510 100644 --- a/src/renderer/src/store/chat-store-runtime-notifications.ts +++ b/src/renderer/src/store/chat-store-runtime-notifications.ts @@ -388,6 +388,46 @@ export function notifyTurnComplete( }) } +/** + * Alert the user that a turn is parked on a user_input gate. Fires only when + * the asking thread is not the one currently visible (e.g. a background watch + * or after switching away); the asking thread itself shows the composer panel, + * the awaiting progress row, and the top-bar badge instead. + */ +export function notifyUserInputAwaiting( + threadId: string | null, + state: ChatState, + dedupeKey: string +): void { + if ( + !threadId || + typeof window === 'undefined' || + typeof window.kunGui?.showTurnCompleteNotification !== 'function' + ) { + return + } + if (!rememberCompletionNotificationKey(dedupeKey)) return + + const threadTitle = + state.threads.find((thread) => thread.id === threadId)?.title?.trim() || + i18n.t('common:untitledThread') + + void window.kunGui + .showTurnCompleteNotification({ + threadId, + source: turnCompleteNotificationSource(threadId, state), + title: i18n.t('common:userInputNotificationTitle'), + body: i18n.t('common:userInputNotificationBody', { title: threadTitle }) + }) + .catch((error: unknown) => { + if (typeof window.kunGui?.logError !== 'function') return + void window.kunGui.logError('notification', 'User-input awaiting notification failed', { + message: error instanceof Error ? error.message : String(error), + threadId + }).catch(() => undefined) + }) +} + /** * Release the worktree pool slot owned by a thread when the task completes. * This makes worktree slots task-scoped (like Talkcody) rather than diff --git a/src/renderer/src/store/chat-store-runtime.ts b/src/renderer/src/store/chat-store-runtime.ts index a0fba75c8..5244693e3 100644 --- a/src/renderer/src/store/chat-store-runtime.ts +++ b/src/renderer/src/store/chat-store-runtime.ts @@ -96,6 +96,7 @@ import { completionNotificationDedupeKeyForWatchedThread, isInterruptSettledError, notifyTurnComplete, + notifyUserInputAwaiting, runtimeErrorDetail, takePendingClawFeishuMirror, watchTurnCompletionNotification, @@ -497,7 +498,13 @@ export function buildThreadEventSink( if (!isCurrentStream()) return resetBusyRecoveryAttempts() clearBusyWatchdog() - markThreadAwaitingUserInput(set, get, boundThreadId || get().activeThreadId) + const awaitingThreadId = boundThreadId || get().activeThreadId + markThreadAwaitingUserInput(set, get, awaitingThreadId) + // Only notify when the asking thread is not on screen; the visible thread + // already shows the composer panel, awaiting progress row, and badge. + if (awaitingThreadId && awaitingThreadId !== get().activeThreadId) { + notifyUserInputAwaiting(awaitingThreadId, get(), `user-input:${request.requestId}`) + } set((state) => reduce(state, { type: 'user_input_requested', payload: request })) }, onUserInputStatus: (event) => { From 23dd2c433c4ab3f317f2b62d6f1309ea13862069 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 00:48:05 +0800 Subject: [PATCH 011/168] fix(runtime): preserve route-pool capability probes through the timing decorator Object-spread wrapping in withModelTiming dropped every prototype member of the wrapped ModelClient, including selectsRouteTargetDuringStream and the model getter. The agent loop then froze the public route-gateway alias (route-gateway:local/kk) as the acting route, and onRouteSelected failed the whole turn when the pool resolved a concrete target. Restore prototype delegation so only stream is decorated, and accept a late alias-to-target resolution in onRouteSelected as a defensive fallback. --- .../loop/model-step-preparation-helpers.ts | 19 +++++ kun/src/loop/model-step-service.ts | 23 +++--- kun/src/loop/model-timing-decorator.test.ts | 75 +++++++++++++++++++ kun/src/loop/model-timing-decorator.ts | 17 +++-- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/kun/src/loop/model-step-preparation-helpers.ts b/kun/src/loop/model-step-preparation-helpers.ts index 21c7024bc..9686ec0bd 100644 --- a/kun/src/loop/model-step-preparation-helpers.ts +++ b/kun/src/loop/model-step-preparation-helpers.ts @@ -1,5 +1,7 @@ import type { ActingTurnModelRoute, Turn } from '../contracts/turns.js' import type { TurnItem } from '../contracts/items.js' +import type { ModelRouteTargetMetadata } from '../ports/model-client.js' +import { LOCAL_MODEL_GATEWAY_PROVIDER_ID } from '../contracts/model-route-pool.js' import type { PptWorkflowScope } from '../ports/tool-host.js' import type { KunTurnContextAuthority, @@ -121,6 +123,23 @@ export function sameActingModelRoute( a.accountId === b.accountId } +/** + * True when the frozen acting route is still the public alias of a local + * model-route pool and the stream resolved one of that pool's concrete + * targets. The alias was frozen only because deferral was missed, so the + * resolution must be accepted instead of failing the turn. + */ +export function isPoolAliasActingRoute( + frozen: ActingTurnModelRoute, + route: ModelRouteTargetMetadata +): boolean { + const frozenProvider = frozen.providerId?.trim().toLowerCase() + const aliasMatch = frozen.model.trim().toLowerCase() === route.requestedModelId.trim().toLowerCase() + const gatewayMatch = frozenProvider === LOCAL_MODEL_GATEWAY_PROVIDER_ID + const poolProviderMatch = frozenProvider === `route-pool:${route.routePoolId}`.toLowerCase() + return aliasMatch && (gatewayMatch || poolProviderMatch) +} + export function modelHistoryRoutesByTurnId( thread: import('../contracts/threads.js').ThreadRecord, currentRoute: ActingTurnModelRoute, diff --git a/kun/src/loop/model-step-service.ts b/kun/src/loop/model-step-service.ts index 7ee8b8e77..6d72a425b 100644 --- a/kun/src/loop/model-step-service.ts +++ b/kun/src/loop/model-step-service.ts @@ -107,7 +107,7 @@ import { rewriteItemHistoryWithRetry } from '../services/history-commit-coordina import { TurnToolCatalogFreezer } from './turn-tool-catalog.js' import { ModelStepPreparationService } from './model-step-preparation-service.js' import type { ModelStepServiceDeps } from './model-step-service-types.js' -import { sameActingModelRoute } from './model-step-preparation-helpers.js' +import { isPoolAliasActingRoute, sameActingModelRoute } from './model-step-preparation-helpers.js' import { composeForwardedModelRequest } from './forwarded-model-request.js' export type { ModelStepServiceDeps } from './model-step-service-types.js' export { buildExtensionProfileInstruction } from './model-step-preparation-helpers.js' @@ -560,21 +560,26 @@ export class ModelStepService extends ModelStepPreparationService { providerId: route.providerId, ...(routeAccountId ? { accountId: routeAccountId } : {}) } - if (!routeSelectionDeferred) { - if (!sameActingModelRoute(actingModelRoute, resolved)) { + if (!routeSelectionDeferred && !sameActingModelRoute(actingModelRoute, resolved)) { + // A frozen local-gateway alias can still resolve mid-stream to one + // of its pool targets (for example when a wrapper hid + // selectsRouteTargetDuringStream). Accept the late resolution and + // pin the concrete target instead of failing the whole turn. + if (!isPoolAliasActingRoute(actingModelRoute, route)) { throw new Error( 'model route changed after the acting route was frozen: ' + `${actingModelRoute.providerId ?? 'default'}/${actingModelRoute.model} -> ` + `${resolved.providerId ?? 'default'}/${resolved.model}` ) } - return } - effectiveActingModelRoute = resolved - streamRouteResolved = true - await this.deps.turns.updateTurnMetadata(threadId, turnId, { - actingModelRoute: resolved - }) + if (routeSelectionDeferred || !sameActingModelRoute(actingModelRoute, resolved)) { + effectiveActingModelRoute = resolved + streamRouteResolved = true + await this.deps.turns.updateTurnMetadata(threadId, turnId, { + actingModelRoute: resolved + }) + } }, writeGeneratedImage: async ({ imageBase64 }) => { await this.ensureWorkspaceCheckpoint( diff --git a/kun/src/loop/model-timing-decorator.test.ts b/kun/src/loop/model-timing-decorator.test.ts index 34cf22233..b6998f0c3 100644 --- a/kun/src/loop/model-timing-decorator.test.ts +++ b/kun/src/loop/model-timing-decorator.test.ts @@ -1,8 +1,27 @@ import { describe, expect, it } from 'vitest' import type { ModelClient, ModelStreamChunk } from '../ports/model-client.js' import { emptyUsageSnapshot } from '../contracts/usage.js' +import { LOCAL_MODEL_GATEWAY_PROVIDER_ID } from '../contracts/model-route-pool.js' +import { isPoolAliasActingRoute } from './model-step-preparation-helpers.js' import { withModelTiming } from './model-timing-decorator.js' +class FakeRoutedClient implements ModelClient { + readonly provider = 'route-pool' + constructor( + private readonly chunks: ModelStreamChunk[], + private readonly clock: { value: number } + ) {} + get model(): string { return 'alias-model' } + selectsRouteTargetDuringStream(): boolean { return true } + routePools(): Array<{ id: string }> { return [{ id: 'pool-1' }] } + async *stream(): AsyncIterable { + for (const chunk of this.chunks) { + this.clock.value += 250 + yield chunk + } + } +} + function makeClient(chunks: ModelStreamChunk[], clock: { value: number }): ModelClient { return { provider: 'test', @@ -116,4 +135,60 @@ describe('withModelTiming', () => { const usage = chunks.find((chunk) => chunk.kind === 'usage') expect(usage?.route).toEqual({ routePoolId: 'p', targetId: 'x', providerId: 'prov', modelId: 'm', requestedModelId: 'alias' }) }) + + it('preserves prototype methods, accessors, and the timing wrapper on class-based clients', async () => { + const clock = { value: 0 } + const client = withModelTiming(new FakeRoutedClient([ + { kind: 'assistant_text_delta', text: 'a' }, + usageChunk(), + { kind: 'completed', stopReason: 'stop' } + ], clock), { now: () => clock.value }) + + // Regression for route pools frozen under their public alias: object + // spread dropped every prototype member, so these probes vanished. + expect(client.selectsRouteTargetDuringStream?.({ model: 'alias-model', providerId: LOCAL_MODEL_GATEWAY_PROVIDER_ID })).toBe(true) + expect(client.model).toBe('alias-model') + expect(client.provider).toBe('route-pool') + expect((client as unknown as FakeRoutedClient).routePools()).toEqual([{ id: 'pool-1' }]) + + const chunks = await drain(client.stream({ + threadId: 't', turnId: 'turn', model: 'alias-model', prefix: [], history: [], + tools: [], abortSignal: new AbortController().signal + })) + const usage = chunks.find((chunk) => chunk.kind === 'usage') + if (usage && usage.kind === 'usage') { + expect(usage.usage.requestTtftMs).toBe(250) + expect(usage.usage.requestGenerationMs).toBe(250) + } + }) +}) + +describe('isPoolAliasActingRoute', () => { + const target = { + routePoolId: 'pool-1', + targetId: 'target-2', + providerId: 'kimi', + modelId: 'kimi-k3', + requestedModelId: 'kk' + } + + it('accepts a local-gateway alias resolving to a pool target', () => { + expect(isPoolAliasActingRoute( + { model: 'kk', providerId: LOCAL_MODEL_GATEWAY_PROVIDER_ID }, + target + )).toBe(true) + }) + + it('accepts a route-pool provider alias resolving to its own pool target', () => { + expect(isPoolAliasActingRoute( + { model: 'kk', providerId: 'Route-Pool:pool-1' }, + target + )).toBe(true) + }) + + it('rejects a concrete frozen route, another pool, or a different alias', () => { + expect(isPoolAliasActingRoute({ model: 'kimi-k3', providerId: 'kimi' }, target)).toBe(false) + expect(isPoolAliasActingRoute({ model: 'kk', providerId: 'route-pool:other' }, target)).toBe(false) + expect(isPoolAliasActingRoute({ model: 'other-alias', providerId: LOCAL_MODEL_GATEWAY_PROVIDER_ID }, target)).toBe(false) + }) }) diff --git a/kun/src/loop/model-timing-decorator.ts b/kun/src/loop/model-timing-decorator.ts index b2c1d9dbc..246f1ff44 100644 --- a/kun/src/loop/model-timing-decorator.ts +++ b/kun/src/loop/model-timing-decorator.ts @@ -31,18 +31,23 @@ function isContentChunk(chunk: ModelStreamChunk): boolean { * The wrapper never modifies the underlying provider parsing; it only * clones the usage snapshot to attach timing. Streams without a usage * chunk (or without any content chunk) pass through unchanged. + * + * The wrapper must preserve the wrapped client's prototype so callers keep + * seeing optional capability probes such as `selectsRouteTargetDuringStream` + * and accessors like `model`. Object spread would drop every prototype + * member and make route pools freeze their public alias as the acting route + * ("model route changed after the acting route was frozen"). */ export function withModelTiming( client: ModelClient, options: { now?: () => number } = {} ): ModelClient { const now = options.now ?? ((): number => performance.now()) - return { - ...client, - stream(request: ModelRequest): AsyncIterable { - return timedStream(client.stream(request), now) - } - } + const wrapped = Object.create(Object.getPrototypeOf(client)) as ModelClient + Object.assign(wrapped, client) + wrapped.stream = (request: ModelRequest): AsyncIterable => + timedStream(client.stream(request), now) + return wrapped } async function* timedStream( From c47208ff3b581b3fed702296e20667ccea1efdc1 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 02:17:51 +0800 Subject: [PATCH 012/168] fix(runtime): stop SSE 404 dead-ends and event-loop stalls on large thread logs - SSE bridge retries thread-events 404 with bounded backoff (750ms x3) before surfacing a terminal threadMissing error, closing the create-vs-subscribe race that stranded empty transcripts. - highestSeq scans the events.jsonl tail backwards instead of stream-parsing the whole log on every cache miss (#621 family); malformed tails fall back to the full forward scan. - Slow non-streaming route handling now logs method+path over 500ms. - Health probe budget for ensure matches the watchdog (2s -> 5s). - Workflow webhook /workflow/run caps concurrent synchronous runs. --- .../file/file-session-seq-tail-scan.test.ts | 90 ++++++++++++ .../file/file-session-seq-tail-scan.ts | 132 ++++++++++++++++++ kun/src/adapters/file/file-session-store.ts | 42 +++--- kun/src/server/http-server.ts | 20 ++- src/main/main-runtime-startup.ts | 5 +- src/main/runtime-sse-ipc.test.ts | 69 +++++++++ src/main/runtime-sse-ipc.ts | 22 ++- src/main/workflow-webhook-server.ts | 17 ++- 8 files changed, 372 insertions(+), 25 deletions(-) create mode 100644 kun/src/adapters/file/file-session-seq-tail-scan.test.ts create mode 100644 kun/src/adapters/file/file-session-seq-tail-scan.ts diff --git a/kun/src/adapters/file/file-session-seq-tail-scan.test.ts b/kun/src/adapters/file/file-session-seq-tail-scan.test.ts new file mode 100644 index 000000000..4d33decff --- /dev/null +++ b/kun/src/adapters/file/file-session-seq-tail-scan.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { scanHighestSeqFromTail } from './file-session-seq-tail-scan.js' + +let dir: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'kun-seq-tail-')) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +function eventLine(seq: number, kind = 'item_created'): string { + return JSON.stringify({ kind, threadId: 'thr_test', seq, timestamp: '2026-01-01T00:00:00Z' }) +} + +describe('scanHighestSeqFromTail', () => { + it('returns the highest seq from a small single-chunk file', async () => { + const path = join(dir, 'events.jsonl') + const contents = [eventLine(1), eventLine(2), eventLine(3)].join('\n') + '\n' + await writeFile(path, contents) + const result = await scanHighestSeqFromTail({ path, fileSize: Buffer.byteLength(contents) }) + expect(result).toEqual({ ok: true, highestSeq: 3 }) + }) + + it('scans across multiple chunks without re-counting shared lines', async () => { + const path = join(dir, 'events.jsonl') + const lines: string[] = [] + for (let seq = 1; seq <= 400; seq += 1) lines.push(eventLine(seq)) + const contents = lines.join('\n') + '\n' + await writeFile(path, contents) + const result = await scanHighestSeqFromTail({ + path, + fileSize: Buffer.byteLength(contents), + chunkBytes: 1_024 + }) + expect(result).toEqual({ ok: true, highestSeq: 400 }) + }) + + it('ignores a trailing partial line from a concurrent append', async () => { + const path = join(dir, 'events.jsonl') + const complete = [eventLine(10), eventLine(11)].join('\n') + '\n' + const torn = '{"kind":"item_created","threadId":"thr_test","seq":12' + await writeFile(path, complete + torn) + const result = await scanHighestSeqFromTail({ + path, + fileSize: Buffer.byteLength(complete + torn) + }) + expect(result).toEqual({ ok: true, highestSeq: 12 }) + }) + + it('degrades to malformed-tail for corrupt lines', async () => { + const path = join(dir, 'events.jsonl') + const contents = eventLine(1) + '\nnot-json\n' + await writeFile(path, contents) + const result = await scanHighestSeqFromTail({ path, fileSize: Buffer.byteLength(contents) }) + expect(result).toEqual({ ok: false, reason: 'malformed-tail' }) + }) + + it('returns zero for an empty file without opening it', async () => { + const result = await scanHighestSeqFromTail({ path: join(dir, 'absent.jsonl'), fileSize: 0 }) + expect(result).toEqual({ ok: true, highestSeq: 0 }) + }) + + it('handles a file whose first line lacks a trailing newline', async () => { + const path = join(dir, 'events.jsonl') + const contents = [eventLine(1), eventLine(2)].join('\n') + await writeFile(path, contents) + const result = await scanHighestSeqFromTail({ path, fileSize: Buffer.byteLength(contents) }) + expect(result).toEqual({ ok: true, highestSeq: 2 }) + }) + + it('stops early once the line budget is reached', async () => { + const path = join(dir, 'events.jsonl') + const lines: string[] = [] + for (let seq = 1; seq <= 100; seq += 1) lines.push(eventLine(seq)) + const contents = lines.join('\n') + '\n' + await writeFile(path, contents) + const result = await scanHighestSeqFromTail({ + path, + fileSize: Buffer.byteLength(contents), + maxLines: 10 + }) + expect(result).toEqual({ ok: true, highestSeq: 100 }) + }) +}) diff --git a/kun/src/adapters/file/file-session-seq-tail-scan.ts b/kun/src/adapters/file/file-session-seq-tail-scan.ts new file mode 100644 index 000000000..22fb3bb36 --- /dev/null +++ b/kun/src/adapters/file/file-session-seq-tail-scan.ts @@ -0,0 +1,132 @@ +import { open, type FileHandle } from 'node:fs/promises' + +/** + * Fast tail scan for the highest persisted event seq. + * + * `FileSessionStore.highestSeq()` used to stream-parse the whole + * `events.jsonl` whenever its size/mtime cache missed — which happens on + * every concurrent append — stalling the single-threaded runtime for large + * logs (#621 family). Events are appended in seq order, so scanning the + * file tail backwards and taking the max seq of complete lines near the end + * is equivalent for healthy logs. + * + * A complete interior line without a readable seq degrades to + * `{ ok: false }` so the caller falls back to a full forward scan; a torn + * unterminated file-end fragment is parsed best-effort and otherwise + * ignored, mirroring the forward parser's silent remainder skip. + */ +const DEFAULT_TAIL_SCAN_CHUNK_BYTES = 64 * 1024 +const DEFAULT_TAIL_SCAN_MAX_CHUNK_BYTES = 1024 * 1024 + +export type TailScanResult = + | { ok: true; highestSeq: number } + | { ok: false; reason: 'handle-unavailable' | 'short-file' | 'malformed-tail' } + +/** + * Read newline-terminated lines from the end of a JSONL file backwards and + * return the highest `seq` among them. + * + * Chunk boundaries split lines on both sides. `carry` holds the end-fragment + * of a line whose beginning lies in the next-older chunk: chunk text is + * older, so `text + carry` restores file order and the final split element + * is a complete line. Only the very first (newest) read can end with a torn + * in-flight append after its last newline; that fragment never becomes a + * carry and is never treated as malformed. + */ +export async function scanHighestSeqFromTail(options: { + path: string + fileSize: number + chunkBytes?: number + maxChunkBytes?: number + maxLines?: number +}): Promise { + const chunkBytes = Math.min( + options.chunkBytes ?? DEFAULT_TAIL_SCAN_CHUNK_BYTES, + options.maxChunkBytes ?? DEFAULT_TAIL_SCAN_MAX_CHUNK_BYTES + ) + if (options.fileSize <= 0) return { ok: true, highestSeq: 0 } + let handle: FileHandle + try { + handle = await open(options.path, 'r') + } catch { + return { ok: false, reason: 'handle-unavailable' } + } + try { + let highest = 0 + let linesSeen = 0 + let budgetExhausted = false + const maxLines = options.maxLines ?? 1_024 + const observe = (seq: number): void => { + if (seq > highest) highest = seq + linesSeen += 1 + } + const parseStrict = (line: string): boolean => { + if (!line.trim()) return true + const seq = trySeq(line) + if (seq === null) return false + observe(seq) + return true + } + let carry = '' + let newest = true + let end = options.fileSize + while (end > 0 && !budgetExhausted) { + const start = Math.max(0, end - chunkBytes) + const length = end - start + const buffer = Buffer.alloc(length) + const { bytesRead } = await handle.read(buffer, 0, length, start) + if (bytesRead !== length) return { ok: false, reason: 'short-file' } + const combined = buffer.toString('utf-8') + carry + if (start === 0) { + const parts = combined.split('\n') + const lastIndex = parts.length - 1 + for (let i = 0; i <= lastIndex; i++) { + const line = parts[i] + if (i === lastIndex && newest && carry === '' && line.trim()) { + // The file's true end without a terminating newline. + const seq = trySeq(line) + if (seq !== null) observe(seq) + continue + } + if (!parseStrict(line)) return { ok: false, reason: 'malformed-tail' } + } + return { ok: true, highestSeq: highest } + } + const firstNewline = combined.indexOf('\n') + if (firstNewline < 0) { + // The whole window is one line's end-fragment; defer to older bytes. + carry = combined + end = start + continue + } + carry = combined.slice(0, firstNewline) + const lastNewline = combined.lastIndexOf('\n') + if (newest) { + const tornTail = combined.slice(lastNewline + 1) + if (tornTail.trim()) { + const seq = trySeq(tornTail) + if (seq !== null) observe(seq) + } + } + const body = newest + ? (lastNewline > firstNewline ? combined.slice(firstNewline + 1, lastNewline) : '') + : combined.slice(firstNewline + 1) + for (const line of body ? body.split('\n') : []) { + if (!parseStrict(line)) return { ok: false, reason: 'malformed-tail' } + } + if (linesSeen >= maxLines) budgetExhausted = true + newest = false + end = start + } + return { ok: true, highestSeq: highest } + } finally { + await handle.close().catch(() => undefined) + } +} + +function trySeq(line: string): number | null { + const match = /"seq"\s*:\s*(\d+)/.exec(line.trim()) + if (!match) return null + const value = Number(match[1]) + return Number.isSafeInteger(value) && value >= 0 ? value : null +} diff --git a/kun/src/adapters/file/file-session-store.ts b/kun/src/adapters/file/file-session-store.ts index 702d6efca..add69212c 100644 --- a/kun/src/adapters/file/file-session-store.ts +++ b/kun/src/adapters/file/file-session-store.ts @@ -31,9 +31,9 @@ import { SessionCompactionScheduler } from './session-compaction-scheduler.js' import { searchItemTextFile } from './file-session-text-search.js' import { writeSessionArchive } from './session-history-archive.js' import { compactUsageEventsIfLarge, sessionDirectoryExists } from './file-session-usage-compaction.js' +import { scanHighestSeqFromTail } from './file-session-seq-tail-scan.js' export { readLatestItemsFromJsonl } from './file-session-jsonl.js' - const DEFAULT_USAGE_EVENT_COMPACTION_MAX_BYTES = 5 * 1024 * 1024 const DEFAULT_USAGE_EVENT_RETENTION_DAYS = 365 /** Log a warning when a cold loadItems read blocks the loop for at least this long (#621). */ @@ -99,25 +99,19 @@ export class FileSessionStore implements SessionStore { compactionDelayMs?: number }) { this.dataDir = resolve(options.dataDir, 'threads') - this.itemsCacheMaxBytes = Math.max( - 1, - Math.floor(options.itemsCacheMaxBytes ?? DEFAULT_ITEMS_CACHE_MAX_BYTES) - ) - this.itemHistoryCompactionMinBytes = Math.max( - 1, - Math.floor( - options.itemHistoryCompactionMinBytes ?? DEFAULT_ITEM_HISTORY_COMPACTION_MIN_BYTES - ) - ) + this.itemsCacheMaxBytes = Math.max(1, Math.floor( + options.itemsCacheMaxBytes ?? DEFAULT_ITEMS_CACHE_MAX_BYTES + )) + this.itemHistoryCompactionMinBytes = Math.max(1, Math.floor( + options.itemHistoryCompactionMinBytes ?? DEFAULT_ITEM_HISTORY_COMPACTION_MIN_BYTES + )) this.usageEventCompaction = { - maxBytes: Math.max( - 1, - Math.floor(options.usageEventCompaction?.maxBytes ?? DEFAULT_USAGE_EVENT_COMPACTION_MAX_BYTES) - ), - retentionDays: Math.max( - 1, - Math.floor(options.usageEventCompaction?.retentionDays ?? DEFAULT_USAGE_EVENT_RETENTION_DAYS) - ), + maxBytes: Math.max(1, Math.floor( + options.usageEventCompaction?.maxBytes ?? DEFAULT_USAGE_EVENT_COMPACTION_MAX_BYTES + )), + retentionDays: Math.max(1, Math.floor( + options.usageEventCompaction?.retentionDays ?? DEFAULT_USAGE_EVENT_RETENTION_DAYS + )), nowIso: options.usageEventCompaction?.nowIso ?? (() => new Date().toISOString()) } this.compactionScheduler = new SessionCompactionScheduler({ @@ -443,8 +437,7 @@ export class FileSessionStore implements SessionStore { const elapsedMs = performance.now() - startedAt if (elapsedMs >= SLOW_LOAD_ITEMS_LOG_MS) { // A slow cold read points at an oversized thread log as the likely - // event-loop staller behind a watchdog restart (#621); the counts say - // how bloated messages.jsonl has become. + // event-loop staller behind a watchdog restart (#621); counts show the bloat. console.warn( `[kun] loadItems(${threadId}) took ${Math.round(elapsedMs)}ms ` + `for ${rawCount} raw → ${ordered.length} items` @@ -484,6 +477,13 @@ export class FileSessionStore implements SessionStore { this.cacheHighestSeq(threadId, cached.seq, info) return cached.seq } + // Events append in seq order: the newest max sits at the tail, so a + // backwards scan avoids stream-parsing the whole log on a cache miss (#621). + const tail = await scanHighestSeqFromTail({ path, fileSize: info.size }) + if (tail.ok) { + this.cacheHighestSeq(threadId, tail.highestSeq, info) + return tail.highestSeq + } let highest = 0 for await (const event of this.iterateEventsSince(threadId, -1)) { highest = Math.max(highest, event.seq) diff --git a/kun/src/server/http-server.ts b/kun/src/server/http-server.ts index de46316c9..4049aa552 100644 --- a/kun/src/server/http-server.ts +++ b/kun/src/server/http-server.ts @@ -6,6 +6,9 @@ export type HttpServerOptions = { router: Router } +/** Warn once a non-streaming request exceeds this budget; SSE streams opt out. */ +const SLOW_REQUEST_LOG_MS = 500 + function toResponse(response: Response | JsonResponse): Response { if (response instanceof Response) return response return new Response(response.body, { @@ -14,6 +17,11 @@ function toResponse(response: Response | JsonResponse): Response { }) } +function isStreamingResponse(response: Response): boolean { + const contentType = response.headers.get('content-type') ?? '' + return contentType.includes('text/event-stream') +} + export async function dispatchRequest(router: Router, request: Request): Promise { const url = new URL(request.url) const match = router.match(request.method, url.pathname) @@ -23,5 +31,15 @@ export async function dispatchRequest(router: Router, request: Request): Promise 404 )) } - return toResponse(await match.handler(request, { params: match.params })) + const startedAt = performance.now() + const response = toResponse(await match.handler(request, { params: match.params })) + const elapsedMs = performance.now() - startedAt + if (elapsedMs >= SLOW_REQUEST_LOG_MS && !isStreamingResponse(response)) { + // Route-level signal for event-loop stalls (#621 family): names the + // endpoint and thread so a slow scan is attributable in stdout logs. + console.warn( + `[kun] ${request.method} ${url.pathname} took ${Math.round(elapsedMs)}ms` + ) + } + return response } diff --git a/src/main/main-runtime-startup.ts b/src/main/main-runtime-startup.ts index 80b722b37..e6619109a 100644 --- a/src/main/main-runtime-startup.ts +++ b/src/main/main-runtime-startup.ts @@ -65,7 +65,10 @@ export async function ensureKunRuntime(settings: AppSettingsV1): Promise { expect(allEvents[2].text).toBe('bye') }) + it('retries a bounded number of times on 404 before surfacing the error', async () => { + registerRuntimeSseIpc({ + ipcMain: mockIpcMain, + store: mockStore, + ensureRuntime: mockEnsureRuntime, + logError: mockLogError + }) + const startHandler = handlers.get('runtime:sse:start') + expect(startHandler).toBeDefined() + + // 1 initial attempt + first 2 retries 404; the 3rd retry reaches a + // stream that ends cleanly (one event) so the loop stops without an + // endless immediate-reconnect spin against the mock reader. + let fetchCalls = 0 + mockFetch.mockImplementation(async () => { + fetchCalls += 1 + if (fetchCalls <= 3) return { ok: false, status: 404, body: null } + return { ok: false, status: 400, body: null } + }) + + const started = await startHandler!(mockEvent, { + threadId: 'thread-404-race', + sinceSeq: 0 + }) + + // Retries use 750ms → 1.5s → 3s backoff; one large advance covers all + // pending sleeps plus the terminal 400 that follows. + await vi.advanceTimersByTimeAsync(6_000) + + expect(mockFetch).toHaveBeenCalledTimes(4) + // The 404s retried instead of terminating on the first response. + expect(mockLogError).toHaveBeenCalledWith( + 'sse', + expect.stringContaining('SSE 404 for thread thread-404-race; retry 1/3'), + expect.objectContaining({ streamId: started.streamId }) + ) + }) + + it('reports a terminal error after exhausting 404 retries', async () => { + registerRuntimeSseIpc({ + ipcMain: mockIpcMain, + store: mockStore, + ensureRuntime: mockEnsureRuntime, + logError: mockLogError + }) + const startHandler = handlers.get('runtime:sse:start') + expect(startHandler).toBeDefined() + + mockFetch.mockImplementation(async () => ({ ok: false, status: 404, body: null })) + + const started = await startHandler!(mockEvent, { + threadId: 'thread-404-final', + sinceSeq: 0 + }) + + await vi.advanceTimersByTimeAsync(10_000) + + expect(mockFetch).toHaveBeenCalledTimes(4) + expect(mockEvent.sender.send).toHaveBeenCalledWith( + 'runtime:sse-error', + expect.objectContaining({ streamId: started.streamId, status: 404, threadMissing: true }) + ) + expect(mockLogError).toHaveBeenCalledWith( + 'sse', + expect.stringContaining('SSE 404'), + expect.objectContaining({ streamId: started.streamId }) + ) + }) + it('treats terminated stream reads as reconnectable SSE disconnects', async () => { registerRuntimeSseIpc({ ipcMain: mockIpcMain, diff --git a/src/main/runtime-sse-ipc.ts b/src/main/runtime-sse-ipc.ts index 108ec9d0b..80c0550a2 100644 --- a/src/main/runtime-sse-ipc.ts +++ b/src/main/runtime-sse-ipc.ts @@ -142,6 +142,14 @@ function isFatalSseStatus(status: number | undefined): boolean { return typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429 } +// A just-created thread can briefly 404 on the events route while its durable +// record becomes visible (runtime restart, writer hand-off). Retry a bounded +// number of times before declaring the thread missing so a raced subscription +// does not permanently strand an empty transcript. +const SSE_NOT_FOUND_RETRY_BASE_MS = 750 +const SSE_NOT_FOUND_RETRY_MAX = 3 + + function isTransientSseErrorMessage(message: string): boolean { return /sse start timeout|sse renderer acknowledgement timeout|fetch failed|network|terminated|aborted|socket|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|UND_ERR/i.test(message) } @@ -205,6 +213,8 @@ export function registerRuntimeSseIpc(options: { const wc = event.sender let nextSinceSeq = request.sinceSeq let reconnectDelayMs = SSE_RECONNECT_BASE_MS + let notFoundRetries = 0 + try { while (!state.stoppedByClient && !ac.signal.aborted) { try { @@ -231,7 +241,16 @@ export function registerRuntimeSseIpc(options: { const res = await fetchSseWithStartTimeout(url, requestHeaders, ac.signal, SSE_START_TIMEOUT_MS) if (!res.ok || !res.body) { if (isFatalSseStatus(res.status)) { - if (!sendSseMessage(wc, 'runtime:sse-error', { streamId: id, status: res.status })) { + if (res.status === 404 && notFoundRetries < SSE_NOT_FOUND_RETRY_MAX) { + notFoundRetries += 1 + const delayMs = SSE_NOT_FOUND_RETRY_BASE_MS * 2 ** (notFoundRetries - 1) + logError('sse', `SSE 404 for thread ${request.threadId}; retry ${notFoundRetries}/${SSE_NOT_FOUND_RETRY_MAX} in ${delayMs}ms`, { + streamId: id + }) + await sleepWithAbort(delayMs, ac.signal) + continue + } + if (!sendSseMessage(wc, 'runtime:sse-error', { streamId: id, status: res.status, ...(res.status === 404 ? { threadMissing: true } : {}) })) { state.stoppedByClient = true ac.abort() return @@ -247,6 +266,7 @@ export function registerRuntimeSseIpc(options: { continue } reconnectDelayMs = SSE_RECONNECT_BASE_MS + notFoundRetries = 0 const reader = res.body.getReader() const dec = new TextDecoder() let buffer = '' diff --git a/src/main/workflow-webhook-server.ts b/src/main/workflow-webhook-server.ts index 97a55ea3e..0c8a25456 100644 --- a/src/main/workflow-webhook-server.ts +++ b/src/main/workflow-webhook-server.ts @@ -44,6 +44,11 @@ type WorkflowWebhookOptions = { export class WorkflowWebhookServer { private server: Server | null = null private serverKey = '' + // Synchronous /workflow/run + internal runs execute inside this server's + // event loop. Cap concurrent awaited runs so several slow workflows cannot + // pile up unbounded work in the main process. + private activeRuns = 0 + private static readonly MAX_CONCURRENT_SYNC_RUNS = 4 constructor(private readonly options: WorkflowWebhookOptions) {} @@ -106,6 +111,10 @@ export class WorkflowWebhookServer { } // Public local API: run any workflow by name/id and get its output back. if (pathname === '/workflow/run') { + if (this.activeRuns >= WorkflowWebhookServer.MAX_CONCURRENT_SYNC_RUNS) { + writeJson(res, 503, { ok: false, message: 'Too many concurrent workflow runs; retry later.' }) + return + } const body = await readRequestBody(req) const parsed = parseJsonObject(body) ?? {} const idOrName = String(parsed.workflow ?? parsed.name ?? parsed.workflowId ?? '').trim() @@ -114,7 +123,13 @@ export class WorkflowWebhookServer { return } const workspaceOverride = typeof parsed.workspaceRoot === 'string' ? parsed.workspaceRoot : undefined - const result = await this.options.runWorkflowByRef(idOrName, parsed.input, workspaceOverride) + this.activeRuns += 1 + let result: Awaited> + try { + result = await this.options.runWorkflowByRef(idOrName, parsed.input, workspaceOverride) + } finally { + this.activeRuns -= 1 + } writeJson(res, result.ok ? 200 : 400, result) return } From 3e08fb1044a4edfe89418e430c6765471fb4edd9 Mon Sep 17 00:00:00 2001 From: Kun Agent Date: Sat, 22 Aug 2026 11:57:01 +0800 Subject: [PATCH 013/168] fix(chat): stop replaying live-progress UI when reopening a settled thread Reopening a thread trusted the persisted snapshot's running claim, so the timeline rendered live-progress rows, running tool rows, and a typewriter replay before recovery settled the turn. Add a busyUnconfirmed flag set by snapshot hydration (selectThread / subscribeThreadEventsLive / recoverActiveTurn) that renders history settled until the live event stream confirms the turn (first accepted user/tool/delta/compaction event) or a terminal event clears it. Input/disabling still follows busy, so a genuinely running turn keeps its composer gating while confirmation is pending. --- .../src/components/chat/AssistantMarkdown.tsx | 7 +++- .../MessageTimeline.busy-unconfirmed.test.ts | 32 ++++++++++++++++++ .../src/components/chat/MessageTimeline.tsx | 7 ++++ .../chat/live-assistant-streaming.tsx | 33 +++++++++++++++++++ .../chat/message-timeline-bubbles.tsx | 17 +++++++--- .../components/chat/use-timeline-stores.ts | 3 ++ .../src/store/chat-projection-reducer-late.ts | 7 ++-- .../src/store/chat-projection-reducer.ts | 5 ++- .../src/store/chat-store-initial-state.ts | 1 + ...t-store-maintenance-interaction-actions.ts | 1 + ...chat-store-maintenance-metadata-actions.ts | 1 + ...chat-store-maintenance-recovery-actions.ts | 1 + .../chat-store-maintenance-session-actions.ts | 1 + .../src/store/chat-store-runtime-helpers.ts | 2 ++ src/renderer/src/store/chat-store-runtime.ts | 11 +++++++ .../src/store/chat-store-schedulers.ts | 1 + .../chat-store-thread-creation-actions.ts | 3 ++ .../store/chat-store-thread-review-actions.ts | 2 ++ .../chat-store-thread-selection-actions.ts | 9 +++++ .../store/chat-store-thread-send-direct.ts | 7 ++++ src/renderer/src/store/chat-store-types.ts | 7 ++++ .../src/store/thread-snapshot-cache.ts | 2 ++ 22 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/chat/MessageTimeline.busy-unconfirmed.test.ts create mode 100644 src/renderer/src/components/chat/live-assistant-streaming.tsx diff --git a/src/renderer/src/components/chat/AssistantMarkdown.tsx b/src/renderer/src/components/chat/AssistantMarkdown.tsx index 7dae01ae7..3aa885318 100644 --- a/src/renderer/src/components/chat/AssistantMarkdown.tsx +++ b/src/renderer/src/components/chat/AssistantMarkdown.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react' import { lazy, Suspense } from 'react' +import { useLiveAssistantStreaming } from './live-assistant-streaming' const LazyStreamdownAssistant = lazy(() => import('./StreamdownAssistant').then((module) => ({ default: module.StreamdownAssistant })) @@ -16,6 +17,10 @@ export function AssistantMarkdown({ className?: string hideHtmlComments?: boolean }): ReactElement { + // An unconfirmed busy flag gates the typewriter off so catch-up replay + // (returning to a thread that ran while away) renders whole instead of + // re-typing text the user already watched settle. + const effectiveStreaming = streaming && useLiveAssistantStreaming() const fallbackText = hideHtmlComments ? text.replace(/|$)/g, '') : text @@ -30,7 +35,7 @@ export function AssistantMarkdown({ > diff --git a/src/renderer/src/components/chat/MessageTimeline.busy-unconfirmed.test.ts b/src/renderer/src/components/chat/MessageTimeline.busy-unconfirmed.test.ts new file mode 100644 index 000000000..35cf97b6b --- /dev/null +++ b/src/renderer/src/components/chat/MessageTimeline.busy-unconfirmed.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { timelineTurnIsProcessing } from './MessageTimeline' + +describe('timelineTurnIsProcessing unconfirmed busy', () => { + it('renders history settled while a hydrated running claim is unconfirmed', () => { + // A persisted snapshot says running, but no live event confirmed it yet: + // the timeline must not replay live-progress UI over finished history. + expect(timelineTurnIsProcessing({ + busy: true, + busyUnconfirmed: true, + isLatestTurn: true, + turnPending: false, + hasLiveStream: false + })).toBe(false) + // Live stream evidence still shows progress even while unconfirmed. + expect(timelineTurnIsProcessing({ + busy: true, + busyUnconfirmed: true, + isLatestTurn: true, + turnPending: false, + hasLiveStream: true + })).toBe(true) + // Once the runtime confirms the turn, normal processing UI returns. + expect(timelineTurnIsProcessing({ + busy: true, + busyUnconfirmed: false, + isLatestTurn: true, + turnPending: false, + hasLiveStream: false + })).toBe(true) + }) +}) diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index 9fcf604c7..629d1e103 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -69,6 +69,7 @@ export { summarizeToolBlock } from './message-timeline-process' export function timelineTurnIsProcessing(input: { busy: boolean + busyUnconfirmed?: boolean isLatestTurn: boolean isActiveTurn?: boolean turnPending: boolean @@ -82,6 +83,10 @@ export function timelineTurnIsProcessing(input: { ) { return false } + // An unconfirmed busy flag comes from a persisted snapshot that claims a + // running turn; until live events confirm it, render the history settled + // instead of replaying live-progress UI over a finished conversation. + if (input.busyUnconfirmed && input.busy) return input.turnPending || input.hasLiveStream return (input.busy && (input.isActiveTurn ?? input.isLatestTurn)) || input.turnPending || input.hasLiveStream @@ -136,6 +141,7 @@ export function MessageTimeline({ chooseWorkspace, activeClawChannel, busy, + busyUnconfirmed, threadHasMoreHistory, threadHistoryLoading, loadEarlierThreadHistory, @@ -509,6 +515,7 @@ export function MessageTimeline({ const hasLiveStream = isActiveTurn && !!(liveReasoning.trim() || live.trim()) const turnIsProcessing = timelineTurnIsProcessing({ busy, + busyUnconfirmed, isLatestTurn, isActiveTurn, turnPending, diff --git a/src/renderer/src/components/chat/live-assistant-streaming.tsx b/src/renderer/src/components/chat/live-assistant-streaming.tsx new file mode 100644 index 000000000..edeae3944 --- /dev/null +++ b/src/renderer/src/components/chat/live-assistant-streaming.tsx @@ -0,0 +1,33 @@ +import { + createContext, + useContext, + type ReactElement, + type ReactNode, +} from "react"; + +/** + * While an unconfirmed busy flag is pending (a hydrated snapshot claims a + * running turn but live events have not confirmed it), the live-assistant + * typewriter must stay off: catch-up replay would otherwise re-type text that + * the user already saw settle. The provider is mounted by the live assistant + * bubble; `streaming` combines the caller's flag with this gate. + */ +const LiveAssistantStreamingContext = createContext(true); + +export function LiveAssistantStreamingProvider({ + streaming, + children, +}: { + streaming: boolean; + children: ReactNode; +}): ReactElement { + return ( + + {children} + + ); +} + +export function useLiveAssistantStreaming(): boolean { + return useContext(LiveAssistantStreamingContext); +} diff --git a/src/renderer/src/components/chat/message-timeline-bubbles.tsx b/src/renderer/src/components/chat/message-timeline-bubbles.tsx index c525d5d70..0fa92963d 100644 --- a/src/renderer/src/components/chat/message-timeline-bubbles.tsx +++ b/src/renderer/src/components/chat/message-timeline-bubbles.tsx @@ -23,6 +23,7 @@ import { formatMessageDateTime } from './message-timeline-bubble-support' import { ToolAttachmentPreviews } from './message-timeline-media-views' +import { LiveAssistantStreamingProvider } from './live-assistant-streaming' import { metaString } from './message-timeline-bubble-meta' export { GeneratedFilesPanel } from './message-timeline-media-views' @@ -83,6 +84,10 @@ function MessageBubbleImpl({ } if (block.kind === 'assistant') { const streaming = block.id === 'live-assistant' + // Gate the typewriter on busy confirmation: catch-up replay after + // reselecting a thread must render whole, not re-type. + const busyUnconfirmed = useChatStore((s) => s.busyUnconfirmed) + const effectiveStreaming = streaming && !busyUnconfirmed const createdAtLabel = block.createdAt ? formatMessageDateTime(block.createdAt, i18n.language) : null @@ -91,10 +96,11 @@ function MessageBubbleImpl({ ? turnTimingMetrics.get(block.turnId) : undefined return ( -
-
- -
+ +
+
+ +
{!streaming ? (
@@ -140,7 +146,8 @@ function MessageBubbleImpl({
) : null} -
+ + ) } if (block.kind === 'reasoning') { diff --git a/src/renderer/src/components/chat/use-timeline-stores.ts b/src/renderer/src/components/chat/use-timeline-stores.ts index 435af0b4f..147e0770c 100644 --- a/src/renderer/src/components/chat/use-timeline-stores.ts +++ b/src/renderer/src/components/chat/use-timeline-stores.ts @@ -16,6 +16,7 @@ export type TimelineStores = { clawChannels: ClawImChannelV1[] activeClawChannel: ClawImChannelV1 | null busy: boolean + busyUnconfirmed: boolean threadHasMoreHistory: boolean threadHistoryLoading: boolean loadEarlierThreadHistory: () => Promise @@ -35,6 +36,7 @@ export function useTimelineStores(activeThreadId: string | null): TimelineStores const clawChannels = useChatStore((s) => s.clawChannels) const activeClawChannelId = useChatStore((s) => s.activeClawChannelId) const busy = useChatStore((s) => s.busy) + const busyUnconfirmed = useChatStore((s) => s.busyUnconfirmed) const threadHasMoreHistory = useChatStore((s) => s.threadHasMoreHistory) const threadHistoryLoading = useChatStore((s) => s.threadHistoryLoading) const loadEarlierThreadHistory = useChatStore((s) => s.loadEarlierThreadHistory) @@ -59,6 +61,7 @@ export function useTimelineStores(activeThreadId: string | null): TimelineStores clawChannels, activeClawChannel, busy, + busyUnconfirmed, threadHasMoreHistory, threadHistoryLoading, loadEarlierThreadHistory, diff --git a/src/renderer/src/store/chat-projection-reducer-late.ts b/src/renderer/src/store/chat-projection-reducer-late.ts index afb0434c6..e15642360 100644 --- a/src/renderer/src/store/chat-projection-reducer-late.ts +++ b/src/renderer/src/store/chat-projection-reducer-late.ts @@ -19,7 +19,7 @@ export function reduceLateChatProjection( switch (action.type) { case 'runtime_status_received': { const event = action.payload - const base: Partial = state.busy ? {} : { busy: true } + const base: Partial = state.busy ? {} : { busy: true, busyUnconfirmed: false } const block: ChatBlock = { kind: 'system', id: event.itemId, @@ -107,7 +107,7 @@ export function reduceLateChatProjection( } case 'review_updated': { const event = action.payload - const base: Partial = !state.busy && event.status === 'running' ? { busy: true } : {} + const base: Partial = !state.busy && event.status === 'running' ? { busy: true, busyUnconfirmed: false } : {} const index = state.blocks.findIndex( (block) => block.kind === 'review' && block.id === event.itemId ) @@ -327,7 +327,7 @@ export function reduceLateChatProjection( currentTurnUserId: null, blocks: context.settlePendingRuntimeWork(state.blocks) } : {}), - ...(state.busy ? { busy: false } : {}), + ...(state.busy ? { busy: false, busyUnconfirmed: false } : {}), ...(threads !== state.threads ? { threads } : {}) }) if (!threadId) return patch @@ -351,6 +351,7 @@ export function reduceLateChatProjection( }) if (!shouldSettle) return patch patch.busy = false + patch.busyUnconfirmed = false patch.currentTurnId = null patch.currentTurnOrchestration = null patch.currentTurnUserId = null diff --git a/src/renderer/src/store/chat-projection-reducer.ts b/src/renderer/src/store/chat-projection-reducer.ts index eaf9b71d5..133816efa 100644 --- a/src/renderer/src/store/chat-projection-reducer.ts +++ b/src/renderer/src/store/chat-projection-reducer.ts @@ -106,6 +106,9 @@ export function reduceChatProjection( ...flushed, blocks: upsertUserBlock(reconciledBlocks, event), busy: true, + // A live user_message event is direct runtime evidence; any pending + // unconfirmed flag from hydration is now resolved. + busyUnconfirmed: false, currentTurnId: event.turnId ?? state.currentTurnId, currentTurnUserId, turnStartedAtByUserId: backgroundNotice @@ -281,7 +284,7 @@ export function reduceChatProjection( const event = action.payload const base: Partial = !state.busy && !event.updateOnly && !isDetachedSubagentToolEvent(event) - ? { busy: true } + ? { busy: true, busyUnconfirmed: false } : {} const childId = toolEventChildId(event) const index = state.blocks.findIndex((block) => diff --git a/src/renderer/src/store/chat-store-initial-state.ts b/src/renderer/src/store/chat-store-initial-state.ts index 337feee8d..b0c981256 100644 --- a/src/renderer/src/store/chat-store-initial-state.ts +++ b/src/renderer/src/store/chat-store-initial-state.ts @@ -51,6 +51,7 @@ export function createInitialChatStoreState(workingDirectoryLabel: string) { lastTurnUsage: null, turnTimingMetrics: new Map(), busy: false, + busyUnconfirmed: false, error: null, runtimeErrorDetail: null, currentTurnId: null, diff --git a/src/renderer/src/store/chat-store-maintenance-interaction-actions.ts b/src/renderer/src/store/chat-store-maintenance-interaction-actions.ts index c34273623..cd483eeaa 100644 --- a/src/renderer/src/store/chat-store-maintenance-interaction-actions.ts +++ b/src/renderer/src/store/chat-store-maintenance-interaction-actions.ts @@ -194,6 +194,7 @@ function settleInterruptedTurn(set: ChatStoreSet, get: ChatStoreGet): void { const out = flushLiveBlocks(s, { ...finalizeTurnTiming(s), busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, diff --git a/src/renderer/src/store/chat-store-maintenance-metadata-actions.ts b/src/renderer/src/store/chat-store-maintenance-metadata-actions.ts index f0fcc5aaa..637387841 100644 --- a/src/renderer/src/store/chat-store-maintenance-metadata-actions.ts +++ b/src/renderer/src/store/chat-store-maintenance-metadata-actions.ts @@ -194,6 +194,7 @@ function settleInterruptedTurn(set: ChatStoreSet, get: ChatStoreGet): void { const out = flushLiveBlocks(s, { ...finalizeTurnTiming(s), busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, diff --git a/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts b/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts index 54af33cbf..17102e972 100644 --- a/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts +++ b/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts @@ -197,6 +197,7 @@ function settleInterruptedTurn(set: ChatStoreSet, get: ChatStoreGet): void { const out = flushLiveBlocks(s, { ...finalizeTurnTiming(s), busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, diff --git a/src/renderer/src/store/chat-store-maintenance-session-actions.ts b/src/renderer/src/store/chat-store-maintenance-session-actions.ts index b605188cb..3f47187b4 100644 --- a/src/renderer/src/store/chat-store-maintenance-session-actions.ts +++ b/src/renderer/src/store/chat-store-maintenance-session-actions.ts @@ -200,6 +200,7 @@ function settleInterruptedTurn(set: ChatStoreSet, get: ChatStoreGet): void { const out = flushLiveBlocks(s, { ...finalizeTurnTiming(s), busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, diff --git a/src/renderer/src/store/chat-store-runtime-helpers.ts b/src/renderer/src/store/chat-store-runtime-helpers.ts index 968669484..9aacf4536 100644 --- a/src/renderer/src/store/chat-store-runtime-helpers.ts +++ b/src/renderer/src/store/chat-store-runtime-helpers.ts @@ -299,6 +299,7 @@ export function clearedThreadSelection(): Pick< | 'liveReasoning' | 'liveAssistant' | 'busy' + | 'busyUnconfirmed' | 'currentTurnId' | 'currentTurnOrchestration' | 'currentTurnUserId' @@ -325,6 +326,7 @@ export function clearedThreadSelection(): Pick< liveReasoning: '', liveAssistant: '', busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, diff --git a/src/renderer/src/store/chat-store-runtime.ts b/src/renderer/src/store/chat-store-runtime.ts index 5244693e3..104040a9c 100644 --- a/src/renderer/src/store/chat-store-runtime.ts +++ b/src/renderer/src/store/chat-store-runtime.ts @@ -306,6 +306,13 @@ export function buildThreadEventSink( ): ThreadEventSink { const boundThreadId = binding.threadId?.trim() ?? '' let appliedDeltaSeqFloor = binding.sinceSeq ?? 0 + // Hydrated threads subscribe exactly at their snapshot's high-water mark, so + // the first accepted event on a stream is live runtime evidence: any pending + // unconfirmed busy flag from snapshot hydration is resolved as soon as one + // arrives. Heartbeats alone do not confirm a running turn. + const confirmBusyOnce = (): void => { + if (get().busyUnconfirmed) set({ busyUnconfirmed: false }) + } // Update-only child lifecycle events can race their parent tool card. Keep // that short-lived repair state inside this one stream so reconnects and // other threads cannot consume each other's child ids. @@ -404,6 +411,7 @@ export function buildThreadEventSink( if (!isCurrentStream()) return resetBusyRecoveryAttempts() armBusyWatchdog(set, get) + confirmBusyOnce() set((state) => reduce(state, { type: 'user_message_received', payload: event })) }, onDeltas: (rawDeltas) => { @@ -419,6 +427,7 @@ export function buildThreadEventSink( if (deltas.length === 0) return resetBusyRecoveryAttempts() if (!get().busy) armBusyWatchdog(set, get) + confirmBusyOnce() set((state) => reduce(state, { type: 'deltas_received', deltas })) }, onAssistantItem: (item) => { @@ -431,6 +440,7 @@ export function buildThreadEventSink( publishLiveOfficePreviewForToolEvent(get(), event, boundThreadId || undefined) runEffects([{ type: 'refresh_write_workspace', event }]) resetBusyRecoveryAttempts() + confirmBusyOnce() if (!get().busy && !event.updateOnly && !isDetachedSubagentToolEvent(event)) { armBusyWatchdog(set, get) } @@ -468,6 +478,7 @@ export function buildThreadEventSink( onCompaction: (event) => { if (!isCurrentStream()) return resetBusyRecoveryAttempts() + confirmBusyOnce() if (!get().busy && event.status === 'running') armBusyWatchdog(set, get) if (get().busy && event.status !== 'running' && !get().currentTurnId) clearBusyWatchdog() set((state) => reduce(state, { type: 'compaction_updated', payload: event })) diff --git a/src/renderer/src/store/chat-store-schedulers.ts b/src/renderer/src/store/chat-store-schedulers.ts index f54827778..499008b1c 100644 --- a/src/renderer/src/store/chat-store-schedulers.ts +++ b/src/renderer/src/store/chat-store-schedulers.ts @@ -122,6 +122,7 @@ export function armBusyWatchdog( const base: Partial = { ...options.finalizeBusyState(snapshot), busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, error: options.busyTimeoutMessage() diff --git a/src/renderer/src/store/chat-store-thread-creation-actions.ts b/src/renderer/src/store/chat-store-thread-creation-actions.ts index 386a54516..cdc635332 100644 --- a/src/renderer/src/store/chat-store-thread-creation-actions.ts +++ b/src/renderer/src/store/chat-store-thread-creation-actions.ts @@ -473,6 +473,9 @@ export function createThreadCreationActions( liveAssistant: '', error: busy ? runtimeStreamRecoveringMessage() : null, busy, + // Recovery re-read a persisted snapshot; its running claim stays + // unconfirmed until the live stream proves the turn is alive. + busyUnconfirmed: busy, currentTurnId, currentTurnOrchestration: busy ? latestTurnOrchestration ?? 'direct' : null, currentTurnUserId, diff --git a/src/renderer/src/store/chat-store-thread-review-actions.ts b/src/renderer/src/store/chat-store-thread-review-actions.ts index b9fbb243e..0824af864 100644 --- a/src/renderer/src/store/chat-store-thread-review-actions.ts +++ b/src/renderer/src/store/chat-store-thread-review-actions.ts @@ -244,6 +244,7 @@ export function createThreadReviewActions( clearBusyWatchdog() set({ busy: true, + busyUnconfirmed: false, liveReasoning: '', liveAssistant: '', error: null, @@ -292,6 +293,7 @@ export function createThreadReviewActions( set({ error: formatRuntimeError(e), busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, diff --git a/src/renderer/src/store/chat-store-thread-selection-actions.ts b/src/renderer/src/store/chat-store-thread-selection-actions.ts index fcf417d1f..67a2eb44b 100644 --- a/src/renderer/src/store/chat-store-thread-selection-actions.ts +++ b/src/renderer/src/store/chat-store-thread-selection-actions.ts @@ -249,6 +249,9 @@ export function createThreadSelectionActions( liveAssistant: cached.liveAssistant, error: null, busy: cached.busy, + // A cached snapshot's running claim is stale until the runtime stream + // proves the turn is still live. Render history settled meanwhile. + busyUnconfirmed: cached.busy, currentTurnId: cached.currentTurnId, currentTurnOrchestration: cached.currentTurnOrchestration, currentTurnUserId: cached.currentTurnUserId, @@ -298,6 +301,7 @@ export function createThreadSelectionActions( liveReasoning: '', liveAssistant: '', busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, @@ -405,6 +409,9 @@ export function createThreadSelectionActions( liveAssistant: '', error: null, busy, + // The persisted snapshot's running claim may be stale (interrupted + // runtime); keep the timeline settled until live events confirm it. + busyUnconfirmed: busy, currentTurnId: busy ? latestTurnId ?? null : null, currentTurnOrchestration: busy ? latestTurnOrchestration ?? 'direct' : null, currentTurnUserId, @@ -608,6 +615,8 @@ export function createThreadSelectionActions( lastSeq: latestSeq, liveDeltaSeqFloor: latestSeq, busy, + // Persisted running claim is unconfirmed until live events arrive. + busyUnconfirmed: busy, currentTurnId: busy ? latestTurnId ?? null : null, currentTurnOrchestration: busy ? latestTurnOrchestration ?? 'direct' : null, currentTurnUserId, diff --git a/src/renderer/src/store/chat-store-thread-send-direct.ts b/src/renderer/src/store/chat-store-thread-send-direct.ts index 61cf16d46..873c98945 100644 --- a/src/renderer/src/store/chat-store-thread-send-direct.ts +++ b/src/renderer/src/store/chat-store-thread-send-direct.ts @@ -124,6 +124,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom runtime.threadSelectionGeneration += 1 set((s) => ({ busy: true, + busyUnconfirmed: false, blocks: [ ...s.blocks, { @@ -174,6 +175,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom set({ blocks: previousBlocks, busy: false, + busyUnconfirmed: false, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -252,6 +254,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom blocks: previousBlocks, lastSeq: previousLastSeq, busy: false, + busyUnconfirmed: false, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -309,6 +312,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom blocks: previousBlocks, lastSeq: previousLastSeq, busy: false, + busyUnconfirmed: false, liveReasoning: previousLiveReasoning, liveAssistant: previousLiveAssistant, currentTurnId: previousCurrentTurnId, @@ -556,6 +560,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom set((state) => ({ blocks: previousBlocks, busy: true, + busyUnconfirmed: false, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -598,6 +603,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom set((state) => ({ blocks: previousBlocks, busy: false, + busyUnconfirmed: false, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -654,6 +660,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom : [...state.blocks, localConversationErrorBlock(e, `local_error_${userBlockId}`)], error: view.summary, busy: false, + busyUnconfirmed: false, currentTurnId: null, currentTurnOrchestration: null, queuedMessages: failQueuedSubmission( diff --git a/src/renderer/src/store/chat-store-types.ts b/src/renderer/src/store/chat-store-types.ts index ae38aac52..60dbcb78d 100644 --- a/src/renderer/src/store/chat-store-types.ts +++ b/src/renderer/src/store/chat-store-types.ts @@ -386,6 +386,13 @@ export type ChatState = { */ turnTimingMetrics: Map busy: boolean + /** + * True right after a thread switch/recovery hydrated a snapshot that claims + * a running turn, before that claim is re-confirmed by the runtime. The + * timeline must render history as settled (no live-progress UI, no + * typewriter replay) while input/disabling decisions still follow `busy`. + */ + busyUnconfirmed: boolean error: string | null runtimeErrorDetail: string | null currentTurnId: string | null diff --git a/src/renderer/src/store/thread-snapshot-cache.ts b/src/renderer/src/store/thread-snapshot-cache.ts index 96427c6df..dccc864df 100644 --- a/src/renderer/src/store/thread-snapshot-cache.ts +++ b/src/renderer/src/store/thread-snapshot-cache.ts @@ -18,6 +18,7 @@ export type ThreadSnapshot = { liveReasoning: string liveAssistant: string busy: boolean + busyUnconfirmed: boolean currentTurnId: string | null currentTurnOrchestration: 'direct' | 'graph' | null currentTurnUserId: string | null @@ -82,6 +83,7 @@ export function snapshotThreadProjection(state: ChatState, payloadBytes?: number liveReasoning: state.liveReasoning, liveAssistant: state.liveAssistant, busy: state.busy, + busyUnconfirmed: state.busyUnconfirmed, currentTurnId: state.currentTurnId, currentTurnOrchestration: state.currentTurnOrchestration, currentTurnUserId: state.currentTurnUserId, From 9d4ee424773d31560b8f4390f2bfd5b7abf0f8f1 Mon Sep 17 00:00:00 2001 From: Kun Agent Date: Sat, 22 Aug 2026 12:04:55 +0800 Subject: [PATCH 014/168] fix(chat): hoist busyUnconfirmed hooks out of conditional render paths The typewriter gate subscriptions landed inside conditional branches, violating react-hooks/rules-of-hooks and risking a hook-count crash if a bubble's kind or streaming flag ever changed mid-mount. Hoist both to the top of their components; behavior is unchanged. --- src/renderer/src/components/chat/AssistantMarkdown.tsx | 3 ++- src/renderer/src/components/chat/message-timeline-bubbles.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/chat/AssistantMarkdown.tsx b/src/renderer/src/components/chat/AssistantMarkdown.tsx index 3aa885318..80be47399 100644 --- a/src/renderer/src/components/chat/AssistantMarkdown.tsx +++ b/src/renderer/src/components/chat/AssistantMarkdown.tsx @@ -20,7 +20,8 @@ export function AssistantMarkdown({ // An unconfirmed busy flag gates the typewriter off so catch-up replay // (returning to a thread that ran while away) renders whole instead of // re-typing text the user already watched settle. - const effectiveStreaming = streaming && useLiveAssistantStreaming() + const liveStreaming = useLiveAssistantStreaming() + const effectiveStreaming = streaming && liveStreaming const fallbackText = hideHtmlComments ? text.replace(/|$)/g, '') : text diff --git a/src/renderer/src/components/chat/message-timeline-bubbles.tsx b/src/renderer/src/components/chat/message-timeline-bubbles.tsx index 0fa92963d..e8faff531 100644 --- a/src/renderer/src/components/chat/message-timeline-bubbles.tsx +++ b/src/renderer/src/components/chat/message-timeline-bubbles.tsx @@ -73,6 +73,7 @@ function MessageBubbleImpl({ const { t, i18n } = useTranslation('common') const resolveApproval = useChatStore((s) => s.resolveApproval) const turnTimingMetrics = useChatStore((s) => s.turnTimingMetrics) + const busyUnconfirmed = useChatStore((s) => s.busyUnconfirmed) if (block.kind === 'user' && isBackgroundShellNoticeBlock(block)) { return } @@ -86,7 +87,6 @@ function MessageBubbleImpl({ const streaming = block.id === 'live-assistant' // Gate the typewriter on busy confirmation: catch-up replay after // reselecting a thread must render whole, not re-type. - const busyUnconfirmed = useChatStore((s) => s.busyUnconfirmed) const effectiveStreaming = streaming && !busyUnconfirmed const createdAtLabel = block.createdAt ? formatMessageDateTime(block.createdAt, i18n.language) From 71a73a139921cfda5337287d737889eda3f9f6a6 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 13:01:34 +0800 Subject: [PATCH 015/168] fix(delegation): stop Fast Context cards rendering as failed A silent Fast Context child that spent its retrieval budget without a final text answer had its summary sourced from a stringified tool_result or duplicated loop error text, and fatal-looking loop bookkeeping errors flipped completed runs to failed. That produced self-contradictory "failed + status: completed" cards. - Bound the childResultSource tool_result fallback preview - Let a Fast Context evidence pack outrank fake no-text summaries - Sanitize failed-record error text that self-describes completion - Downgrade legacy contradictory failed details in the card status --- kun/src/delegation/child-agent-executor.ts | 40 ++++++++++++-- .../child-result-materializer.test.ts | 22 +++++++- .../delegation/child-result-materializer.ts | 10 +++- .../delegation-runtime-support.test.ts | 54 +++++++++++++++++++ .../delegation/delegation-runtime-support.ts | 17 +++++- .../fast-context-child-executor.test.ts | 39 ++++++++++++++ .../chat/subagent-call-card-support.test.ts | 18 +++++++ .../chat/subagent-call-card-support.tsx | 18 +++++++ 8 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 kun/src/delegation/delegation-runtime-support.test.ts diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index 4d1cd804b..5796cf67a 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -479,6 +479,16 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch ...(status === 'completed' ? {} : { failure: `Retrieval child ${status}.` }) }) : undefined + // For a Fast Context child the evidence pack is the contract product. + // When the model spent its whole budget on retrieval and wrote no final + // text, the pack still carries every task conclusion, so never let a + // stringified tool_result or duplicated loop error text impersonate the + // summary (that produced self-contradictory "failed + status: completed" + // cards). + if (input.fastContext && evidencePack && childResultUsedNoTextSummary(items, started.turnId)) { + result.summary = 'Fast Context retrieval completed; see evidence pack.' + result.summaryTruncated = undefined + } const structuredResult = { ...result, ...(directionBundle !== undefined ? { directionBundle } : {}), @@ -503,10 +513,19 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch event.severity !== 'info' ) if (runtimeError?.kind === 'error') { - throw new ChildResultExecutionError(runtimeError.message, structuredResult, { - ...settlement, - failure: childFailureFromRuntimeError(runtimeError) - }) + // A Fast Context child that exhausted its step budget on retrieval can + // still settle `completed` with a fatal-looking loop error such as the + // repeat-tool-call suppression notice. The evidence pack is the contract + // product, so it outranks that loop bookkeeping error; flipping the run + // to failed produced self-contradictory "failed + status: completed" + // cards while the parent had usable evidence. + const fastContextRecovered = input.fastContext === true && status === 'completed' && evidencePack !== undefined + if (!fastContextRecovered) { + throw new ChildResultExecutionError(runtimeError.message, structuredResult, { + ...settlement, + failure: childFailureFromRuntimeError(runtimeError) + }) + } } if (executionError !== undefined) { throw new ChildResultExecutionError(childExecutionErrorMessage(executionError), structuredResult, { @@ -544,6 +563,19 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +/** True when childResultSource had no assistant text and fell back to a + * tool_result stringification or loop error text — the fake-summary cases. */ +function childResultUsedNoTextSummary(items: readonly TurnItem[], turnId: string): boolean { + const turnItems = items.filter((item) => item.turnId === turnId) + const hasAssistantText = turnItems.some( + (item) => item.kind === 'assistant_text' && item.text.trim().length > 0 + ) + if (hasAssistantText) return false + return turnItems.some( + (item) => item.kind === 'tool_result' || item.kind === 'error' + ) +} + function childToolEvidence(items: readonly TurnItem[], turnId: string): string[] { const results = new Map(items .filter((item): item is Extract => diff --git a/kun/src/delegation/child-result-materializer.test.ts b/kun/src/delegation/child-result-materializer.test.ts index 679e63eaa..58f89abdf 100644 --- a/kun/src/delegation/child-result-materializer.test.ts +++ b/kun/src/delegation/child-result-materializer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { InMemoryArtifactStore, type ArtifactStore } from '../artifacts/artifact-store.js' -import { makeAssistantTextItem } from '../domain/item.js' +import { makeAssistantTextItem, makeToolResultItem } from '../domain/item.js' import { CHILD_RESULT_MAX_BYTES, CHILD_RESULT_PREVIEW_CHARS, @@ -24,6 +24,26 @@ describe('child result materialization', () => { expect(childResultSource(items, 'turn', 'completed')).toBe('final answer') }) + it('bounds the tool_result fallback preview when the child wrote no text', () => { + const oversized = 'x'.repeat(600_000) + const items = [makeToolResultItem({ + id: 'result', threadId: 'child', turnId: 'turn', callId: 'call', + toolName: 'grep', output: { status: 'completed', childId: 'child_x', payload: oversized } + })] + const summary = childResultSource(items, 'turn', 'completed') + expect(summary.length).toBeLessThanOrEqual(CHILD_RESULT_PREVIEW_CHARS) + expect(summary.startsWith('{"status":"completed"')).toBe(true) + }) + + it('uses the placeholder when the tool_result output stringifies to empty', () => { + const items = [makeToolResultItem({ + id: 'result', threadId: 'child', turnId: 'turn', callId: 'call', + toolName: 'grep', output: '' + })] + expect(childResultSource(items, 'turn', 'completed')) + .toBe('Child agent completed without a text response.') + }) + it('keeps a small answer inline', async () => { await expect(materializeChildResult({ content: 'small answer', diff --git a/kun/src/delegation/child-result-materializer.ts b/kun/src/delegation/child-result-materializer.ts index 20ae1e24c..f8ae53db2 100644 --- a/kun/src/delegation/child-result-materializer.ts +++ b/kun/src/delegation/child-result-materializer.ts @@ -109,7 +109,15 @@ export function childResultSource( const toolResult = [...turnItems] .reverse() .find((item): item is Extract => item.kind === 'tool_result') - if (toolResult) return stringifyResult(toolResult.output) + // Never inline a raw tool_result as the child summary: a single 512KB search + // payload would flood record.error/summary when the child produced no text + // (issue: Fast Context cards rendered as failed with a self-contradictory + // `status: completed` JSON blob). A bounded preview keeps the last signal + // without breaking the parent-context budget. + if (toolResult) { + const preview = stringifyResult(toolResult.output) + if (preview) return preview.slice(0, CHILD_RESULT_PREVIEW_CHARS) + } return status === 'completed' ? 'Child agent completed without a text response.' : `Child agent ${status}.` diff --git a/kun/src/delegation/delegation-runtime-support.test.ts b/kun/src/delegation/delegation-runtime-support.test.ts new file mode 100644 index 000000000..9d0b1221a --- /dev/null +++ b/kun/src/delegation/delegation-runtime-support.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { ChildRunRecord } from './delegation-runtime-contracts.js' +import { buildFailedChildRecord, childAbortOutcome } from './delegation-runtime-support.js' + +function runningRecord(patch: Partial> = {}) { + return ChildRunRecord.parse({ + id: 'child_fc', + parentThreadId: 'parent', + parentTurnId: 'turn-1', + launcher: 'fast_context', + prompt: 'retrieve evidence', + workspace: '/workspace', + profile: 'explore', + profileSnapshot: { mode: 'subagent', toolPolicy: 'readOnly' }, + security: { sandboxRoot: '/workspace', memoryEnabled: false }, + status: 'running', + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:30.000Z', + ...patch + }) +} + +function failedBuild(error: string) { + const current = runningRecord() + return buildFailedChildRecord(current, { + signal: new AbortController().signal, + runtimeRestart: false, + abort: childAbortOutcome(new AbortController().signal, false, new Error(error)), + parentTurnId: 'turn-1', + childId: current.id, + startedAt: '2026-08-19T00:00:30.000Z', + finishedAt: '2026-08-19T00:01:00.000Z', + previewChars: 4_000 + }) +} + +describe('buildFailedChildRecord error sanitization', () => { + it('rewrites error text that self-describes a completed child', () => { + const fakeSummary = 'status: completed childId: child_fc toolInvocations: 6 durationMs: 11480' + const record = failedBuild(fakeSummary) + expect(record.status).toBe('failed') + expect(record.error).toBe('Child result materialization failed; open the child session for details.') + }) + + it('rewrites JSON-style completed markers the same way', () => { + const record = failedBuild('{"status":"completed","childId":"child_fc"}') + expect(record.error).toBe('Child result materialization failed; open the child session for details.') + }) + + it('keeps genuine failure messages untouched', () => { + const record = failedBuild('model provider returned HTTP 520') + expect(record.error).toBe('model provider returned HTTP 520') + }) +}) diff --git a/kun/src/delegation/delegation-runtime-support.ts b/kun/src/delegation/delegation-runtime-support.ts index cd2f59ec7..f6362f333 100644 --- a/kun/src/delegation/delegation-runtime-support.ts +++ b/kun/src/delegation/delegation-runtime-support.ts @@ -278,7 +278,7 @@ export function buildFailedChildRecord( // (issue #1155); a child that never reached a model request reports zero. ...(input.usage !== undefined ? { usage: input.usage } : {}), ...(input.toolInvocations !== undefined ? { toolInvocations: input.toolInvocations } : {}), - error: input.abort.error.slice(0, input.previewChars), + error: sanitizeFailedChildError(input.abort.error).slice(0, input.previewChars), durationMs: (current.durationMs ?? 0) + Math.max(0, Date.parse(input.finishedAt) - Date.parse(input.startedAt)), updatedAt: input.finishedAt }) @@ -289,6 +289,21 @@ function ownedPptChildBundle(value: unknown, childId: string): boolean { (value as Record).childId === childId } +const COMPLETED_STATUS_MARKERS = [ + 'status: completed', + '"status":"completed"', + '"status": "completed"' +] as const + +/** A failed record must never carry error text that self-describes success + * (e.g. a stringified completed tool_result used as a fake summary). Keeps the + * UI from rendering self-contradictory "failed + status: completed" cards. */ +function sanitizeFailedChildError(message: string): string { + const normalized = message.replace(/\s+/g, ' ') + if (!COMPLETED_STATUS_MARKERS.some((marker) => normalized.includes(marker))) return message + return 'Child result materialization failed; open the child session for details.' +} + export function fingerprintProfile(profile: SubagentProfileConfig): string { return createHash('sha256') .update(JSON.stringify(profile, Object.keys(profile).sort())) diff --git a/kun/src/delegation/fast-context-child-executor.test.ts b/kun/src/delegation/fast-context-child-executor.test.ts index 47bd7eb98..6806e7ffb 100644 --- a/kun/src/delegation/fast-context-child-executor.test.ts +++ b/kun/src/delegation/fast-context-child-executor.test.ts @@ -100,6 +100,24 @@ class ReadThenConcludeModel implements ModelClient { } } +class ReadThenSilentFinishModel implements ModelClient { + readonly provider = 'test' + readonly model = 'read-then-silent-finish-model' + requests = 0 + + async *stream(): AsyncIterable { + this.requests += 1 + if (this.requests <= 3) { + yield { kind: 'tool_call_complete', callId: `read_${this.requests}`, toolName: 'read', arguments: { path: 'src/target.ts', task_indexes: [1] } } + yield { kind: 'completed', stopReason: 'tool_calls' } + return + } + // Final round: no synthesis text at all — the turn settles completed with + // tool_results only (the regression scenario). + yield { kind: 'completed', stopReason: 'stop' } + } +} + describe('Fast Context child executor', () => { it('bypasses provider-native SDK composition and exposes only grep, glob, and read', async () => { const model = new CatalogModel() @@ -142,6 +160,27 @@ describe('Fast Context child executor', () => { expect(reads).toBe(3) }) + it('replaces the fake tool_result summary with the evidence-pack placeholder', async () => { + const model = new ReadThenSilentFinishModel() + const executor = createChildAgentExecutor({ + model, toolHost: new LocalToolHost({ tools: [sourceTool('grep'), sourceTool('glob'), sourceTool('read')] }), + prefix: createImmutablePrefix({ systemPrompt: 'test' }), defaultModel: model.model + }) + + // The empty final round legitimately fails the loop turn, but the child + // result must not carry a stringified tool_result as its summary. + await expect(executor(fastContextInput(model.model))).rejects.toMatchObject({ + name: 'ChildResultExecutionError', + result: { + summary: 'Fast Context retrieval completed; see evidence pack.', + evidencePack: { + version: 1, + tasks: [{ evidence: [{ path: 'src/target.ts', ranges: [[10, 12]] }] }] + } + } + }) + }) + it('truncates tool-call overflow and continues with the accepted batch', async () => { const model = new OverflowThenConcludeModel() let reads = 0 diff --git a/src/renderer/src/components/chat/subagent-call-card-support.test.ts b/src/renderer/src/components/chat/subagent-call-card-support.test.ts index 0add9492c..9bac06fcf 100644 --- a/src/renderer/src/components/chat/subagent-call-card-support.test.ts +++ b/src/renderer/src/components/chat/subagent-call-card-support.test.ts @@ -139,6 +139,24 @@ describe('resolveStatus', () => { expect(resolveStatus(toolBlock('success'), {})).toBe('done') expect(resolveStatus(toolBlock('error'), {})).toBe('failed') }) + + it('treats a failed detail whose error self-describes completion as done', () => { + const block = toolBlock('error', { + childId: 'child_bad_record', + status: 'failed', + error: 'status: completed childId: child_bad_record toolInvocations: 6 resumabl...' + }) + expect(resolveStatus(block, {}, parseDelegateDetail(block.detail))).toBe('done') + }) + + it('keeps genuine failed details failed', () => { + const block = toolBlock('error', { + childId: 'child_genuine', + status: 'failed', + error: 'model provider returned HTTP 520' + }) + expect(resolveStatus(block, {}, parseDelegateDetail(block.detail))).toBe('failed') + }) }) function toolBlock(status: ToolBlock['status'], detail?: Record): ToolBlock { diff --git a/src/renderer/src/components/chat/subagent-call-card-support.tsx b/src/renderer/src/components/chat/subagent-call-card-support.tsx index 20f26fa45..3bb83b0ab 100644 --- a/src/renderer/src/components/chat/subagent-call-card-support.tsx +++ b/src/renderer/src/components/chat/subagent-call-card-support.tsx @@ -308,6 +308,20 @@ function stringList(value: unknown, maxItems: number, maxLength: number): string : [] } +const COMPLETED_STATUS_MARKERS = [ + 'status: completed', + '"status":"completed"', + '"status": "completed"' +] as const + +/** Detects legacy bad records where a stringified completed tool_result was + * used as the failure error text; such cards must not render as failed. */ +function errorSelfDescribesCompletion(error: string | undefined): boolean { + if (!error) return false + const normalized = error.replace(/\s+/g, ' ') + return COMPLETED_STATUS_MARKERS.some((marker) => normalized.includes(marker)) +} + /** Parse the new aggregate explore result without changing legacy scalar parsing. */ export function parseExploreBatchChildren(detail: string | undefined): ExploreBatchChildDetail[] { if (!detail || !detail.trim()) return [] @@ -447,6 +461,10 @@ export function resolveStatus(block: ChatBlock, child: ChildMeta, detail?: Deleg if (cs === 'failed') return 'failed' if (detail?.status === 'completed') return 'done' if (detail?.status === 'aborted') return userStopped ? 'stopped' : 'failed' + // Legacy bad records: a failed detail whose error text self-describes a + // completed child (stringified tool_result used as a fake summary) must not + // render a misleading red "failed" card; treat it as done instead. + if (detail?.status === 'failed' && errorSelfDescribesCompletion(detail.error)) return 'done' if (detail?.status === 'failed') return 'failed' // Detaching settles the wrapper tool call, not the child run. Keep live From 8a610e7ce85fa69c6be4d76e0a60d9af88582912 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 13:35:03 +0800 Subject: [PATCH 016/168] fix(delegation): tighten Fast Context false-failure guards Second-pass review follow-up of 71a73a139: - Scope the runtime-error exemption to whitelisted loop bookkeeping codes (model_empty_response, empty_post_tool_continuation, tool_loop_suppressed); other fatal errors still fail the child - Distinguish the evidence-pack placeholder summary by terminal status (completed vs incomplete) - Mark the bounded tool_result fallback preview with an ellipsis so truncated JSON is never mistaken for a complete payload - Pin the card status downgrade to Fast Context bad records that carry an evidence pack, keeping genuine failures red --- kun/src/delegation/child-agent-executor.ts | 33 +++++-- .../child-result-materializer.test.ts | 1 + .../delegation/child-result-materializer.ts | 7 +- .../fast-context-child-executor.test.ts | 90 ++++++++++++++++++- .../chat/subagent-call-card-support.test.ts | 18 +++- .../chat/subagent-call-card-support.tsx | 12 ++- 6 files changed, 145 insertions(+), 16 deletions(-) diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index 5796cf67a..f9692bf77 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -484,9 +484,11 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch // text, the pack still carries every task conclusion, so never let a // stringified tool_result or duplicated loop error text impersonate the // summary (that produced self-contradictory "failed + status: completed" - // cards). + // cards). The placeholder text tracks the settled terminal status. if (input.fastContext && evidencePack && childResultUsedNoTextSummary(items, started.turnId)) { - result.summary = 'Fast Context retrieval completed; see evidence pack.' + result.summary = status === 'completed' + ? 'Fast Context retrieval completed; see evidence pack.' + : 'Fast Context retrieval incomplete; see evidence pack.' result.summaryTruncated = undefined } const structuredResult = { @@ -513,13 +515,17 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch event.severity !== 'info' ) if (runtimeError?.kind === 'error') { - // A Fast Context child that exhausted its step budget on retrieval can - // still settle `completed` with a fatal-looking loop error such as the - // repeat-tool-call suppression notice. The evidence pack is the contract - // product, so it outranks that loop bookkeeping error; flipping the run - // to failed produced self-contradictory "failed + status: completed" - // cards while the parent had usable evidence. - const fastContextRecovered = input.fastContext === true && status === 'completed' && evidencePack !== undefined + // A Fast Context child that exhausted its retrieval budget can still + // settle `completed` with a fatal-looking loop bookkeeping error (empty + // final answer / suppressed repeat tool calls). Only those whitelisted + // loop-cleanup codes are outranked by the evidence pack; any other fatal + // error (provider crash, sandbox failure, unknown) still fails the run. + const fastContextRecovered = + input.fastContext === true && + status === 'completed' && + evidencePack !== undefined && + runtimeError.code !== undefined && + FAST_CONTEXT_RECOVERABLE_LOOP_ERROR_CODES.has(runtimeError.code) if (!fastContextRecovered) { throw new ChildResultExecutionError(runtimeError.message, structuredResult, { ...settlement, @@ -559,6 +565,15 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch } } +/** Loop bookkeeping error codes that a completed Fast Context child may + * outrank with its evidence pack (kun/src/loop/round-outcome-recovery-phase.ts). + * Anything outside this set remains fatal and fails the run as before. */ +const FAST_CONTEXT_RECOVERABLE_LOOP_ERROR_CODES = new Set([ + 'model_empty_response', + 'empty_post_tool_continuation', + 'tool_loop_suppressed' +]) + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/kun/src/delegation/child-result-materializer.test.ts b/kun/src/delegation/child-result-materializer.test.ts index 58f89abdf..6019623d4 100644 --- a/kun/src/delegation/child-result-materializer.test.ts +++ b/kun/src/delegation/child-result-materializer.test.ts @@ -32,6 +32,7 @@ describe('child result materialization', () => { })] const summary = childResultSource(items, 'turn', 'completed') expect(summary.length).toBeLessThanOrEqual(CHILD_RESULT_PREVIEW_CHARS) + expect(summary.endsWith('…')).toBe(true) expect(summary.startsWith('{"status":"completed"')).toBe(true) }) diff --git a/kun/src/delegation/child-result-materializer.ts b/kun/src/delegation/child-result-materializer.ts index f8ae53db2..8f14b487e 100644 --- a/kun/src/delegation/child-result-materializer.ts +++ b/kun/src/delegation/child-result-materializer.ts @@ -116,7 +116,12 @@ export function childResultSource( // without breaking the parent-context budget. if (toolResult) { const preview = stringifyResult(toolResult.output) - if (preview) return preview.slice(0, CHILD_RESULT_PREVIEW_CHARS) + // Truncating mid-string would produce invalid JSON; mark the omission so + // downstream JSON.parse never sees a seemingly complete payload. + if (preview.length > CHILD_RESULT_PREVIEW_CHARS) { + return `${preview.slice(0, CHILD_RESULT_PREVIEW_CHARS - 1)}…` + } + if (preview) return preview } return status === 'completed' ? 'Child agent completed without a text response.' diff --git a/kun/src/delegation/fast-context-child-executor.test.ts b/kun/src/delegation/fast-context-child-executor.test.ts index 6806e7ffb..73f44676d 100644 --- a/kun/src/delegation/fast-context-child-executor.test.ts +++ b/kun/src/delegation/fast-context-child-executor.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' import { LocalToolHost, type LocalTool } from '../adapters/tool/local-tool-host.js' import { createImmutablePrefix } from '../cache/immutable-prefix.js' import type { ModelClient, ModelRequest, ModelStreamChunk } from '../ports/model-client.js' @@ -118,6 +119,31 @@ class ReadThenSilentFinishModel implements ModelClient { } } +class ConcludeThenInjectErrorModel implements ModelClient { + readonly provider = 'test' + readonly model = 'conclude-then-inject-error-model' + requests = 0 + + constructor( + private readonly inject: () => Promise + ) {} + + async *stream(): AsyncIterable { + this.requests += 1 + if (this.requests === 1) { + yield { kind: 'tool_call_complete', callId: 'read_once', toolName: 'read', arguments: { path: 'src/target.ts', task_indexes: [1] } } + yield { kind: 'completed', stopReason: 'tool_calls' } + return + } + // The turn settles completed normally; the injected error event lands in + // the shared session store after the loop finished but before the executor + // settles the child run. + await this.inject() + yield { kind: 'assistant_text_delta', text: 'Task 1: source found.' } + yield { kind: 'completed', stopReason: 'stop' } + } +} + describe('Fast Context child executor', () => { it('bypasses provider-native SDK composition and exposes only grep, glob, and read', async () => { const model = new CatalogModel() @@ -172,7 +198,7 @@ describe('Fast Context child executor', () => { await expect(executor(fastContextInput(model.model))).rejects.toMatchObject({ name: 'ChildResultExecutionError', result: { - summary: 'Fast Context retrieval completed; see evidence pack.', + summary: 'Fast Context retrieval incomplete; see evidence pack.', evidencePack: { version: 1, tasks: [{ evidence: [{ path: 'src/target.ts', ranges: [[10, 12]] }] }] @@ -181,6 +207,68 @@ describe('Fast Context child executor', () => { }) }) + it.each([ + ['tool_loop_suppressed'], + ['model_empty_response'], + ['empty_post_tool_continuation'] + ] as const)('lets a completed Fast Context child outrank whitelisted loop error %s', async (code) => { + const sessionStore = new InMemorySessionStore() + const model = new ConcludeThenInjectErrorModel(async () => { + const started = (await sessionStore.loadEventsSince('child_fast_context', 0)) + .find((event) => event.kind === 'turn_started') + await sessionStore.appendEvent('child_fast_context', { + seq: 100, + kind: 'error', + threadId: 'child_fast_context', + ...(started?.turnId ? { turnId: started.turnId } : {}), + message: 'loop bookkeeping error', + code, + severity: 'error', + timestamp: new Date().toISOString() + }) + }) + const executor = createChildAgentExecutor({ + model, + sessionStore, + toolHost: new LocalToolHost({ tools: [sourceTool('grep'), sourceTool('glob'), sourceTool('read')] }), + prefix: createImmutablePrefix({ systemPrompt: 'test' }), defaultModel: model.model + }) + + await expect(executor(fastContextInput(model.model))).resolves.toMatchObject({ + summary: 'Task 1: source found.', + evidencePack: { version: 1 } + }) + }) + + it('still fails a completed Fast Context child for a non-whitelisted fatal error', async () => { + const sessionStore = new InMemorySessionStore() + const model = new ConcludeThenInjectErrorModel(async () => { + const started = (await sessionStore.loadEventsSince('child_fast_context', 0)) + .find((event) => event.kind === 'turn_started') + await sessionStore.appendEvent('child_fast_context', { + seq: 100, + kind: 'error', + threadId: 'child_fast_context', + ...(started?.turnId ? { turnId: started.turnId } : {}), + message: 'provider returned HTTP 520', + code: 'upstream', + severity: 'error', + timestamp: new Date().toISOString() + }) + }) + const executor = createChildAgentExecutor({ + model, + sessionStore, + toolHost: new LocalToolHost({ tools: [sourceTool('grep'), sourceTool('glob'), sourceTool('read')] }), + prefix: createImmutablePrefix({ systemPrompt: 'test' }), defaultModel: model.model + }) + + await expect(executor(fastContextInput(model.model))).rejects.toMatchObject({ + name: 'ChildResultExecutionError', + message: 'provider returned HTTP 520' + }) + }) + it('truncates tool-call overflow and continues with the accepted batch', async () => { const model = new OverflowThenConcludeModel() let reads = 0 diff --git a/src/renderer/src/components/chat/subagent-call-card-support.test.ts b/src/renderer/src/components/chat/subagent-call-card-support.test.ts index 9bac06fcf..52432d82c 100644 --- a/src/renderer/src/components/chat/subagent-call-card-support.test.ts +++ b/src/renderer/src/components/chat/subagent-call-card-support.test.ts @@ -140,15 +140,29 @@ describe('resolveStatus', () => { expect(resolveStatus(toolBlock('error'), {})).toBe('failed') }) - it('treats a failed detail whose error self-describes completion as done', () => { + it('treats a failed fast_context detail whose error self-describes completion as done', () => { const block = toolBlock('error', { childId: 'child_bad_record', status: 'failed', - error: 'status: completed childId: child_bad_record toolInvocations: 6 resumabl...' + error: 'status: completed childId: child_bad_record toolInvocations: 6 resumabl...', + evidencePack: { + version: 1, + tasks: [{ index: 0, title: 'Trace', query: 'Find', evidence: [], uncertainties: [] }], + uncertainties: [] + } }) expect(resolveStatus(block, {}, parseDelegateDetail(block.detail))).toBe('done') }) + it('keeps a contradictory failed detail failed when no evidence pack exists', () => { + const block = toolBlock('error', { + childId: 'child_no_pack', + status: 'failed', + error: 'status: completed childId: child_no_pack toolInvocations: 6 resumabl...' + }) + expect(resolveStatus(block, {}, parseDelegateDetail(block.detail))).toBe('failed') + }) + it('keeps genuine failed details failed', () => { const block = toolBlock('error', { childId: 'child_genuine', diff --git a/src/renderer/src/components/chat/subagent-call-card-support.tsx b/src/renderer/src/components/chat/subagent-call-card-support.tsx index 3bb83b0ab..5d856dc6b 100644 --- a/src/renderer/src/components/chat/subagent-call-card-support.tsx +++ b/src/renderer/src/components/chat/subagent-call-card-support.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactElement } from 'react' import type { TFunction } from 'i18next' -import type { ChatBlock } from '../../agent/types' +import type { ChatBlock, ToolBlock } from '../../agent/types' import { isTerminalSubagentStatus, type SubagentLivenessStatus @@ -463,8 +463,14 @@ export function resolveStatus(block: ChatBlock, child: ChildMeta, detail?: Deleg if (detail?.status === 'aborted') return userStopped ? 'stopped' : 'failed' // Legacy bad records: a failed detail whose error text self-describes a // completed child (stringified tool_result used as a fake summary) must not - // render a misleading red "failed" card; treat it as done instead. - if (detail?.status === 'failed' && errorSelfDescribesCompletion(detail.error)) return 'done' + // render a misleading red "failed" card. Requiring an evidence pack keeps + // this downgrade pinned to the Fast Context bad-record shape instead of any + // failure whose error text happens to mention "status: completed". + if ( + detail?.status === 'failed' && + errorSelfDescribesCompletion(detail.error) && + parseFastContextEvidencePack(block.kind === 'tool' ? (block as ToolBlock).detail : undefined) !== undefined + ) return 'done' if (detail?.status === 'failed') return 'failed' // Detaching settles the wrapper tool call, not the child run. Keep live From f72d5e0f782510dbf7fb8e33f14455194639e037 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 22 Aug 2026 13:41:01 +0800 Subject: [PATCH 017/168] feat: implement batch thread state retrieval and improve user input handling - Added `getThreadStates` method to `AgentProvider` for batch retrieval of thread states. - Updated `KunRuntimeProvider` to utilize the new batch endpoint and handle per-thread failures. - Introduced `loadThreadStates` utility to manage fallback to single-thread reads when batch retrieval fails. - Enhanced `syncTurnCompletionPoll` to prefer batch reads for multiple threads and limit concurrent single reads. - Updated sidebar activity management to reflect awaiting user input states based on thread state responses. - Added tests for new functionality, ensuring correct behavior for batch reads and state management. --- kun/src/contracts/threads.ts | 34 +++++ .../server/routes/register-thread-routes.ts | 46 +++++- kun/src/server/routes/thread-states.test.ts | 114 ++++++++++++++ kun/src/server/routes/threads.test.ts | 23 +++ kun/src/server/routes/threads.ts | 78 +++++++++- src/main/ipc/app-ipc-schemas/runtime.ts | 2 + src/renderer/src/agent/kun-contract.ts | 12 ++ .../src/agent/kun-runtime-thread-services.ts | 51 +++++++ src/renderer/src/agent/kun-runtime.test.ts | 56 +++++++ src/renderer/src/agent/kun-runtime.ts | 32 ---- src/renderer/src/agent/provider-types.ts | 30 +++- .../src/agent/thread-state-loader.test.ts | 55 +++++++ src/renderer/src/agent/thread-state-loader.ts | 67 ++++++++ .../chat/SidebarProjectsSection.tsx | 54 ++++--- ...jectsSection.worktree-dependencies.test.ts | 38 +++++ .../chat/message-timeline-bubble-support.tsx | 6 +- .../chat/message-timeline-user-bubbles.tsx | 9 +- src/renderer/src/store/chat-store-runtime.ts | 21 +++ .../src/store/chat-store-schedulers.test.ts | 54 +++++++ .../src/store/chat-store-schedulers.ts | 71 +++++++-- .../store/chat-store-sidebar-activity.test.ts | 144 +++++++++++++++++- .../src/store/chat-store-sidebar-activity.ts | 122 ++++++++++++--- src/shared/kun-endpoints.ts | 3 + 23 files changed, 1013 insertions(+), 109 deletions(-) create mode 100644 kun/src/server/routes/thread-states.test.ts create mode 100644 src/renderer/src/agent/thread-state-loader.test.ts create mode 100644 src/renderer/src/agent/thread-state-loader.ts create mode 100644 src/renderer/src/components/chat/SidebarProjectsSection.worktree-dependencies.test.ts diff --git a/kun/src/contracts/threads.ts b/kun/src/contracts/threads.ts index 21c06aaf3..36295a3fe 100644 --- a/kun/src/contracts/threads.ts +++ b/kun/src/contracts/threads.ts @@ -26,6 +26,8 @@ export const ThreadRuntimeStateSchema = z.object({ status: ThreadStatus, updatedAt: z.string(), latestSeq: z.number().int().nonnegative(), + /** Live request ids that still require a user response. */ + pendingUserInputIds: z.array(z.string().min(1)), latestTurn: z.object({ id: z.string().min(1), status: TurnStatus, @@ -34,6 +36,38 @@ export const ThreadRuntimeStateSchema = z.object({ }) export type ThreadRuntimeState = z.infer +export const THREAD_RUNTIME_STATE_BATCH_MAX_IDS = 200 +export const THREAD_RUNTIME_STATE_BATCH_CONCURRENCY = 4 + +export const ThreadRuntimeStateBatchRequestSchema = z.object({ + threadIds: z.array(z.string().trim().min(1)) + .min(1) + .max(THREAD_RUNTIME_STATE_BATCH_MAX_IDS) +}).strict() +export type ThreadRuntimeStateBatchRequest = z.infer + +export const ThreadRuntimeStateBatchResultSchema = z.discriminatedUnion('ok', [ + z.object({ + id: z.string().min(1), + ok: z.literal(true), + state: ThreadRuntimeStateSchema + }), + z.object({ + id: z.string().min(1), + ok: z.literal(false), + error: z.object({ + code: z.enum(['not_found', 'unavailable']), + message: z.string().min(1) + }) + }) +]) +export type ThreadRuntimeStateBatchResult = z.infer + +export const ThreadRuntimeStateBatchResponseSchema = z.object({ + results: z.array(ThreadRuntimeStateBatchResultSchema).max(THREAD_RUNTIME_STATE_BATCH_MAX_IDS) +}) +export type ThreadRuntimeStateBatchResponse = z.infer + export const THREAD_TIMELINE_MAX_ITEMS = 300 export const THREAD_TIMELINE_MAX_ITEM_BYTES = 4 * 1024 * 1024 diff --git a/kun/src/server/routes/register-thread-routes.ts b/kun/src/server/routes/register-thread-routes.ts index 5960174f6..b62935f87 100644 --- a/kun/src/server/routes/register-thread-routes.ts +++ b/kun/src/server/routes/register-thread-routes.ts @@ -1,4 +1,5 @@ import type { Router } from '../router.js' +import { ThreadRuntimeStateSchema, type ThreadRuntimeState } from '../../contracts/threads.js' import { createThread, clearThreadGoal, @@ -9,7 +10,9 @@ import { getThreadTodos, getThread, getThreadState, + getThreadStates, getThreadTimeline, + loadThreadRuntimeState, listThreads, setThreadGoal, setThreadTodos, @@ -65,13 +68,24 @@ export function registerThreadRoutes( if (!authorize(request, runtime)) return ERRORS.unauthorized() return contentSearchThreads(runtime.threadService, runtime.sessionStore, request) }) + // Static batch suffix must stay before the generic `/:id` detail route. + router.add('POST', '/v1/threads/states', async (request) => { + if (!authorize(request, runtime)) return ERRORS.unauthorized() + return getThreadStates(request, (threadId) => + loadOwnerAwareThreadState(runtime, request, threadId)) + }) // This static suffix must be registered before `/:id`, because Router uses // first-match ordering for parameterized paths. router.add('GET', '/v1/threads/:id/state', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() const forwarded = await runtime.forwardThreadControl?.(request, ctx.params.id) if (forwarded) return forwarded - return getThreadState(runtime.threadService, ctx.params.id, runtime.sessionStore) + return getThreadState( + runtime.threadService, + ctx.params.id, + runtime.sessionStore, + runtime.userInputGate + ) }) router.add('GET', '/v1/threads/:id/timeline', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() @@ -330,3 +344,33 @@ export function registerThreadRoutes( return llmDebugRoundsResponse(runtime) }) } + +async function loadOwnerAwareThreadState( + runtime: ServerRuntime, + batchRequest: Request, + threadId: string +): Promise { + const stateUrl = new URL( + `/v1/threads/${encodeURIComponent(threadId)}/state`, + batchRequest.url + ) + const headers = new Headers(batchRequest.headers) + headers.delete('content-length') + headers.delete('content-type') + const stateRequest = new Request(stateUrl, { + method: 'GET', + headers + }) + const forwarded = await runtime.forwardThreadControl?.(stateRequest, threadId) + if (forwarded) { + if (forwarded.status === 404) return null + if (!forwarded.ok) throw new Error(`owner state request failed: ${forwarded.status}`) + return ThreadRuntimeStateSchema.parse(await forwarded.json()) + } + return loadThreadRuntimeState( + runtime.threadService, + threadId, + runtime.sessionStore, + runtime.userInputGate + ) +} diff --git a/kun/src/server/routes/thread-states.test.ts b/kun/src/server/routes/thread-states.test.ts new file mode 100644 index 000000000..79a4d5cb1 --- /dev/null +++ b/kun/src/server/routes/thread-states.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import { getThreadStates } from './threads.js' +import { buildRouter } from './index.js' +import type { ServerRuntime } from './server-runtime.js' +import type { JsonResponse } from '../response.js' + +function runtimeState(id: string) { + return { + id, + status: 'running' as const, + updatedAt: '2026-08-22T00:00:00.000Z', + latestSeq: 1, + pendingUserInputIds: id === 'thr_7' ? ['in_7'] : [], + latestTurn: null + } +} + +describe('getThreadStates', () => { + it('deduplicates ids, bounds concurrency at four, and preserves request order', async () => { + let active = 0 + let maxActive = 0 + const loadState = vi.fn(async (id: string) => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 0)) + active -= 1 + return runtimeState(id) + }) + const threadIds = Array.from({ length: 20 }, (_, index) => `thr_${index}`) + const response = await getThreadStates(new Request('http://kun.local/v1/threads/states', { + method: 'POST', + body: JSON.stringify({ threadIds: [...threadIds, 'thr_7'] }) + }), loadState) + const body = JSON.parse(response.body) + + expect(maxActive).toBe(4) + expect(loadState).toHaveBeenCalledTimes(20) + expect(body.results.map((result: { id: string }) => result.id)).toEqual(threadIds) + expect(body.results[7].state.pendingUserInputIds).toEqual(['in_7']) + }) + + it('keeps missing and unavailable failures scoped to their thread', async () => { + const response = await getThreadStates(new Request('http://kun.local/v1/threads/states', { + method: 'POST', + body: JSON.stringify({ threadIds: ['thr_ok', 'thr_missing', 'thr_error'] }) + }), async (id) => { + if (id === 'thr_missing') return null + if (id === 'thr_error') throw new Error('owner offline') + return runtimeState(id) + }) + + expect(JSON.parse(response.body).results).toEqual([ + { id: 'thr_ok', ok: true, state: runtimeState('thr_ok') }, + { + id: 'thr_missing', ok: false, + error: { code: 'not_found', message: 'thread not found: thr_missing' } + }, + { + id: 'thr_error', ok: false, + error: { code: 'unavailable', message: 'thread state unavailable: thr_error' } + } + ]) + }) + + it('rejects more than 200 requested ids before loading any state', async () => { + const loadState = vi.fn(async (id: string) => runtimeState(id)) + const response = await getThreadStates(new Request('http://kun.local/v1/threads/states', { + method: 'POST', + body: JSON.stringify({ + threadIds: Array.from({ length: 201 }, (_, index) => `thr_${index}`) + }) + }), loadState) + + expect(response.status).toBe(400) + expect(loadState).not.toHaveBeenCalled() + }) + + it('forwards each batch state read to its execution owner', async () => { + const forwardThreadControl = vi.fn(async (_request: Request, threadId: string) => + new Response(JSON.stringify({ + ...runtimeState(threadId), + latestSeq: 3, + pendingUserInputIds: threadId === 'thr_waiting' ? ['in_waiting'] : [] + }), { status: 200 })) + const router = buildRouter({ + runtimeToken: 'thread-route-token', insecure: false, forwardThreadControl + } as unknown as ServerRuntime) + const request = new Request('http://127.0.0.1/v1/threads/states', { + method: 'POST', + headers: { + authorization: 'Bearer thread-route-token', + 'content-type': 'application/json' + }, + body: JSON.stringify({ threadIds: ['thr_running', 'thr_waiting'] }) + }) + const match = router.match('POST', new URL(request.url).pathname) + if (!match) throw new Error('thread states route not found') + + const result = await match.handler(request, { params: match.params }) as JsonResponse + expect(JSON.parse(result.body).results).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'thr_running', ok: true }), + expect.objectContaining({ + id: 'thr_waiting', + state: expect.objectContaining({ pendingUserInputIds: ['in_waiting'] }) + }) + ])) + expect(forwardThreadControl).toHaveBeenCalledTimes(2) + expect(forwardThreadControl.mock.calls.map((call) => call[1])).toEqual([ + 'thr_running', 'thr_waiting' + ]) + expect(forwardThreadControl.mock.calls[0][0]).toMatchObject({ method: 'GET' }) + expect(forwardThreadControl.mock.calls[0][0].headers.get('content-type')).toBeNull() + }) +}) diff --git a/kun/src/server/routes/threads.test.ts b/kun/src/server/routes/threads.test.ts index 9c3435d2e..f17679f6c 100644 --- a/kun/src/server/routes/threads.test.ts +++ b/kun/src/server/routes/threads.test.ts @@ -152,6 +152,7 @@ describe('getThreadState', () => { status: 'running', updatedAt: record.updatedAt, latestSeq: 73, + pendingUserInputIds: [], latestTurn: { id: 'turn_state', status: 'running', orchestration: 'direct' } }) expect(getMetadata).toHaveBeenCalledWith(record.id) @@ -167,6 +168,27 @@ describe('getThreadState', () => { expect(response.status).toBe(404) expect(JSON.parse(response.body)).toMatchObject({ code: 'not_found' }) }) + + it('projects live pending user-input ids without reading item history', async () => { + const gate = new InMemoryUserInputGate() + void gate.request({ + id: 'in_state', + threadId: 'thr_state', + turnId: 'turn_state', + itemId: 'item_state', + prompt: 'choose', + questions: [] + }).catch(() => undefined) + + const response = await getThreadState( + serviceWith('thr_state'), + 'thr_state', + undefined, + gate + ) + + expect(JSON.parse(response.body).pendingUserInputIds).toEqual(['in_state']) + }) }) describe('getThreadTimeline', () => { @@ -593,4 +615,5 @@ describe('GET /v1/threads/:id active-owner forwarding (#1053)', () => { const rejected = await match.handler(unauthorized, { params: match.params }) expect(rejected.status).toBe(401) }) + }) diff --git a/kun/src/server/routes/threads.ts b/kun/src/server/routes/threads.ts index c545c764c..11b6f769a 100644 --- a/kun/src/server/routes/threads.ts +++ b/kun/src/server/routes/threads.ts @@ -11,6 +11,8 @@ import { SetThreadGoalRequest, SetThreadTodosRequest, ThreadGoalResponse, + ThreadRuntimeStateBatchRequestSchema, + ThreadRuntimeStateBatchResponseSchema, ThreadRuntimeStateSchema, ThreadSchema, ThreadSchemaReadable, @@ -18,6 +20,7 @@ import { ThreadTodosResponse, THREAD_TIMELINE_MAX_ITEM_BYTES, THREAD_TIMELINE_MAX_ITEMS, + THREAD_RUNTIME_STATE_BATCH_CONCURRENCY, UpdateThreadRequest, type ThreadRecord } from '../../contracts/threads.js' @@ -194,22 +197,40 @@ export async function getThread( export async function getThreadState( service: ThreadService, threadId: string, - sessionStore?: SessionStore + sessionStore?: SessionStore, + userInputGate?: UserInputGate ): Promise { - const latestSeq = sessionStore ? await sessionStore.highestSeq(threadId) : 0 - const thread = await loadThreadMetadata(service, threadId) - if (!thread) { + const state = await loadThreadRuntimeState(service, threadId, sessionStore, userInputGate) + if (!state) { return jsonResponse( { code: 'not_found', message: `thread not found: ${threadId}` }, 404 ) } + return jsonResponse(state) +} + +/** Build the lightweight state projection without materializing item history. */ +export async function loadThreadRuntimeState( + service: ThreadService, + threadId: string, + sessionStore?: SessionStore, + userInputGate?: UserInputGate +): Promise | null> { + const [latestSeq, thread] = await Promise.all([ + sessionStore ? sessionStore.highestSeq(threadId) : Promise.resolve(0), + loadThreadMetadata(service, threadId) + ]) + if (!thread) { + return null + } const latestTurn = thread.turns.at(-1) - return jsonResponse(ThreadRuntimeStateSchema.parse({ + return ThreadRuntimeStateSchema.parse({ id: thread.id, status: thread.status, updatedAt: thread.updatedAt, latestSeq, + pendingUserInputIds: userInputGate?.pending(threadId).map((request) => request.id) ?? [], latestTurn: latestTurn ? { id: latestTurn.id, @@ -217,7 +238,52 @@ export async function getThreadState( orchestration: latestTurn.orchestration === 'graph' ? 'graph' : 'direct' } : null - })) + }) +} + +/** + * Resolve a bounded set of lightweight states. Failures stay scoped to their + * thread so one unavailable execution owner cannot block the rest of the list. + */ +export async function getThreadStates( + request: Request, + loadState: (threadId: string) => Promise | null> +): Promise { + const body = await readJsonBody(request) + if (!body.ok) return body.response + const parsed = ThreadRuntimeStateBatchRequestSchema.safeParse(body.value) + if (!parsed.success) { + return validationError('invalid thread states body', parsed.error.issues) + } + const threadIds = [...new Set(parsed.data.threadIds)] + const results: z.infer['results'] = + new Array(threadIds.length) + let cursor = 0 + const worker = async (): Promise => { + for (;;) { + const index = cursor + cursor += 1 + if (index >= threadIds.length) return + const id = threadIds[index] + try { + const state = await loadState(id) + results[index] = state + ? { id, ok: true, state } + : { id, ok: false, error: { code: 'not_found', message: `thread not found: ${id}` } } + } catch { + results[index] = { + id, + ok: false, + error: { code: 'unavailable', message: `thread state unavailable: ${id}` } + } + } + } + } + await Promise.all(Array.from( + { length: Math.min(THREAD_RUNTIME_STATE_BATCH_CONCURRENCY, threadIds.length) }, + worker + )) + return jsonResponse(ThreadRuntimeStateBatchResponseSchema.parse({ results })) } /** diff --git a/src/main/ipc/app-ipc-schemas/runtime.ts b/src/main/ipc/app-ipc-schemas/runtime.ts index f89dbfedc..a7749cc26 100644 --- a/src/main/ipc/app-ipc-schemas/runtime.ts +++ b/src/main/ipc/app-ipc-schemas/runtime.ts @@ -49,6 +49,7 @@ import { KUN_THREAD_MODEL_REQUESTS_TEMPLATE, KUN_THREAD_STEER_TEMPLATE, KUN_THREAD_STATE_TEMPLATE, + KUN_THREAD_STATES_TEMPLATE, KUN_THREAD_TIMELINE_TEMPLATE, KUN_THREAD_TEMPLATE, KUN_USER_INPUT_TEMPLATE, @@ -184,6 +185,7 @@ const ENDPOINTS: readonly EndpointTemplate[] = [ compileEndpoint(KUN_MCP_OAUTH_TEMPLATE, ['GET', 'DELETE']), compileEndpoint(KUN_MCP_OAUTH_SERVER_TEMPLATE, ['DELETE']), compileEndpoint(KUN_THREADS_TEMPLATE, ['GET', 'POST']), + compileEndpoint(KUN_THREAD_STATES_TEMPLATE, ['POST']), compileEndpoint(KUN_THREAD_STATE_TEMPLATE, ['GET']), compileEndpoint(KUN_THREAD_TIMELINE_TEMPLATE, ['GET']), compileEndpoint(KUN_THREAD_KNOWLEDGE_BASES_TEMPLATE, ['GET']), diff --git a/src/renderer/src/agent/kun-contract.ts b/src/renderer/src/agent/kun-contract.ts index 2ae1eeff5..d33238da0 100644 --- a/src/renderer/src/agent/kun-contract.ts +++ b/src/renderer/src/agent/kun-contract.ts @@ -94,6 +94,7 @@ export type CoreThreadRuntimeStateJson = { status: string updatedAt: string latestSeq: number + pendingUserInputIds: string[] latestTurn: { id: string status: string @@ -101,6 +102,17 @@ export type CoreThreadRuntimeStateJson = { } | null } +export type CoreThreadRuntimeStateBatchResponseJson = { + results: Array< + | { id: string; ok: true; state: CoreThreadRuntimeStateJson } + | { + id: string + ok: false + error: { code: 'not_found' | 'unavailable'; message: string } + } + > +} + export type CoreAttachmentMetadataJson = { id: string name: string diff --git a/src/renderer/src/agent/kun-runtime-thread-services.ts b/src/renderer/src/agent/kun-runtime-thread-services.ts index ed9e281e7..e14e62dbf 100644 --- a/src/renderer/src/agent/kun-runtime-thread-services.ts +++ b/src/renderer/src/agent/kun-runtime-thread-services.ts @@ -20,6 +20,7 @@ import { KUN_RUNTIME_INFO_PATH, KUN_RUNTIME_TOOLS_PATH, KUN_SKILLS_PATH, + KUN_THREAD_STATES_PATH, kunThreadCompactPath, kunThreadEventsPath, kunThreadForkPath, @@ -76,10 +77,15 @@ import type { CoreThreadGoalResponseJson, CoreThreadJson, CoreThreadRuntimeStateJson, + CoreThreadRuntimeStateBatchResponseJson, CoreThreadTimelineJson, CoreThreadSummaryJson, CoreThreadTodosResponseJson } from './kun-contract' +import type { + ThreadRuntimeState, + ThreadRuntimeStateBatchResult +} from './provider-types' import { buildQuery, chatBlockFromItem, @@ -99,6 +105,35 @@ import { } from './kun-runtime-services' export class KunRuntimeThreadServices extends KunRuntimeProviderServices { + async getThreadState(threadId: string): Promise { + const response = await rendererRuntimeClient.runtimeRequest(kunThreadStatePath(threadId), 'GET') + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to load thread state')) + } + return runtimeStateFromCore(readRuntimeJson( + response.body, + 'runtime returned an invalid thread state response' + )) + } + + async getThreadStates(threadIds: string[]): Promise { + const response = await rendererRuntimeClient.runtimeRequest( + KUN_THREAD_STATES_PATH, + 'POST', + JSON.stringify({ threadIds }) + ) + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to load thread states')) + } + const body = readRuntimeJson( + response.body, + 'runtime returned an invalid thread states response' + ) + return body.results.map((result) => result.ok + ? { id: result.id, ok: true, state: runtimeStateFromCore(result.state) } + : result) + } + async rewindThread(threadId: string, turnId: string): Promise { const response = await rendererRuntimeClient.runtimeRequest( kunThreadRewindPath(threadId), @@ -509,3 +544,19 @@ export class KunRuntimeThreadServices extends KunRuntimeProviderServices { } } + +function runtimeStateFromCore(state: CoreThreadRuntimeStateJson): ThreadRuntimeState { + return { + status: state.status, + updatedAt: state.updatedAt, + latestSeq: state.latestSeq, + pendingUserInputIds: state.pendingUserInputIds, + ...(state.latestTurn + ? { + latestTurnId: state.latestTurn.id, + latestTurnStatus: state.latestTurn.status, + latestTurnOrchestration: state.latestTurn.orchestration + } + : {}) + } +} diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index 034e29a20..0800fb72b 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -372,6 +372,7 @@ describe('KunRuntimeProvider', () => { status: 'running', updatedAt: '2026-08-07T00:00:00.000Z', latestSeq: 91, + pendingUserInputIds: ['input-state'], latestTurn: { id: 'turn_state', status: 'running', orchestration: 'direct' } }) })) @@ -381,6 +382,7 @@ describe('KunRuntimeProvider', () => { status: 'running', updatedAt: '2026-08-07T00:00:00.000Z', latestSeq: 91, + pendingUserInputIds: ['input-state'], latestTurnId: 'turn_state', latestTurnStatus: 'running', latestTurnOrchestration: 'direct' @@ -388,6 +390,60 @@ describe('KunRuntimeProvider', () => { expect(runtimeRequest).toHaveBeenCalledWith('/v1/threads/thr_state/state', 'GET') }) + it('maps batch thread states and keeps per-thread failures', async () => { + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + results: [ + { + id: 'thr_waiting', + ok: true, + state: { + id: 'thr_waiting', + status: 'running', + updatedAt: '2026-08-07T00:00:00.000Z', + latestSeq: 92, + pendingUserInputIds: ['input-waiting'], + latestTurn: null + } + }, + { + id: 'thr_missing', + ok: false, + error: { code: 'not_found', message: 'thread not found: thr_missing' } + } + ] + }) + })) + installDsGui({ runtimeRequest }) + + await expect(new KunRuntimeProvider().getThreadStates([ + 'thr_waiting', 'thr_missing' + ])).resolves.toEqual([ + { + id: 'thr_waiting', + ok: true, + state: { + status: 'running', + updatedAt: '2026-08-07T00:00:00.000Z', + latestSeq: 92, + pendingUserInputIds: ['input-waiting'] + } + }, + { + id: 'thr_missing', + ok: false, + error: { code: 'not_found', message: 'thread not found: thr_missing' } + } + ]) + expect(runtimeRequest).toHaveBeenCalledWith( + '/v1/threads/states', + 'POST', + JSON.stringify({ threadIds: ['thr_waiting', 'thr_missing'] }) + ) + }) + it('falls back to legacy full detail only when the timeline route is unavailable', async () => { const runtimeRequest = vi.fn() .mockResolvedValueOnce({ diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index 7c7944507..5698bdb17 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -29,7 +29,6 @@ import { kunThreadInterruptPath, kunThreadToolCancelPath, kunThreadPath, - kunThreadStatePath, kunThreadTimelinePath, kunThreadSteerPath, kunThreadTurnsPath, @@ -72,7 +71,6 @@ import type { CoreStartTurnResponseJson, CoreThreadGoalResponseJson, CoreThreadJson, - CoreThreadRuntimeStateJson, CoreThreadTimelineJson, CoreThreadSummaryJson, CoreThreadTodosResponseJson @@ -476,36 +474,6 @@ export class KunRuntimeProvider extends KunRuntimeThreadServices implements Agen } } - async getThreadState(threadId: string): Promise<{ - status: string - updatedAt: string - latestSeq: number - latestTurnId?: string - latestTurnStatus?: string - latestTurnOrchestration?: 'direct' | 'graph' - }> { - const response = await rendererRuntimeClient.runtimeRequest(kunThreadStatePath(threadId), 'GET') - if (!response.ok) { - throw runtimeErrorToError(readRuntimeError(response.body, 'failed to load thread state')) - } - const state = readRuntimeJson( - response.body, - 'runtime returned an invalid thread state response' - ) - return { - status: state.status, - updatedAt: state.updatedAt, - latestSeq: state.latestSeq, - ...(state.latestTurn - ? { - latestTurnId: state.latestTurn.id, - latestTurnStatus: state.latestTurn.status, - latestTurnOrchestration: state.latestTurn.orchestration - } - : {}) - } - } - async sendUserMessage( threadId: string, text: string, diff --git a/src/renderer/src/agent/provider-types.ts b/src/renderer/src/agent/provider-types.ts index e32110636..2613d201c 100644 --- a/src/renderer/src/agent/provider-types.ts +++ b/src/renderer/src/agent/provider-types.ts @@ -77,6 +77,25 @@ export type ThreadListPage = { total?: number } +export type ThreadRuntimeState = { + status: string + updatedAt: string + latestSeq: number + latestTurnId?: string + latestTurnStatus?: string + latestTurnOrchestration?: 'direct' | 'graph' + /** Undefined means an older provider did not expose live input state. */ + pendingUserInputIds?: string[] +} + +export type ThreadRuntimeStateBatchResult = + | { id: string; ok: true; state: ThreadRuntimeState } + | { + id: string + ok: false + error: { code: 'not_found' | 'unavailable'; message: string } + } + export type ThreadEventSink = { /** The HTTP/SSE stream is established, even when no replay or live event is pending. */ onConnected?(): void @@ -156,14 +175,9 @@ export interface AgentProvider { hasMoreHistory?: boolean designProfile?: DesignTaskProfile }> - getThreadState(threadId: string): Promise<{ - status: string - updatedAt: string - latestSeq: number - latestTurnId?: string - latestTurnStatus?: string - latestTurnOrchestration?: 'direct' | 'graph' - }> + getThreadState(threadId: string): Promise + /** Optional bounded bulk capability for background observers. */ + getThreadStates?(threadIds: string[]): Promise sendUserMessage( threadId: string, text: string, diff --git a/src/renderer/src/agent/thread-state-loader.test.ts b/src/renderer/src/agent/thread-state-loader.test.ts new file mode 100644 index 000000000..1a7b3fefd --- /dev/null +++ b/src/renderer/src/agent/thread-state-loader.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentProvider } from './provider-types' +import { + loadThreadStates, + THREAD_STATE_FALLBACK_CONCURRENCY +} from './thread-state-loader' + +describe('loadThreadStates', () => { + it('falls back from an unavailable batch route with bounded single reads', async () => { + const ids = Array.from({ length: 20 }, (_, index) => `thr_${index}`) + let active = 0 + let maxActive = 0 + const provider = { + getThreadStates: vi.fn(async () => { + throw new Error(JSON.stringify({ code: 'not_found', message: 'legacy route not found' })) + }), + getThreadState: vi.fn(async (id: string) => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 0)) + active -= 1 + return { + status: 'idle', + updatedAt: '', + latestSeq: Number(id.slice(4)), + pendingUserInputIds: [] + } + }) + } satisfies Pick + + const results = await loadThreadStates(provider, ids) + + expect(provider.getThreadStates).toHaveBeenCalledWith(ids) + expect(provider.getThreadState).toHaveBeenCalledTimes(20) + expect(maxActive).toBe(THREAD_STATE_FALLBACK_CONCURRENCY) + expect(results.every((result) => result.ok)).toBe(true) + }) + + it('does not fan out single reads after a transient batch failure', async () => { + const provider = { + getThreadStates: vi.fn(async () => { + throw new Error(JSON.stringify({ code: 'runtime_offline', message: 'restarting' })) + }), + getThreadState: vi.fn() + } satisfies Pick + + const results = await loadThreadStates(provider, ['thr_1', 'thr_2']) + + expect(provider.getThreadState).not.toHaveBeenCalled() + expect(results).toEqual([ + expect.objectContaining({ id: 'thr_1', ok: false }), + expect.objectContaining({ id: 'thr_2', ok: false }) + ]) + }) +}) diff --git a/src/renderer/src/agent/thread-state-loader.ts b/src/renderer/src/agent/thread-state-loader.ts new file mode 100644 index 000000000..830802957 --- /dev/null +++ b/src/renderer/src/agent/thread-state-loader.ts @@ -0,0 +1,67 @@ +import type { + AgentProvider, + ThreadRuntimeStateBatchResult +} from './provider-types' +import { getRuntimeErrorCode } from '../lib/format-runtime-error' + +export const THREAD_STATE_FALLBACK_CONCURRENCY = 4 + +/** + * Prefer the runtime's bounded bulk endpoint. Older runtimes transparently + * fall back to single-state reads, still capped so background work cannot + * saturate the renderer bridge. + */ +export async function loadThreadStates( + provider: Pick, + requestedIds: readonly string[] +): Promise { + const threadIds = [...new Set(requestedIds.filter(Boolean))] + if (threadIds.length === 0) return [] + + if (typeof provider.getThreadStates === 'function') { + try { + return await provider.getThreadStates(threadIds) + } catch (error) { + // A new renderer can connect to an older Kun runtime that has no batch + // route. The bounded single-read path below preserves compatibility. + const code = getRuntimeErrorCode(error) + if (code !== 'not_found' && code !== 'not_implemented') { + const message = error instanceof Error ? error.message : String(error) + return threadIds.map((id) => ({ + id, + ok: false, + error: { code: 'unavailable', message } + })) + } + } + } + + const results: ThreadRuntimeStateBatchResult[] = new Array(threadIds.length) + let cursor = 0 + const worker = async (): Promise => { + for (;;) { + const index = cursor + cursor += 1 + if (index >= threadIds.length) return + const id = threadIds[index] + try { + results[index] = { id, ok: true, state: await provider.getThreadState(id) } + } catch (error) { + const missing = getRuntimeErrorCode(error) === 'not_found' + results[index] = { + id, + ok: false, + error: { + code: missing ? 'not_found' : 'unavailable', + message: error instanceof Error ? error.message : String(error) + } + } + } + } + } + await Promise.all(Array.from( + { length: Math.min(THREAD_STATE_FALLBACK_CONCURRENCY, threadIds.length) }, + worker + )) + return results +} diff --git a/src/renderer/src/components/chat/SidebarProjectsSection.tsx b/src/renderer/src/components/chat/SidebarProjectsSection.tsx index e1f026c6d..2f3465c12 100644 --- a/src/renderer/src/components/chat/SidebarProjectsSection.tsx +++ b/src/renderer/src/components/chat/SidebarProjectsSection.tsx @@ -189,6 +189,35 @@ export function sddDraftHistorySavedRevision( return draft ? `${draft.id}\n${draft.updatedAt}` : '' } +/** Stable across title, status, sequence, and other activity-only updates. */ +export function sidebarThreadWorkspaceIdentityKey(threads: NormalizedThread[]): string { + return threads + .map((thread) => `${thread.id}\u0000${normalizeWorkspaceRoot(thread.workspace ?? '')}`) + .sort() + .join('\n') +} + +export function sidebarWorktreeDiscoveryKey( + threads: NormalizedThread[], + workspaceRoot: string, + workspaceRoots: string[] +): string { + const pathsByIdentity = new Map() + for (const path of [ + workspaceRoot, + ...workspaceRoots, + ...threads.map((thread) => thread.workspace ?? '') + ]) { + const key = workspaceRootIdentityKey(path) + if (key && !pathsByIdentity.has(key)) pathsByIdentity.set(key, path) + } + return JSON.stringify( + [...pathsByIdentity.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, path]) => path) + ) +} + export function SidebarProjectsSection({ threads, activeView, @@ -247,27 +276,18 @@ export function SidebarProjectsSection({ () => readThreadWorktreeRegistry().worktrees ) const [discoveredThreadWorktrees, setDiscoveredThreadWorktrees] = useState({}) + const threadWorkspaceIdentityKey = sidebarThreadWorkspaceIdentityKey(threads) + const workspaceRootsIdentityKey = workspaceRoots.map(normalizeWorkspaceRoot).sort().join('\n') useEffect(() => { setRegisteredThreadWorktrees(readThreadWorktreeRegistry().worktrees) - }, [activeThreadId, threads, workspaceRoots]) + }, [activeThreadId, threadWorkspaceIdentityKey, workspaceRootsIdentityKey]) - const worktreeDiscoveryKey = useMemo(() => { - const pathsByIdentity = new Map() - for (const path of [ - workspaceRoot, - ...workspaceRoots, - ...threads.map((thread) => thread.workspace ?? '') - ]) { - const key = workspaceRootIdentityKey(path) - if (key && !pathsByIdentity.has(key)) pathsByIdentity.set(key, path) - } - return JSON.stringify( - [...pathsByIdentity.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([, path]) => path) - ) - }, [threads, workspaceRoot, workspaceRoots]) + const worktreeDiscoveryKey = sidebarWorktreeDiscoveryKey( + threads, + workspaceRoot, + workspaceRoots + ) useEffect(() => { if (typeof window === 'undefined' || typeof window.kunGui?.getGitBranches !== 'function') return diff --git a/src/renderer/src/components/chat/SidebarProjectsSection.worktree-dependencies.test.ts b/src/renderer/src/components/chat/SidebarProjectsSection.worktree-dependencies.test.ts new file mode 100644 index 000000000..92413b673 --- /dev/null +++ b/src/renderer/src/components/chat/SidebarProjectsSection.worktree-dependencies.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import type { NormalizedThread } from '../../agent/types' +import { + sidebarThreadWorkspaceIdentityKey, + sidebarWorktreeDiscoveryKey +} from './SidebarProjectsSection' + +describe('SidebarProjectsSection worktree dependencies', () => { + it('ignores activity-only thread changes but reacts to workspace changes', () => { + const original = { + id: 'thread-1', + title: 'Thread', + workspace: '/Users/zxy/project-a', + model: 'reasonix', + mode: 'agent', + status: 'running', + latestSeq: 10, + updatedAt: '2026-08-21T00:00:00.000Z' + } satisfies NormalizedThread + const activityUpdate = { + ...original, + status: 'idle', + latestSeq: 11, + updatedAt: '2026-08-22T00:00:00.000Z' + } satisfies NormalizedThread + const workspaceUpdate = { ...activityUpdate, workspace: '/Users/zxy/project-b' } + + expect(sidebarThreadWorkspaceIdentityKey([activityUpdate])) + .toBe(sidebarThreadWorkspaceIdentityKey([original])) + expect(sidebarWorktreeDiscoveryKey( + [activityUpdate], '/Users/zxy/project-a', ['/Users/zxy/project-a'] + )).toBe(sidebarWorktreeDiscoveryKey( + [original], '/Users/zxy/project-a', ['/Users/zxy/project-a'] + )) + expect(sidebarThreadWorkspaceIdentityKey([workspaceUpdate])) + .not.toBe(sidebarThreadWorkspaceIdentityKey([original])) + }) +}) diff --git a/src/renderer/src/components/chat/message-timeline-bubble-support.tsx b/src/renderer/src/components/chat/message-timeline-bubble-support.tsx index 7e79a6f3e..abdde80b2 100644 --- a/src/renderer/src/components/chat/message-timeline-bubble-support.tsx +++ b/src/renderer/src/components/chat/message-timeline-bubble-support.tsx @@ -228,10 +228,10 @@ export function CopyFeedbackButton({ onClick={() => void handleCopy()} title={label} aria-label={label} - className={`flex shrink-0 items-center rounded-md transition ${ + className={`flex shrink-0 items-center transition ${ iconOnly - ? 'gap-0 p-1 hover:bg-ds-hover' - : 'gap-1 px-1.5 py-0.5 hover:bg-ds-hover' + ? 'gap-0 rounded-full p-1.5 hover:bg-ds-hover' + : 'gap-1 rounded-md px-1.5 py-0.5 hover:bg-ds-hover' } ${ success ? 'text-emerald-500' diff --git a/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx b/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx index ac0161d40..7777cff4b 100644 --- a/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx +++ b/src/renderer/src/components/chat/message-timeline-user-bubbles.tsx @@ -391,11 +391,8 @@ export function UserMessageBubble({ data-user-message-actions="inline" className="invisible absolute right-0 top-full z-20 flex translate-y-0.5 items-center pt-1 text-ds-faint opacity-0 transition-[opacity,transform,visibility] duration-150 motion-reduce:transition-none group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100" > -
- - {block.modelLabel ? ( -
+ ) + } return (
diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index 0e84cbc08..0acae74e5 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -590,6 +590,8 @@ export function Workbench(): ReactElement { canvasFocusMode, exitCanvasFocusMode, startNewDesignCanvasConversation, + leftSidebarCollapsed, + toggleLeftSidebar, input, setInput, composerMode, setComposerMode, composerOrchestration, graphEnabled, taskSurface, taskSurfaceLocked, taskSurfaceTransitioning, designTaskProfile, designProfileLocked, threadHasDesignDocument, lockedDesignProfile, onTaskSurfaceChange, onDesignTaskProfileChange, diff --git a/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts b/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts new file mode 100644 index 000000000..e6b51cee4 --- /dev/null +++ b/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts @@ -0,0 +1,74 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useChatStore } from '../../store/chat-store' + +const instances = vi.hoisted(() => ({ next: 0, mounted: [] as number[], unmounted: [] as number[] })) + +vi.mock('./MessageTimeline', async () => { + const React = await import('react') + return { + MessageTimeline: ({ activeThreadId }: { activeThreadId: string | null }) => { + const [instanceId] = React.useState(() => ++instances.next) + React.useEffect(() => { + instances.mounted.push(instanceId) + return () => { instances.unmounted.push(instanceId) } + }, [instanceId]) + return React.createElement('div', { + 'data-testid': 'timeline-instance', + 'data-instance-id': instanceId, + 'data-thread-id': activeThreadId + }) + } + } +}) + +import { LazyMessageTimeline } from './LazyMessageTimeline' + +function timeline(threadId: string) { + return createElement(LazyMessageTimeline, { + blocks: [], + liveReasoning: '', + live: '', + activeThreadId: threadId, + runtimeConnection: 'ready', + onRetryConnection: () => undefined, + onOpenSettings: () => undefined + }) +} + +describe('LazyMessageTimeline thread scope', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + instances.next = 0 + instances.mounted = [] + instances.unmounted = [] + useChatStore.setState({ threadLoadingId: null }) + }) + + afterEach(async () => { + if (renderer) await act(async () => renderer?.unmount()) + renderer = null + useChatStore.setState({ threadLoadingId: null }) + }) + + it('recreates local timeline state for thread and hydration-phase boundaries', async () => { + await act(async () => { renderer = create(timeline('thread-a')) }) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(1) + + await act(async () => { renderer!.update(timeline('thread-b')) }) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) + + await act(async () => { useChatStore.setState({ threadLoadingId: 'thread-b' }) }) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(3) + + await act(async () => { useChatStore.setState({ threadLoadingId: null }) }) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(4) + + await act(async () => { useChatStore.setState({ threadLoadingId: 'thread-c' }) }) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(4) + expect(instances.unmounted).toEqual([1, 2, 3]) + }) +}) diff --git a/src/renderer/src/components/chat/LazyMessageTimeline.tsx b/src/renderer/src/components/chat/LazyMessageTimeline.tsx index d86ee4f04..4de754c7e 100644 --- a/src/renderer/src/components/chat/LazyMessageTimeline.tsx +++ b/src/renderer/src/components/chat/LazyMessageTimeline.tsx @@ -6,6 +6,7 @@ import { type ReactNode } from 'react' import type { MessageTimeline } from './MessageTimeline' +import { useChatStore } from '../../store/chat-store' const LazyLoadedMessageTimeline = lazy(() => import('./MessageTimeline').then((module) => ({ default: module.MessageTimeline })) @@ -19,9 +20,14 @@ export function LazyMessageTimeline({ fallback = null, ...props }: LazyMessageTimelineProps): ReactElement { + const threadLoadingId = useChatStore((state) => state.threadLoadingId) + const hydrationPhase = props.activeThreadId && threadLoadingId === props.activeThreadId + ? 'hydrating' + : 'ready' + const timelineKey = `${props.activeThreadId ?? 'empty'}:${hydrationPhase}` return ( - + ) } diff --git a/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts b/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts new file mode 100644 index 000000000..d9ddbead1 --- /dev/null +++ b/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NormalizedThread } from '../../agent/types' +import { useChatStore } from '../../store/chat-store' +import { MessageTimeline } from './MessageTimeline' + +const activeThread: NormalizedThread = { + id: 'thread-target', + title: 'Target', + updatedAt: '2026-08-23T00:00:00.000Z', + model: 'deepseek-v4-pro', + mode: 'agent', + workspace: '/workspace/deepseek-gui', + status: 'idle' +} + +describe('MessageTimeline hydration presentation', () => { + let container: HTMLDivElement + let root: Root | null = null + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal('ResizeObserver', class { + observe(): void {} + disconnect(): void {} + }) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0) + return 1 + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined) + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) + useChatStore.setState({ + route: 'chat', + workspaceRoot: '/workspace/deepseek-gui', + activeThreadId: activeThread.id, + threadLoadingId: activeThread.id, + threads: [activeThread], + busy: false, + busyUnconfirmed: false, + currentTurnId: null, + currentTurnUserId: null, + turnStartedAtByUserId: {}, + turnDurationByUserId: {}, + turnReasoningFirstAtByUserId: {}, + turnReasoningLastAtByUserId: {}, + clawChannels: [], + activeClawChannelId: '' + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + if (root) await act(async () => root?.unmount()) + root = null + container.remove() + delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('mounts only loading until the target projection becomes ready', async () => { + const element = createElement(MessageTimeline, { + blocks: [{ kind: 'assistant', id: 'target-answer', text: 'target-ready-content' }], + liveReasoning: '', + live: '', + activeThreadId: activeThread.id, + runtimeConnection: 'ready', + onRetryConnection: () => undefined, + onOpenSettings: () => undefined + }) + await act(async () => root!.render(element)) + + expect(container.querySelector('[data-testid="thread-hydration-loading"]')).not.toBeNull() + expect(container.textContent).not.toContain('target-ready-content') + expect(container.querySelector('.timeline-jump-rail')).toBeNull() + + await act(async () => useChatStore.setState({ threadLoadingId: null })) + + expect(container.querySelector('[data-testid="thread-hydration-loading"]')).toBeNull() + expect(container.textContent).toContain('target-ready-content') + }) +}) diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index 629d1e103..93b4ac9f1 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -45,7 +45,7 @@ import { } from './message-timeline-jump-preview' import { MemoMessageTurn } from './message-timeline-conversation-turn' import type { MessageTimelineProps } from './message-timeline-props' -import { ThreadHydrationLoading } from './ThreadHydrationLoading' +import { ThreadHydrationGate } from './ThreadHydrationLoading' import { useTurnUsageState } from '../../hooks/use-turn-usage' export { @@ -161,7 +161,6 @@ export function MessageTimeline({ activeThread ? [activeThread] : [], workspaceRoot ) - const heroRoute: 'chat' | 'claw' = route === 'claw' ? 'claw' : 'chat' const hasContent = blocks.length > 0 || live || liveReasoning const endRef = useRef(null) @@ -185,7 +184,6 @@ export function MessageTimeline({ position: { x: number; y: number } context: JsonValue } | null>(null) - const turns = useMemo(() => groupTurns(blocks), [blocks]) const latestBlock = blocks[blocks.length - 1] const scrollContentKey = [ @@ -372,7 +370,6 @@ export function MessageTimeline({ const jumpRailHoveredIndex = jumpRailPreview ? visibleTurnAnchors.findIndex((item) => item.key === jumpRailPreview.key) : -1 - return (
+ {visibleTurnAnchors.length > 2 && jumpRailLayout ? (
diff --git a/src/renderer/src/components/chat/ThreadHydrationLoading.tsx b/src/renderer/src/components/chat/ThreadHydrationLoading.tsx index 51d0d9a49..9cac36607 100644 --- a/src/renderer/src/components/chat/ThreadHydrationLoading.tsx +++ b/src/renderer/src/components/chat/ThreadHydrationLoading.tsx @@ -1,7 +1,14 @@ -import type { ReactElement } from 'react' +import type { ReactElement, ReactNode } from 'react' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' +export function ThreadHydrationGate({ loading, children }: { + loading: boolean + children: ReactNode +}): ReactElement { + return loading ? : <>{children} +} + export function ThreadHydrationLoading(): ReactElement { const { t } = useTranslation('common') diff --git a/src/renderer/src/components/design/canvas/CodeCanvasPanel.test.ts b/src/renderer/src/components/design/canvas/CodeCanvasPanel.test.ts index 8dd45ac46..fdc2ac020 100644 --- a/src/renderer/src/components/design/canvas/CodeCanvasPanel.test.ts +++ b/src/renderer/src/components/design/canvas/CodeCanvasPanel.test.ts @@ -47,6 +47,7 @@ describe('CodeCanvasPanel', () => { it('reserves Electron window-control space in focused presentation', () => { expect(codeCanvasPanelTitlebarStyle('focused')).toEqual({ left: 'calc(0.75rem + var(--ds-window-controls-safe-inset))', + right: 'auto', top: 'calc(0.75rem + var(--ds-window-controls-safe-block))' }) expect(codeCanvasPanelTitlebarStyle('docked')).toBeUndefined() diff --git a/src/renderer/src/components/design/canvas/CodeCanvasPanel.tsx b/src/renderer/src/components/design/canvas/CodeCanvasPanel.tsx index d9480bc75..c2681dcf5 100644 --- a/src/renderer/src/components/design/canvas/CodeCanvasPanel.tsx +++ b/src/renderer/src/components/design/canvas/CodeCanvasPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useTranslation } from 'react-i18next' -import { Loader2, Maximize2, Minimize2, PanelRightClose, Shapes } from 'lucide-react' +import { Loader2, Maximize2, Minimize2, PanelLeft, PanelRightClose, Shapes } from 'lucide-react' import { useCanvasImageGenerationProgress, failedImageGenerationEntries, @@ -71,6 +71,11 @@ type Props = Pick< presentation?: 'docked' | 'focused' onExitFocus?: () => void onCollapse: () => void + /** Left-sidebar toggle shown in the focused titlebar so it stays reachable. */ + leftSidebarCollapsed?: boolean + onToggleLeftSidebar?: () => void + sidebarExpandLabel?: string + sidebarCollapseLabel?: string className?: string } @@ -83,18 +88,19 @@ export function codeCanvasPanelShellClass(className?: string, presentation: 'doc } export function codeCanvasPanelTitlebarClass(): string { - return 'pointer-events-auto flex h-10 max-w-[calc(100%-72px)] min-w-0 items-center gap-1.5 rounded-full border border-ds-border bg-white/82 px-1.5 shadow-[0_16px_42px_rgba(20,47,95,0.13)] backdrop-blur-2xl dark:bg-ds-card/84 dark:shadow-none' + return 'pointer-events-auto inline-flex h-10 max-w-[calc(100%-72px)] min-w-0 items-center gap-1.5 rounded-full border border-ds-border bg-white/82 px-1.5 shadow-[0_16px_42px_rgba(20,47,95,0.13)] backdrop-blur-2xl dark:bg-ds-card/84 dark:shadow-none' } export function codeCanvasPanelTitlebarStyle( -presentation: 'docked' | 'focused' + presentation: 'docked' | 'focused' ): CSSProperties | undefined { -return presentation === 'focused' -? { -left: 'calc(0.75rem + var(--ds-window-controls-safe-inset))', -top: 'calc(0.75rem + var(--ds-window-controls-safe-block))' -} -: undefined + return presentation === 'focused' + ? { + left: 'calc(0.75rem + var(--ds-window-controls-safe-inset))', + right: 'auto', + top: 'calc(0.75rem + var(--ds-window-controls-safe-block))' + } + : undefined } export function codeCanvasPanelDesignHostClass(): string { @@ -175,6 +181,10 @@ export function CodeCanvasPanel({ presentation = 'docked', onExitFocus, onCollapse, + leftSidebarCollapsed = false, + onToggleLeftSidebar, + sidebarExpandLabel, + sidebarCollapseLabel, className, busy, onOpenAgentSettings, @@ -405,6 +415,21 @@ export function CodeCanvasPanel({ style={codeCanvasPanelTitlebarStyle(presentation)} >
+ {focusedPresentation && onToggleLeftSidebar ? ( + + ) : null} {!focusedPresentation ? ( +
+ + +
+ {guardianMessage ? ( +

{guardianMessage}

+ ) : null} {daemons.length === 0 ? (
diff --git a/src/renderer/src/locales/en/common/sdd-frameworks.json b/src/renderer/src/locales/en/common/sdd-frameworks.json index 6a52608a1..67526c430 100644 --- a/src/renderer/src/locales/en/common/sdd-frameworks.json +++ b/src/renderer/src/locales/en/common/sdd-frameworks.json @@ -387,6 +387,21 @@ "daemonMasterAllPaused": "Master switch is off; no daemon will start.", "daemonMasterSaveFailed": "Could not save: {{error}}", "daemonMasterSaveMismatch": "The saved setting did not confirm the requested state.", + "daemonGuardianRun": "Run inspection", + "daemonGuardianRunning": "Inspecting…", + "daemonGuardianFailed": "Session inspection failed.", + "daemonGuardianFailedWithError": "Session inspection failed: {{error}}", + "daemonGuardianResult": "Inspection complete: repaired {{repaired}} session indexes; {{issues}} issues remain.", + "sidebarThreadPrune": "Trim conversation…", + "sidebarThreadPruneKeepTurnsPrompt": "How many recent turns should be kept? Leave blank or enter 0 to ignore turns.", + "sidebarThreadPruneKeepDaysPrompt": "How many recent days should be kept? Leave blank or enter 0 to ignore days.", + "sidebarThreadPrunePolicyRequired": "Set at least one valid turn or day retention value.", + "sidebarThreadPruneDialogTitle": "Trim “{{title}}”?", + "sidebarThreadPruneDialogDescription": "Keep messages from the latest {{turns}} turns or {{days}} days.", + "sidebarThreadPruneDialogDetail": "Older messages will be archived first; the active conversation will retain a summary and recent messages.", + "sidebarThreadPruneDialogEstimate": "Up to {{count}} turns may be trimmed after an automatic archive.", + "sidebarThreadPruneConfirmButton": "Archive and trim", + "sidebarThreadPruneFailed": "Could not trim the conversation.", "daemonKeepAwakeHint": "prevents sleep only; it does not enable daemons", "daemonSummary": "{{running}} running · {{paused}} paused", "daemonState_starting": "Starting", diff --git a/src/renderer/src/locales/hi/common/sdd-frameworks.json b/src/renderer/src/locales/hi/common/sdd-frameworks.json index f85275ca5..600baaa2c 100644 --- a/src/renderer/src/locales/hi/common/sdd-frameworks.json +++ b/src/renderer/src/locales/hi/common/sdd-frameworks.json @@ -382,6 +382,21 @@ "daemonMasterAllPaused": "Master switch is off; no daemon will start.", "daemonMasterSaveFailed": "Could not save: {{error}}", "daemonMasterSaveMismatch": "The saved setting did not confirm the requested state.", + "daemonGuardianRun": "Run inspection", + "daemonGuardianRunning": "Inspecting…", + "daemonGuardianFailed": "Session inspection failed.", + "daemonGuardianFailedWithError": "Session inspection failed: {{error}}", + "daemonGuardianResult": "Inspection complete: repaired {{repaired}} session indexes; {{issues}} issues remain.", + "sidebarThreadPrune": "Trim conversation…", + "sidebarThreadPruneKeepTurnsPrompt": "How many recent turns should be kept? Leave blank or enter 0 to ignore turns.", + "sidebarThreadPruneKeepDaysPrompt": "How many recent days should be kept? Leave blank or enter 0 to ignore days.", + "sidebarThreadPrunePolicyRequired": "Set at least one valid turn or day retention value.", + "sidebarThreadPruneDialogTitle": "Trim “{{title}}”?", + "sidebarThreadPruneDialogDescription": "Keep messages from the latest {{turns}} turns or {{days}} days.", + "sidebarThreadPruneDialogDetail": "Older messages will be archived first; the active conversation will retain a summary and recent messages.", + "sidebarThreadPruneDialogEstimate": "Up to {{count}} turns may be trimmed after an automatic archive.", + "sidebarThreadPruneConfirmButton": "Archive and trim", + "sidebarThreadPruneFailed": "Could not trim the conversation.", "daemonKeepAwakeHint": "prevents sleep only; it does not enable daemons", "daemonSummary": "{{running}} running · {{paused}} paused", "daemonState_starting": "Starting", diff --git a/src/renderer/src/locales/ja/common/sdd-frameworks.json b/src/renderer/src/locales/ja/common/sdd-frameworks.json index 014de6eeb..2964b3297 100644 --- a/src/renderer/src/locales/ja/common/sdd-frameworks.json +++ b/src/renderer/src/locales/ja/common/sdd-frameworks.json @@ -382,6 +382,21 @@ "daemonMasterAllPaused": "Master switch is off; no daemon will start.", "daemonMasterSaveFailed": "Could not save: {{error}}", "daemonMasterSaveMismatch": "The saved setting did not confirm the requested state.", + "daemonGuardianRun": "Run inspection", + "daemonGuardianRunning": "Inspecting…", + "daemonGuardianFailed": "Session inspection failed.", + "daemonGuardianFailedWithError": "Session inspection failed: {{error}}", + "daemonGuardianResult": "Inspection complete: repaired {{repaired}} session indexes; {{issues}} issues remain.", + "sidebarThreadPrune": "Trim conversation…", + "sidebarThreadPruneKeepTurnsPrompt": "How many recent turns should be kept? Leave blank or enter 0 to ignore turns.", + "sidebarThreadPruneKeepDaysPrompt": "How many recent days should be kept? Leave blank or enter 0 to ignore days.", + "sidebarThreadPrunePolicyRequired": "Set at least one valid turn or day retention value.", + "sidebarThreadPruneDialogTitle": "Trim “{{title}}”?", + "sidebarThreadPruneDialogDescription": "Keep messages from the latest {{turns}} turns or {{days}} days.", + "sidebarThreadPruneDialogDetail": "Older messages will be archived first; the active conversation will retain a summary and recent messages.", + "sidebarThreadPruneDialogEstimate": "Up to {{count}} turns may be trimmed after an automatic archive.", + "sidebarThreadPruneConfirmButton": "Archive and trim", + "sidebarThreadPruneFailed": "Could not trim the conversation.", "daemonKeepAwakeHint": "prevents sleep only; it does not enable daemons", "daemonSummary": "{{running}} running · {{paused}} paused", "daemonState_starting": "Starting", diff --git a/src/renderer/src/locales/ko/common/sdd-frameworks.json b/src/renderer/src/locales/ko/common/sdd-frameworks.json index b67faceea..ef67869d2 100644 --- a/src/renderer/src/locales/ko/common/sdd-frameworks.json +++ b/src/renderer/src/locales/ko/common/sdd-frameworks.json @@ -382,6 +382,21 @@ "daemonMasterAllPaused": "Master switch is off; no daemon will start.", "daemonMasterSaveFailed": "Could not save: {{error}}", "daemonMasterSaveMismatch": "The saved setting did not confirm the requested state.", + "daemonGuardianRun": "Run inspection", + "daemonGuardianRunning": "Inspecting…", + "daemonGuardianFailed": "Session inspection failed.", + "daemonGuardianFailedWithError": "Session inspection failed: {{error}}", + "daemonGuardianResult": "Inspection complete: repaired {{repaired}} session indexes; {{issues}} issues remain.", + "sidebarThreadPrune": "Trim conversation…", + "sidebarThreadPruneKeepTurnsPrompt": "How many recent turns should be kept? Leave blank or enter 0 to ignore turns.", + "sidebarThreadPruneKeepDaysPrompt": "How many recent days should be kept? Leave blank or enter 0 to ignore days.", + "sidebarThreadPrunePolicyRequired": "Set at least one valid turn or day retention value.", + "sidebarThreadPruneDialogTitle": "Trim “{{title}}”?", + "sidebarThreadPruneDialogDescription": "Keep messages from the latest {{turns}} turns or {{days}} days.", + "sidebarThreadPruneDialogDetail": "Older messages will be archived first; the active conversation will retain a summary and recent messages.", + "sidebarThreadPruneDialogEstimate": "Up to {{count}} turns may be trimmed after an automatic archive.", + "sidebarThreadPruneConfirmButton": "Archive and trim", + "sidebarThreadPruneFailed": "Could not trim the conversation.", "daemonKeepAwakeHint": "prevents sleep only; it does not enable daemons", "daemonSummary": "{{running}} running · {{paused}} paused", "daemonState_starting": "Starting", diff --git a/src/renderer/src/locales/ru/common/sdd-frameworks.json b/src/renderer/src/locales/ru/common/sdd-frameworks.json index b3de7170c..36f3e7689 100644 --- a/src/renderer/src/locales/ru/common/sdd-frameworks.json +++ b/src/renderer/src/locales/ru/common/sdd-frameworks.json @@ -382,6 +382,21 @@ "daemonMasterAllPaused": "Master switch is off; no daemon will start.", "daemonMasterSaveFailed": "Could not save: {{error}}", "daemonMasterSaveMismatch": "The saved setting did not confirm the requested state.", + "daemonGuardianRun": "Run inspection", + "daemonGuardianRunning": "Inspecting…", + "daemonGuardianFailed": "Session inspection failed.", + "daemonGuardianFailedWithError": "Session inspection failed: {{error}}", + "daemonGuardianResult": "Inspection complete: repaired {{repaired}} session indexes; {{issues}} issues remain.", + "sidebarThreadPrune": "Trim conversation…", + "sidebarThreadPruneKeepTurnsPrompt": "How many recent turns should be kept? Leave blank or enter 0 to ignore turns.", + "sidebarThreadPruneKeepDaysPrompt": "How many recent days should be kept? Leave blank or enter 0 to ignore days.", + "sidebarThreadPrunePolicyRequired": "Set at least one valid turn or day retention value.", + "sidebarThreadPruneDialogTitle": "Trim “{{title}}”?", + "sidebarThreadPruneDialogDescription": "Keep messages from the latest {{turns}} turns or {{days}} days.", + "sidebarThreadPruneDialogDetail": "Older messages will be archived first; the active conversation will retain a summary and recent messages.", + "sidebarThreadPruneDialogEstimate": "Up to {{count}} turns may be trimmed after an automatic archive.", + "sidebarThreadPruneConfirmButton": "Archive and trim", + "sidebarThreadPruneFailed": "Could not trim the conversation.", "daemonKeepAwakeHint": "prevents sleep only; it does not enable daemons", "daemonSummary": "{{running}} running · {{paused}} paused", "daemonState_starting": "Starting", diff --git a/src/renderer/src/locales/th/common/sdd-frameworks.json b/src/renderer/src/locales/th/common/sdd-frameworks.json index 48baa3d39..0536068f9 100644 --- a/src/renderer/src/locales/th/common/sdd-frameworks.json +++ b/src/renderer/src/locales/th/common/sdd-frameworks.json @@ -382,6 +382,21 @@ "daemonMasterAllPaused": "Master switch is off; no daemon will start.", "daemonMasterSaveFailed": "Could not save: {{error}}", "daemonMasterSaveMismatch": "The saved setting did not confirm the requested state.", + "daemonGuardianRun": "Run inspection", + "daemonGuardianRunning": "Inspecting…", + "daemonGuardianFailed": "Session inspection failed.", + "daemonGuardianFailedWithError": "Session inspection failed: {{error}}", + "daemonGuardianResult": "Inspection complete: repaired {{repaired}} session indexes; {{issues}} issues remain.", + "sidebarThreadPrune": "Trim conversation…", + "sidebarThreadPruneKeepTurnsPrompt": "How many recent turns should be kept? Leave blank or enter 0 to ignore turns.", + "sidebarThreadPruneKeepDaysPrompt": "How many recent days should be kept? Leave blank or enter 0 to ignore days.", + "sidebarThreadPrunePolicyRequired": "Set at least one valid turn or day retention value.", + "sidebarThreadPruneDialogTitle": "Trim “{{title}}”?", + "sidebarThreadPruneDialogDescription": "Keep messages from the latest {{turns}} turns or {{days}} days.", + "sidebarThreadPruneDialogDetail": "Older messages will be archived first; the active conversation will retain a summary and recent messages.", + "sidebarThreadPruneDialogEstimate": "Up to {{count}} turns may be trimmed after an automatic archive.", + "sidebarThreadPruneConfirmButton": "Archive and trim", + "sidebarThreadPruneFailed": "Could not trim the conversation.", "daemonKeepAwakeHint": "prevents sleep only; it does not enable daemons", "daemonSummary": "{{running}} running · {{paused}} paused", "daemonState_starting": "Starting", diff --git a/src/renderer/src/locales/zh/common/sdd-frameworks.json b/src/renderer/src/locales/zh/common/sdd-frameworks.json index 12f6e5639..2b2bb6248 100644 --- a/src/renderer/src/locales/zh/common/sdd-frameworks.json +++ b/src/renderer/src/locales/zh/common/sdd-frameworks.json @@ -387,6 +387,21 @@ "daemonMasterAllPaused": "总开关已关闭,不会启动任何守护任务。", "daemonMasterSaveFailed": "保存失败:{{error}}", "daemonMasterSaveMismatch": "保存后的设置未确认请求的状态。", + "daemonGuardianRun": "立即巡检", + "daemonGuardianRunning": "正在巡检…", + "daemonGuardianFailed": "会话巡检失败。", + "daemonGuardianFailedWithError": "会话巡检失败:{{error}}", + "daemonGuardianResult": "巡检完成:已修复 {{repaired}} 个会话索引,剩余 {{issues}} 个问题。", + "sidebarThreadPrune": "裁剪会话…", + "sidebarThreadPruneKeepTurnsPrompt": "保留最近多少轮?留空或填 0 表示不按轮数限制。", + "sidebarThreadPruneKeepDaysPrompt": "保留最近多少天?留空或填 0 表示不按天数限制。", + "sidebarThreadPrunePolicyRequired": "请至少设置一个有效的保留轮数或天数。", + "sidebarThreadPruneDialogTitle": "裁剪会话「{{title}}」?", + "sidebarThreadPruneDialogDescription": "将保留最近 {{turns}} 轮或 {{days}} 天内的消息。", + "sidebarThreadPruneDialogDetail": "旧消息会在裁剪前自动归档;裁剪后活动线程将只保留摘要和近期消息。", + "sidebarThreadPruneDialogEstimate": "预计裁剪最多 {{count}} 轮;旧消息会先自动归档。", + "sidebarThreadPruneConfirmButton": "归档并裁剪", + "sidebarThreadPruneFailed": "裁剪会话失败。", "daemonKeepAwakeHint": "仅阻止休眠,不会启用守护", "daemonSummary": "{{running}} 正在运行 · {{paused}} 已暂停", "daemonState_starting": "正在启动", diff --git a/src/shared/app-settings-schedule.ts b/src/shared/app-settings-schedule.ts index 719e468c9..789c73c6f 100644 --- a/src/shared/app-settings-schedule.ts +++ b/src/shared/app-settings-schedule.ts @@ -106,7 +106,7 @@ export function normalizeDaemonSettings(value: unknown): SessionDaemonSettingsV1 const source = isRecord(value) ? value : {} const now = new Date().toISOString() return { - enabled: normalizeBoolean(source.enabled, false), + enabled: normalizeBoolean(source.enabled, true), items: Array.isArray(source.items) ? source.items .filter(isRecord) @@ -135,7 +135,7 @@ export function defaultScheduleSettings(): ScheduleSettingsV1 { }, tasks: [], daemons: { - enabled: false, + enabled: true, items: [] } } diff --git a/src/shared/app-settings.schedule-claw.test.ts b/src/shared/app-settings.schedule-claw.test.ts index ca9e011cf..adb39a5bd 100644 --- a/src/shared/app-settings.schedule-claw.test.ts +++ b/src/shared/app-settings.schedule-claw.test.ts @@ -215,8 +215,9 @@ describe('schedule settings', () => { expect(daemon.enabled).toBe(true) }) - it('defaults daemons to disabled and preserves them through merge', () => { - expect(normalizeScheduleSettings(undefined).daemons).toEqual({ enabled: false, items: [] }) + it('defaults daemons to enabled and preserves explicit disable through merge', () => { + expect(normalizeScheduleSettings(undefined).daemons).toEqual({ enabled: true, items: [] }) + expect(normalizeScheduleSettings({ daemons: { enabled: false, items: [] } }).daemons.enabled).toBe(false) const merged = mergeScheduleSettings(normalizeScheduleSettings(undefined), { daemons: { diff --git a/src/shared/kun-endpoints.ts b/src/shared/kun-endpoints.ts index a76e947ac..2b6491fd6 100644 --- a/src/shared/kun-endpoints.ts +++ b/src/shared/kun-endpoints.ts @@ -16,6 +16,8 @@ export const KUN_RUNTIME_INFO_TEMPLATE = '/v1/runtime/info' export const KUN_RUNTIME_TOOLS_PATH = '/v1/runtime/tools' export const KUN_RUNTIME_TOOLS_TEMPLATE = '/v1/runtime/tools' +export const KUN_THREAD_GUARDIAN_PATH = '/v1/runtime/thread-guardian' +export const KUN_THREAD_GUARDIAN_TEMPLATE = '/v1/runtime/thread-guardian' export const KUN_MODEL_CONNECTIONS_PATH = '/v1/model-connections' export const KUN_MODEL_CONNECTIONS_TEMPLATE = '/v1/model-connections' @@ -274,6 +276,11 @@ export function kunThreadCompactPath(threadId: string): string { return `${kunThreadPath(threadId)}/compact` } +export const KUN_THREAD_PRUNE_TEMPLATE = '/v1/threads/{id}/prune' +export function kunThreadPrunePath(threadId: string): string { + return `${kunThreadPath(threadId)}/prune` +} + export const KUN_THREAD_REVIEW_TEMPLATE = '/v1/threads/{id}/review' export function kunThreadReviewPath(threadId: string): string { return `${kunThreadPath(threadId)}/review` From 7403e9c869cfb42408db81449db2e1032fa8de25 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 23 Aug 2026 23:42:06 +0800 Subject: [PATCH 042/168] fix(renderer): bound snapshots and preserve hydration state --- src/renderer/src/StartupGate.test.ts | 109 +++++++++- src/renderer/src/StartupGate.tsx | 192 ++++++++++++++---- .../src/components/AppErrorBoundary.tsx | 25 +-- .../LazyMessageTimeline.thread-scope.test.ts | 10 +- .../components/chat/LazyMessageTimeline.tsx | 7 +- ...essageTimeline.hydration-exclusive.test.ts | 9 +- .../src/components/chat/MessageTimeline.tsx | 4 +- .../chat/ThreadHydrationLoading.tsx | 9 +- src/renderer/src/lib/application-reload.ts | 21 ++ src/renderer/src/startup-shell.test.ts | 13 +- src/renderer/src/startup-shell.ts | 16 ++ .../store/chat-store-thread-refresh.test.ts | 163 +++++++++++++++ .../chat-store-thread-selection-actions.ts | 39 +++- .../src/store/thread-snapshot-cache.test.ts | 71 ++++++- .../src/store/thread-snapshot-cache.ts | 73 ++++++- 15 files changed, 672 insertions(+), 89 deletions(-) create mode 100644 src/renderer/src/lib/application-reload.ts create mode 100644 src/renderer/src/store/chat-store-thread-refresh.test.ts diff --git a/src/renderer/src/StartupGate.test.ts b/src/renderer/src/StartupGate.test.ts index a3d303083..218992e12 100644 --- a/src/renderer/src/StartupGate.test.ts +++ b/src/renderer/src/StartupGate.test.ts @@ -3,7 +3,7 @@ import { act, createElement, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopStartupPhase } from '@shared/desktop-startup-state' -import { StartupGate } from './StartupGate' +import { StartupGate, STARTUP_STATE_TIMEOUT_MS } from './StartupGate' vi.mock('./components/StorageRelocationBootView', () => ({ StorageRelocationBootView: () => createElement('div', { 'data-testid': 'storage-relocation-view' }) @@ -31,6 +31,15 @@ async function flushAsync(rounds = 6): Promise { type PhaseListener = (phase: DesktopStartupPhase) => void +function deferredValue(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + function setReactActEnvironment(value: boolean): void { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = value } @@ -68,6 +77,7 @@ describe('StartupGate', () => { container.remove() setReactActEnvironment(false) delete (window as unknown as { kunGui?: unknown }).kunGui + vi.useRealTimers() vi.clearAllMocks() }) @@ -86,6 +96,103 @@ describe('StartupGate', () => { }) } + it('shows a retryable error when the startup API is missing', async () => { + renderGate({}) + await flushAsync() + + expect(container.textContent).toContain('Failed to read Kun startup state') + expect(container.textContent).toContain('desktop startup API is unavailable') + expect(container.querySelector('[data-testid="workbench-app"]')).toBeNull() + }) + + it('subscribes before reading startup state and never regresses a ready event', async () => { + const calls: string[] = [] + const pending = deferredValue() + const listeners = new Set() + ;(window as unknown as { kunGui: unknown }).kunGui = { + startup: { + onState: vi.fn((listener: PhaseListener) => { + calls.push('subscribe') + listeners.add(listener) + return () => listeners.delete(listener) + }), + getState: vi.fn(() => { + calls.push('getState') + return pending.promise + }) + } + } + renderGate({}) + expect(calls[0]).toBe('subscribe') + expect(calls[1]).toBe('getState') + + await act(async () => listeners.forEach((listener) => listener('ready'))) + await flushAsync() + expect(container.querySelector('[data-testid="workbench-app"]')).not.toBeNull() + + await act(async () => pending.resolve('runtime_starting')) + await flushAsync() + expect(container.querySelector('[data-testid="workbench-app"]')).not.toBeNull() + }) + + it('retries after startup state rejects', async () => { + let shouldReject = true + const listeners = new Set() + ;(window as unknown as { kunGui: unknown }).kunGui = { + startup: { + onState: (listener: PhaseListener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + getState: () => shouldReject + ? Promise.reject(new Error('startup IPC unavailable')) + : Promise.resolve('ready' as const) + } + } + renderGate({}) + await flushAsync() + expect(container.textContent).toContain('startup IPC unavailable') + + shouldReject = false + const retry = [...container.querySelectorAll('button')] + .find((button) => button.textContent === 'Retry') + await act(async () => retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + await flushAsync() + expect(container.querySelector('[data-testid="workbench-app"]')).not.toBeNull() + }) + + it('times out a pending startup snapshot and ignores its late result', async () => { + vi.useFakeTimers() + const pending = deferredValue() + const listeners = new Set() + ;(window as unknown as { kunGui: unknown }).kunGui = { + startup: { + onState: (listener: PhaseListener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + getState: () => pending.promise + } + } + renderGate({}) + await act(async () => vi.advanceTimersByTimeAsync(STARTUP_STATE_TIMEOUT_MS)) + expect(container.textContent).toContain('timed out') + + await act(async () => pending.resolve('ready')) + await flushAsync() + expect(container.textContent).toContain('Failed to read Kun startup state') + expect(container.querySelector('[data-testid="workbench-app"]')).toBeNull() + }) + + it('opens logs from a startup error and reports unavailable recovery APIs', async () => { + renderGate({}) + await flushAsync() + const openLogs = [...container.querySelectorAll('button')] + .find((button) => button.textContent === 'Open log folder') + await act(async () => openLogs?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + expect(container.textContent).toContain('log folder API is unavailable') + }) + it('shows the startup shell for the initial phase', async () => { const api = installStartupApi('bootstrapping') renderGate({}) diff --git a/src/renderer/src/StartupGate.tsx b/src/renderer/src/StartupGate.tsx index 39256b60b..19292acc3 100644 --- a/src/renderer/src/StartupGate.tsx +++ b/src/renderer/src/StartupGate.tsx @@ -1,6 +1,11 @@ import React, { lazy, useCallback, useEffect, useRef, useState } from 'react' import type { DesktopStartupPhase } from '@shared/desktop-startup-state' -import { startupPhaseLabel, startupShellAllowsWorkbench } from './startup-shell' +import { requestApplicationReload } from './lib/application-reload' +import { + mergeStartupPhase, + startupPhaseLabel, + startupShellAllowsWorkbench +} from './startup-shell' const StorageRelocationBootView = lazy(async () => { const { StorageRelocationBootView: view } = await import('./components/StorageRelocationBootView') @@ -17,6 +22,7 @@ const WorkbenchApp = lazy(async () => { }) const fallback =
+export const STARTUP_STATE_TIMEOUT_MS = 10_000 export interface StartupGateProps { storageRelocationMode: boolean @@ -29,44 +35,144 @@ type WorkbenchBootState = | { status: 'error'; message: string } | { status: 'ready' } +type StartupHandshakeState = + | { status: 'loading' } + | { status: 'ready' } + | { status: 'error'; message: string } + function bootErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message return String(error) } +function StartupErrorView({ + title, + message, + detail, + actionError, + onRetry, + onOpenLogs +}: { + title: string + message: string + detail: string + actionError: string | null + onRetry: () => void + onOpenLogs: () => void +}): React.ReactElement { + return ( +
+
+
+
+ ) +} + /** - * Owns the full renderer lifecycle for the single React root: startup shell, - * special boot views, and the workbench App. Phase transitions go through - * normal reconciliation instead of repeated createRoot calls on #root. - * - * The workbench bootstrap (shared storage install + App chunk load) is an - * explicit idle/loading/error/ready state machine. A failed bootstrap surfaces - * an error view with a retry action instead of leaving the shell locked, and - * shared storage installation is idempotent so a retry never starts a second - * polling timer. + * Owns the full renderer lifecycle for the single React root. Startup state and + * workbench bootstrap failures are independently retryable. */ export function StartupGate({ storageRelocationMode, runtimeMigrationRecoveryMode }: StartupGateProps): React.ReactElement { const [phase, setPhase] = useState('bootstrapping') + const [startupHandshake, setStartupHandshake] = useState({ + status: 'loading' + }) + const [startupAttempt, setStartupAttempt] = useState(0) + const [recoveryActionError, setRecoveryActionError] = useState(null) const [boot, setBoot] = useState({ status: 'idle' }) const bootRunRef = useRef(0) useEffect(() => { if (storageRelocationMode || runtimeMigrationRecoveryMode) return + setStartupHandshake({ status: 'loading' }) + setRecoveryActionError(null) const startup = window.kunGui?.startup - if (!startup) return - let cancelled = false - void startup.getState().then((initial) => { - if (!cancelled) setPhase(initial) - }) - const unsubscribe = startup.onState((next) => setPhase(next)) - return () => { - cancelled = true - unsubscribe() + if (!startup) { + setStartupHandshake({ + status: 'error', + message: 'The desktop startup API is unavailable.' + }) + return + } + let active = true + let observedPhase = false + let timeout: ReturnType | null = null + let unsubscribe: (() => void) | null = null + const dispose = (): void => { + if (!active) return + active = false + if (timeout) clearTimeout(timeout) + unsubscribe?.() } - }, [storageRelocationMode, runtimeMigrationRecoveryMode]) + const acceptPhase = (next: DesktopStartupPhase): void => { + if (!active) return + observedPhase = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + setPhase((current) => mergeStartupPhase(current, next)) + setStartupHandshake({ status: 'ready' }) + } + const fail = (error: unknown): void => { + if (!active || observedPhase) return + const message = bootErrorMessage(error) + dispose() + setStartupHandshake({ status: 'error', message }) + } + try { + unsubscribe = startup.onState(acceptPhase) + } catch (error) { + fail(error) + return dispose + } + if (!observedPhase) { + timeout = setTimeout(() => { + fail(new Error(`Desktop startup state timed out after ${STARTUP_STATE_TIMEOUT_MS}ms.`)) + }, STARTUP_STATE_TIMEOUT_MS) + } + try { + void startup.getState().then(acceptPhase, fail) + } catch (error) { + fail(error) + } + return dispose + }, [storageRelocationMode, runtimeMigrationRecoveryMode, startupAttempt]) + + const openLogs = useCallback(() => { + setRecoveryActionError(null) + const openLogDir = window.kunGui?.openLogDir + if (typeof openLogDir !== 'function') { + setRecoveryActionError('The desktop log folder API is unavailable.') + return + } + void openLogDir().then((result) => { + if (!result.ok) setRecoveryActionError(result.message || 'Failed to open the log folder.') + }, (error) => setRecoveryActionError(bootErrorMessage(error))) + }, []) + + const retryStartup = useCallback(() => { + setStartupAttempt((attempt) => attempt + 1) + }, []) const startWorkbench = useCallback(() => { bootRunRef.current += 1 @@ -87,12 +193,20 @@ export function StartupGate({ useEffect(() => { if (storageRelocationMode || runtimeMigrationRecoveryMode) return + if (startupHandshake.status !== 'ready') return if (!startupShellAllowsWorkbench(phase)) return // 'idle' starts automatically once the shell allows the workbench; // 'error' only restarts through the explicit retry action. if (boot.status !== 'idle') return startWorkbench() - }, [phase, boot.status, storageRelocationMode, runtimeMigrationRecoveryMode, startWorkbench]) + }, [ + phase, + boot.status, + startupHandshake.status, + storageRelocationMode, + runtimeMigrationRecoveryMode, + startWorkbench + ]) if (storageRelocationMode) { return ( @@ -108,6 +222,18 @@ export function StartupGate({ ) } + if (startupHandshake.status === 'error') { + return ( + + ) + } if (boot.status === 'ready') { return ( @@ -117,22 +243,14 @@ export function StartupGate({ } if (boot.status === 'error') { return ( -
-
-
-
+ ) } return ( diff --git a/src/renderer/src/components/AppErrorBoundary.tsx b/src/renderer/src/components/AppErrorBoundary.tsx index b7cf7bb20..d88065d78 100644 --- a/src/renderer/src/components/AppErrorBoundary.tsx +++ b/src/renderer/src/components/AppErrorBoundary.tsx @@ -1,5 +1,8 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' import i18n from '../i18n' +import { requestApplicationReload } from '../lib/application-reload' + +export { requestApplicationReload } from '../lib/application-reload' type Props = { children: ReactNode @@ -9,28 +12,6 @@ type State = { error: Error | null } -type AppReloadWindow = { - kunGui?: { - runDesktopCommand?: (command: 'reload') => Promise - } - location: { - reload: () => void - } -} - -export function requestApplicationReload(target: AppReloadWindow = window): void { - const runDesktopCommand = target.kunGui?.runDesktopCommand - if (typeof runDesktopCommand !== 'function') { - target.location.reload() - return - } - try { - void runDesktopCommand('reload').catch(() => target.location.reload()) - } catch { - target.location.reload() - } -} - export class AppErrorBoundary extends Component { state: State = { error: null } diff --git a/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts b/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts index e6b51cee4..87671e1c5 100644 --- a/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts +++ b/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts @@ -54,7 +54,7 @@ describe('LazyMessageTimeline thread scope', () => { useChatStore.setState({ threadLoadingId: null }) }) - it('recreates local timeline state for thread and hydration-phase boundaries', async () => { + it('recreates local state only when the thread identity changes', async () => { await act(async () => { renderer = create(timeline('thread-a')) }) expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(1) @@ -62,13 +62,13 @@ describe('LazyMessageTimeline thread scope', () => { expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) await act(async () => { useChatStore.setState({ threadLoadingId: 'thread-b' }) }) - expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(3) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) await act(async () => { useChatStore.setState({ threadLoadingId: null }) }) - expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(4) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) await act(async () => { useChatStore.setState({ threadLoadingId: 'thread-c' }) }) - expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(4) - expect(instances.unmounted).toEqual([1, 2, 3]) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) + expect(instances.unmounted).toEqual([1]) }) }) diff --git a/src/renderer/src/components/chat/LazyMessageTimeline.tsx b/src/renderer/src/components/chat/LazyMessageTimeline.tsx index 4de754c7e..895b43a09 100644 --- a/src/renderer/src/components/chat/LazyMessageTimeline.tsx +++ b/src/renderer/src/components/chat/LazyMessageTimeline.tsx @@ -6,7 +6,6 @@ import { type ReactNode } from 'react' import type { MessageTimeline } from './MessageTimeline' -import { useChatStore } from '../../store/chat-store' const LazyLoadedMessageTimeline = lazy(() => import('./MessageTimeline').then((module) => ({ default: module.MessageTimeline })) @@ -20,11 +19,7 @@ export function LazyMessageTimeline({ fallback = null, ...props }: LazyMessageTimelineProps): ReactElement { - const threadLoadingId = useChatStore((state) => state.threadLoadingId) - const hydrationPhase = props.activeThreadId && threadLoadingId === props.activeThreadId - ? 'hydrating' - : 'ready' - const timelineKey = `${props.activeThreadId ?? 'empty'}:${hydrationPhase}` + const timelineKey = props.activeThreadId ?? 'empty' return ( diff --git a/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts b/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts index d9ddbead1..2701f213c 100644 --- a/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts +++ b/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts @@ -67,7 +67,7 @@ describe('MessageTimeline hydration presentation', () => { vi.unstubAllGlobals() }) - it('mounts only loading until the target projection becomes ready', async () => { + it('keeps the timeline mounted beneath the loading overlay', async () => { const element = createElement(MessageTimeline, { blocks: [{ kind: 'assistant', id: 'target-answer', text: 'target-ready-content' }], liveReasoning: '', @@ -79,13 +79,16 @@ describe('MessageTimeline hydration presentation', () => { }) await act(async () => root!.render(element)) + const messageNode = [...container.querySelectorAll('*')] + .find((node) => node.textContent === 'target-ready-content') expect(container.querySelector('[data-testid="thread-hydration-loading"]')).not.toBeNull() - expect(container.textContent).not.toContain('target-ready-content') - expect(container.querySelector('.timeline-jump-rail')).toBeNull() + expect(container.textContent).toContain('target-ready-content') + expect(messageNode).toBeDefined() await act(async () => useChatStore.setState({ threadLoadingId: null })) expect(container.querySelector('[data-testid="thread-hydration-loading"]')).toBeNull() expect(container.textContent).toContain('target-ready-content') + expect([...container.querySelectorAll('*')]).toContain(messageNode) }) }) diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index 93b4ac9f1..efc1425f0 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -376,8 +376,9 @@ export function MessageTimeline({ threadId={activeThreadId} > -
+
+
{visibleTurnAnchors.length > 2 && jumpRailLayout ? (
diff --git a/src/renderer/src/components/chat/ThreadHydrationLoading.tsx b/src/renderer/src/components/chat/ThreadHydrationLoading.tsx index 9cac36607..742a9c688 100644 --- a/src/renderer/src/components/chat/ThreadHydrationLoading.tsx +++ b/src/renderer/src/components/chat/ThreadHydrationLoading.tsx @@ -6,7 +6,12 @@ export function ThreadHydrationGate({ loading, children }: { loading: boolean children: ReactNode }): ReactElement { - return loading ? : <>{children} + return ( + <> + {children} + {loading ? : null} + + ) } export function ThreadHydrationLoading(): ReactElement { @@ -18,7 +23,7 @@ export function ThreadHydrationLoading(): ReactElement { role="status" aria-busy="true" aria-live="polite" - className="absolute inset-0 z-20 flex min-h-[18rem] select-none items-center justify-center bg-white px-6 dark:bg-ds-main" + className="pointer-events-auto absolute inset-0 z-20 flex min-h-[18rem] select-none items-center justify-center bg-white px-6 dark:bg-ds-main" >
diff --git a/src/renderer/src/lib/application-reload.ts b/src/renderer/src/lib/application-reload.ts new file mode 100644 index 000000000..17db8a82f --- /dev/null +++ b/src/renderer/src/lib/application-reload.ts @@ -0,0 +1,21 @@ +type ApplicationReloadTarget = { + kunGui?: { + runDesktopCommand?: (command: 'reload') => Promise + } + location: { + reload: () => void + } +} + +export function requestApplicationReload(target: ApplicationReloadTarget = window): void { + const runDesktopCommand = target.kunGui?.runDesktopCommand + if (typeof runDesktopCommand !== 'function') { + target.location.reload() + return + } + try { + void runDesktopCommand('reload').catch(() => target.location.reload()) + } catch { + target.location.reload() + } +} diff --git a/src/renderer/src/startup-shell.test.ts b/src/renderer/src/startup-shell.test.ts index a2efb3768..36d7f89ab 100644 --- a/src/renderer/src/startup-shell.test.ts +++ b/src/renderer/src/startup-shell.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { startupPhaseLabel, startupShellAllowsWorkbench } from './startup-shell' +import { + mergeStartupPhase, + startupPhaseLabel, + startupShellAllowsWorkbench +} from './startup-shell' describe('desktop startup shell policy', () => { it('allows the workbench only after the ready phase', () => { @@ -10,6 +14,13 @@ describe('desktop startup shell policy', () => { expect(startupShellAllowsWorkbench('ready')).toBe(true) }) + it('merges phases monotonically and keeps terminal phases terminal', () => { + expect(mergeStartupPhase('runtime_starting', 'bootstrapping')).toBe('runtime_starting') + expect(mergeStartupPhase('runtime_handoff', 'runtime_starting')).toBe('runtime_starting') + expect(mergeStartupPhase('ready', 'runtime_starting')).toBe('ready') + expect(mergeStartupPhase('recovery_required', 'ready')).toBe('recovery_required') + }) + it('uses actionable but non-sensitive phase labels', () => { expect(startupPhaseLabel('runtime_handoff')).toContain('runtime') expect(startupPhaseLabel('recovery_required')).toContain('recovery') diff --git a/src/renderer/src/startup-shell.ts b/src/renderer/src/startup-shell.ts index 22c4ee726..04321e4c7 100644 --- a/src/renderer/src/startup-shell.ts +++ b/src/renderer/src/startup-shell.ts @@ -1,5 +1,21 @@ import type { DesktopStartupPhase } from '@shared/desktop-startup-state' +const STARTUP_PHASE_RANK: Record = { + bootstrapping: 0, + runtime_handoff: 1, + runtime_starting: 2, + ready: 3, + recovery_required: 3 +} + +export function mergeStartupPhase( + current: DesktopStartupPhase, + next: DesktopStartupPhase +): DesktopStartupPhase { + if (current === 'ready' || current === 'recovery_required') return current + return STARTUP_PHASE_RANK[next] >= STARTUP_PHASE_RANK[current] ? next : current +} + export function startupPhaseLabel(phase: DesktopStartupPhase): string { switch (phase) { case 'runtime_handoff': diff --git a/src/renderer/src/store/chat-store-thread-refresh.test.ts b/src/renderer/src/store/chat-store-thread-refresh.test.ts new file mode 100644 index 000000000..6264b5b1f --- /dev/null +++ b/src/renderer/src/store/chat-store-thread-refresh.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChatBlock, NormalizedThread } from '../agent/types' +import type { ChatState, ChatStoreGet, ChatStoreSet } from './chat-store-types' + +const registryMock = vi.hoisted(() => ({ getProvider: vi.fn() })) + +vi.mock('../agent/registry', () => ({ getProvider: registryMock.getProvider })) + +import { createThreadActions } from './chat-store-thread-actions' + +function deferredValue(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + +function thread(id: string): NormalizedThread { + return { + id, + title: id, + updatedAt: '2026-08-23T00:00:00.000Z', + model: 'deepseek-v4-pro', + mode: 'agent', + workspace: '/workspace/deepseek-gui', + status: 'idle' + } +} + +function buildHarness(): { + actions: ReturnType + state: ChatState + activeStream: AbortController +} { + let state = { + activeThreadId: 'thr_existing', + blocks: [] as ChatBlock[], + busy: false, + busyUnconfirmed: false, + clawChannels: [], + codeWorkspaceRoots: [], + composerModel: '', + composerMode: 'agent', + composerOrchestration: 'direct', + composerPickList: [], + composerModelGroups: [], + composerProviderId: '', + currentTurnId: null, + currentTurnOrchestration: null, + currentTurnUserId: null, + error: null, + extensionComposerContexts: [], + lastSeq: 1, + liveAssistant: '', + liveDeltaSeqFloor: 1, + liveReasoning: '', + queuedMessages: [], + route: 'chat', + runtimeConnection: 'ready', + threadHasMoreHistory: false, + threadHistoryCursor: null, + threadHistoryLoading: false, + threadLoadingId: null, + turnDurationByUserId: {}, + turnReasoningFirstAtByUserId: {}, + turnReasoningLastAtByUserId: {}, + turnStartedAtByUserId: {}, + unreadThreadIds: {}, + watchTurnCompletion: {}, + threads: [thread('thr_existing')] + } as unknown as ChatState + const set: ChatStoreSet = (partial) => { + const update = typeof partial === 'function' ? partial(state) : partial + Object.assign(state, update) + } + const get: ChatStoreGet = () => state + const activeStream = new AbortController() + const actions = createThreadActions({ set, get, sseAbortRef: { current: activeStream } }) + state = Object.assign(state, actions) + return { actions, state, activeStream } +} + +function providerFor(detail: ReturnType>): void { + registryMock.getProvider.mockReturnValue({ + getThreadDetail: vi.fn(() => detail.promise), + subscribeThreadEvents: vi.fn(async () => undefined) + }) +} + +describe('same-thread detail refresh', () => { + beforeEach(() => registryMock.getProvider.mockReset()) + + it('keeps the current projection until refreshed detail replaces it', async () => { + const detail = deferredValue<{ + blocks: ChatBlock[] + latestSeq: number + threadStatus: 'idle' + }>() + providerFor(detail) + const { actions, state, activeStream } = buildHarness() + const previous = [{ kind: 'assistant' as const, id: 'old-answer', text: 'old answer' }] + state.blocks = previous + + const refreshing = actions.selectThread('thr_existing') + expect(state.threadLoadingId).toBe('thr_existing') + expect(state.blocks).toBe(previous) + expect(activeStream.signal.aborted).toBe(false) + + detail.resolve({ + blocks: [{ kind: 'assistant', id: 'new-answer', text: 'new answer' }], + latestSeq: 2, + threadStatus: 'idle' + }) + await refreshing + + expect(state.threadLoadingId).toBeNull() + expect(activeStream.signal.aborted).toBe(true) + expect(state.blocks).toEqual([ + expect.objectContaining({ id: 'new-answer', text: 'new answer' }) + ]) + }) + + it('keeps the current projection when refresh fails', async () => { + const detail = deferredValue() + providerFor(detail) + const { actions, state, activeStream } = buildHarness() + const previous = [{ kind: 'assistant' as const, id: 'old-answer', text: 'old answer' }] + state.blocks = previous + + const refreshing = actions.selectThread('thr_existing') + detail.reject(new Error('detail unavailable')) + await refreshing + + expect(state.threadLoadingId).toBeNull() + expect(activeStream.signal.aborted).toBe(false) + expect(state.blocks).toBe(previous) + expect(state.error).toContain('detail unavailable') + }) + + it('clears the previous projection while another thread hydrates', async () => { + const detail = deferredValue() + providerFor(detail) + const { actions, state, activeStream } = buildHarness() + state.blocks = [{ kind: 'assistant', id: 'old-answer', text: 'old answer' }] + state.threads = [thread('thr_existing'), thread('thr_next')] + + const selecting = actions.selectThread('thr_next') + expect(state.activeThreadId).toBe('thr_next') + expect(activeStream.signal.aborted).toBe(true) + expect(state.threadLoadingId).toBe('thr_next') + expect(state.blocks).toEqual([]) + + detail.reject(new Error('detail unavailable')) + await selecting + }) +}) diff --git a/src/renderer/src/store/chat-store-thread-selection-actions.ts b/src/renderer/src/store/chat-store-thread-selection-actions.ts index 81278f8dd..5d7bb0d2b 100644 --- a/src/renderer/src/store/chat-store-thread-selection-actions.ts +++ b/src/renderer/src/store/chat-store-thread-selection-actions.ts @@ -195,8 +195,11 @@ export function createThreadSelectionActions( const nextUnread = { ...get().unreadThreadIds } delete nextUnread[id] - sseAbortRef.current?.abort() - sseAbortRef.current = null + const refreshingActiveThread = prevId === id + if (!refreshingActiveThread) { + sseAbortRef.current?.abort() + sseAbortRef.current = null + } const p = getProvider() const durableQueuedMessages = queuedMessagesForThread(id) // Park the outgoing renderer projection before its state is replaced. This @@ -213,8 +216,10 @@ export function createThreadSelectionActions( const cached = prevId !== id && targetThread ? getThreadSnapshotForSelection(targetThread) : null - resetBusyRecoveryAttempts() - clearBusyWatchdog() + if (!refreshingActiveThread) { + resetBusyRecoveryAttempts() + clearBusyWatchdog() + } if (cached) { // The durable queue is the only authoritative queue source. The parked // snapshot may hold a queue that was already consumed (e.g. guidance @@ -290,7 +295,20 @@ export function createThreadSelectionActions( // shows a skeleton and the composer is disabled until detail hydration // commits, preventing sends against an unhydrated thread. const initialComposerState = resolveThreadComposerState(get(), targetThread) - set({ + if (prevId === id) { + // A same-thread refresh keeps the current projection mounted beneath the + // hydration overlay. The detail response replaces it atomically; failure + // removes the overlay while leaving the last usable projection intact. + set({ + watchTurnCompletion: nextWatch, + unreadThreadIds: nextUnread, + threadLoadingId: id, + threadHistoryLoading: false, + error: null, + ...initialComposerState + }) + } else { + set({ watchTurnCompletion: nextWatch, unreadThreadIds: nextUnread, activeThreadId: id, @@ -319,8 +337,9 @@ export function createThreadSelectionActions( inspectorSelectedId: null, queuedMessages: [], error: null, - ...initialComposerState - }) + ...initialComposerState + }) + } try { const prewarmHandle = targetThread ? getThreadPrewarmHandle(targetThread) : null let detail = await (prewarmHandle?.promise ?? p.getThreadDetail(id)) @@ -397,6 +416,12 @@ export function createThreadSelectionActions( turnId: latestTurnId, blocks }) + if (refreshingActiveThread) { + sseAbortRef.current?.abort() + sseAbortRef.current = null + resetBusyRecoveryAttempts() + clearBusyWatchdog() + } // Re-derive the awaiting-input marker from the runtime's pending gate so // switching threads (or restarting) keeps the sidebar hint accurate. const hasLivePendingUserInput = blocks.some( diff --git a/src/renderer/src/store/thread-snapshot-cache.test.ts b/src/renderer/src/store/thread-snapshot-cache.test.ts index 346563ec3..8b2044245 100644 --- a/src/renderer/src/store/thread-snapshot-cache.test.ts +++ b/src/renderer/src/store/thread-snapshot-cache.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NormalizedThread } from '../agent/types' import type { ChatState } from './chat-store-types' import { @@ -62,7 +62,10 @@ describe('thread snapshot cache', () => { snapshotThreadProjection(stateFor(`thr_${index}`), 1) } - expect(threadSnapshotCacheStats()).toEqual({ entries: 6, bytes: 6 }) + const stats = threadSnapshotCacheStats() + expect(stats.entries).toBe(6) + expect(stats.bytes).toBeGreaterThan(6) + expect(stats.bytes).toBeLessThan(THREAD_SNAPSHOT_CACHE_MAX_BYTES) expect(getThreadSnapshot('thr_0')).toBeNull() expect(getThreadSnapshot('thr_6')?.lastSeq).toBe(1) }) @@ -74,6 +77,70 @@ describe('thread snapshot cache', () => { expect(threadSnapshotCacheStats()).toEqual({ entries: 0, bytes: 0 }) }) + it('evicts a snapshot that grows beyond the budget after hydration', () => { + const state = stateFor('thr_growing') + snapshotThreadProjection(state, 1 * 1024 * 1024) + expect(getThreadSnapshot('thr_growing')).not.toBeNull() + + state.blocks = [{ + kind: 'assistant', + id: 'large-answer', + text: 'x'.repeat(40 * 1024 * 1024) + }] + snapshotThreadProjection(state) + + expect(getThreadSnapshot('thr_growing')).toBeNull() + expect(threadSnapshotCacheStats()).toEqual({ entries: 0, bytes: 0 }) + }) + + it('accounts for budget-safe projection growth during LRU eviction', () => { + const growing = stateFor('thr_growing') + snapshotThreadProjection(growing, 1 * 1024 * 1024) + const initialBytes = threadSnapshotCacheStats().bytes + growing.blocks = [{ + kind: 'assistant', + id: 'grown-answer', + text: 'x'.repeat(12 * 1024 * 1024) + }] + snapshotThreadProjection(growing) + expect(threadSnapshotCacheStats().bytes).toBeGreaterThan(initialBytes) + + for (const threadId of ['thr_second', 'thr_third']) { + const state = stateFor(threadId) + state.blocks = [{ + kind: 'assistant', + id: `${threadId}-answer`, + text: 'x'.repeat(11 * 1024 * 1024) + }] + snapshotThreadProjection(state, 1) + } + + expect(getThreadSnapshot('thr_growing')).toBeNull() + expect(threadSnapshotCacheStats().bytes).toBeLessThanOrEqual(THREAD_SNAPSHOT_CACHE_MAX_BYTES) + }) + + it('walks structured metadata without materializing object entry arrays', () => { + const state = stateFor('thr_wide_meta') + const meta = Object.fromEntries( + Array.from({ length: 2_000 }, (_, index) => [`field_${index}`, index]) + ) + state.blocks = [{ + kind: 'tool', + id: 'wide-tool', + summary: 'wide metadata', + status: 'success', + meta + }] + const entries = vi.spyOn(Object, 'entries').mockImplementation(() => { + throw new Error('Object.entries must not be used by snapshot estimation') + }) + + snapshotThreadProjection(state, 1) + entries.mockRestore() + + expect(getThreadSnapshot('thr_wide_meta')).not.toBeNull() + }) + it('rejects a snapshot when the authoritative thread fingerprint changes', () => { const state = stateFor('thr_changed') snapshotThreadProjection(state, 10) diff --git a/src/renderer/src/store/thread-snapshot-cache.ts b/src/renderer/src/store/thread-snapshot-cache.ts index 549751b45..8d408388a 100644 --- a/src/renderer/src/store/thread-snapshot-cache.ts +++ b/src/renderer/src/store/thread-snapshot-cache.ts @@ -23,6 +23,7 @@ export const THREAD_SNAPSHOT_CACHE_MAX_BYTES = 32 * 1024 * 1024 // conservative fallback still makes a locally-created thread bounded if it is // switched away before a durable detail response has been observed. const UNKNOWN_SNAPSHOT_BYTES = 4 * 1024 * 1024 +const SNAPSHOT_ESTIMATE_OVERFLOW = THREAD_SNAPSHOT_CACHE_MAX_BYTES + 1 export type ThreadSnapshot = { threadId: string @@ -107,6 +108,73 @@ function normalizedPayloadBytes(value: number | undefined): number { : UNKNOWN_SNAPSHOT_BYTES } +/** Estimate retained bytes without allocating a full serialized projection. */ +function estimateSnapshotBytes(value: unknown): number { + let bytes = 0 + const ancestors = new WeakSet() + const add = (amount: number): boolean => { + bytes += amount + return bytes <= THREAD_SNAPSHOT_CACHE_MAX_BYTES + } + const addString = (text: string): boolean => { + if (!add(2)) return false + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index) + let amount = 1 + if (code === 0x22 || code === 0x5c || code < 0x20) amount = code < 0x20 ? 6 : 2 + else if (code < 0x80) amount = 1 + else if (code < 0x800) amount = 2 + else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) { + const next = text.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + amount = 4 + index += 1 + } else amount = 3 + } else amount = 3 + if (!add(amount)) return false + } + return true + } + const visit = (candidate: unknown): boolean => { + if (candidate === null) return add(4) + switch (typeof candidate) { + case 'string': return addString(candidate) + case 'boolean': return add(candidate ? 4 : 5) + case 'number': return add(Number.isFinite(candidate) ? 24 : 4) + case 'undefined': return add(4) + case 'object': { + if (ancestors.has(candidate)) return false + if (!Array.isArray(candidate)) { + const prototype = Object.getPrototypeOf(candidate) + if (prototype !== Object.prototype && prototype !== null) return false + } + ancestors.add(candidate) + if (!add(2)) return false + if (Array.isArray(candidate)) { + for (let index = 0; index < candidate.length; index += 1) { + if (index > 0 && !add(1)) return false + if (!visit(candidate[index])) return false + } + } else { + const record = candidate as Record + let index = 0 + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue + if (index > 0 && !add(1)) return false + if (!addString(key) || !add(1) || !visit(record[key])) return false + index += 1 + } + } + ancestors.delete(candidate) + return true + } + default: + return false + } + } + return visit(value) ? bytes : SNAPSHOT_ESTIMATE_OVERFLOW +} + function evictUntilBounded(): void { while ( snapshots.size > THREAD_SNAPSHOT_CACHE_MAX_ENTRIES || @@ -132,9 +200,10 @@ export function cacheThreadSnapshot( token?: ThreadSnapshotCacheToken ): boolean { if (token && !threadSnapshotCacheTokenIsCurrent(snapshot.threadId, token)) return false - const bytes = normalizedPayloadBytes(snapshot.payloadBytes) + const payloadBytes = normalizedPayloadBytes(snapshot.payloadBytes) + const bytes = Math.max(payloadBytes, estimateSnapshotBytes(snapshot)) if (bytes > THREAD_SNAPSHOT_CACHE_MAX_BYTES) { - removeSnapshot(snapshot.threadId) + invalidateThreadSnapshot(snapshot.threadId) return false } removeSnapshot(snapshot.threadId) From 65b6806d95bb15414fab2ef82dbfcc167c00bb04 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 00:11:18 +0800 Subject: [PATCH 043/168] fix: keep recovery paths retryable and fail closed on runtime upgrades - Store the original settings task so a rejected getSettings() read can be retried, and gate cache writes on request identity - Add a durable shared-business-storage dirty journal so unacknowledged local updates and deletions survive restarts instead of being rolled back by stale remote snapshots - Refresh the active thread transactionally: keep the previous projection, cursor, busy state, and SSE subscription until detail hydration succeeds, then atomically swap the subscription from the new sequence - Replace boolean bundled-runtime replacement checks with a matched/mismatched/unknown probe, fail closed into the existing handoff recovery on unknown identities, and stop skipping replacement when discovery or the bundled build id cannot be resolved --- kun/src/manager/manager-discovery.test.ts | 12 + kun/src/manager/manager-discovery.ts | 15 ++ kun/src/server/runtime-discovery.test.ts | 12 + kun/src/server/runtime-discovery.ts | 20 ++ src/main/kun-process.ts | 42 ++-- .../main-runtime-startup.replacement.test.ts | 32 ++- src/main/main-runtime-startup.ts | 4 +- src/main/runtime/kun-adapter.ts | 46 ++-- .../kun-installed-build-handoff.test.ts | 55 ++++- .../runtime/kun-installed-build-handoff.ts | 101 ++++++-- src/renderer/src/agent/runtime-client.test.ts | 69 ++++++ src/renderer/src/agent/runtime-client.ts | 18 +- .../workbench/WorkbenchChatStage.tsx | 13 +- src/renderer/src/lib/browser-storage.ts | 38 ++- .../lib/shared-business-storage-journal.ts | 108 +++++++++ .../src/lib/shared-business-storage.test.ts | 99 ++++++++ .../src/lib/shared-business-storage.ts | 223 ++++++++++-------- .../src/locales/en/common/sidebar.json | 1 + .../src/locales/zh/common/sidebar.json | 1 + .../src/store/chat-store-initial-state.ts | 1 + .../src/store/chat-store-runtime-helpers.ts | 2 + .../chat-store-thread-live-loading.test.ts | 73 +++++- .../chat-store-thread-selection-actions.ts | 73 +++--- src/renderer/src/store/chat-store-types.ts | 2 + 24 files changed, 850 insertions(+), 210 deletions(-) create mode 100644 src/renderer/src/lib/shared-business-storage-journal.ts diff --git a/kun/src/manager/manager-discovery.test.ts b/kun/src/manager/manager-discovery.test.ts index 5e48bd9dd..e01156513 100644 --- a/kun/src/manager/manager-discovery.test.ts +++ b/kun/src/manager/manager-discovery.test.ts @@ -7,6 +7,7 @@ import { managerDiscoveryPath, publishManagerDiscovery, readManagerHandoffDiscovery, + readManagerHandoffDiscoveryStrict, readManagerDiscovery, removeManagerDiscovery, withManagerStartLock @@ -126,6 +127,17 @@ describe('manager discovery', () => { expect(await readManagerHandoffDiscovery(controlDir)).toBeNull() }) + it('fails closed in strict replacement probes when discovery exists but is invalid', async () => { + const controlDir = await root() + await writeFile(managerDiscoveryPath(controlDir), '{broken', 'utf8') + + await expect(readManagerHandoffDiscoveryStrict(controlDir)).rejects.toThrow( + /invalid Kun Service Manager discovery/u + ) + await rm(managerDiscoveryPath(controlDir)) + await expect(readManagerHandoffDiscoveryStrict(controlDir)).resolves.toBeNull() + }) + it('does not let an old manager remove a replacement record', async () => { const controlDir = await root() await publishManagerDiscovery(controlDir, { ...input(), instanceId: 'manager-old' }) diff --git a/kun/src/manager/manager-discovery.ts b/kun/src/manager/manager-discovery.ts index e64e75c84..34d6693d2 100644 --- a/kun/src/manager/manager-discovery.ts +++ b/kun/src/manager/manager-discovery.ts @@ -108,6 +108,21 @@ export async function readManagerHandoffDiscovery( return safeHandoffManagerUrl(parsed.data) ? parsed.data : null } +/** Strict installed-build probe; an existing invalid record is not "no owner". */ +export async function readManagerHandoffDiscoveryStrict( + controlDir: string +): Promise { + const record = await readManagerHandoffDiscovery(controlDir) + if (record) return record + try { + await stat(managerDiscoveryPath(controlDir)) + } catch (error) { + if (errorCode(error) === 'ENOENT') return null + throw error + } + throw new Error('invalid Kun Service Manager discovery record') +} + export async function publishManagerDiscovery( controlDir: string, input: PublishManagerDiscoveryInput diff --git a/kun/src/server/runtime-discovery.test.ts b/kun/src/server/runtime-discovery.test.ts index a0067a9d0..473d550e8 100644 --- a/kun/src/server/runtime-discovery.test.ts +++ b/kun/src/server/runtime-discovery.test.ts @@ -6,6 +6,7 @@ import { createRuntimeDiscoveryRecord, publishRuntimeDiscovery, readRuntimeHandoffDiscovery, + readRuntimeHandoffDiscoveryStrict, readRuntimeDiscovery, removeRuntimeDiscovery, runtimeDiscoveryPath, @@ -113,6 +114,17 @@ describe('runtime discovery', () => { expect(await readRuntimeHandoffDiscovery(root, 'development')).toBeNull() }) + it('fails closed in strict replacement probes when discovery exists but is invalid', async () => { + const root = await tempRoot() + await writeFile(runtimeDiscoveryPath(root), '{broken', 'utf8') + + await expect(readRuntimeHandoffDiscoveryStrict(root)).rejects.toThrow( + /invalid Kun production Runtime discovery/u + ) + await rm(runtimeDiscoveryPath(root)) + await expect(readRuntimeHandoffDiscoveryStrict(root)).resolves.toBeNull() + }) + it('keeps development discovery separate from the production compatibility record', async () => { const root = await tempRoot() const production = await publishRuntimeDiscovery(root, input({ instanceId: 'production-runtime' })) diff --git a/kun/src/server/runtime-discovery.ts b/kun/src/server/runtime-discovery.ts index bfe90c4be..2280a377e 100644 --- a/kun/src/server/runtime-discovery.ts +++ b/kun/src/server/runtime-discovery.ts @@ -111,6 +111,26 @@ export async function readRuntimeHandoffDiscovery( return isSafeRuntimeHandoffDiscovery(parsed.data) ? parsed.data : null } +/** + * Replacement probes must distinguish an absent owner from an unreadable or + * unsafe discovery record. Normal attachment keeps the compatibility behavior + * above, while installed-build handoff fails closed on an existing invalid file. + */ +export async function readRuntimeHandoffDiscoveryStrict( + dataDir: string, + flavor: RuntimeFlavor = 'production' +): Promise { + const record = await readRuntimeHandoffDiscovery(dataDir, flavor) + if (record) return record + try { + await stat(runtimeDiscoveryPath(dataDir, flavor)) + } catch (error) { + if (errorCode(error) === 'ENOENT') return null + throw error + } + throw new Error(`invalid Kun ${flavor} Runtime discovery record`) +} + export async function publishRuntimeDiscovery( dataDir: string, input: PublishRuntimeDiscoveryInput diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index 4de334d3f..3eeb4943b 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -125,7 +125,8 @@ import { handoffExistingKunServiceManagerForDataDir } from './runtime/service-ma import { drainKunOwnersForHandoff, drainKunOwnersForHandoffWithLock, - requiresInstalledBuildHandoff + installedBuildProbeError, + probeInstalledBuildHandoff } from './runtime/kun-installed-build-handoff' import { logKunHandoffEvent } from './runtime/kun-handoff-logging' @@ -214,16 +215,25 @@ export async function ensureKunServiceManager(input: { onEvent: logKunHandoffEvent, ...(buildId ? { targetBuildId: buildId } : {}) } - if (app.isPackaged && flavor === 'production' && - await requiresInstalledBuildHandoff(handoffInput)) { - manager = await withManagerStartLock(controlDir, async () => { - // Recheck after acquiring the election lock so a replacement that won - // the race before us is not interrupted unnecessarily. - if (await requiresInstalledBuildHandoff(handoffInput)) { - await drainKunOwnersForHandoffWithLock(handoffInput) - } - return ensureServiceManagerWithStartLockHeld(managerInput) - }) + if (app.isPackaged && flavor === 'production') { + const probe = await probeInstalledBuildHandoff(handoffInput) + const probeError = installedBuildProbeError(handoffInput, probe) + if (probeError) throw probeError + if (probe === 'mismatched') { + manager = await withManagerStartLock(controlDir, async () => { + // Recheck after acquiring the election lock so a replacement that won + // the race before us is not interrupted unnecessarily. + const lockedProbe = await probeInstalledBuildHandoff(handoffInput) + const lockedProbeError = installedBuildProbeError(handoffInput, lockedProbe) + if (lockedProbeError) throw lockedProbeError + if (lockedProbe === 'mismatched') { + await drainKunOwnersForHandoffWithLock(handoffInput) + } + return ensureServiceManagerWithStartLockHeld(managerInput) + }) + } else { + manager = await ensureServiceManager(managerInput) + } } else { manager = await ensureServiceManager(managerInput) } @@ -237,16 +247,18 @@ export async function preparePackagedKunBuildHandoff(input: { const flavor = resolveCliRuntimeFlavor({ env: process.env }) if (!app.isPackaged || flavor !== 'production') return false const buildId = await resolveKunRuntimeBuildId(resolveKunExecutable(appRoot(), '')) - if (!buildId) return false const handoffInput = { reason: 'installed-build-change' as const, dataDirs: [input.dataDir], settingsPath: input.settingsPath, controlDir: defaultKunControlDir(), - targetBuildId: buildId, - onEvent: logKunHandoffEvent + onEvent: logKunHandoffEvent, + ...(buildId ? { targetBuildId: buildId } : {}) } - if (!(await requiresInstalledBuildHandoff(handoffInput))) return false + const probe = await probeInstalledBuildHandoff(handoffInput) + const probeError = installedBuildProbeError(handoffInput, probe) + if (probeError) throw probeError + if (probe === 'matched') return false await drainKunOwnersForHandoff(handoffInput) return true } diff --git a/src/main/main-runtime-startup.replacement.test.ts b/src/main/main-runtime-startup.replacement.test.ts index 533d54943..9d1609b1f 100644 --- a/src/main/main-runtime-startup.replacement.test.ts +++ b/src/main/main-runtime-startup.replacement.test.ts @@ -12,7 +12,11 @@ const harness = vi.hoisted(() => { const ensureRunning = vi.fn(async () => undefined) const ensureReplacementRunning = vi.fn(async () => undefined) const resolveConnection = vi.fn(async () => false) - const requiresBundledBuildReplacement = vi.fn(async () => false) + const probeBundledBuildReplacement = vi.fn<() => Promise< + | { state: 'matched'; ownership: 'none' | 'current' } + | { state: 'mismatched' } + | { state: 'unknown'; error: Error } + >>(async () => ({ state: 'matched', ownership: 'none' })) const waitForHealthy = vi.fn(async () => true) const probeRuntimeApi = vi.fn(async () => ({ ok: true as const })) const noteRuntimeHealthy = vi.fn() @@ -47,7 +51,7 @@ const harness = vi.hoisted(() => { mainState, noteRuntimeHealthy, probeRuntimeApi, - requiresBundledBuildReplacement, + probeBundledBuildReplacement, runtimeSupervisor, setLatest: (settings: unknown): void => { latest = settings }, stopSharedAndWait, @@ -62,7 +66,7 @@ vi.mock('./runtime/kun-adapter', () => ({ ensureRunning: harness.ensureRunning, ensureReplacementRunning: harness.ensureReplacementRunning, isChildRunning: () => false, - requiresBundledBuildReplacement: harness.requiresBundledBuildReplacement, + probeBundledBuildReplacement: harness.probeBundledBuildReplacement, resolveConnection: harness.resolveConnection, stopSharedAndWait: harness.stopSharedAndWait, stopSharedForReplacementAndWait: harness.stopSharedForReplacementAndWait @@ -120,8 +124,8 @@ beforeEach(() => { harness.ensureReplacementRunning.mockClear() harness.resolveConnection.mockReset() harness.resolveConnection.mockResolvedValue(false) - harness.requiresBundledBuildReplacement.mockReset() - harness.requiresBundledBuildReplacement.mockResolvedValue(false) + harness.probeBundledBuildReplacement.mockReset() + harness.probeBundledBuildReplacement.mockResolvedValue({ state: 'matched', ownership: 'none' }) harness.waitForHealthy.mockClear() harness.probeRuntimeApi.mockClear() harness.noteRuntimeHealthy.mockClear() @@ -159,17 +163,29 @@ describe('explicit Kun serve replacement', () => { it('hands a packaged build mismatch to the same explicit replacement path before startup attach', async () => { const current = settings() - harness.requiresBundledBuildReplacement.mockResolvedValue(true) + harness.probeBundledBuildReplacement.mockResolvedValue({ state: 'mismatched' }) await expect(reconcileBundledRuntimeAfterInstall(current)).resolves.toBeUndefined() - expect(harness.requiresBundledBuildReplacement).toHaveBeenCalledWith(current) + expect(harness.probeBundledBuildReplacement).toHaveBeenCalledWith(current) expect(harness.runtimeSupervisor.replace).toHaveBeenCalledOnce() expect(harness.stopSharedForReplacementAndWait).toHaveBeenCalledWith(current) expect(harness.ensureReplacementRunning).toHaveBeenCalledWith(current) expect(harness.ensureRunning).not.toHaveBeenCalled() }) + it('fails closed when the bundled replacement probe is unknown', async () => { + const current = settings() + const probeError = new Error('manager status unavailable') + harness.probeBundledBuildReplacement.mockResolvedValue({ state: 'unknown', error: probeError }) + + await expect(reconcileBundledRuntimeAfterInstall(current)).rejects.toBe(probeError) + + expect(harness.runtimeSupervisor.replace).not.toHaveBeenCalled() + expect(harness.stopSharedForReplacementAndWait).not.toHaveBeenCalled() + expect(harness.ensureReplacementRunning).not.toHaveBeenCalled() + }) + it('clears all historical serves after stopping the current owner and before launching', async () => { const order: string[] = [] harness.stopSharedForReplacementAndWait.mockImplementationOnce(async () => { @@ -224,7 +240,7 @@ describe('startup Kun serve restart', () => { expect(harness.stopSharedForReplacementAndWait).not.toHaveBeenCalled() expect(harness.ensureReplacementRunning).not.toHaveBeenCalled() expect(harness.ensureRunning).not.toHaveBeenCalled() - expect(harness.waitForHealthy).toHaveBeenCalledWith(current, 2_000) + expect(harness.waitForHealthy).toHaveBeenCalledWith(current, 5_000) expect(harness.probeRuntimeApi).toHaveBeenCalledWith(current) }) diff --git a/src/main/main-runtime-startup.ts b/src/main/main-runtime-startup.ts index e6619109a..04e9174e7 100644 --- a/src/main/main-runtime-startup.ts +++ b/src/main/main-runtime-startup.ts @@ -244,7 +244,9 @@ export async function reconcileBundledRuntimeAfterInstall( ): Promise { mainState.assertCanonicalRuntimeMigrationReady() const requested = runtimeSupervisor.latestOr(settings) - if (!(await kunRuntimeAdapter.requiresBundledBuildReplacement(requested))) return + const probe = await kunRuntimeAdapter.probeBundledBuildReplacement(requested) + if (probe.state === 'matched') return + if (probe.state === 'unknown') throw probe.error if (getKunRuntimeSettings(requested).autoStart) { await replaceKunServe(requested) return diff --git a/src/main/runtime/kun-adapter.ts b/src/main/runtime/kun-adapter.ts index 5cf82ae85..3c5590dc4 100644 --- a/src/main/runtime/kun-adapter.ts +++ b/src/main/runtime/kun-adapter.ts @@ -34,6 +34,12 @@ import { import { sameCanonicalPath } from '../../../kun/src/manager/canonical-path.js' const KUN_RUNTIME_ID = 'kun' as const + +export type BundledBuildReplacementProbe = + | { state: 'matched'; ownership: 'none' | 'current' } + | { state: 'mismatched' } + | { state: 'unknown'; error: Error } + let resolvedConnection: RuntimeDiscoveryRecord | null = null function appRoot(): string { @@ -124,27 +130,37 @@ export const kunRuntimeAdapter = { * A packaged production app owns the bundled build after an install/update. * Custom binaries and development runtimes retain their normal attach policy. */ - async requiresBundledBuildReplacement(settings: AppSettingsV1): Promise { + async probeBundledBuildReplacement(settings: AppSettingsV1): Promise { const runtime = getKunRuntimeSettings(settings) - const dataDir = expandDataDir(runtime.dataDir) const runtimeFlavor = resolveCliRuntimeFlavor({ env: process.env }) + if (!app.isPackaged || runtime.binaryPath.trim() || runtimeFlavor !== 'production') { + return { state: 'matched', ownership: 'none' } + } + const dataDir = expandDataDir(runtime.dataDir) const expectedBuildId = expectedKunRuntimeBuildId( await resolveKunRuntimeBuildId(resolveKunExecutable(appRoot(), runtime.binaryPath)), runtimeFlavor ) - const inspected = await inspectSharedRuntime( - dataDir, - fetch, - sharedRuntimeScope(dataDir, runtimeFlavor) - ).catch(() => null) - if (!inspected) return false - return bundledRuntimeBuildReplacementRequired({ - isPackaged: app.isPackaged, - hasCustomBinary: Boolean(runtime.binaryPath.trim()), - runtimeFlavor, - expectedBuildId, - discoveredBuildId: inspected.discovery.buildId - }) + if (!expectedBuildId) { + return { state: 'unknown', error: new Error('The packaged Kun Runtime build identity is missing.') } + } + let inspected: Awaited> + try { + inspected = await inspectSharedRuntime( + dataDir, + fetch, + sharedRuntimeScope(dataDir, runtimeFlavor) + ) + } catch (error) { + return { + state: 'unknown', + error: error instanceof Error ? error : new Error(String(error)) + } + } + if (!inspected) return { state: 'matched', ownership: 'none' } + return inspected.discovery.buildId === expectedBuildId + ? { state: 'matched', ownership: 'current' } + : { state: 'mismatched' } }, reclaimPort(port: number): Promise<{ ok: true } | { ok: false; message: string }> { diff --git a/src/main/runtime/kun-installed-build-handoff.test.ts b/src/main/runtime/kun-installed-build-handoff.test.ts index 93539ff7b..5ed20a238 100644 --- a/src/main/runtime/kun-installed-build-handoff.test.ts +++ b/src/main/runtime/kun-installed-build-handoff.test.ts @@ -11,7 +11,7 @@ import { import { drainKunOwnersForHandoff, KunHandoffError, - requiresInstalledBuildHandoff, + probeInstalledBuildHandoff, withDrainedKunOwners } from './kun-installed-build-handoff' @@ -272,10 +272,10 @@ describe('installed build handoff coordinator', () => { }) as never } - await expect(requiresInstalledBuildHandoff({ + await expect(probeInstalledBuildHandoff({ ...input(), fetch: fetchMock as unknown as typeof fetch - }, overrides)).resolves.toBe(true) + }, overrides)).resolves.toBe('mismatched') await drainKunOwnersForHandoff({ ...input(), fetch: fetchMock as unknown as typeof fetch @@ -293,7 +293,7 @@ describe('installed build handoff coordinator', () => { slots: [{ registration: { ...slot, flavor: 'production' } }] })) - await expect(requiresInstalledBuildHandoff({ + await expect(probeInstalledBuildHandoff({ ...input(), fetch: fetchMock as unknown as typeof fetch }, { @@ -302,7 +302,52 @@ describe('installed build handoff coordinator', () => { processAlive: (pid) => pid === currentManager.pid || pid === slot.pid, stopRuntime: vi.fn() as never, stopManager: vi.fn() as never - })).resolves.toBe(false) + })).resolves.toBe('matched') + }) + + it('classifies missing build identity and unavailable Manager status as unknown', async () => { + const legacyManager = manager() + const legacyRuntime = runtime('production') + const baseOverrides = { + readManager: async () => legacyManager, + readRuntime: async (_dir: string, flavor?: 'production' | 'development') => + flavor === 'production' ? legacyRuntime : null, + processAlive: (pid: number) => pid === legacyManager.pid || pid === legacyRuntime.pid, + stopRuntime: vi.fn() as never, + stopManager: vi.fn() as never + } + + await expect(probeInstalledBuildHandoff({ + ...input(), + fetch: vi.fn(async () => Response.json({ + instanceId: legacyManager.instanceId, + pid: legacyManager.pid, + startedAt: legacyManager.startedAt, + slots: [] + })) as unknown as typeof fetch + }, baseOverrides)).resolves.toBe('unknown') + + await expect(probeInstalledBuildHandoff({ + ...input(), + fetch: vi.fn(async () => new Response(null, { status: 503 })) as unknown as typeof fetch + }, { + ...baseOverrides, + readManager: async () => manager({ buildId: 'b'.repeat(64) }), + readRuntime: async () => null + })).resolves.toBe('unknown') + }) + + it('fails closed on unreadable discovery instead of treating it as no owner', async () => { + const failure = await probeInstalledBuildHandoff(input(), { + readManager: async () => { throw new Error('invalid manager discovery') }, + readRuntime: async () => null, + processAlive: () => false, + stopRuntime: vi.fn() as never, + stopManager: vi.fn() as never + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(KunHandoffError) + expect(failure).toMatchObject({ code: 'unsafe_scope', phase: 'discover', retryable: false }) }) it('fails closed before stopping anything when Manager settings scope differs', async () => { diff --git a/src/main/runtime/kun-installed-build-handoff.ts b/src/main/runtime/kun-installed-build-handoff.ts index 6635e9e98..17357e229 100644 --- a/src/main/runtime/kun-installed-build-handoff.ts +++ b/src/main/runtime/kun-installed-build-handoff.ts @@ -3,12 +3,12 @@ import { z } from 'zod' import type { RuntimeFlavor } from '../../../kun/src/contracts/runtime-flavor.js' import { isSafeRuntimeHandoffDiscovery, - readRuntimeHandoffDiscovery, + readRuntimeHandoffDiscoveryStrict, type RuntimeHandoffDiscoveryRecord } from '../../../kun/src/server/runtime-discovery.js' import { defaultKunControlDir, - readManagerHandoffDiscovery, + readManagerHandoffDiscoveryStrict, withManagerStartLock, type ManagerHandoffDiscoveryRecord } from '../../../kun/src/manager/manager-discovery.js' @@ -31,6 +31,8 @@ const MANAGED_RUNTIME_FLAVORS = ['production', 'development'] as const const MAX_RUNTIME_DRAIN_PASSES = 3 const STATUS_TIMEOUT_MS = 2_000 +export type KunInstalledBuildProbe = 'matched' | 'mismatched' | 'unknown' + export type KunHandoffReason = | 'in-app-update' | 'installed-build-change' @@ -46,6 +48,8 @@ export type KunHandoffPhase = export type KunHandoffErrorCode = | 'unsafe_scope' + | 'probe_failed' + | 'target_build_id_missing' | 'runtime_stop_failed' | 'manager_stop_failed' | 'postcondition_failed' @@ -119,8 +123,8 @@ type RuntimeOwner = { } type HandoffDependencies = { - readManager: typeof readManagerHandoffDiscovery - readRuntime: typeof readRuntimeHandoffDiscovery + readManager: typeof readManagerHandoffDiscoveryStrict + readRuntime: typeof readRuntimeHandoffDiscoveryStrict withManagerLock: (controlDir: string, action: () => Promise) => Promise stopRuntime: typeof stopExactSharedRuntimeForReplacement stopManager: typeof stopServiceManagerForReplacement @@ -130,8 +134,8 @@ type HandoffDependencies = { } const defaultDependencies: HandoffDependencies = { - readManager: readManagerHandoffDiscovery, - readRuntime: readRuntimeHandoffDiscovery, + readManager: readManagerHandoffDiscoveryStrict, + readRuntime: readRuntimeHandoffDiscoveryStrict, withManagerLock: withManagerStartLock, stopRuntime: stopExactSharedRuntimeForReplacement, stopManager: stopServiceManagerForReplacement, @@ -147,18 +151,48 @@ export async function drainKunOwnersForHandoff( return (await withDrainedKunOwners(input, async () => undefined, overrides)).report } -export async function requiresInstalledBuildHandoff( +export async function probeInstalledBuildHandoff( input: KunInstalledBuildHandoffInput, overrides: Partial = {} -): Promise { - if (!input.targetBuildId) return false +): Promise { + if (!input.targetBuildId) return 'unknown' const deps = { ...defaultDependencies, ...overrides } - const discovered = await discoverHandoffOwners(input, deps) - if (discovered.manager && discovered.manager.buildId !== input.targetBuildId) return true + const discovered = await discoverHandoffOwnersSafely(input, deps) const targetBuildId = input.targetBuildId - return discovered.runtimes.some((runtime) => - runtime.inspection.discovery.buildId !== - runtimeBuildIdForFlavor(targetBuildId, runtime.flavor) + const identities: Array<{ actual: string | undefined; expected: string }> = [ + ...(discovered.manager + ? [{ actual: discovered.manager.buildId, expected: targetBuildId }] + : []), + ...discovered.runtimes.map((runtime) => ({ + actual: runtime.inspection.discovery.buildId, + expected: runtimeBuildIdForFlavor(targetBuildId, runtime.flavor) ?? '' + })) + ] + if (identities.some(({ actual, expected }) => actual !== undefined && actual !== expected)) { + return 'mismatched' + } + if (identities.some(({ actual, expected }) => !actual || !expected) || + discovered.probeClassifications.includes('manager-status-unavailable')) { + return 'unknown' + } + return 'matched' +} + +export function installedBuildProbeError( + input: KunInstalledBuildHandoffInput, + probe: KunInstalledBuildProbe +): KunHandoffError | null { + if (probe !== 'unknown') return null + const missingBuild = !input.targetBuildId + return new KunHandoffError( + missingBuild ? 'target_build_id_missing' : 'probe_failed', + 'discover', + input.reason, + !missingBuild, + undefined, + missingBuild + ? 'The packaged Kun Runtime build identity is missing.' + : 'Kun could not safely determine the installed Runtime owner build.' ) } @@ -194,7 +228,7 @@ export async function drainKunOwnersForHandoffWithLock( emit(input, startedAt, deps, { phase: 'discover' }) let discovered: Awaited> try { - discovered = await discoverHandoffOwners(input, deps) + discovered = await discoverHandoffOwnersSafely(input, deps) } catch (error) { if (error instanceof KunHandoffError) { emit(input, startedAt, deps, { @@ -222,7 +256,7 @@ export async function drainKunOwnersForHandoffWithLock( { runtimeFlavor: runtime.flavor, controlDir }, { inspect: async () => { - const latest = await discoverHandoffOwners(input, deps) + const latest = await discoverHandoffOwnersSafely(input, deps) return latest.runtimes.find((candidate) => sameRuntimeIdentity(candidate, runtime) )?.inspection ?? null @@ -264,7 +298,7 @@ export async function drainKunOwnersForHandoffWithLock( throw failure } } - discovered = await discoverHandoffOwners(input, deps) + discovered = await discoverHandoffOwnersSafely(input, deps) } if (discovered.manager) { @@ -305,7 +339,7 @@ export async function drainKunOwnersForHandoffWithLock( // Once the Manager is down, a Runtime heartbeat cannot elect a replacement // while this process holds the same start lock. Drain any owner that raced // with the first pass, then prove the scope is stable. - discovered = await discoverHandoffOwners(input, deps) + discovered = await discoverHandoffOwnersSafely(input, deps) for (const runtime of discovered.runtimes) { const owner = runtimeOwnerReport(runtime) try { @@ -316,7 +350,7 @@ export async function drainKunOwnersForHandoffWithLock( { runtimeFlavor: runtime.flavor, controlDir }, { inspect: async () => { - const latest = await discoverHandoffOwners(input, deps) + const latest = await discoverHandoffOwnersSafely(input, deps) return latest.runtimes.find((candidate) => sameRuntimeIdentity(candidate, runtime) )?.inspection ?? null @@ -354,7 +388,7 @@ export async function drainKunOwnersForHandoffWithLock( } } - const remaining = await discoverHandoffOwners(input, deps) + const remaining = await discoverHandoffOwnersSafely(input, deps) if (remaining.manager || remaining.runtimes.length > 0) { const owner = remaining.manager ? managerOwnerReport(remaining.manager) @@ -388,6 +422,26 @@ export async function drainKunOwnersForHandoffWithLock( } } +async function discoverHandoffOwnersSafely( + input: KunInstalledBuildHandoffInput, + deps: HandoffDependencies +): ReturnType { + try { + return await discoverHandoffOwners(input, deps) + } catch (error) { + if (error instanceof KunHandoffError) throw error + throw new KunHandoffError( + 'unsafe_scope', + 'discover', + input.reason, + false, + undefined, + 'Kun update handoff could not safely read Runtime or Service Manager discovery', + { cause: error } + ) + } +} + async function discoverHandoffOwners( input: KunInstalledBuildHandoffInput, deps: HandoffDependencies @@ -493,13 +547,16 @@ async function readCompatibleManagerSlots( for (const value of body.data.slots) { const envelope = z.object({ registration: z.unknown() }).passthrough().safeParse(value) const parsed = RuntimeSlotSchema.safeParse(envelope.success ? envelope.data.registration : value) - if (!parsed.success) continue + if (!parsed.success) return { records: [], classification: 'manager-status-unavailable' } const record: RuntimeHandoffDiscoveryRecord & { flavor: RuntimeFlavor } = { version: 1, ...parsed.data, flavor: parsed.data.flavor } - if (isSafeRuntimeHandoffDiscovery(record)) records.push(record) + if (!isSafeRuntimeHandoffDiscovery(record)) { + return { records: [], classification: 'manager-status-unavailable' } + } + records.push(record) } return { records, classification: 'manager-status-compatible' } } catch { diff --git a/src/renderer/src/agent/runtime-client.test.ts b/src/renderer/src/agent/runtime-client.test.ts index f8d3f4849..469ee50fc 100644 --- a/src/renderer/src/agent/runtime-client.test.ts +++ b/src/renderer/src/agent/runtime-client.test.ts @@ -49,12 +49,81 @@ function settings(apiKey: string): AppSettingsV1 { } } +function deferredValue(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + afterEach(() => { rendererRuntimeClient.invalidateSettings() vi.unstubAllGlobals() }) describe('rendererRuntimeClient', () => { + it('returns the same in-flight settings promise to concurrent callers', async () => { + const pending = deferredValue() + const getSettings = vi.fn(() => pending.promise) + vi.stubGlobal('window', { kunGui: { getSettings } }) + + const first = rendererRuntimeClient.getSettings() + const second = rendererRuntimeClient.getSettings() + + expect(second).toBe(first) + expect(getSettings).toHaveBeenCalledTimes(1) + pending.resolve(settings('sk-shared')) + await expect(first).resolves.toMatchObject({ agents: { kun: { apiKey: 'sk-shared' } } }) + }) + + it('retries settings after a shared in-flight read rejects', async () => { + const pending = deferredValue() + const getSettings = vi.fn() + .mockImplementationOnce(() => pending.promise) + .mockResolvedValueOnce(settings('sk-retried')) + vi.stubGlobal('window', { kunGui: { getSettings } }) + + const first = rendererRuntimeClient.getSettings() + const shared = rendererRuntimeClient.getSettings() + pending.reject(new Error('settings unavailable')) + + await expect(first).rejects.toThrow('settings unavailable') + await expect(shared).rejects.toThrow('settings unavailable') + await expect(rendererRuntimeClient.getSettings()).resolves.toMatchObject({ + agents: { kun: { apiKey: 'sk-retried' } } + }) + expect(getSettings).toHaveBeenCalledTimes(2) + }) + + it('does not let an older request clear or overwrite a forced refresh', async () => { + const first = deferredValue() + const second = deferredValue() + const getSettings = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + vi.stubGlobal('window', { kunGui: { getSettings } }) + + const older = rendererRuntimeClient.getSettings() + const newer = rendererRuntimeClient.getSettings({ forceRefresh: true }) + first.resolve(settings('sk-old')) + await expect(older).resolves.toMatchObject({ agents: { kun: { apiKey: 'sk-old' } } }) + + expect(rendererRuntimeClient.getSettings()).toBe(newer) + second.resolve(settings('sk-new')) + await expect(newer).resolves.toMatchObject({ agents: { kun: { apiKey: 'sk-new' } } }) + await expect(rendererRuntimeClient.getSettings()).resolves.toMatchObject({ + agents: { kun: { apiKey: 'sk-new' } } + }) + expect(getSettings).toHaveBeenCalledTimes(2) + }) + it('caches settings reads until invalidated', async () => { const getSettings = vi.fn(async () => settings('sk-1')) vi.stubGlobal('window', { diff --git a/src/renderer/src/agent/runtime-client.ts b/src/renderer/src/agent/runtime-client.ts index 5c5a2c800..9aa221ebb 100644 --- a/src/renderer/src/agent/runtime-client.ts +++ b/src/renderer/src/agent/runtime-client.ts @@ -11,19 +11,21 @@ class RendererRuntimeClient { private cachedSettings: AppSettingsV1 | null = null private settingsPromise: Promise | null = null - async getSettings(options?: { forceRefresh?: boolean }): Promise { + getSettings(options?: { forceRefresh?: boolean }): Promise { if (options?.forceRefresh) { this.invalidateSettings() } - if (this.cachedSettings) return this.cachedSettings + if (this.cachedSettings) return Promise.resolve(this.cachedSettings) if (this.settingsPromise) return this.settingsPromise - const task = window.kunGui.getSettings().then((settings) => { - this.cachedSettings = settings - return settings - }) - this.settingsPromise = task.finally(() => { + const task = window.kunGui.getSettings() + .then((settings) => { + if (this.settingsPromise === task) this.cachedSettings = settings + return settings + }) + this.settingsPromise = task + void task.finally(() => { if (this.settingsPromise === task) this.settingsPromise = null - }) + }).catch(() => undefined) return task } diff --git a/src/renderer/src/components/workbench/WorkbenchChatStage.tsx b/src/renderer/src/components/workbench/WorkbenchChatStage.tsx index efb874c14..f28219b63 100644 --- a/src/renderer/src/components/workbench/WorkbenchChatStage.tsx +++ b/src/renderer/src/components/workbench/WorkbenchChatStage.tsx @@ -31,7 +31,7 @@ import type { GuiPlanToolMeta } from '../../plan/plan-tool' import { useChatStore } from '../../store/chat-store' import { hasLivePendingUserInput } from '../../store/chat-store-runtime-helpers' import { shouldUseEmptyTaskLayout } from './workbench-chat-layout' -import { CircleHelp } from 'lucide-react' +import { CircleHelp, Loader2 } from 'lucide-react' const TerminalPanel = lazy(() => import('../terminal/TerminalPanel').then((module) => ({ default: module.TerminalPanel })) @@ -157,6 +157,7 @@ export function WorkbenchChatStage({ }: WorkbenchChatStageProps): ReactElement { const { t } = useTranslation('common') const threadLoadingId = useChatStore((state) => state.threadLoadingId) + const threadRefreshingId = useChatStore((state) => state.threadRefreshingId) const effectiveConversationDropWorkspaceRoot = normalizeWorkspaceRoot(conversationDropWorkspaceRoot) const canComposeForConversationDrop = composerProps.fileReferenceEnabled === true && @@ -225,6 +226,16 @@ export function WorkbenchChatStage({ compact /> ) : null} + {threadRefreshingId === activeThreadId ? ( + + + ) : null} {busy ? ( hasLivePendingUserInput(blocks) ? ( diff --git a/src/renderer/src/lib/browser-storage.ts b/src/renderer/src/lib/browser-storage.ts index 19630a330..e6fd6f402 100644 --- a/src/renderer/src/lib/browser-storage.ts +++ b/src/renderer/src/lib/browser-storage.ts @@ -4,6 +4,40 @@ export type BrowserStorageLike = { removeItem?: (key: string) => void } +export type BrowserStorageMutation = { + key: string + value: string | null +} + +type BrowserStorageMutationObserver = (mutation: BrowserStorageMutation) => void + +let mutationObserver: BrowserStorageMutationObserver | null = null + +export function setBrowserStorageMutationObserver( + observer: BrowserStorageMutationObserver | null +): void { + mutationObserver = observer +} + +function observedStorage(storage: BrowserStorageLike): BrowserStorageLike { + if (!mutationObserver) return storage + return { + getItem: (key) => storage.getItem(key), + setItem: (key, value) => { + storage.setItem(key, value) + mutationObserver?.({ key, value }) + }, + ...(storage.removeItem + ? { + removeItem: (key: string) => { + storage.removeItem?.(key) + mutationObserver?.({ key, value: null }) + } + } + : {}) + } +} + function isStorageLike(value: unknown): value is BrowserStorageLike { return ( Boolean(value) && @@ -16,7 +50,7 @@ function isStorageLike(value: unknown): value is BrowserStorageLike { export function browserStorage(): BrowserStorageLike | null { try { if (typeof window !== 'undefined' && isStorageLike(window.localStorage)) { - return window.localStorage + return observedStorage(window.localStorage) } } catch { return null @@ -25,7 +59,7 @@ export function browserStorage(): BrowserStorageLike | null { try { const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') if (descriptor && 'value' in descriptor && isStorageLike(descriptor.value)) { - return descriptor.value + return observedStorage(descriptor.value) } } catch { return null diff --git a/src/renderer/src/lib/shared-business-storage-journal.ts b/src/renderer/src/lib/shared-business-storage-journal.ts new file mode 100644 index 000000000..5917d3f36 --- /dev/null +++ b/src/renderer/src/lib/shared-business-storage-journal.ts @@ -0,0 +1,108 @@ +import type { BrowserStorageMutation } from './browser-storage' + +export const SHARED_BUSINESS_KEYS = [ + 'kun.codeWorkspaceRoots.v1', + 'kun.write.threadRegistry.v1', + 'kun.design.threadRegistry.v1', + 'kun.design-assistant.threadRegistry.v1', + 'kun.threadWorktrees.v1', + 'kun.threadForks.v1', + 'kun.sdd.threadRegistry.v1', + 'kun.plan.registry.v1' +] as const + +export type SharedEntries = Record + +export type SharedBusinessStorageJournal = { + version: 1 + acknowledgedRevision: number + acknowledgedEntries: SharedEntries + dirtyKeys: string[] +} + +export const SHARED_BUSINESS_STORAGE_JOURNAL_KEY = 'kun.sharedBusinessStorageSync.v1' + +const sharedKeySet = new Set(SHARED_BUSINESS_KEYS) + +export function isSharedBusinessKey(key: string): boolean { + return sharedKeySet.has(key) +} + +export function readSharedBusinessStorageJournal(): SharedBusinessStorageJournal | null { + try { + const raw = localStorage.getItem(SHARED_BUSINESS_STORAGE_JOURNAL_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + const value = parsed as Record + if (value.version !== 1 || !Number.isInteger(value.acknowledgedRevision) || + (value.acknowledgedRevision as number) < 0) return null + const acknowledgedEntries = sharedEntriesFrom(value.acknowledgedEntries) + const dirtyKeys = Array.isArray(value.dirtyKeys) + ? [...new Set(value.dirtyKeys.filter( + (key): key is string => typeof key === 'string' && isSharedBusinessKey(key) + ))] + : [] + return { + version: 1, + acknowledgedRevision: value.acknowledgedRevision as number, + acknowledgedEntries, + dirtyKeys + } + } catch { + return null + } +} + +export function writeSharedBusinessStorageJournal( + journal: SharedBusinessStorageJournal +): void { + try { + localStorage.setItem(SHARED_BUSINESS_STORAGE_JOURNAL_KEY, JSON.stringify({ + version: 1, + acknowledgedRevision: Math.max(0, Math.floor(journal.acknowledgedRevision)), + acknowledgedEntries: sharedEntriesFrom(journal.acknowledgedEntries), + dirtyKeys: [...new Set(journal.dirtyKeys.filter(isSharedBusinessKey))] + })) + } catch { + // Business values remain authoritative even if journal persistence is unavailable. + } +} + +export function updateJournalForMutation(mutation: BrowserStorageMutation): void { + if (!isSharedBusinessKey(mutation.key)) return + const journal = readSharedBusinessStorageJournal() + if (!journal) return + const acknowledged = journal.acknowledgedEntries[mutation.key] + const dirtyKeys = new Set(journal.dirtyKeys) + if (acknowledged === mutation.value || (acknowledged === undefined && mutation.value === null)) { + dirtyKeys.delete(mutation.key) + } else { + dirtyKeys.add(mutation.key) + } + writeSharedBusinessStorageJournal({ ...journal, dirtyKeys: [...dirtyKeys] }) +} + +export function readSharedLocalEntries(): SharedEntries { + const entries: SharedEntries = {} + for (const key of SHARED_BUSINESS_KEYS) { + const value = localStorage.getItem(key) + if (value !== null) entries[key] = value + } + return entries +} + +export function sharedEntriesFrom(value: unknown): SharedEntries { + const source = value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} + const entries: SharedEntries = {} + for (const key of SHARED_BUSINESS_KEYS) { + if (typeof source[key] === 'string') entries[key] = source[key] + } + return entries +} + +export function changedSharedKeys(previous: SharedEntries, current: SharedEntries): string[] { + return SHARED_BUSINESS_KEYS.filter((key) => previous[key] !== current[key]) +} diff --git a/src/renderer/src/lib/shared-business-storage.test.ts b/src/renderer/src/lib/shared-business-storage.test.ts index e1864b209..bfd9e7d26 100644 --- a/src/renderer/src/lib/shared-business-storage.test.ts +++ b/src/renderer/src/lib/shared-business-storage.test.ts @@ -7,6 +7,11 @@ import { type SharedBusinessStorageCursor } from './shared-business-storage' +import { + SHARED_BUSINESS_STORAGE_JOURNAL_KEY, + writeSharedBusinessStorageJournal +} from './shared-business-storage-journal' + const DESIGN_REGISTRY_KEY = 'kun.design.threadRegistry.v1' class MemoryStorage implements Storage { @@ -157,6 +162,100 @@ describe('shared business storage synchronization', () => { delete (window as unknown as { kunGui?: unknown }).kunGui }) + it('restores a dirty local update across restart and uploads it over an old remote value', async () => { + const storage = new MemoryStorage() + const oldRegistry = '{"old":true}' + const newRegistry = '{"new":true}' + storage.setItem(DESIGN_REGISTRY_KEY, newRegistry) + vi.stubGlobal('localStorage', storage) + writeSharedBusinessStorageJournal({ + version: 1, + acknowledgedRevision: 4, + acknowledgedEntries: { [DESIGN_REGISTRY_KEY]: oldRegistry }, + dirtyKeys: [DESIGN_REGISTRY_KEY] + }) + const write = vi.fn(async (_revision: number, value: Record) => ({ + revision: 5, + value + })) + ;(window as unknown as { kunGui: unknown }).kunGui = { + sharedClientState: { + read: vi.fn(async () => ({ revision: 4, value: { [DESIGN_REGISTRY_KEY]: oldRegistry } })), + write + }, + appEnvironment: { flavor: 'development' } + } + + await installSharedBusinessStorage() + + expect(storage.getItem(DESIGN_REGISTRY_KEY)).toBe(newRegistry) + expect(write).toHaveBeenCalledWith(4, { [DESIGN_REGISTRY_KEY]: newRegistry }) + expect(JSON.parse(storage.getItem(SHARED_BUSINESS_STORAGE_JOURNAL_KEY) ?? '{}')).toMatchObject({ + acknowledgedRevision: 5, + dirtyKeys: [] + }) + delete (window as unknown as { kunGui?: unknown }).kunGui + }) + + it('keeps local data after an initial read failure and uploads it on the next install', async () => { + const storage = new MemoryStorage() + const oldRegistry = '{"old":true}' + const newRegistry = '{"new":true}' + storage.setItem(DESIGN_REGISTRY_KEY, newRegistry) + vi.stubGlobal('localStorage', storage) + writeSharedBusinessStorageJournal({ + version: 1, + acknowledgedRevision: 2, + acknowledgedEntries: { [DESIGN_REGISTRY_KEY]: oldRegistry }, + dirtyKeys: [DESIGN_REGISTRY_KEY] + }) + const read = vi.fn().mockRejectedValue(new Error('manager unavailable')) + const write = vi.fn(async (_revision: number, value: Record) => ({ revision: 3, value })) + ;(window as unknown as { kunGui: unknown }).kunGui = { + sharedClientState: { read, write }, + appEnvironment: { flavor: 'development' } + } + + await expect(installSharedBusinessStorage()).rejects.toThrow('manager unavailable') + expect(storage.getItem(DESIGN_REGISTRY_KEY)).toBe(newRegistry) + resetSharedBusinessStorageInstallForTests() + read.mockResolvedValue({ revision: 2, value: { [DESIGN_REGISTRY_KEY]: oldRegistry } }) + await installSharedBusinessStorage() + + expect(storage.getItem(DESIGN_REGISTRY_KEY)).toBe(newRegistry) + expect(write).toHaveBeenCalledWith(2, { [DESIGN_REGISTRY_KEY]: newRegistry }) + delete (window as unknown as { kunGui?: unknown }).kunGui + }) + + it('preserves a dirty deletion tombstone instead of resurrecting the remote value', async () => { + const storage = new MemoryStorage() + const oldRegistry = '{"old":true}' + vi.stubGlobal('localStorage', storage) + writeSharedBusinessStorageJournal({ + version: 1, + acknowledgedRevision: 8, + acknowledgedEntries: { [DESIGN_REGISTRY_KEY]: oldRegistry }, + dirtyKeys: [DESIGN_REGISTRY_KEY] + }) + const write = vi.fn(async (_revision: number, value: Record) => ({ + revision: 9, + value + })) + ;(window as unknown as { kunGui: unknown }).kunGui = { + sharedClientState: { + read: vi.fn(async () => ({ revision: 8, value: { [DESIGN_REGISTRY_KEY]: oldRegistry } })), + write + }, + appEnvironment: { flavor: 'development' } + } + + await installSharedBusinessStorage() + + expect(storage.getItem(DESIGN_REGISTRY_KEY)).toBeNull() + expect(write).toHaveBeenCalledWith(8, {}) + delete (window as unknown as { kunGui?: unknown }).kunGui + }) + it('protects a newer local registry written while its previous value is being committed', async () => { const storage = new MemoryStorage() const remoteRegistry = '{"version":1,"workspaces":{}}' diff --git a/src/renderer/src/lib/shared-business-storage.ts b/src/renderer/src/lib/shared-business-storage.ts index 30bf91ca7..82b527361 100644 --- a/src/renderer/src/lib/shared-business-storage.ts +++ b/src/renderer/src/lib/shared-business-storage.ts @@ -1,15 +1,15 @@ -const SHARED_BUSINESS_KEYS = [ - 'kun.codeWorkspaceRoots.v1', - 'kun.write.threadRegistry.v1', - 'kun.design.threadRegistry.v1', - 'kun.design-assistant.threadRegistry.v1', - 'kun.threadWorktrees.v1', - 'kun.threadForks.v1', - 'kun.sdd.threadRegistry.v1', - 'kun.plan.registry.v1' -] as const - -type SharedEntries = Record +import { setBrowserStorageMutationObserver } from './browser-storage' +import { + SHARED_BUSINESS_KEYS, + changedSharedKeys, + readSharedBusinessStorageJournal, + readSharedLocalEntries, + sharedEntriesFrom, + updateJournalForMutation, + writeSharedBusinessStorageJournal, + type SharedBusinessStorageJournal, + type SharedEntries +} from './shared-business-storage-journal' type SharedClientStateSnapshot = { revision: number @@ -33,19 +33,26 @@ export type SharedBusinessStorageSyncResult = SharedBusinessStorageCursor & { const POLL_INTERVAL_MS = 1_000 const INITIAL_READ_ATTEMPTS = 3 const INITIAL_READ_RETRY_DELAY_MS = 300 +const UNLOAD_FLUSH_DEADLINE_MS = 250 let installPromise: Promise | null = null +let installedCleanup: (() => void) | null = null +let activeFlush: (() => Promise) | null = null -// Test-only escape hatch: the production singleton intentionally survives a -// successful install, but unit tests must reset it between scenarios. export function resetSharedBusinessStorageInstallForTests(): void { + installedCleanup?.() + installedCleanup = null + activeFlush = null installPromise = null + setBrowserStorageMutationObserver(null) +} + +export function flushSharedBusinessStorage(): Promise { + return activeFlush?.() ?? Promise.resolve() } const delay = (ms: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, ms) - }) + new Promise((resolve) => setTimeout(resolve, ms)) async function readInitialSnapshot(api: SharedClientStateApi): Promise { let lastError: unknown @@ -67,8 +74,6 @@ export async function installSharedBusinessStorage(): Promise { try { await promise } catch (error) { - // Allow a later retry (e.g. the StartupGate retry button) to install again. - // A settled success keeps `installPromise` so the poller is never duplicated. if (installPromise === promise) installPromise = null throw error } @@ -78,74 +83,99 @@ async function doInstallSharedBusinessStorage(): Promise { const api = window.kunGui?.sharedClientState if (!api || typeof localStorage === 'undefined') return + setBrowserStorageMutationObserver(updateJournalForMutation) + const localAtStartup = readSharedLocalEntries() let snapshot = await readInitialSnapshot(api) - const localAtStartup = readLocalEntries() - if ( - snapshot.revision === 0 && - Object.keys(snapshot.value).length === 0 && - window.kunGui.appEnvironment.flavor === 'production' && - Object.keys(localAtStartup).length > 0 - ) { - try { - snapshot = await api.write(snapshot.revision, localAtStartup) - } catch { - snapshot = await api.read() + let journal = readSharedBusinessStorageJournal() + if (!journal) { + const dirtyKeys = SHARED_BUSINESS_KEYS.filter((key) => + localAtStartup[key] !== undefined && localAtStartup[key] !== snapshot.value[key] + ) + journal = { + version: 1, + acknowledgedRevision: snapshot.revision, + acknowledgedEntries: sharedEntriesFrom(snapshot.value), + dirtyKeys } + writeSharedBusinessStorageJournal(journal) } - applyEntries(snapshot.value) - let baseline = readLocalEntries() - let revision = snapshot.revision - let syncing = false - - const sync = async () => { - if (syncing) return - syncing = true - let retry = false - try { - const result = await syncSharedBusinessStorageOnce(api, { baseline, revision }) - baseline = result.baseline - revision = result.revision - retry = result.retry - } catch { - // Manager loss is surfaced by Runtime/settings operations. Keep the - // profile-local mirror intact and retry when the stable manager returns. - } finally { - syncing = false - if (retry) queueMicrotask(() => void sync()) - } + + const startupDirty = new Set([ + ...journal.dirtyKeys, + ...changedSharedKeys(journal.acknowledgedEntries, localAtStartup) + ]) + applyEntries(snapshot.value, startupDirty) + let baseline = journal.acknowledgedEntries + let revision = journal.acknowledgedRevision + let syncing: Promise | null = null + + const sync = (): Promise => { + if (syncing) return syncing + syncing = (async () => { + try { + const result = await syncSharedBusinessStorageOnce(api, { baseline, revision }) + baseline = result.baseline + revision = result.revision + if (result.retry) queueMicrotask(() => void sync()) + } catch { + // Keep the profile-local mirror and durable journal intact for retry. + } + })().finally(() => { + syncing = null + }) + return syncing + } + activeFlush = sync + + if (startupDirty.size > 0) await sync() + else { + baseline = sharedEntriesFrom(snapshot.value) + revision = snapshot.revision + persistAcknowledgement(snapshot, readSharedLocalEntries()) } const timer = window.setInterval(() => void sync(), POLL_INTERVAL_MS) - window.addEventListener('beforeunload', () => window.clearInterval(timer), { once: true }) + const handleUnload = (): void => { + window.clearInterval(timer) + persistDirtyAgainstAcknowledgement() + void Promise.race([sync(), delay(UNLOAD_FLUSH_DEADLINE_MS)]) + } + window.addEventListener('beforeunload', handleUnload, { once: true }) + window.addEventListener('pagehide', handleUnload, { once: true }) + installedCleanup = () => { + window.clearInterval(timer) + window.removeEventListener('beforeunload', handleUnload) + window.removeEventListener('pagehide', handleUnload) + } } -/** - * Reconcile one shared-state poll without allowing an async remote read or - * compare-and-swap retry to overwrite local writes that happened while it was - * awaiting. `baseline` is the last acknowledged remote snapshot, not simply - * the latest local value, so protected late writes remain pending next poll. - */ export async function syncSharedBusinessStorageOnce( api: SharedClientStateApi, cursor: SharedBusinessStorageCursor ): Promise { - // Read remote first. Any local mutation during this await is then visible in - // the snapshot below and will be pushed instead of being overwritten. let remote = await api.read() - let localSnapshot = readLocalEntries() - let pendingKeys = changedKeys(cursor.baseline, localSnapshot) + const journal = readSharedBusinessStorageJournal() + const acknowledged = journal?.acknowledgedEntries ?? cursor.baseline + let localSnapshot = readSharedLocalEntries() + let pendingKeys = new Set([ + ...(journal?.dirtyKeys ?? []), + ...changedSharedKeys(acknowledged, localSnapshot) + ]) + let submittedLocal = localSnapshot let wrotePendingKeys = false - if (pendingKeys.length > 0) { + if (pendingKeys.size > 0) { for (let attempt = 0; attempt < 3; attempt += 1) { - // A previous CAS/read await may have admitted newer local writes. Always - // rebuild the complete pending set against the acknowledged baseline. - localSnapshot = readLocalEntries() - pendingKeys = changedKeys(cursor.baseline, localSnapshot) - if (pendingKeys.length === 0) break + localSnapshot = readSharedLocalEntries() + pendingKeys = new Set([ + ...(readSharedBusinessStorageJournal()?.dirtyKeys ?? []), + ...changedSharedKeys(acknowledged, localSnapshot) + ]) + if (pendingKeys.size === 0) break + submittedLocal = localSnapshot const merged = { ...remote.value } for (const key of pendingKeys) { - const value = localSnapshot[key] + const value = submittedLocal[key] if (value === undefined) delete merged[key] else merged[key] = value } @@ -159,36 +189,47 @@ export async function syncSharedBusinessStorageOnce( } } - const latestLocal = readLocalEntries() - const protectedKeys = new Set(changedKeys(localSnapshot, latestLocal)) - if (pendingKeys.length > 0 && !wrotePendingKeys) { - for (const key of changedKeys(cursor.baseline, latestLocal)) protectedKeys.add(key) + const latestLocal = readSharedLocalEntries() + const protectedKeys = new Set(changedSharedKeys(submittedLocal, latestLocal)) + if (pendingKeys.size > 0 && !wrotePendingKeys) { + for (const key of pendingKeys) protectedKeys.add(key) } - applyEntries(remote.value, protectedKeys) + const afterApply = readSharedLocalEntries() + const remainingDirty = new Set(changedSharedKeys(remote.value, afterApply)) + for (const key of protectedKeys) remainingDirty.add(key) + writeSharedBusinessStorageJournal({ + version: 1, + acknowledgedRevision: remote.revision, + acknowledgedEntries: sharedEntriesFrom(remote.value), + dirtyKeys: [...remainingDirty] + }) return { baseline: sharedEntriesFrom(remote.value), revision: remote.revision, - retry: protectedKeys.size > 0 + retry: remainingDirty.size > 0 } } -function readLocalEntries(): SharedEntries { - const entries: SharedEntries = {} - for (const key of SHARED_BUSINESS_KEYS) { - const value = localStorage.getItem(key) - if (value !== null) entries[key] = value - } - return entries +function persistAcknowledgement(snapshot: SharedClientStateSnapshot, local: SharedEntries): void { + writeSharedBusinessStorageJournal({ + version: 1, + acknowledgedRevision: snapshot.revision, + acknowledgedEntries: sharedEntriesFrom(snapshot.value), + dirtyKeys: changedSharedKeys(snapshot.value, local) + }) } -function sharedEntriesFrom(entries: SharedEntries): SharedEntries { - const shared: SharedEntries = {} - for (const key of SHARED_BUSINESS_KEYS) { - const value = entries[key] - if (value !== undefined) shared[key] = value - } - return shared +function persistDirtyAgainstAcknowledgement(): void { + const journal = readSharedBusinessStorageJournal() + if (!journal) return + writeSharedBusinessStorageJournal({ + ...journal, + dirtyKeys: [...new Set([ + ...journal.dirtyKeys, + ...changedSharedKeys(journal.acknowledgedEntries, readSharedLocalEntries()) + ])] + }) } function applyEntries(entries: SharedEntries, protectedKeys: ReadonlySet = new Set()): void { @@ -203,7 +244,3 @@ function applyEntries(entries: SharedEntries, protectedKeys: ReadonlySet } } } - -function changedKeys(previous: SharedEntries, current: SharedEntries): string[] { - return SHARED_BUSINESS_KEYS.filter((key) => previous[key] !== current[key]) -} diff --git a/src/renderer/src/locales/en/common/sidebar.json b/src/renderer/src/locales/en/common/sidebar.json index e66cc6eaf..1ac258d4b 100644 --- a/src/renderer/src/locales/en/common/sidebar.json +++ b/src/renderer/src/locales/en/common/sidebar.json @@ -1,4 +1,5 @@ { + "threadRefreshing": "Refreshing…", "sidebarWorkspaceLoading": "Loading threads…", "sidebarWorkspaceLoadError": "Failed to load threads.", "threadHydrationLoadingTitle": "Loading conversation…", diff --git a/src/renderer/src/locales/zh/common/sidebar.json b/src/renderer/src/locales/zh/common/sidebar.json index 23b845741..c3422d860 100644 --- a/src/renderer/src/locales/zh/common/sidebar.json +++ b/src/renderer/src/locales/zh/common/sidebar.json @@ -1,4 +1,5 @@ { + "threadRefreshing": "正在刷新…", "sidebarWorkspaceLoading": "正在加载会话…", "sidebarWorkspaceLoadError": "会话加载失败。", "threadHydrationLoadingTitle": "正在加载会话…", diff --git a/src/renderer/src/store/chat-store-initial-state.ts b/src/renderer/src/store/chat-store-initial-state.ts index b0c981256..93f933887 100644 --- a/src/renderer/src/store/chat-store-initial-state.ts +++ b/src/renderer/src/store/chat-store-initial-state.ts @@ -33,6 +33,7 @@ export function createInitialChatStoreState(workingDirectoryLabel: string) { showArchivedThreads: false, activeThreadId: null, threadLoadingId: null, + threadRefreshingId: null, threadHistoryCursor: null, threadHasMoreHistory: false, threadHistoryLoading: false, diff --git a/src/renderer/src/store/chat-store-runtime-helpers.ts b/src/renderer/src/store/chat-store-runtime-helpers.ts index 9aacf4536..69f0a34f3 100644 --- a/src/renderer/src/store/chat-store-runtime-helpers.ts +++ b/src/renderer/src/store/chat-store-runtime-helpers.ts @@ -286,6 +286,7 @@ export function clearedThreadSelection(): Pick< ChatState, | 'activeThreadId' | 'threadLoadingId' + | 'threadRefreshingId' | 'threadHistoryCursor' | 'threadHasMoreHistory' | 'threadHistoryLoading' @@ -313,6 +314,7 @@ export function clearedThreadSelection(): Pick< return { activeThreadId: null, threadLoadingId: null, + threadRefreshingId: null, threadHistoryCursor: null, threadHasMoreHistory: false, threadHistoryLoading: false, diff --git a/src/renderer/src/store/chat-store-thread-live-loading.test.ts b/src/renderer/src/store/chat-store-thread-live-loading.test.ts index 0b730de9b..219564f63 100644 --- a/src/renderer/src/store/chat-store-thread-live-loading.test.ts +++ b/src/renderer/src/store/chat-store-thread-live-loading.test.ts @@ -39,7 +39,11 @@ function detail(id: string): ThreadDetail { } } -function buildHarness(): { actions: ReturnType; state: ChatState } { +function buildHarness(): { + actions: ReturnType + state: ChatState + sseAbortRef: { current: AbortController | null } +} { let state: ChatState state = { activeThreadId: 'thread-a', @@ -55,7 +59,9 @@ function buildHarness(): { actions: ReturnType; stat codeWorkspaceRoots: [], composerModel: '', composerMode: 'agent', + composerModelGroups: [], composerOrchestration: 'direct', + composerPickList: [], composerProviderId: '', currentTurnId: null, currentTurnOrchestration: null, @@ -87,9 +93,11 @@ function buildHarness(): { actions: ReturnType; stat Object.assign(state, update) } const get: ChatStoreGet = () => state + const sseAbortRef = { current: null as AbortController | null } return { - actions: createThreadActions({ set, get, sseAbortRef: { current: null } }), - state + actions: createThreadActions({ set, get, sseAbortRef }), + state, + sseAbortRef } } @@ -107,6 +115,65 @@ describe('live thread hydration loading', () => { vi.unstubAllGlobals() }) + it('refreshes the active thread transactionally without blanking its projection or SSE', async () => { + const pending = deferred() + const subscribeThreadEvents = vi.fn(async () => undefined) + registryMock.getProvider.mockReturnValue({ + getThreadDetail: vi.fn(() => pending.promise), + subscribeThreadEvents + }) + const { actions, state, sseAbortRef } = buildHarness() + const existingBlocks: ChatBlock[] = [...state.blocks] + const existingSse = new AbortController() + sseAbortRef.current = existingSse + + const refresh = actions.selectThread('thread-a') + + expect(state.threadRefreshingId).toBe('thread-a') + expect(state.threadLoadingId).toBeNull() + expect(state.blocks).toEqual(existingBlocks) + expect(existingSse.signal.aborted).toBe(false) + expect(sseAbortRef.current).toBe(existingSse) + expect(subscribeThreadEvents).not.toHaveBeenCalled() + + pending.resolve(detail('thread-a')) + await refresh + + expect(existingSse.signal.aborted).toBe(true) + expect(state.threadRefreshingId).toBeNull() + expect(state.blocks).toEqual(detail('thread-a').blocks) + expect(subscribeThreadEvents).toHaveBeenCalledWith( + 'thread-a', + detail('thread-a').latestSeq, + expect.anything(), + expect.anything() + ) + }) + + it('preserves the active projection and SSE when a same-thread refresh fails', async () => { + const pending = deferred() + const subscribeThreadEvents = vi.fn(async () => undefined) + registryMock.getProvider.mockReturnValue({ + getThreadDetail: vi.fn(() => pending.promise), + subscribeThreadEvents + }) + const { actions, state, sseAbortRef } = buildHarness() + const existingBlocks: ChatBlock[] = [...state.blocks] + const existingSse = new AbortController() + sseAbortRef.current = existingSse + + const refresh = actions.selectThread('thread-a') + pending.reject(new Error('refresh failed')) + await refresh + + expect(state.threadRefreshingId).toBeNull() + expect(state.blocks).toEqual(existingBlocks) + expect(existingSse.signal.aborted).toBe(false) + expect(sseAbortRef.current).toBe(existingSse) + expect(subscribeThreadEvents).not.toHaveBeenCalled() + expect(state.error).toContain('refresh failed') + }) + it('keeps a cross-thread live target loading until canonical detail commits', async () => { const pending = deferred() registryMock.getProvider.mockReturnValue({ diff --git a/src/renderer/src/store/chat-store-thread-selection-actions.ts b/src/renderer/src/store/chat-store-thread-selection-actions.ts index 5d7bb0d2b..0006512fd 100644 --- a/src/renderer/src/store/chat-store-thread-selection-actions.ts +++ b/src/renderer/src/store/chat-store-thread-selection-actions.ts @@ -248,6 +248,7 @@ export function createThreadSelectionActions( unreadThreadIds: nextUnread, activeThreadId: id, threadLoadingId: null, + threadRefreshingId: null, threadHistoryCursor: cached.threadHistoryCursor, threadHasMoreHistory: cached.threadHasMoreHistory, threadHistoryLoading: false, @@ -294,49 +295,45 @@ export function createThreadSelectionActions( // Give the sidebar its selected state in this render frame. The timeline // shows a skeleton and the composer is disabled until detail hydration // commits, preventing sends against an unhydrated thread. - const initialComposerState = resolveThreadComposerState(get(), targetThread) - if (prevId === id) { - // A same-thread refresh keeps the current projection mounted beneath the - // hydration overlay. The detail response replaces it atomically; failure - // removes the overlay while leaving the last usable projection intact. + if (refreshingActiveThread) { set({ watchTurnCompletion: nextWatch, unreadThreadIds: nextUnread, - threadLoadingId: id, - threadHistoryLoading: false, - error: null, - ...initialComposerState + threadRefreshingId: id, + error: null }) } else { + const initialComposerState = resolveThreadComposerState(get(), targetThread) set({ - watchTurnCompletion: nextWatch, - unreadThreadIds: nextUnread, - activeThreadId: id, - threadLoadingId: id, - threadHistoryCursor: null, - threadHasMoreHistory: false, - threadHistoryLoading: false, - activeThreadRelation: targetThread?.relation ?? 'primary', - activeThreadParentId: targetThread?.parentThreadId ?? null, - activeThreadGoal: targetThread?.goal ?? null, - activeThreadTodos: targetThread?.todos ?? null, - blocks: [], - lastSeq: 0, - liveDeltaSeqFloor: 0, - liveReasoning: '', - liveAssistant: '', - busy: false, - busyUnconfirmed: false, - currentTurnId: null, - currentTurnOrchestration: null, - currentTurnUserId: null, - turnStartedAtByUserId: {}, - turnDurationByUserId: {}, - turnReasoningFirstAtByUserId: {}, - turnReasoningLastAtByUserId: {}, - inspectorSelectedId: null, - queuedMessages: [], - error: null, + watchTurnCompletion: nextWatch, + unreadThreadIds: nextUnread, + activeThreadId: id, + threadLoadingId: id, + threadRefreshingId: null, + threadHistoryCursor: null, + threadHasMoreHistory: false, + threadHistoryLoading: false, + activeThreadRelation: targetThread?.relation ?? 'primary', + activeThreadParentId: targetThread?.parentThreadId ?? null, + activeThreadGoal: targetThread?.goal ?? null, + activeThreadTodos: targetThread?.todos ?? null, + blocks: [], + lastSeq: 0, + liveDeltaSeqFloor: 0, + liveReasoning: '', + liveAssistant: '', + busy: false, + busyUnconfirmed: false, + currentTurnId: null, + currentTurnOrchestration: null, + currentTurnUserId: null, + turnStartedAtByUserId: {}, + turnDurationByUserId: {}, + turnReasoningFirstAtByUserId: {}, + turnReasoningLastAtByUserId: {}, + inspectorSelectedId: null, + queuedMessages: [], + error: null, ...initialComposerState }) } @@ -439,6 +436,7 @@ export function createThreadSelectionActions( })(), activeThreadId: id, threadLoadingId: null, + threadRefreshingId: null, threadHistoryCursor: historyCursor ?? null, threadHasMoreHistory: hasMoreHistory, threadHistoryLoading: false, @@ -493,6 +491,7 @@ export function createThreadSelectionActions( if (!selectionStillCurrent()) return set({ threadLoadingId: null, + threadRefreshingId: get().threadRefreshingId === id ? null : get().threadRefreshingId, error: formatRuntimeError(e), ...(shouldOpenSettingsForError(e) ? { route: 'settings' as const, settingsSection: 'agents' as const } diff --git a/src/renderer/src/store/chat-store-types.ts b/src/renderer/src/store/chat-store-types.ts index 60dbcb78d..28acc85d7 100644 --- a/src/renderer/src/store/chat-store-types.ts +++ b/src/renderer/src/store/chat-store-types.ts @@ -332,6 +332,8 @@ export type ChatState = { activeThreadId: string | null /** Thread selected immediately but whose durable snapshot is still loading. */ threadLoadingId: string | null + /** Active-thread durable refresh; unlike initial hydration, its projection stays interactive. */ + threadRefreshingId: string | null /** Opaque cursor for the next older durable timeline page. */ threadHistoryCursor: string | null threadHasMoreHistory: boolean From fd26f4cf46af1d28b5685c13c082b8147f1257f8 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 01:10:56 +0800 Subject: [PATCH 044/168] fix(usage): keep sidebar usage history readable under load Global usage aggregations hydrate a full thread record per legacy usage row for per-turn provider attribution, which regularly outlives the generic 15s GET budget and surfaced as the sidebar banner with no diagnostic detail. - main: give /v1/usage history groupings (day/model/thread/turn) the same 120s budget as provider quotas; runtime-cumulative reads keep the fast default - kun: memoize hydrated thread records across loads keyed by threadId::updatedAt so refreshes stop re-reading every thread, and degrade a failed hydration to the summary instead of failing the whole aggregation with a 500 - renderer: surface the concrete load error under the generic banner in the sidebar usage history and model cards Tests: usage-history memo/degradation cases, kun-adapter timeout grouping cases; kun typecheck/build and affected vitest suites pass. --- kun/src/services/usage-history.test.ts | 81 +++++++++++++++++++ kun/src/services/usage-history.ts | 73 +++++++++++++++-- src/main/runtime/kun-adapter.test.ts | 17 ++++ src/main/runtime/kun-adapter.ts | 19 +++++ .../workbench/SidebarUsagePanel.tsx | 19 ++++- 5 files changed, 202 insertions(+), 7 deletions(-) diff --git a/kun/src/services/usage-history.test.ts b/kun/src/services/usage-history.test.ts index 678a9d8c9..c9e919ed7 100644 --- a/kun/src/services/usage-history.test.ts +++ b/kun/src/services/usage-history.test.ts @@ -168,6 +168,87 @@ describe('loadUsageHistory provider attribution', () => { expect(peakInFlight).toBeLessThanOrEqual(4) }) + it('degrades a corrupt thread document to the summary instead of failing aggregation', async () => { + const source = { + threadService: { + list: async () => [ + { id: 'thread-broken', model: 'glm-5.3', providerId: 'summary-provider', status: 'active', updatedAt: '2026-08-23T00:00:00.000Z' }, + { id: 'thread-healthy', model: 'glm-5.3', providerId: 'summary-provider', status: 'active', updatedAt: '2026-08-23T00:00:00.000Z' } + ], + get: async (threadId: string) => { + if (threadId === 'thread-broken') throw new Error('corrupt thread document') + return { + id: threadId, + model: 'glm-5.3', + providerId: 'thread-provider', + updatedAt: '2026-08-23T00:00:00.000Z', + turns: [{ id: 'turn-healthy', model: 'glm-5.3', providerId: 'turn-provider' }] + } + } + }, + sessionStore: { + loadUsageRecords: async () => [ + indexedRecord('turn-broken', undefined, 1_000, 100, 'thread-broken'), + indexedRecord('turn-healthy', undefined, 1_000, 100, 'thread-healthy') + ], + loadLatestUsageSnapshots: async () => [] + }, + usageService: { forThread: () => emptyUsageSnapshot() }, + nowIso: () => '2026-08-23T00:00:03.000Z' + } + + const records = await loadUsageHistory(source as never) + + expect(providerByTurn(records)).toEqual({ + // The corrupt document falls back to thread-current attribution. + 'turn-broken': 'summary-provider', + 'turn-healthy': 'turn-provider' + }) + }) + + it('reuses hydrated threads across loads keyed by updatedAt', async () => { + const source = { + threadService: { + list: async () => [ + { id: 'thread-memo', model: 'glm-5.3', providerId: 'provider-b', status: 'active', updatedAt: '2026-08-23T00:00:02.000Z' } + ], + get: vi.fn(async () => ({ + id: 'thread-memo', + model: 'glm-5.3', + providerId: 'provider-b', + updatedAt: '2026-08-23T00:00:02.000Z', + turns: [ + { id: 'turn-1', model: 'glm-5.3', providerId: 'provider-a' }, + { id: 'turn-2', model: 'glm-5.3', providerId: 'provider-b' } + ] + })) + }, + sessionStore: { + loadUsageRecords: vi.fn(async () => [ + indexedRecord('turn-1', undefined, 1_000, 100, 'thread-memo'), + indexedRecord('turn-2', undefined, 2_000, 200, 'thread-memo') + ]), + loadLatestUsageSnapshots: async () => [{ + threadId: 'thread-memo', + usage: cumulativeUsage(2_000, 200) + }] + }, + usageService: { forThread: () => emptyUsageSnapshot() }, + nowIso: () => '2026-08-23T00:00:03.000Z' + } + + const first = await loadUsageHistory(source as never) + const second = await loadUsageHistory(source as never) + + expect(providerByTurn(first)).toEqual({ + 'turn-1': 'provider-a', + 'turn-2': 'provider-b' + }) + expect(providerByTurn(second)).toEqual(providerByTurn(first)) + // Only the first load pays the full-record read. + expect(source.threadService.get).toHaveBeenCalledTimes(1) + }) + it('feeds per-turn provider attribution into coding-plan zero-price aggregation', async () => { const source = { threadService: { diff --git a/kun/src/services/usage-history.ts b/kun/src/services/usage-history.ts index 338cb0b57..ddf083a36 100644 --- a/kun/src/services/usage-history.ts +++ b/kun/src/services/usage-history.ts @@ -28,6 +28,41 @@ type ThreadHydrator = (threadId: string) => Promise const usageRecordLoads = new WeakMap>>() const USAGE_FALLBACK_READ_CONCURRENCY = 4 +/** + * Cross-request memo of fully hydrated thread records keyed by + * `threadId::updatedAt`. Attribution only needs `turns/providerId/model`, all + * frozen once `updatedAt` stops moving, so a memoized record stays valid until + * the thread changes. Without this every usage refresh re-read every thread + * document, which made global history aggregation exceed the desktop GET + * budget after per-turn provider attribution landed. + */ +const hydratedThreadMemo = new Map() +const HYDRATED_THREAD_MEMO_MAX = 512 + +function hydratedThreadMemoKey(threadId: string, updatedAt: string): string { + return `${threadId}::${updatedAt}` +} + +function readHydratedThreadMemo( + threadId: string, + updatedAt: string | undefined +): ThreadRecord | null | undefined { + if (!updatedAt) return undefined + return hydratedThreadMemo.get(hydratedThreadMemoKey(threadId, updatedAt)) +} + +function writeHydratedThreadMemo(threadId: string, record: ThreadRecord | null): void { + const updatedAt = record?.updatedAt + if (!updatedAt) return + const key = hydratedThreadMemoKey(threadId, updatedAt) + if (hydratedThreadMemo.has(key)) return + if (hydratedThreadMemo.size >= HYDRATED_THREAD_MEMO_MAX) { + const oldest = hydratedThreadMemo.keys().next().value + if (oldest !== undefined) hydratedThreadMemo.delete(oldest) + } + hydratedThreadMemo.set(key, record) +} + /** * Load durable differential usage with the optional SQLite index first and a * JSONL replay fallback. Live counters newer than persistence are appended as @@ -64,6 +99,7 @@ async function loadUsageRecords( ? [] : (await source.threadService.list({ includeArchived: true, includeSide: true })) .filter((thread) => thread.status !== 'deleted') + const summariesById = new Map(threadSummaries.map((thread) => [thread.id, thread])) // Summaries omit `turns`, so per-turn provider/model attribution needs the // full ThreadRecord. The cache deduplicates hydrations within one load and @@ -72,7 +108,27 @@ async function loadUsageRecords( const hydrateThread: ThreadHydrator = (threadId) => { const cached = threadCache.get(threadId) if (cached) return cached - const load = source.threadService.get(threadId) + const summary = summariesById.get(threadId) + const memoized = readHydratedThreadMemo(threadId, summary?.updatedAt) + if (memoized !== undefined) { + const settled = Promise.resolve(memoized) + threadCache.set(threadId, settled) + return settled + } + const load = source.threadService + .get(threadId) + // A corrupt thread document must degrade to the summary (thread-current + // provider attribution) instead of failing the whole usage aggregation. + .then( + (record) => { + writeHydratedThreadMemo(threadId, record) + return record + }, + () => { + writeHydratedThreadMemo(threadId, null) + return null + } + ) threadCache.set(threadId, load) return load } @@ -83,7 +139,6 @@ async function loadUsageRecords( options.threadId ? [options.threadId] : threadSummaries.map((thread) => thread.id) ) const indexedRaw = await source.sessionStore.loadUsageRecords({ threadId: options.threadId }) - const summariesById = new Map(threadSummaries.map((thread) => [thread.id, thread])) // Legacy indexed rows carry no persisted providerId; without a hydrate // they would be attributed to the thread's *current* provider. const hydrationIds: string[] = [] @@ -145,7 +200,8 @@ async function loadUsageRecords( } return records } catch { - // Fall back to JSONL replay when the optional usage index is unavailable. + // Fall back to JSONL replay when the optional usage index is + // unavailable or one of its reads failed mid-aggregation. } } @@ -198,8 +254,15 @@ async function loadUsageRecordsForSource( ): Promise { // Hydrate the full record before falling back to the summary: the summary // lacks `turns`, so provider attribution on it would use the thread's - // current provider instead of the turn's own route. - const thread = item.thread ?? await hydrateThread(item.id) ?? item.summary + // current provider instead of the turn's own route. A failed hydration + // degrades to the summary instead of failing the whole aggregation. + let hydrated: ThreadRecord | null = null + try { + hydrated = await hydrateThread(item.id) + } catch { + hydrated = null + } + const thread: ThreadRecord | ThreadSummary | undefined = item.thread ?? hydrated ?? item.summary if (!thread) return [] const records: ThreadUsageRecord[] = [] let latestPersisted = emptyUsageSnapshot() diff --git a/src/main/runtime/kun-adapter.test.ts b/src/main/runtime/kun-adapter.test.ts index 63c7554f7..f2e3a661a 100644 --- a/src/main/runtime/kun-adapter.test.ts +++ b/src/main/runtime/kun-adapter.test.ts @@ -149,6 +149,23 @@ describe('runtimeRequestViaHost', () => { expect(resolveRuntimeRequestTimeoutMs('/v1/provider-quotas', 'POST')).toBe(60_000) }) + it('lets usage history aggregations outlive the generic GET budget', () => { + const usagePath = + '/v1/usage?group_by=day&from=2026-05-01&to=2026-08-24&timezone=Asia%2FShanghai' + expect(resolveRuntimeRequestTimeoutMs(usagePath, 'GET')).toBe(120_000) + expect(resolveRuntimeRequestTimeoutMs( + '/v1/usage?group_by=model&from=2026-08-01&to=2026-08-24&timezone=UTC', + 'GET' + )).toBe(120_000) + expect(resolveRuntimeRequestTimeoutMs('/v1/usage?group_by=turn&thread_id=thr_1', 'GET')).toBe(120_000) + expect(resolveRuntimeRequestTimeoutMs(usagePath, 'GET', 45_000)).toBe(45_000) + // Runtime-cumulative usage is a cheap in-memory counter read; keep the + // generic budget so status-style callers still fail fast. + expect(resolveRuntimeRequestTimeoutMs('/v1/usage', 'GET')).toBe(15_000) + expect(resolveRuntimeRequestTimeoutMs('/v1/usage?group_by=runtime', 'GET')).toBe(15_000) + expect(resolveRuntimeRequestTimeoutMs(usagePath, 'POST')).toBe(60_000) + }) + it('lets an on-demand session summary outlive the generic POST budget', () => { expect(resolveRuntimeRequestTimeoutMs( '/v1/threads/thr_1/summarize', diff --git a/src/main/runtime/kun-adapter.ts b/src/main/runtime/kun-adapter.ts index 3c5590dc4..56e7e03a7 100644 --- a/src/main/runtime/kun-adapter.ts +++ b/src/main/runtime/kun-adapter.ts @@ -322,6 +322,7 @@ const DEFAULT_RUNTIME_POST_TIMEOUT_MS = 60_000 const THREAD_TIMELINE_GET_TIMEOUT_MS = 120_000 const THREAD_SUMMARIZE_POST_TIMEOUT_MS = 120_000 const PROVIDER_QUOTA_GET_TIMEOUT_MS = 120_000 +const USAGE_HISTORY_GET_TIMEOUT_MS = 120_000 const MODEL_CONNECTION_EVENTS_TIMEOUT_MARGIN_MS = 5_000 const MAX_MODEL_CONNECTION_EVENTS_WAIT_MS = 120_000 @@ -343,6 +344,21 @@ function isProviderQuotaPath(pathNorm: string): boolean { return pathname === '/v1/provider-quotas' } +/** + * History aggregations replay every thread's usage records and hydrate full + * thread records for per-turn attribution, so they are not a cheap status + * route. The generic GET budget aborted them mid-aggregation and surfaced as + * the sidebar "cannot read usage" banner even though the renderer allowed 65s. + */ +function isUsageHistoryPath(pathNorm: string): boolean { + const queryIndex = pathNorm.indexOf('?') + const pathname = queryIndex >= 0 ? pathNorm.slice(0, queryIndex) : pathNorm + if (pathname !== '/v1/usage') return false + if (queryIndex < 0) return false + const groupBy = new URLSearchParams(pathNorm.slice(queryIndex + 1)).get('group_by') + return groupBy === 'day' || groupBy === 'model' || groupBy === 'thread' || groupBy === 'turn' +} + export function resolveRuntimeRequestTimeoutMs( pathNorm: string, method: string, @@ -358,6 +374,9 @@ export function resolveRuntimeRequestTimeoutMs( if (method === 'GET' && isProviderQuotaPath(pathNorm)) { return PROVIDER_QUOTA_GET_TIMEOUT_MS } + if (method === 'GET' && isUsageHistoryPath(pathNorm)) { + return USAGE_HISTORY_GET_TIMEOUT_MS + } // A whole-session summary is one blocking model call over the full // transcript. The generic POST budget cut it off before the runtime could // answer, which surfaced as an unexplained desktop failure (#1200). diff --git a/src/renderer/src/components/workbench/SidebarUsagePanel.tsx b/src/renderer/src/components/workbench/SidebarUsagePanel.tsx index bc703adef..b3a5e94e7 100644 --- a/src/renderer/src/components/workbench/SidebarUsagePanel.tsx +++ b/src/renderer/src/components/workbench/SidebarUsagePanel.tsx @@ -204,7 +204,15 @@ export function SidebarUsagePanel({ className="mx-4 mb-4 flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-[10.5px] leading-4 text-amber-800 dark:border-amber-800/70 dark:bg-amber-950/35 dark:text-amber-200" > - {t('usageHeatmapErrorTitle')} + + {t('usageHeatmapErrorTitle')} + + {dailyState.error} + + ) : null} @@ -288,8 +296,15 @@ export function SidebarUsagePanel({ {t('usageHeatmapLoading')} ) : modelState.error ? ( -

+

{t('usageHeatmapErrorTitle')} + + {modelState.error} +

) : visibleModelBuckets.length > 0 ? ( <> From 6ff07625f15f343b34efe7833258dea0ebff64c5 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 01:54:17 +0800 Subject: [PATCH 045/168] feat: add dark UI color customization and improve live projection handling - Introduced support for customizable dark UI colors with default values. - Added functions to normalize and merge dark UI color settings. - Updated various components to utilize the new dark UI color settings. - Enhanced live projection management by consolidating related properties. - Improved tests to cover new dark UI features and ensure proper functionality. --- docs/releases/v0.3.7.md | 58 +++++ .../application-state-migration.test.ts | 15 ++ .../application-state-migration.ts | 5 + .../data-migration/export-inventory.test.ts | 6 + src/main/data-migration/export-inventory.ts | 3 +- src/main/ipc/app-ipc-schemas.dark-ui.test.ts | 14 ++ src/main/ipc/app-ipc-schemas/settings.ts | 7 + src/main/main-ready-services.ts | 4 +- src/main/settings-store-foundation.ts | 11 +- src/main/settings-store.persistence.test.ts | 21 ++ src/renderer/src/components/SettingsView.tsx | 4 +- .../LazyMessageTimeline.thread-scope.test.ts | 10 +- .../components/chat/LazyMessageTimeline.tsx | 7 +- ...essageTimeline.hydration-exclusive.test.ts | 7 +- .../chat/ThreadHydrationLoading.tsx | 7 +- .../DesignCanvasConversationOverlay.test.ts | 54 ++++- .../DesignCanvasConversationOverlay.tsx | 134 ++++++++---- .../design/DesignConversationContent.tsx | 12 +- .../design-canvas-conversation-layout.test.ts | 19 ++ .../design-canvas-conversation-layout.ts | 48 +++-- .../components/settings-color-controls.tsx | 198 ++++++++++++++++++ .../components/settings-dark-ui-colors.tsx | 78 +++++++ .../settings-section-general.test.ts | 13 ++ .../components/settings-section-general.tsx | 162 +------------- .../src/components/settings-utils.test.ts | 14 ++ src/renderer/src/components/settings-utils.ts | 11 +- .../tray/TrayProviderQuotaPopover.test.ts | 59 +++++- .../tray/TrayProviderQuotaPopover.tsx | 3 + .../components/use-settings-view-bootstrap.ts | 7 +- src/renderer/src/lib/apply-theme.test.ts | 30 ++- src/renderer/src/lib/apply-theme.ts | 10 + .../src/locales/en/common/shell-workflow.json | 1 + .../en/settings/navigation-providers.json | 10 + .../hi/settings/navigation-providers.json | 10 + .../ja/settings/navigation-providers.json | 10 + .../ko/settings/navigation-providers.json | 10 + .../src/locales/locale-resources.test.ts | 21 ++ .../ru/settings/navigation-providers.json | 10 + .../th/settings/navigation-providers.json | 10 + .../src/locales/zh/common/shell-workflow.json | 1 + .../zh/settings/navigation-providers.json | 10 + ...-projection-reducer-reconciliation.test.ts | 33 +++ .../src/store/chat-projection-reducer.ts | 48 +++++ ...-store-app-actions-model-switching.test.ts | 1 + .../src/store/chat-store-app-actions.test.ts | 2 + .../src/store/chat-store-app-actions.ts | 3 + .../src/store/chat-store-claw-actions.ts | 4 +- .../src/store/chat-store-live-projection.ts | 98 +++++++++ ...chat-store-maintenance-recovery-actions.ts | 4 +- .../chat-store-navigation-runtime-actions.ts | 2 + .../store/chat-store-runtime-helpers.test.ts | 15 ++ .../src/store/chat-store-runtime-helpers.ts | 11 +- .../chat-store-thread-creation-actions.ts | 5 +- .../store/chat-store-thread-review-actions.ts | 5 +- .../chat-store-thread-running-resume.test.ts | 81 ++++++- .../chat-store-thread-selection-actions.ts | 38 ++-- .../store/chat-store-thread-send-direct.ts | 15 +- src/renderer/src/store/chat-store.ts | 2 + .../src/store/thread-snapshot-cache.test.ts | 45 ++++ .../src/store/thread-snapshot-cache.ts | 27 ++- .../base-shell/tokens-window-workspace.css | 79 ++++--- .../src/styles/kun-home-palette.test.ts | 18 ++ .../shell-and-overview.css | 14 +- src/shared/app-settings-dark-ui.ts | 33 +++ src/shared/app-settings-domain.ts | 2 +- src/shared/app-settings-normalize.ts | 3 + src/shared/app-settings-types-product.ts | 13 +- src/shared/app-settings.test.ts | 36 ++++ src/shared/app-settings.ts | 1 + src/shared/tray-provider-quota.ts | 2 + 70 files changed, 1441 insertions(+), 323 deletions(-) create mode 100644 docs/releases/v0.3.7.md create mode 100644 src/main/ipc/app-ipc-schemas.dark-ui.test.ts create mode 100644 src/renderer/src/components/settings-color-controls.tsx create mode 100644 src/renderer/src/components/settings-dark-ui-colors.tsx create mode 100644 src/renderer/src/store/chat-store-live-projection.ts create mode 100644 src/shared/app-settings-dark-ui.ts diff --git a/docs/releases/v0.3.7.md b/docs/releases/v0.3.7.md new file mode 100644 index 000000000..b4ab548f3 --- /dev/null +++ b/docs/releases/v0.3.7.md @@ -0,0 +1,58 @@ +# Kun v0.3.7 Release Notes + +本版本聚焦于**运行时稳定性**与**会话体验修复**:新增 OpenCore Free 免密钥提供方、用量费用估算,并系统性地加固了启动恢复、线程切换和 SSE 事件流等关键路径。 + +## ✨ 新功能 + +- **新增 OpenCore Free 提供方**:无需 API Key 即可使用的免费模型接入,以匿名方式发送请求;在免费分组中展示且不再弹出密钥提示;同步清理种子数据中的遗留凭证 (#61b3306, #de714f5, #bcd9123, #b7fbdce, #3f7075f, #2f94726) +- **用量费用估算**:基于 models.dev 目录价格估算各提供方及订阅制模型的使用成本;历史 GLM Coding Plan 用量按零成本计价;历史用量按每轮实际使用的提供方归属 (#45a1ed7, #7e92d35, #14584af, #7ebd75c) +- **工作台启动状态管理**:新增 boot 状态机,启动失败时提供明确的错误提示与重试机制 (#7288bae) +- **待处理输入可见性**:`user_input` 支持超时后由模型自主决策;侧边栏新增 awaiting-input 提示,并在聊天 UI 中完整呈现等待输入状态 (#d847a5c, #ea91214) +- **批量线程状态获取**:批量拉取线程状态,改进用户输入处理,并增强线程状态的错误处理与诊断信息 (#f72d5e0, #2186721) +- **侧边栏预热**:改进侧边栏行焦点处理,线程详情支持 prewarm,切换更流畅 (#8000b03) +- **画布会话布局**:增强画布会话的布局与缩放逻辑,改进线程快照处理 (#9c1d675) +- **服务管理器替换**:实现管理器优雅/强制替换能力,启动失败窗口支持恢复操作与详细的交接错误展示 (#b8e56c2) + +## 🐛 修复 + +### 启动与运行时恢复 + +- 启动时增加运行时交接门禁,加固恢复窗口 (#e0616e3) +- 支持多目录场景下的强制交接恢复 (#15fff3f) +- 恢复路径保持可重试,运行时升级时 fail-closed,避免半初始化状态 (#65b6806) +- 修复大线程日志导致的 SSE 404 死胡同与事件循环停滞 (#c47208f) +- 加固状态游标与用户输入竞态 (#2b40a19) +- 修复 channel 与线程维护流程 (#ba8d089) +- 修复 route-pool 能力探测在计时装饰器中丢失的问题 (#23dd2c4) + +### 渲染与状态一致性 + +- 整个应用生命周期改为单一 React root 渲染,消除挂载竞态 (#e2f95c2) +- 限制快照体积并保留水合状态 (#7403e9c) +- `turn_failed` 投影保留 turn 身份并增加过期守卫;并发完成时保护 turn 身份 (#2b59f92, #5f08c4b) +- `selectThread` 中 promise 落定后重新校验 prewarm 句柄 (#bfd5964) +- 运行中的会话行在侧边栏中保持位置冻结,不再跳动 (#f634a98) + +### 聊天与会话 + +- 按线程恢复输入框(composer)状态 (#655387b) +- 切换线程时保留用户选择的模型 (#c7a6c26) +- 修复引导中的排队消息在切换线程后"复活"的问题 (#c11bc64) +- 媒体瓦片按图片宽高比自适应,不再裁剪 (#54a1ed7) +- 修复 rehype-harden 的 `[blocked]` 标记泄漏到文件链接 (#b7d33ba) +- 重新打开已结束线程时不再重放 live-progress UI (#3e08fb1) +- 修复条件渲染路径中 `busyUnconfirmed` hooks 的悬挂问题 (#9d4ee42) +- 用户消息操作按钮在 hover 时更清晰 (#20e7256) + +### 委托与检索 + +- Fast Context 卡片不再错误渲染为失败状态,并收紧误报失败的守卫 (#71a73a1, #8a610e7) + +### 界面与其他 + +- 修复 macOS 下会话面板位置,为窗口控制按钮留出空间 (#42c58b5) +- 提供方配额超时隔离测试与请求超时时间调整 (#6fcfa92) + +--- + +**完整变更**:`v0.3.6...v0.3.7`(共 43 个提交) diff --git a/src/main/data-migration/application-state-migration.test.ts b/src/main/data-migration/application-state-migration.test.ts index f04edbd2a..e1b0cacc4 100644 --- a/src/main/data-migration/application-state-migration.test.ts +++ b/src/main/data-migration/application-state-migration.test.ts @@ -56,6 +56,21 @@ describe('application state migration', () => { expect(applyPortableSettingsMigration(current, { locale: 'ko' }).locale).toBe('ko') }) + it('merges and normalizes imported dark UI colors field by field', () => { + const current = settings({ + darkUiColors: { background: '#101010', border: '#202020', panel: '#303030' } + }) + const migrated = applyPortableSettingsMigration(current, { + darkUiColors: { border: '#AABBCC', panel: 'invalid' } + }) + + expect(migrated.darkUiColors).toEqual({ + background: '#101010', + border: '#aabbcc', + panel: '#2c2c2c' + }) + }) + it('rebinds schema-declared renderer references without rewriting prose', () => { const restored = restoreSemanticRendererState({ state: { diff --git a/src/main/data-migration/application-state-migration.ts b/src/main/data-migration/application-state-migration.ts index 876c0b727..70422f228 100644 --- a/src/main/data-migration/application-state-migration.ts +++ b/src/main/data-migration/application-state-migration.ts @@ -25,6 +25,7 @@ export function applyPortableSettingsMigration( const write = asRecord(value.write) const design = asRecord(value.design) const notifications = asRecord(value.notifications) + const darkUiColors = asRecord(value.darkUiColors) return normalizeAppSettings({ ...current, ...(isLocale(value.locale) ? { locale: value.locale } : {}), @@ -38,6 +39,10 @@ export function applyPortableSettingsMigration( : {}), ...(typeof value.cursorSpotlight === 'boolean' ? { cursorSpotlight: value.cursorSpotlight } : {}), ...(typeof value.cursorSpotlightColor === 'string' ? { cursorSpotlightColor: value.cursorSpotlightColor } : {}), + darkUiColors: { + ...current.darkUiColors, + ...pickDefined(darkUiColors, ['background', 'border', 'panel']) + }, notifications: { ...current.notifications, ...(typeof notifications.turnComplete === 'boolean' diff --git a/src/main/data-migration/export-inventory.test.ts b/src/main/data-migration/export-inventory.test.ts index 75e4b0f7b..2709b9148 100644 --- a/src/main/data-migration/export-inventory.test.ts +++ b/src/main/data-migration/export-inventory.test.ts @@ -40,6 +40,7 @@ function settings(workspaceRoot: string, nestedRoot = workspaceRoot): AppSetting uiFontScale: 1, chatContentMaxWidthPx: 896, composerSendKey: 'enter', + darkUiColors: { background: '#101010', border: '#202020', panel: '#303030' }, provider: { ...defaultModelProviderSettings(), apiKey: 'must-not-export' }, agents: { kun: { ...defaultKunRuntimeSettings(), runtimeToken: 'must-not-export' } }, workspaceRoot, @@ -136,6 +137,11 @@ describe('data migration export inventory', () => { const portable = portableSettingsForMigration(value) expect(portable).not.toHaveProperty('provider') expect(portable).not.toHaveProperty('agents') + expect(portable.darkUiColors).toEqual({ + background: '#101010', + border: '#202020', + panel: '#303030' + }) expect(JSON.stringify(portable)).not.toContain('must-not-export') const automations = sanitizedAutomationsForMigration(value) as { schedules: Array> } expect(automations.schedules[0]).toMatchObject({ enabled: false, clawChannelId: '', lastThreadId: '' }) diff --git a/src/main/data-migration/export-inventory.ts b/src/main/data-migration/export-inventory.ts index 68e7673cc..d11b5f7a1 100644 --- a/src/main/data-migration/export-inventory.ts +++ b/src/main/data-migration/export-inventory.ts @@ -4,7 +4,7 @@ import type { Stats } from 'node:fs' import { lstat, opendir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, isAbsolute, relative, resolve, sep } from 'node:path' -import type { AppSettingsV1 } from '../../shared/app-settings' +import { normalizeDarkUiColors, type AppSettingsV1 } from '../../shared/app-settings' import { classifyDataMigrationPath, parsePackageRelativePath, @@ -223,6 +223,7 @@ export function portableSettingsForMigration(settings: AppSettingsV1): Record { + it('accepts strict partial colors and rejects invalid or unknown fields', () => { + expect(settingsPatchSchema.parse({ + darkUiColors: { background: ' #AABBCC ', panel: '#123456' } + }).darkUiColors).toEqual({ background: '#AABBCC', panel: '#123456' }) + expect(() => settingsPatchSchema.parse({ darkUiColors: { border: 'transparent' } })).toThrow() + expect(() => settingsPatchSchema.parse({ + darkUiColors: { background: '#112233', accent: '#445566' } + })).toThrow() + }) +}) diff --git a/src/main/ipc/app-ipc-schemas/settings.ts b/src/main/ipc/app-ipc-schemas/settings.ts index a11c8fcf8..ea62f95c9 100644 --- a/src/main/ipc/app-ipc-schemas/settings.ts +++ b/src/main/ipc/app-ipc-schemas/settings.ts @@ -90,6 +90,12 @@ const notificationsPatchSchema = z.object({ subagentTurnComplete: z.boolean().optional() }).strict() +const darkUiColorsPatchSchema = z.object({ + background: hexColorSchema.optional(), + border: hexColorSchema.optional(), + panel: hexColorSchema.optional() +}).strict() + const appBehaviorPatchSchema = z.object({ openAtLogin: z.boolean().optional(), startMinimized: z.boolean().optional(), @@ -491,6 +497,7 @@ const settingsPatchObjectSchema = z.object({ composerSendKey: z.enum(['enter', 'shiftEnter']).optional(), cursorSpotlight: z.boolean().optional(), cursorSpotlightColor: hexColorSchema.optional(), + darkUiColors: darkUiColorsPatchSchema.optional(), provider: modelProviderPatchSchema.optional(), agents: z.object({ kun: kunRuntimePatchSchema.optional() diff --git a/src/main/main-ready-services.ts b/src/main/main-ready-services.ts index 0d6fb2d12..817af107d 100644 --- a/src/main/main-ready-services.ts +++ b/src/main/main-ready-services.ts @@ -21,6 +21,7 @@ import { syncLoginItemSettings } from './desktop-behavior' import { resolveLogDirectory, resolveNamedPreloadPath } from './main-paths' import { SETTINGS_FILE_NAME } from './settings-file-paths' import { + normalizeDarkUiColors, type AppSettingsV1 } from '../shared/app-settings' import { @@ -269,7 +270,8 @@ export async function initializeMainServices(): Promise { colorMode: settings.theme === 'dark' || (settings.theme === 'system' && nativeTheme.shouldUseDarkColors) ? 'dark' - : 'light' + : 'light', + darkUiColors: normalizeDarkUiColors(settings.darkUiColors) } }, action: (action) => { diff --git a/src/main/settings-store-foundation.ts b/src/main/settings-store-foundation.ts index 6792f661d..3435748d7 100644 --- a/src/main/settings-store-foundation.ts +++ b/src/main/settings-store-foundation.ts @@ -13,6 +13,7 @@ import { DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS, DEFAULT_GIT_CHECKPOINT_CREATE_ENABLED, DEFAULT_CURSOR_SPOTLIGHT_COLOR, + DEFAULT_DARK_UI_COLORS, DEFAULT_GIT_BRANCH_PREFIX, DEFAULT_LOG_RETENTION_DAYS, DEFAULT_WRITE_WORKSPACE_ROOT, @@ -36,6 +37,7 @@ import { mergeWriteSettings, defaultTerminalSettings, mergeTerminalSettings, + mergeDarkUiColors, DEFAULT_CHAT_CONTENT_MAX_WIDTH_PX, DEFAULT_COMPOSER_SEND_KEY, DEFAULT_UI_FONT_SCALE, @@ -267,6 +269,7 @@ export const defaultSettings = (): AppSettingsV1 => ({ composerSendKey: DEFAULT_COMPOSER_SEND_KEY, cursorSpotlight: true, cursorSpotlightColor: DEFAULT_CURSOR_SPOTLIGHT_COLOR, + darkUiColors: { ...DEFAULT_DARK_UI_COLORS }, provider: defaultModelProviderSettings(), agents: { kun: defaultKunRuntimeSettings() @@ -448,10 +451,16 @@ export function applySettingsPatchToSnapshot( current: AppSettingsV1, partial: AppSettingsPatch ): AppSettingsV1 { - const { agents: agentsPatch, provider: providerPatch, ...restPatch } = partial + const { + agents: agentsPatch, + provider: providerPatch, + darkUiColors: darkUiColorsPatch, + ...restPatch + } = partial return normalizeStoredSettings({ ...applyKunRuntimePatch(current, agentsPatch?.kun), ...restPatch, + darkUiColors: mergeDarkUiColors(current.darkUiColors, darkUiColorsPatch), provider: mergeModelProviderSettings(current.provider, providerPatch), log: { ...current.log, ...(partial.log ?? {}) }, checkpointCleanup: normalizeCheckpointCleanupSettings({ diff --git a/src/main/settings-store.persistence.test.ts b/src/main/settings-store.persistence.test.ts index 17b4f73fb..49da48dd0 100644 --- a/src/main/settings-store.persistence.test.ts +++ b/src/main/settings-store.persistence.test.ts @@ -145,6 +145,27 @@ it('ignores null entries in persisted Claw channels and schedule tasks', async ( expect(saved.agents.kun.approvalPolicy).toBe('on-request') }) + it('persists Graphite defaults and preserves dark color siblings on partial patches', async () => { + const userDataDir = await mkdtemp(join(tmpdir(), 'ds-gui-settings-')) + const store = new JsonSettingsStore(userDataDir) + const initial = await store.load() + + expect(initial.darkUiColors).toEqual({ + background: '#181818', + border: '#272727', + panel: '#2c2c2c' + }) + await store.patch({ darkUiColors: { background: '#101010', panel: '#303030' } }) + const saved = await store.patch({ darkUiColors: { border: '#AABBCC' } }) + + expect(saved.darkUiColors).toEqual({ + background: '#101010', + border: '#aabbcc', + panel: '#303030' + }) + expect((await new JsonSettingsStore(userDataDir).load()).darkUiColors).toEqual(saved.darkUiColors) + }) + it('merges desktop behavior patches without keeping invalid startup state', async () => { const userDataDir = await mkdtemp(join(tmpdir(), 'ds-gui-settings-')) const store = new JsonSettingsStore(userDataDir) diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 3a74a6108..fb3ffb329 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -175,6 +175,7 @@ export function SettingsView(): ReactElement { const formGuiUpdateChannel = form?.guiUpdate?.channel const formCursorSpotlight = form?.cursorSpotlight const formCursorSpotlightColor = form?.cursorSpotlightColor + const formDarkUiColors = form?.darkUiColors const markAgentsSectionReady = useCallback(() => setAgentsSectionReady(true), []) const settingsPlatform = typeof window !== 'undefined' ? window.kunGui?.platform ?? '' : '' const settingsHomeDir = typeof window !== 'undefined' ? window.kunGui?.homeDir ?? '' : '' @@ -220,7 +221,8 @@ export function SettingsView(): ReactElement { setWriteCompletionDebugEntries, setWriteCompletionDebugSelectedId, setWriteDebugLoading, setWriteDebugError, extensionContributionSnapshotReady, extensionSettingsAvailable, settingsScrollerRef, persistedSettingsRef, formTheme, formUiFontScale, - formChatContentMaxWidthPx, writeTypography, formCursorSpotlight, formCursorSpotlightColor + formChatContentMaxWidthPx, writeTypography, formCursorSpotlight, formCursorSpotlightColor, + formDarkUiColors }) useSettingsRouteSynchronization({ diff --git a/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts b/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts index 87671e1c5..dbe1aa22a 100644 --- a/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts +++ b/src/renderer/src/components/chat/LazyMessageTimeline.thread-scope.test.ts @@ -54,7 +54,7 @@ describe('LazyMessageTimeline thread scope', () => { useChatStore.setState({ threadLoadingId: null }) }) - it('recreates local state only when the thread identity changes', async () => { + it('recreates local state when the thread identity or hydration phase changes', async () => { await act(async () => { renderer = create(timeline('thread-a')) }) expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(1) @@ -62,13 +62,13 @@ describe('LazyMessageTimeline thread scope', () => { expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) await act(async () => { useChatStore.setState({ threadLoadingId: 'thread-b' }) }) - expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(3) await act(async () => { useChatStore.setState({ threadLoadingId: null }) }) - expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(4) await act(async () => { useChatStore.setState({ threadLoadingId: 'thread-c' }) }) - expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(2) - expect(instances.unmounted).toEqual([1]) + expect(renderer!.root.findByProps({ 'data-testid': 'timeline-instance' }).props['data-instance-id']).toBe(4) + expect(instances.unmounted).toEqual([1, 2, 3]) }) }) diff --git a/src/renderer/src/components/chat/LazyMessageTimeline.tsx b/src/renderer/src/components/chat/LazyMessageTimeline.tsx index 895b43a09..8919d3898 100644 --- a/src/renderer/src/components/chat/LazyMessageTimeline.tsx +++ b/src/renderer/src/components/chat/LazyMessageTimeline.tsx @@ -6,6 +6,7 @@ import { type ReactNode } from 'react' import type { MessageTimeline } from './MessageTimeline' +import { useChatStore } from '../../store/chat-store' const LazyLoadedMessageTimeline = lazy(() => import('./MessageTimeline').then((module) => ({ default: module.MessageTimeline })) @@ -19,7 +20,11 @@ export function LazyMessageTimeline({ fallback = null, ...props }: LazyMessageTimelineProps): ReactElement { - const timelineKey = props.activeThreadId ?? 'empty' + const threadLoadingId = useChatStore((state) => state.threadLoadingId) + const timelinePhase = props.activeThreadId && threadLoadingId === props.activeThreadId + ? 'hydrating' + : 'ready' + const timelineKey = `${props.activeThreadId ?? 'empty'}:${timelinePhase}` return ( diff --git a/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts b/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts index 2701f213c..6be540b8b 100644 --- a/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts +++ b/src/renderer/src/components/chat/MessageTimeline.hydration-exclusive.test.ts @@ -67,7 +67,7 @@ describe('MessageTimeline hydration presentation', () => { vi.unstubAllGlobals() }) - it('keeps the timeline mounted beneath the loading overlay', async () => { + it('renders only loading until the target projection is ready', async () => { const element = createElement(MessageTimeline, { blocks: [{ kind: 'assistant', id: 'target-answer', text: 'target-ready-content' }], liveReasoning: '', @@ -82,13 +82,12 @@ describe('MessageTimeline hydration presentation', () => { const messageNode = [...container.querySelectorAll('*')] .find((node) => node.textContent === 'target-ready-content') expect(container.querySelector('[data-testid="thread-hydration-loading"]')).not.toBeNull() - expect(container.textContent).toContain('target-ready-content') - expect(messageNode).toBeDefined() + expect(container.textContent).not.toContain('target-ready-content') + expect(messageNode).toBeUndefined() await act(async () => useChatStore.setState({ threadLoadingId: null })) expect(container.querySelector('[data-testid="thread-hydration-loading"]')).toBeNull() expect(container.textContent).toContain('target-ready-content') - expect([...container.querySelectorAll('*')]).toContain(messageNode) }) }) diff --git a/src/renderer/src/components/chat/ThreadHydrationLoading.tsx b/src/renderer/src/components/chat/ThreadHydrationLoading.tsx index 742a9c688..33ea933d6 100644 --- a/src/renderer/src/components/chat/ThreadHydrationLoading.tsx +++ b/src/renderer/src/components/chat/ThreadHydrationLoading.tsx @@ -6,12 +6,7 @@ export function ThreadHydrationGate({ loading, children }: { loading: boolean children: ReactNode }): ReactElement { - return ( - <> - {children} - {loading ? : null} - - ) + return loading ? : <>{children} } export function ThreadHydrationLoading(): ReactElement { diff --git a/src/renderer/src/components/design/DesignCanvasConversationOverlay.test.ts b/src/renderer/src/components/design/DesignCanvasConversationOverlay.test.ts index d73e09439..f10377f6e 100644 --- a/src/renderer/src/components/design/DesignCanvasConversationOverlay.test.ts +++ b/src/renderer/src/components/design/DesignCanvasConversationOverlay.test.ts @@ -5,8 +5,13 @@ import i18n from '../../i18n' import { DesignCanvasConversationOverlay } from './DesignCanvasConversationOverlay' import type { DesignCanvasConversationOverlayConversationProps } from './DesignCanvasConversationOverlay' +const contentProps = vi.hoisted(() => ({ current: null as Record | null })) + vi.mock('./DesignConversationContent', () => ({ - DesignConversationContent: () => createElement('div'), + DesignConversationContent: (props: Record) => { + contentProps.current = props + return createElement('div') + }, DesignConversationHistoryHeader: () => createElement('div') })) @@ -137,4 +142,51 @@ describe('DesignCanvasConversationOverlay', () => { expect(onNewConversation).toHaveBeenCalledTimes(1) expect(onClearHistory).not.toHaveBeenCalled() }) + + it('offsets the launcher by the window-controls safe area so it clears the titlebar', () => { + const { root } = render() + const launcher = root.findByProps({ 'aria-label': i18n.t('designCanvasConversationOpen') }) + const style = launcher.props.style as { top: string } + expect(style.top).toContain('72px') + expect(style.top).toContain('--ds-window-controls-safe-block') + }) + + it('mirrors the active conversation inside the floating panel', () => { + const { root } = render() + openPanel(root) + expect(contentProps.current?.showActiveThreadConversation).toBe(true) + }) + + it('resizes the panel from the bottom-right grip', () => { + const { root } = render() + openPanel(root) + const grip = root.findByProps({ 'data-design-canvas-conversation-resize-handle': true }) + const styleBefore = root + .findByProps({ 'data-design-canvas-conversation-panel': true }) + .props.style as { width: number; height: number } + act(() => { + grip.props.onPointerDown({ + button: 0, + pointerId: 7, + clientX: 500, + clientY: 500, + preventDefault: () => {}, + stopPropagation: () => {}, + currentTarget: { setPointerCapture: () => {} } + }) + }) + act(() => { + grip.props.onPointerMove({ + pointerId: 7, + clientX: 560, + clientY: 460, + preventDefault: () => {} + }) + }) + const styleAfter = root + .findByProps({ 'data-design-canvas-conversation-panel': true }) + .props.style as { width: number; height: number } + expect(styleAfter.width).toBe(styleBefore.width + 60) + expect(styleAfter.height).toBe(styleBefore.height - 40) + }) }) diff --git a/src/renderer/src/components/design/DesignCanvasConversationOverlay.tsx b/src/renderer/src/components/design/DesignCanvasConversationOverlay.tsx index 666f74c2f..1682e0a71 100644 --- a/src/renderer/src/components/design/DesignCanvasConversationOverlay.tsx +++ b/src/renderer/src/components/design/DesignCanvasConversationOverlay.tsx @@ -15,12 +15,14 @@ import { } from './DesignConversationContent' import { CANVAS_CONVERSATION_EDGE_MARGIN, + CANVAS_CONVERSATION_TOP_MARGIN, canvasConversationLayoutKey, canvasConversationPanelSize, canvasConversationResponsiveMode, clampCanvasConversationLayout, defaultCanvasConversationLayout, readCanvasConversationLayout, + readCanvasConversationTopInset, writeCanvasConversationLayout, type CanvasConversationLayout } from './design-canvas-conversation-layout' @@ -37,6 +39,14 @@ type PanelDragState = { originY: number } +type PanelResizeState = { + pointerId: number + clientX: number + clientY: number + originWidth: number + originHeight: number +} + type Props = { hostBounds: { width: number; height: number } workspaceRoot: string @@ -70,27 +80,29 @@ export function DesignCanvasConversationOverlay({ () => canvasConversationLayoutKey(workspaceRoot, documentId ?? ''), [documentId, workspaceRoot] ) + const [topInset] = useState(() => readCanvasConversationTopInset()) const [layout, setLayout] = useState(() => - readCanvasConversationLayout(storageKey, hostBounds, mode) + readCanvasConversationLayout(storageKey, hostBounds, mode, topInset) ) const dragRef = useRef(null) + const resizeRef = useRef(null) const panelRef = useRef(null) const launcherRef = useRef(null) const conversationOpen = layout.open && !layout.minimized useEffect(() => { - setLayout(readCanvasConversationLayout(storageKey, hostBounds, mode)) - }, [hostBounds, mode, storageKey]) + setLayout(readCanvasConversationLayout(storageKey, hostBounds, mode, topInset)) + }, [hostBounds, mode, storageKey, topInset]) useEffect(() => { - const next = clampCanvasConversationLayout(layout, hostBounds, mode) + const next = clampCanvasConversationLayout(layout, hostBounds, mode, topInset) if ( next.x === layout.x && next.y === layout.y && next.width === layout.width && next.height === layout.height ) return setLayout(next) // Intentionally not persisted: a resize clamp is a transient correction. - }, [hostBounds, layout, mode]) + }, [hostBounds, layout, mode, topInset]) const persist = useCallback((next: CanvasConversationLayout): void => { setLayout(next) @@ -113,11 +125,11 @@ export function DesignCanvasConversationOverlay({ const resetPosition = useCallback((): void => { persist({ - ...defaultCanvasConversationLayout(hostBounds, mode), + ...defaultCanvasConversationLayout(hostBounds, mode, topInset), open: true, minimized: false }) - }, [hostBounds, mode, persist]) + }, [hostBounds, mode, persist, topInset]) useEffect(() => { if (!conversationOpen) return @@ -170,7 +182,8 @@ export function DesignCanvasConversationOverlay({ y: drag.originY + (event.clientY - drag.clientY) }, hostBounds, - mode + mode, + topInset ) setLayout(next) } @@ -186,12 +199,56 @@ export function DesignCanvasConversationOverlay({ y: drag.originY + (event.clientY - drag.clientY) }, hostBounds, - mode + mode, + topInset + ) + persist(next) + } + + const beginResize = (event: ReactPointerEvent): void => { + if (mode === 'sheet') return + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture?.(event.pointerId) + resizeRef.current = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + originWidth: layout.width, + originHeight: layout.height + } + } + + const resizedLayout = (event: ReactPointerEvent): CanvasConversationLayout | null => { + const resize = resizeRef.current + if (!resize || resize.pointerId !== event.pointerId) return null + event.preventDefault() + return clampCanvasConversationLayout( + { + ...layout, + width: resize.originWidth + (event.clientX - resize.clientX), + height: resize.originHeight + (event.clientY - resize.clientY) + }, + hostBounds, + mode, + topInset ) + } + + const moveResize = (event: ReactPointerEvent): void => { + const next = resizedLayout(event) + if (next) setLayout(next) + } + + const endResize = (event: ReactPointerEvent): void => { + const next = resizedLayout(event) + if (!next) return + resizeRef.current = null persist(next) } - const panelSize = canvasConversationPanelSize(hostBounds, mode, layout) + const panelSize = canvasConversationPanelSize(hostBounds, mode, layout, topInset) const panelStyle = mode === 'sheet' ? { @@ -205,40 +262,20 @@ export function DesignCanvasConversationOverlay({ left: layout.x, top: layout.y, width: panelSize.width, - height: panelSize.height, - resize: 'both' as const + height: panelSize.height } - useEffect(() => { - const panel = panelRef.current - if (!conversationOpen || mode === 'sheet' || !panel || typeof ResizeObserver !== 'function') return - const observer = new ResizeObserver(([entry]) => { - if (!entry) return - const width = Math.round(entry.contentRect.width) - const height = Math.round(entry.contentRect.height) - setLayout((current) => { - if (current.width === width && current.height === height) return current - const next = clampCanvasConversationLayout( - { ...current, width, height }, - hostBounds, - mode - ) - writeCanvasConversationLayout(storageKey, next) - return next - }) - }) - observer.observe(panel) - return () => observer.disconnect() - }, [conversationOpen, hostBounds, mode, storageKey]) - return (
- + + {mode !== 'sheet' ? ( +
+ + + +
+ ) : null} ) : null} diff --git a/src/renderer/src/components/design/DesignConversationContent.tsx b/src/renderer/src/components/design/DesignConversationContent.tsx index 8869ce5b6..c463c40fa 100644 --- a/src/renderer/src/components/design/DesignConversationContent.tsx +++ b/src/renderer/src/components/design/DesignConversationContent.tsx @@ -87,7 +87,8 @@ export function DesignConversationContent({ onSwitchThread, onViewingChildThreadChange, historyClearing = false, - drawingCreationSubmitting: drawingCreationSubmittingOverride + drawingCreationSubmitting: drawingCreationSubmittingOverride, + showActiveThreadConversation = false }: { input: string setInput: (value: string) => void @@ -131,6 +132,12 @@ export function DesignConversationContent({ onViewingChildThreadChange?: (viewing: boolean) => void historyClearing?: boolean drawingCreationSubmitting?: boolean + /** + * Focused-canvas floating panel: always mirror the current active + * conversation instead of gating the timeline on registered design-history + * threads, so it shows the same conversation as the main chat. + */ + showActiveThreadConversation?: boolean }): ReactElement { const { t, i18n } = useTranslation('common') const workspaceRoot = useDesignWorkspaceStore((s) => s.workspaceRoot) @@ -204,7 +211,8 @@ export function DesignConversationContent({ }) const registeredHistoryThreadIds = historyMenuEntries.map((entry) => entry.id) const showingDocumentThread = Boolean( - activeThreadId && registeredHistoryThreadIds.includes(activeThreadId) + activeThreadId && + (showActiveThreadConversation || registeredHistoryThreadIds.includes(activeThreadId)) ) const viewingChildThread = Boolean(childThreadId) diff --git a/src/renderer/src/components/design/design-canvas-conversation-layout.test.ts b/src/renderer/src/components/design/design-canvas-conversation-layout.test.ts index 1658ccdd5..1f7156028 100644 --- a/src/renderer/src/components/design/design-canvas-conversation-layout.test.ts +++ b/src/renderer/src/components/design/design-canvas-conversation-layout.test.ts @@ -44,6 +44,15 @@ describe('defaultCanvasConversationLayout', () => { expect(layout.x).toBe(0) expect(layout.y).toBeGreaterThan(0) }) + + it('drops below the focused titlebar when a window-controls top inset applies', () => { + // Height is small enough that the reserved top band shrinks the panel. + const bounds = { width: 1600, height: 760 } + const withoutInset = defaultCanvasConversationLayout(bounds, 'desktop') + const withInset = defaultCanvasConversationLayout(bounds, 'desktop', 42) + expect(withInset.y).toBe(withoutInset.y + 42) + expect(withInset.height).toBe(withoutInset.height - 42) + }) }) describe('clampCanvasConversationLayout', () => { @@ -58,6 +67,16 @@ describe('clampCanvasConversationLayout', () => { expect(clamped.y).toBeGreaterThanOrEqual(CANVAS_CONVERSATION_EDGE_MARGIN) }) + it('keeps the panel clear of the focused titlebar band', () => { + const clamped = clampCanvasConversationLayout( + { open: true, minimized: false, x: 200, y: 4, width: 420, height: 680 }, + { width: 1200, height: 800 }, + 'desktop', + 42 + ) + expect(clamped.y).toBe(72 + 42) + }) + it('forces sheet mode geometry on mobile', () => { const clamped = clampCanvasConversationLayout( { open: true, minimized: false, x: 300, y: 120, width: 420, height: 680 }, diff --git a/src/renderer/src/components/design/design-canvas-conversation-layout.ts b/src/renderer/src/components/design/design-canvas-conversation-layout.ts index 7a6ee26ac..192cb2b2f 100644 --- a/src/renderer/src/components/design/design-canvas-conversation-layout.ts +++ b/src/renderer/src/components/design/design-canvas-conversation-layout.ts @@ -48,7 +48,8 @@ export function canvasConversationLayoutKey(workspaceRoot: string, documentId: s export function defaultCanvasConversationLayout( bounds: CanvasConversationLayoutBounds, - mode: CanvasConversationResponsiveMode = 'desktop' + mode: CanvasConversationResponsiveMode = 'desktop', + topInset = 0 ): CanvasConversationLayout { if (mode === 'sheet') { const size = canvasConversationPanelSize(bounds, mode) @@ -61,12 +62,12 @@ export function defaultCanvasConversationLayout( height: size.height } } - const size = canvasConversationPanelSize(bounds, mode) + const size = canvasConversationPanelSize(bounds, mode, undefined, topInset) return { open: false, minimized: false, x: CANVAS_CONVERSATION_EDGE_MARGIN + CANVAS_CONVERSATION_SAFE_INSET, - y: CANVAS_CONVERSATION_TOP_MARGIN, + y: CANVAS_CONVERSATION_TOP_MARGIN + topInset, width: size.width, height: size.height } @@ -83,7 +84,8 @@ export function canvasConversationResponsiveMode( export function canvasConversationPanelSize( bounds: CanvasConversationLayoutBounds, mode: CanvasConversationResponsiveMode, - requested?: Pick + requested?: Pick, + topInset = 0 ): { width: number; height: number } { if (mode === 'sheet') { return { @@ -113,7 +115,7 @@ export function canvasConversationPanelSize( ) const maxHeight = Math.min( CANVAS_CONVERSATION_PANEL_MAX_HEIGHT, - Math.max(0, bounds.height - CANVAS_CONVERSATION_TOP_MARGIN - CANVAS_CONVERSATION_EDGE_MARGIN) + Math.max(0, bounds.height - CANVAS_CONVERSATION_TOP_MARGIN - topInset - CANVAS_CONVERSATION_EDGE_MARGIN) ) const minHeight = Math.min(CANVAS_CONVERSATION_PANEL_MIN_HEIGHT, maxHeight) const height = clampNumber( @@ -127,26 +129,28 @@ export function canvasConversationPanelSize( export function clampCanvasConversationLayout( layout: CanvasConversationLayout, bounds: CanvasConversationLayoutBounds, - mode: CanvasConversationResponsiveMode = 'desktop' + mode: CanvasConversationResponsiveMode = 'desktop', + topInset = 0 ): CanvasConversationLayout { if (mode === 'sheet') { const size = canvasConversationPanelSize(bounds, mode, layout) return { ...layout, x: 0, y: 0, ...size } } - const size = canvasConversationPanelSize(bounds, mode, layout) + const size = canvasConversationPanelSize(bounds, mode, layout, topInset) const maxX = Math.max( CANVAS_CONVERSATION_EDGE_MARGIN, bounds.width - size.width - CANVAS_CONVERSATION_EDGE_MARGIN ) + const minY = CANVAS_CONVERSATION_TOP_MARGIN + topInset const maxY = Math.max( - CANVAS_CONVERSATION_TOP_MARGIN, + minY, bounds.height - size.height - CANVAS_CONVERSATION_EDGE_MARGIN ) return { ...layout, ...size, x: clampNumber(layout.x, CANVAS_CONVERSATION_EDGE_MARGIN, maxX), - y: clampNumber(layout.y, CANVAS_CONVERSATION_TOP_MARGIN, maxY) + y: clampNumber(layout.y, minY, maxY) } } @@ -175,9 +179,10 @@ export function normalizeCanvasConversationLayout(value: unknown): CanvasConvers export function readCanvasConversationLayout( key: string, bounds: CanvasConversationLayoutBounds, - mode: CanvasConversationResponsiveMode = 'desktop' + mode: CanvasConversationResponsiveMode = 'desktop', + topInset = 0 ): CanvasConversationLayout { - if (!key) return defaultCanvasConversationLayout(bounds, mode) + if (!key) return defaultCanvasConversationLayout(bounds, mode, topInset) let stored: Record = {} try { const raw = readBrowserStorageItem(CANVAS_CONVERSATION_LAYOUT_STORAGE_KEY) @@ -197,13 +202,28 @@ export function readCanvasConversationLayout( return clampCanvasConversationLayout( { ...legacy, open: true, minimized: false }, bounds, - mode + mode, + topInset ) } } const normalized = normalizeCanvasConversationLayout(target) - if (!normalized) return defaultCanvasConversationLayout(bounds, mode) - return clampCanvasConversationLayout(normalized, bounds, mode) + if (!normalized) return defaultCanvasConversationLayout(bounds, mode, topInset) + return clampCanvasConversationLayout(normalized, bounds, mode, topInset) +} + +/** + * The focused whiteboard titlebar drops below the macOS window controls via + * `--ds-window-controls-safe-block`; the floating conversation must clear the + * same band so its launcher and panel never slide under that chrome. + */ +export function readCanvasConversationTopInset(): number { + if (typeof window === 'undefined' || typeof document === 'undefined') return 0 + const raw = window + .getComputedStyle(document.documentElement) + .getPropertyValue('--ds-window-controls-safe-block') + const value = Number.parseFloat(raw) + return Number.isFinite(value) ? Math.max(0, value) : 0 } export function writeCanvasConversationLayout(key: string, layout: CanvasConversationLayout): void { diff --git a/src/renderer/src/components/settings-color-controls.tsx b/src/renderer/src/components/settings-color-controls.tsx new file mode 100644 index 000000000..855dbd998 --- /dev/null +++ b/src/renderer/src/components/settings-color-controls.tsx @@ -0,0 +1,198 @@ +import { DEFAULT_CURSOR_SPOTLIGHT_COLOR } from '@shared/app-settings' +import { useEffect, useMemo, useState, type ReactElement } from 'react' + +type Rgb = { r: number; g: number; b: number } +type Translate = (key: string, values?: Record) => string + +function normalizeHexColor(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback + const color = value.trim().toLowerCase() + return /^#[0-9a-f]{6}$/.test(color) ? color : fallback +} + +function hexToRgb(color: string): Rgb { + return { + r: Number.parseInt(color.slice(1, 3), 16), + g: Number.parseInt(color.slice(3, 5), 16), + b: Number.parseInt(color.slice(5, 7), 16) + } +} + +function rgbToHex(rgb: Rgb): string { + const part = (value: number): string => + Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0') + return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}` +} + +function mixRgb(from: Rgb, to: Rgb, amount: number): Rgb { + return { + r: Math.round(from.r + (to.r - from.r) * amount), + g: Math.round(from.g + (to.g - from.g) * amount), + b: Math.round(from.b + (to.b - from.b) * amount) + } +} + +function spotlightColorScale(color: string): string[] { + const rgb = hexToRgb(normalizeHexColor(color, DEFAULT_CURSOR_SPOTLIGHT_COLOR)) + return [ + rgbToHex(mixRgb(rgb, { r: 0, g: 0, b: 0 }, 0.46)), + rgbToHex(mixRgb(rgb, { r: 0, g: 0, b: 0 }, 0.28)), + rgbToHex(mixRgb(rgb, { r: 0, g: 0, b: 0 }, 0.12)), + rgbToHex(rgb), + rgbToHex(mixRgb(rgb, { r: 255, g: 255, b: 255 }, 0.18)), + rgbToHex(mixRgb(rgb, { r: 255, g: 255, b: 255 }, 0.36)), + rgbToHex(mixRgb(rgb, { r: 255, g: 255, b: 255 }, 0.54)) + ] +} + +export function HexColorControl({ + value, + ariaLabel, + disabled = false, + resetValue, + resetLabel, + onChange +}: { + value: string + ariaLabel: string + disabled?: boolean + resetValue?: string + resetLabel?: string + onChange: (color: string) => void +}): ReactElement { + const normalized = normalizeHexColor(value, resetValue ?? '#000000') + const [draft, setDraft] = useState(normalized) + + useEffect(() => setDraft(normalized), [normalized]) + + const commit = (candidate: string): boolean => { + const trimmed = candidate.trim() + if (!/^#[0-9a-fA-F]{6}$/.test(trimmed)) return false + onChange(trimmed.toLowerCase()) + return true + } + + return ( +
+ onChange(event.target.value.toLowerCase())} + /> + { + setDraft(event.target.value) + void commit(event.target.value) + }} + onBlur={() => { + if (!commit(draft)) setDraft(normalized) + }} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + if (!commit(draft)) setDraft(normalized) + event.currentTarget.blur() + }} + /> + {resetValue && resetLabel ? ( + + ) : null} +
+ ) +} + +export function SpotlightColorControl({ + color, + disabled, + t, + onChange +}: { + color: string + disabled: boolean + t: Translate + onChange: (color: string) => void +}): ReactElement { + const normalized = normalizeHexColor(color, DEFAULT_CURSOR_SPOTLIGHT_COLOR) + const [baseColor, setBaseColor] = useState(normalized) + const [toneIndex, setToneIndex] = useState(3) + const scale = useMemo(() => spotlightColorScale(baseColor), [baseColor]) + const gradient = `linear-gradient(90deg, ${scale.join(', ')})` + + useEffect(() => { + const nextIndex = scale.indexOf(normalized) + if (nextIndex >= 0) { + setToneIndex(nextIndex) + return + } + setBaseColor(normalized) + setToneIndex(3) + }, [normalized, scale]) + + const selectColor = (nextColor: string): void => { + const next = normalizeHexColor(nextColor, DEFAULT_CURSOR_SPOTLIGHT_COLOR) + setBaseColor(next) + setToneIndex(3) + onChange(next) + } + const selectTone = (index: number): void => { + const nextIndex = Math.max(0, Math.min(scale.length - 1, index)) + setToneIndex(nextIndex) + onChange(scale[nextIndex] ?? normalized) + } + + return ( +
+ + selectTone(Number(event.target.value))} + /> +
+ {scale.map((shade, index) => ( +
+

{t('cursorSpotlightColorDesc')}

+
+ ) +} diff --git a/src/renderer/src/components/settings-dark-ui-colors.tsx b/src/renderer/src/components/settings-dark-ui-colors.tsx new file mode 100644 index 000000000..d4a49da3e --- /dev/null +++ b/src/renderer/src/components/settings-dark-ui-colors.tsx @@ -0,0 +1,78 @@ +import { + DEFAULT_DARK_UI_COLORS, + type DarkUiColorsPatchV1, + type DarkUiColorsV1 +} from '@shared/app-settings' +import type { ReactElement } from 'react' +import { HexColorControl } from './settings-color-controls' +import { SettingRow, SettingsCard } from './settings-controls' + +type Translate = (key: string, values?: Record) => string + +export function DarkUiColorsSettingsCard({ + colors, + t, + onChange +}: { + colors: DarkUiColorsV1 + t: Translate + onChange: (patch: DarkUiColorsPatchV1) => void +}): ReactElement { + const fields = [ + { key: 'background' as const, label: t('darkUiColorsBackground'), description: t('darkUiColorsBackgroundDesc') }, + { key: 'border' as const, label: t('darkUiColorsBorder'), description: t('darkUiColorsBorderDesc') }, + { key: 'panel' as const, label: t('darkUiColorsPanel'), description: t('darkUiColorsPanelDesc') } + ] + const isDefault = fields.every(({ key }) => colors[key] === DEFAULT_DARK_UI_COLORS[key]) + + return ( + + {fields.map(({ key, label, description }) => ( + onChange({ [key]: color })} + /> + } + /> + ))} + +
+
+
+
+
+
+
+ +
+
+ } + /> + + ) +} diff --git a/src/renderer/src/components/settings-section-general.test.ts b/src/renderer/src/components/settings-section-general.test.ts index 209fb8fee..5fb58b83d 100644 --- a/src/renderer/src/components/settings-section-general.test.ts +++ b/src/renderer/src/components/settings-section-general.test.ts @@ -37,6 +37,7 @@ function baseCtx(): Record { workspaceRoot: '~/data/code/python/Kook-Voices', cursorSpotlight: true, cursorSpotlightColor: '#3b82f6', + darkUiColors: { background: '#101010', border: '#202020', panel: '#303030' }, appBehavior: { openAtLogin: false, startMinimized: false, @@ -136,6 +137,18 @@ describe('GeneralSettingsSection workspace layout', () => { } }) + it('shows editable dark colors, a live preview, and the global reset action', () => { + const html = renderToStaticMarkup(createElement(GeneralSettingsSection, { ctx: baseCtx() })) + + expect(html).toContain('darkUiColorsTitle') + expect(html).toContain('value="#101010"') + expect(html).toContain('value="#202020"') + expect(html).toContain('value="#303030"') + expect(html).toContain('background-color:#101010') + expect(html).toContain('border-color:#202020') + expect(html).toContain('darkUiColorsReset') + }) + it('keeps every directory and desktop subtab panel mounted', () => { const html = renderToStaticMarkup(createElement(GeneralSettingsSection, { ctx: baseCtx() })) diff --git a/src/renderer/src/components/settings-section-general.tsx b/src/renderer/src/components/settings-section-general.tsx index 922a5762b..00d5b2d4e 100644 --- a/src/renderer/src/components/settings-section-general.tsx +++ b/src/renderer/src/components/settings-section-general.tsx @@ -3,7 +3,7 @@ import { APP_LOCALE_OPTIONS, CHAT_CONTENT_MAX_WIDTH_MAX, CHAT_CONTENT_MAX_WIDTH_MIN, - DEFAULT_CURSOR_SPOTLIGHT_COLOR, + normalizeDarkUiColors, normalizeChatContentMaxWidth, normalizeUiFontScale, UI_FONT_SCALE_MAX, @@ -17,7 +17,7 @@ import { MessageSquareText, Monitor } from 'lucide-react' -import { useEffect, useMemo, useState, type ReactElement } from 'react' +import { useState, type ReactElement } from 'react' import { SettingRow, SettingsCard, @@ -30,159 +30,13 @@ import { GeneralConversationSettingsPanel } from './settings-section-general-con import { GeneralDesktopSettingsPanel } from './settings-section-general-desktop' import { LegacySessionImportCard } from './settings-section-general-legacy-import' import { CheckpointSettingsPanel } from './settings-section-general-checkpoints' +import { SpotlightColorControl } from './settings-color-controls' +import { DarkUiColorsSettingsCard } from './settings-dark-ui-colors' -type Rgb = { r: number; g: number; b: number } type GeneralSettingsTab = 'appearance' | 'conversation' | 'directories' | 'desktop' type DirectorySettingsSubTab = 'workspace' | 'migration' | 'checkpoints' type DesktopSettingsSubTab = 'command' | 'behavior' | 'logs' - -function normalizeHexColor(value: unknown): string { - if (typeof value !== 'string') return DEFAULT_CURSOR_SPOTLIGHT_COLOR - const color = value.trim().toLowerCase() - return /^#[0-9a-f]{6}$/.test(color) ? color : DEFAULT_CURSOR_SPOTLIGHT_COLOR -} - -function hexToRgb(color: string): Rgb { - return { - r: Number.parseInt(color.slice(1, 3), 16), - g: Number.parseInt(color.slice(3, 5), 16), - b: Number.parseInt(color.slice(5, 7), 16) - } -} - -function rgbToHex(rgb: Rgb): string { - const part = (value: number): string => - Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0') - return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}` -} - -function mixRgb(from: Rgb, to: Rgb, amount: number): Rgb { - return { - r: Math.round(from.r + (to.r - from.r) * amount), - g: Math.round(from.g + (to.g - from.g) * amount), - b: Math.round(from.b + (to.b - from.b) * amount) - } -} - -function spotlightColorScale(color: string): string[] { - const rgb = hexToRgb(normalizeHexColor(color)) - return [ - rgbToHex(mixRgb(rgb, { r: 0, g: 0, b: 0 }, 0.46)), - rgbToHex(mixRgb(rgb, { r: 0, g: 0, b: 0 }, 0.28)), - rgbToHex(mixRgb(rgb, { r: 0, g: 0, b: 0 }, 0.12)), - rgbToHex(rgb), - rgbToHex(mixRgb(rgb, { r: 255, g: 255, b: 255 }, 0.18)), - rgbToHex(mixRgb(rgb, { r: 255, g: 255, b: 255 }, 0.36)), - rgbToHex(mixRgb(rgb, { r: 255, g: 255, b: 255 }, 0.54)) - ] -} - -function SpotlightColorControl({ - color, - disabled, - t, - onChange -}: { - color: string - disabled: boolean - t: (key: string, values?: Record) => string - onChange: (color: string) => void -}): ReactElement { - const normalized = normalizeHexColor(color) - const [baseColor, setBaseColor] = useState(normalized) - const [toneIndex, setToneIndex] = useState(3) - const [draftColor, setDraftColor] = useState(normalized) - const scale = useMemo(() => spotlightColorScale(baseColor), [baseColor]) - const gradient = `linear-gradient(90deg, ${scale.join(', ')})` - useEffect(() => { - setDraftColor(normalized) - const nextIndex = scale.indexOf(normalized) - if (nextIndex >= 0) { - setToneIndex(nextIndex) - return - } - setBaseColor(normalized) - setToneIndex(3) - }, [normalized, scale]) - const selectColor = (nextColor: string): void => { - const next = normalizeHexColor(nextColor) - setBaseColor(next) - setToneIndex(3) - onChange(next) - } - const selectTone = (index: number): void => { - const nextIndex = Math.max(0, Math.min(scale.length - 1, index)) - setToneIndex(nextIndex) - onChange(scale[nextIndex] ?? normalized) - } - return ( -
-
- selectColor(event.target.value)} - /> - { - const next = event.target.value.trim() - setDraftColor(event.target.value) - if (/^#[0-9a-fA-F]{6}$/.test(next)) selectColor(next) - }} - onBlur={() => { - if (!/^#[0-9a-fA-F]{6}$/.test(draftColor.trim())) setDraftColor(normalized) - }} - /> - -
- selectTone(Number(event.target.value))} - /> -
- {scale.map((shade, index) => ( -
-

{t('cursorSpotlightColorDesc')}

-
- ) -} - export function GeneralSettingsSection({ ctx }: { ctx: Record }): ReactElement { const { t, @@ -260,7 +114,8 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R const chatContentMaxWidthPx = normalizeChatContentMaxWidth(form.chatContentMaxWidthPx) const setChatContentMaxWidthPx = (value: number): void => update({ chatContentMaxWidthPx: normalizeChatContentMaxWidth(value) }) - const cursorSpotlightColor = normalizeHexColor(form.cursorSpotlightColor) + const cursorSpotlightColor = form.cursorSpotlightColor + const darkUiColors = normalizeDarkUiColors(form.darkUiColors) const tabs = [ { id: 'appearance' as const, label: t('generalTabAppearance'), icon: Monitor }, { id: 'conversation' as const, label: t('generalTabConversation'), icon: MessageSquareText }, @@ -450,6 +305,11 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R } /> + update({ darkUiColors })} + /> diff --git a/src/renderer/src/components/settings-utils.test.ts b/src/renderer/src/components/settings-utils.test.ts index 700ef258b..07233e72f 100644 --- a/src/renderer/src/components/settings-utils.test.ts +++ b/src/renderer/src/components/settings-utils.test.ts @@ -11,6 +11,20 @@ function settings(kunPatch: Partial = {}): AppSettingsV1 { } describe('coerceRendererSettings', () => { + it('normalizes dark colors and merges one-field edits without resetting siblings', () => { + const base = coerceRendererSettings({ + darkUiColors: { background: '#101010', border: '#202020', panel: '#303030' } + } as AppSettingsV1) + + expect(mergeSettings(base, { darkUiColors: { border: '#AABBCC' } }).darkUiColors).toEqual({ + background: '#101010', + border: '#aabbcc', + panel: '#303030' + }) + expect(coerceRendererSettings({ darkUiColors: { panel: 'bad' } } as AppSettingsV1).darkUiColors) + .toEqual({ background: '#181818', border: '#272727', panel: '#2c2c2c' }) + }) + it('preserves the persisted initial setup completion flag', () => { expect(coerceRendererSettings({ initialSetupCompleted: true diff --git a/src/renderer/src/components/settings-utils.ts b/src/renderer/src/components/settings-utils.ts index b869d3625..1b98279b0 100644 --- a/src/renderer/src/components/settings-utils.ts +++ b/src/renderer/src/components/settings-utils.ts @@ -12,6 +12,7 @@ import { mergeWorkflowSettings, mergeWriteSettings, mergeTerminalSettings, + mergeDarkUiColors, normalizeAppBehaviorSettings, normalizeClawSettings, normalizeDesignSettings, @@ -25,6 +26,7 @@ import { normalizeWriteSettings, normalizeCodeAgentPresets, normalizeTerminalSettings, + normalizeDarkUiColors, normalizeChatContentMaxWidth, normalizeChatWelcomeMessage, normalizeComposerSendKey, @@ -90,10 +92,16 @@ export function hasValidPort(settings: AppSettingsV1): boolean { export function mergeSettings(current: AppSettingsV1, patch: SettingsPatch): AppSettingsV1 { const safeCurrent = coerceRendererSettings(current) - const { agents: agentsPatch, provider: providerPatch, ...restPatch } = patch + const { + agents: agentsPatch, + provider: providerPatch, + darkUiColors: darkUiColorsPatch, + ...restPatch + } = patch return { ...applyKunRuntimePatch(safeCurrent, agentsPatch?.kun), ...restPatch, + darkUiColors: mergeDarkUiColors(safeCurrent.darkUiColors, darkUiColorsPatch), provider: mergeModelProviderSettings(safeCurrent.provider, providerPatch), log: { ...safeCurrent.log, @@ -150,6 +158,7 @@ export function coerceRendererSettings(settings: AppSettingsV1): AppSettingsV1 { composerSendKey: normalizeComposerSendKey(raw.composerSendKey), cursorSpotlight: raw.cursorSpotlight !== false, cursorSpotlightColor: normalizeCursorSpotlightColor(raw.cursorSpotlightColor), + darkUiColors: normalizeDarkUiColors(raw.darkUiColors), provider: normalizeModelProviderSettings(raw.provider), agents: kunSettingsEnvelope(mergeKunRuntimeSettings(defaultKunRuntimeSettings(), getKunRuntimeSettings(settings))), workspaceRoot: typeof raw.workspaceRoot === 'string' ? raw.workspaceRoot : DEFAULT_WORKSPACE_ROOT, diff --git a/src/renderer/src/components/tray/TrayProviderQuotaPopover.test.ts b/src/renderer/src/components/tray/TrayProviderQuotaPopover.test.ts index 505c7ae83..7dca34911 100644 --- a/src/renderer/src/components/tray/TrayProviderQuotaPopover.test.ts +++ b/src/renderer/src/components/tray/TrayProviderQuotaPopover.test.ts @@ -50,7 +50,8 @@ function createApi(overrides: Partial = {}): KunTrayPro context: vi.fn(async () => ({ locale: 'en' as const, colorMode: 'light' as const, - platform: 'win32' as const + platform: 'win32' as const, + darkUiColors: { background: '#181818', border: '#272727', panel: '#2c2c2c' } })), action: vi.fn(async () => undefined), openExternal: vi.fn(async () => undefined), @@ -91,7 +92,11 @@ describe('TrayProviderQuotaPopover', () => { await i18n.changeLanguage('en') keydown = undefined vi.stubGlobal('document', { - documentElement: { lang: 'en', dataset: {} } + documentElement: { + lang: 'en', + dataset: {}, + style: { setProperty: vi.fn() } + } }) vi.stubGlobal('window', { addEventListener: vi.fn((name: string, handler: (event: KeyboardEvent) => void) => { @@ -139,7 +144,8 @@ describe('TrayProviderQuotaPopover', () => { context: vi.fn(async () => ({ locale: 'zh' as const, colorMode: 'dark' as const, - platform: 'darwin' as const + platform: 'darwin' as const, + darkUiColors: { background: '#101010', border: '#202020', panel: '#303030' } })) }) let renderer!: ReactTestRenderer @@ -152,12 +158,52 @@ describe('TrayProviderQuotaPopover', () => { theme: 'dark', platform: 'darwin' }) + expect(document.documentElement.style.setProperty).toHaveBeenCalledWith( + '--kun-dark-ui-background', + '#101010' + ) await vi.waitFor(() => { expect(renderer.root.findByType('main').props['data-context-ready']).toBe('true') }) await act(async () => renderer.unmount()) }) + it('refreshes tray source colors when the settings notification fires', async () => { + let publishRefresh: (() => void) | undefined + const context = vi.fn() + .mockResolvedValueOnce({ + locale: 'en' as const, + colorMode: 'dark' as const, + platform: 'win32' as const, + darkUiColors: { background: '#101010', border: '#202020', panel: '#303030' } + }) + .mockResolvedValueOnce({ + locale: 'en' as const, + colorMode: 'dark' as const, + platform: 'win32' as const, + darkUiColors: { background: '#111111', border: '#222222', panel: '#333333' } + }) + const api = createApi({ + context, + onRefresh: vi.fn((handler) => { + publishRefresh = handler + return () => undefined + }) + }) + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create(createElement(TrayProviderQuotaPopover, { api })) + }) + await act(async () => publishRefresh?.()) + + expect(context).toHaveBeenCalledTimes(2) + expect(document.documentElement.style.setProperty).toHaveBeenLastCalledWith( + '--kun-dark-ui-panel', + '#333333' + ) + await act(async () => renderer.unmount()) + }) + it('maps a mouse wheel to the overflowing provider switcher', async () => { const api = createApi() let renderer!: ReactTestRenderer @@ -262,8 +308,9 @@ describe('TrayProviderQuotaPopover', () => { expect(css).toContain('--tray-bg: #ededed') expect(css).toContain('--tray-panel: #f8f8f8') expect(css).toContain('--tray-text: #3c3f43') - expect(css).toContain('--tray-bg: #2a2828') - expect(css).toContain('--tray-panel: #312f2f') + expect(css).toContain('--tray-bg: var(--kun-dark-ui-background, #181818)') + expect(css).toContain('--tray-panel: var(--kun-dark-ui-panel, #2c2c2c)') + expect(css).toContain('--tray-border: var(--kun-dark-ui-border, #272727)') expect(css).toContain('--tray-text: #d4d4d4') expect(css).toContain('--tray-radius-control: 8px') expect(css).toContain('--tray-radius-card: 12px') @@ -276,6 +323,6 @@ describe('TrayProviderQuotaPopover', () => { expect(css).not.toMatch(/font-size:\s*(?:8(?:\.5)?|9(?:\.5)?|10(?:\.5)?)px/) expect(css).not.toMatch(/font-weight:\s*(?:5[1-9]\d|6\d{2}|7\d{2})/) expect(contrastRatio('#686b72', '#f8f8f8')).toBeGreaterThanOrEqual(4.5) - expect(contrastRatio('#999ba0', '#312f2f')).toBeGreaterThanOrEqual(4.5) + expect(contrastRatio('#999ba0', '#2c2c2c')).toBeGreaterThanOrEqual(4.5) }) }) diff --git a/src/renderer/src/components/tray/TrayProviderQuotaPopover.tsx b/src/renderer/src/components/tray/TrayProviderQuotaPopover.tsx index 003bd7577..21d3c18ea 100644 --- a/src/renderer/src/components/tray/TrayProviderQuotaPopover.tsx +++ b/src/renderer/src/components/tray/TrayProviderQuotaPopover.tsx @@ -85,6 +85,9 @@ export function TrayProviderQuotaPopover({ document.documentElement.lang = context.locale document.documentElement.dataset.theme = context.colorMode document.documentElement.dataset.platform = context.platform + document.documentElement.style.setProperty('--kun-dark-ui-background', context.darkUiColors.background) + document.documentElement.style.setProperty('--kun-dark-ui-border', context.darkUiColors.border) + document.documentElement.style.setProperty('--kun-dark-ui-panel', context.darkUiColors.panel) if (i18n.resolvedLanguage !== context.locale) { await i18n.changeLanguage(context.locale) } diff --git a/src/renderer/src/components/use-settings-view-bootstrap.ts b/src/renderer/src/components/use-settings-view-bootstrap.ts index 08a56e0d0..613a4a6af 100644 --- a/src/renderer/src/components/use-settings-view-bootstrap.ts +++ b/src/renderer/src/components/use-settings-view-bootstrap.ts @@ -7,6 +7,7 @@ import { applyChatContentMaxWidth, applyCursorSpotlight, applyCursorSpotlightColor, + applyDarkUiColors, applyTheme, applyUiFontScale, applyWriteTypography @@ -17,7 +18,7 @@ import { } from './settings-utils' export function useSettingsViewBootstrap(scope: Record): { loadWriteDebugEntries: () => Promise } { - const { category, setCategory, form, setForm, setLoadError, setLogPath, setWriteCompletionDebugEntries, setWriteCompletionDebugSelectedId, setWriteDebugLoading, setWriteDebugError, extensionContributionSnapshotReady, extensionSettingsAvailable, settingsScrollerRef, persistedSettingsRef, formTheme, formUiFontScale, formChatContentMaxWidthPx, writeTypography, formCursorSpotlight, formCursorSpotlightColor } = scope + const { category, setCategory, form, setForm, setLoadError, setLogPath, setWriteCompletionDebugEntries, setWriteCompletionDebugSelectedId, setWriteDebugLoading, setWriteDebugError, extensionContributionSnapshotReady, extensionSettingsAvailable, settingsScrollerRef, persistedSettingsRef, formTheme, formUiFontScale, formChatContentMaxWidthPx, writeTypography, formCursorSpotlight, formCursorSpotlightColor, formDarkUiColors } = scope useEffect(() => { if ( category === 'extensions' && @@ -63,6 +64,10 @@ export function useSettingsViewBootstrap(scope: Record): { loadWrit applyCursorSpotlightColor(formCursorSpotlightColor) }, [formCursorSpotlight, formCursorSpotlightColor]) + useEffect(() => { + applyDarkUiColors(formDarkUiColors) + }, [formDarkUiColors]) + // Live-preview the Write editor typography as the form changes, mirroring the // theme/scale preview above. useEffect(() => { diff --git a/src/renderer/src/lib/apply-theme.test.ts b/src/renderer/src/lib/apply-theme.test.ts index 39a0d0f58..c20613faa 100644 --- a/src/renderer/src/lib/apply-theme.test.ts +++ b/src/renderer/src/lib/apply-theme.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { APP_LOCALE_OPTIONS } from '@shared/app-locales' -import { applyCursorSpotlight, applyCursorSpotlightColor, applyDocumentLocale } from './apply-theme' +import { + applyCursorSpotlight, + applyCursorSpotlightColor, + applyDarkUiColors, + applyDocumentLocale +} from './apply-theme' describe('applyDocumentLocale', () => { afterEach(() => { @@ -79,3 +84,26 @@ describe('applyCursorSpotlight', () => { expect(values.size).toBe(0) }) }) + +describe('applyDarkUiColors', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('writes normalized source colors and uses Graphite fallbacks', () => { + const values = new Map() + vi.stubGlobal('document', { + documentElement: { + style: { setProperty: (name: string, value: string) => values.set(name, value) } + } + }) + + applyDarkUiColors({ background: '#AABBCC', border: 'bad', panel: '#123456' }) + + expect(Object.fromEntries(values)).toEqual({ + '--kun-dark-ui-background': '#aabbcc', + '--kun-dark-ui-border': '#272727', + '--kun-dark-ui-panel': '#123456' + }) + }) +}) diff --git a/src/renderer/src/lib/apply-theme.ts b/src/renderer/src/lib/apply-theme.ts index d1ab48c8f..766e6bce0 100644 --- a/src/renderer/src/lib/apply-theme.ts +++ b/src/renderer/src/lib/apply-theme.ts @@ -1,10 +1,12 @@ import { documentLanguageForAppLocale, DEFAULT_CURSOR_SPOTLIGHT_COLOR, + normalizeDarkUiColors, normalizeChatContentMaxWidth, normalizeUiFontScale, writeFontStackFor, type ChatContentMaxWidthPx, + type DarkUiColorsPatchV1, type UiFontScale, type WriteTypographySettingsV1 } from '@shared/app-settings' @@ -48,6 +50,14 @@ export function applyTheme(pref: ThemePreference): void { apply() } +export function applyDarkUiColors(colors?: DarkUiColorsPatchV1 | null): void { + const normalized = normalizeDarkUiColors(colors) + const root = document.documentElement.style + root.setProperty('--kun-dark-ui-background', normalized.background) + root.setProperty('--kun-dark-ui-border', normalized.border) + root.setProperty('--kun-dark-ui-panel', normalized.panel) +} + export function applyUiFontScale(scale: UiFontScale): void { const root = document.documentElement root.style.setProperty('--ds-ui-scale', String(normalizeUiFontScale(scale))) diff --git a/src/renderer/src/locales/en/common/shell-workflow.json b/src/renderer/src/locales/en/common/shell-workflow.json index c036af023..2f6298367 100644 --- a/src/renderer/src/locales/en/common/shell-workflow.json +++ b/src/renderer/src/locales/en/common/shell-workflow.json @@ -657,6 +657,7 @@ "designCanvasConversationNew": "New design conversation", "designCanvasConversationDragHint": "Drag to move; double-click to reset position", "designCanvasConversationResetPosition": "Reset panel position", + "designCanvasConversationResize": "Drag to resize the panel", "designCanvasConversationRunning": "Conversation is running", "designCanvasConversationPanelLabel": "Design conversation: {{title}}" } diff --git a/src/renderer/src/locales/en/settings/navigation-providers.json b/src/renderer/src/locales/en/settings/navigation-providers.json index 358fbec2c..5f0f0dbf1 100644 --- a/src/renderer/src/locales/en/settings/navigation-providers.json +++ b/src/renderer/src/locales/en/settings/navigation-providers.json @@ -284,6 +284,16 @@ "cursorSpotlightColorReset": "Default", "cursorSpotlightColorShade": "Interaction effect shade {{index}}", "cursorSpotlightColorTone": "Interaction effect tone bar", + "darkUiColorsTitle": "Dark interface colors", + "darkUiColorsBackground": "Background", + "darkUiColorsBackgroundDesc": "Base color for the app, sidebar, canvas, and ghost surfaces.", + "darkUiColorsBorder": "Border", + "darkUiColorsBorderDesc": "Base color for dividers, outlines, and control borders.", + "darkUiColorsPanel": "Panel", + "darkUiColorsPanelDesc": "Base color for cards, panels, chips, and hover surfaces.", + "darkUiColorsPreview": "Preview", + "darkUiColorsDarkOnlyHint": "Applies to Kun's standard dark theme. Appearance packs keep their own colors.", + "darkUiColorsReset": "Reset all colors", "turnCompleteNotification": "Reply completion notifications", "turnCompleteNotificationDesc": "Show a native Windows/macOS notification when the AI assistant finishes replying.", "mainAgentTurnCompleteNotification": "Main agent completions", diff --git a/src/renderer/src/locales/hi/settings/navigation-providers.json b/src/renderer/src/locales/hi/settings/navigation-providers.json index 4c6b32b36..149ee707a 100644 --- a/src/renderer/src/locales/hi/settings/navigation-providers.json +++ b/src/renderer/src/locales/hi/settings/navigation-providers.json @@ -284,6 +284,16 @@ "cursorSpotlightColorReset": "डिफ़ॉल्ट", "cursorSpotlightColorShade": "Interaction effect shade {{index}}", "cursorSpotlightColorTone": "इंटरेक्शन प्रभाव टोन बार", + "darkUiColorsTitle": "डार्क इंटरफ़ेस रंग", + "darkUiColorsBackground": "पृष्ठभूमि", + "darkUiColorsBackgroundDesc": "ऐप, साइडबार, कैनवास और घोस्ट सतहों का मूल रंग।", + "darkUiColorsBorder": "बॉर्डर", + "darkUiColorsBorderDesc": "डिवाइडर, आउटलाइन और कंट्रोल बॉर्डर का मूल रंग।", + "darkUiColorsPanel": "पैनल", + "darkUiColorsPanelDesc": "कार्ड, पैनल, चिप और होवर सतहों का मूल रंग।", + "darkUiColorsPreview": "पूर्वावलोकन", + "darkUiColorsDarkOnlyHint": "Kun की मानक डार्क थीम पर लागू होता है। अपीयरेंस पैक अपने रंग रखते हैं।", + "darkUiColorsReset": "सभी रंग रीसेट करें", "turnCompleteNotification": "उत्तर पूर्ण होने की सूचनाएँ", "turnCompleteNotificationDesc": "जब AI सहायक उत्तर देना समाप्त कर दे तो एक मूल Windows/macOS अधिसूचना दिखाएं।", "mainAgentTurnCompleteNotification": "मुख्य एजेंट पूर्णता सूचनाएँ", diff --git a/src/renderer/src/locales/ja/settings/navigation-providers.json b/src/renderer/src/locales/ja/settings/navigation-providers.json index 75f7d48c1..54b1e56f3 100644 --- a/src/renderer/src/locales/ja/settings/navigation-providers.json +++ b/src/renderer/src/locales/ja/settings/navigation-providers.json @@ -284,6 +284,16 @@ "cursorSpotlightColorReset": "デフォルト", "cursorSpotlightColorShade": "インタラクション エフェクト シェード {{index}}", "cursorSpotlightColorTone": "インタラクションエフェクトトーンバー", + "darkUiColorsTitle": "ダークインターフェースの色", + "darkUiColorsBackground": "背景", + "darkUiColorsBackgroundDesc": "アプリ、サイドバー、キャンバス、ゴーストサーフェスの基本色です。", + "darkUiColorsBorder": "境界線", + "darkUiColorsBorderDesc": "区切り線、アウトライン、コントロール境界の基本色です。", + "darkUiColorsPanel": "パネル", + "darkUiColorsPanelDesc": "カード、パネル、チップ、ホバーサーフェスの基本色です。", + "darkUiColorsPreview": "プレビュー", + "darkUiColorsDarkOnlyHint": "Kun の標準ダークテーマに適用されます。外観パックは独自の色を維持します。", + "darkUiColorsReset": "すべての色をリセット", "turnCompleteNotification": "返信完了通知", "turnCompleteNotificationDesc": "AI アシスタントが応答を終了したときに、ネイティブの Windows/macOS 通知を表示します。", "mainAgentTurnCompleteNotification": "メインエージェントの完了通知", diff --git a/src/renderer/src/locales/ko/settings/navigation-providers.json b/src/renderer/src/locales/ko/settings/navigation-providers.json index fba26364a..2af231d60 100644 --- a/src/renderer/src/locales/ko/settings/navigation-providers.json +++ b/src/renderer/src/locales/ko/settings/navigation-providers.json @@ -284,6 +284,16 @@ "cursorSpotlightColorReset": "기본값", "cursorSpotlightColorShade": "상호작용 효과 음영 {{index}}", "cursorSpotlightColorTone": "상호작용 효과 톤바", + "darkUiColorsTitle": "다크 인터페이스 색상", + "darkUiColorsBackground": "배경", + "darkUiColorsBackgroundDesc": "앱, 사이드바, 캔버스 및 고스트 표면의 기본 색상입니다.", + "darkUiColorsBorder": "테두리", + "darkUiColorsBorderDesc": "구분선, 윤곽선 및 컨트롤 테두리의 기본 색상입니다.", + "darkUiColorsPanel": "패널", + "darkUiColorsPanelDesc": "카드, 패널, 칩 및 호버 표면의 기본 색상입니다.", + "darkUiColorsPreview": "미리보기", + "darkUiColorsDarkOnlyHint": "Kun의 표준 다크 테마에 적용됩니다. 외관 팩은 자체 색상을 유지합니다.", + "darkUiColorsReset": "모든 색상 재설정", "turnCompleteNotification": "답장 완료 알림", "turnCompleteNotificationDesc": "AI 보조자가 응답을 마치면 기본 Windows/macOS 알림을 표시합니다.", "mainAgentTurnCompleteNotification": "주 에이전트 완료 알림", diff --git a/src/renderer/src/locales/locale-resources.test.ts b/src/renderer/src/locales/locale-resources.test.ts index 7aafa0ac2..8f838a5ac 100644 --- a/src/renderer/src/locales/locale-resources.test.ts +++ b/src/renderer/src/locales/locale-resources.test.ts @@ -88,6 +88,19 @@ const PLAN_BUILD_ACTION_KEYS = [ 'planWorktreeCurrentWorkspaceWarning' ] as const +const DARK_UI_COLOR_KEYS = [ + 'darkUiColorsTitle', + 'darkUiColorsBackground', + 'darkUiColorsBackgroundDesc', + 'darkUiColorsBorder', + 'darkUiColorsBorderDesc', + 'darkUiColorsPanel', + 'darkUiColorsPanelDesc', + 'darkUiColorsPreview', + 'darkUiColorsDarkOnlyHint', + 'darkUiColorsReset' +] as const + function flattenStrings( tree: LocaleTree, prefix = '', @@ -160,6 +173,14 @@ describe('active locale resources', () => { } ) + it.each(APP_LOCALES)('authors every dark UI color label for %s', (locale) => { + for (const key of DARK_UI_COLOR_KEYS) { + const value = authoredSettings[locale][key] + expect(typeof value, `settings:${key}`).toBe('string') + expect(String(value).trim(), `settings:${key}`).not.toBe('') + } + }) + it.each(APP_LOCALES)('preserves model-route protocol literals in %s guidance', (locale) => { const modelRoutes = authoredSettings[locale].modelRoutes as Record const expectedLiterals: Record = { diff --git a/src/renderer/src/locales/ru/settings/navigation-providers.json b/src/renderer/src/locales/ru/settings/navigation-providers.json index 240c78897..25b5f8ff7 100644 --- a/src/renderer/src/locales/ru/settings/navigation-providers.json +++ b/src/renderer/src/locales/ru/settings/navigation-providers.json @@ -284,6 +284,16 @@ "cursorSpotlightColorReset": "По умолчанию", "cursorSpotlightColorShade": "Оттенок эффекта {{index}}", "cursorSpotlightColorTone": "Ползунок оттенка эффекта", + "darkUiColorsTitle": "Цвета тёмного интерфейса", + "darkUiColorsBackground": "Фон", + "darkUiColorsBackgroundDesc": "Основной цвет приложения, боковой панели, холста и прозрачных поверхностей.", + "darkUiColorsBorder": "Граница", + "darkUiColorsBorderDesc": "Основной цвет разделителей, контуров и границ элементов управления.", + "darkUiColorsPanel": "Панель", + "darkUiColorsPanelDesc": "Основной цвет карточек, панелей, меток и поверхностей при наведении.", + "darkUiColorsPreview": "Предпросмотр", + "darkUiColorsDarkOnlyHint": "Применяется к стандартной тёмной теме Kun. Пакеты оформления сохраняют свои цвета.", + "darkUiColorsReset": "Сбросить все цвета", "turnCompleteNotification": "Уведомления о завершении ответа", "turnCompleteNotificationDesc": "Показывать системное уведомление Windows/macOS, когда ИИ-ассистент завершает ответ.", "mainAgentTurnCompleteNotification": "Завершение основного агента", diff --git a/src/renderer/src/locales/th/settings/navigation-providers.json b/src/renderer/src/locales/th/settings/navigation-providers.json index e2f744edf..91f7ba7ec 100644 --- a/src/renderer/src/locales/th/settings/navigation-providers.json +++ b/src/renderer/src/locales/th/settings/navigation-providers.json @@ -284,6 +284,16 @@ "cursorSpotlightColorReset": "ค่าเริ่มต้น", "cursorSpotlightColorShade": "เฉดสีเอฟเฟกต์การโต้ตอบ {{index}}", "cursorSpotlightColorTone": "แถบโทนเอฟเฟกต์การโต้ตอบ", + "darkUiColorsTitle": "สีอินเทอร์เฟซโหมดมืด", + "darkUiColorsBackground": "พื้นหลัง", + "darkUiColorsBackgroundDesc": "สีพื้นฐานสำหรับแอป แถบด้านข้าง แคนวาส และพื้นผิวโปร่งใส", + "darkUiColorsBorder": "เส้นขอบ", + "darkUiColorsBorderDesc": "สีพื้นฐานสำหรับเส้นแบ่ง เส้นรอบ และขอบตัวควบคุม", + "darkUiColorsPanel": "แผง", + "darkUiColorsPanelDesc": "สีพื้นฐานสำหรับการ์ด แผง ชิป และพื้นผิวเมื่อวางเมาส์", + "darkUiColorsPreview": "ตัวอย่าง", + "darkUiColorsDarkOnlyHint": "ใช้กับธีมมืดมาตรฐานของ Kun แพ็กตกแต่งจะใช้สีของตัวเอง", + "darkUiColorsReset": "รีเซ็ตสีทั้งหมด", "turnCompleteNotification": "การแจ้งเตือนการตอบกลับเสร็จสิ้น", "turnCompleteNotificationDesc": "แสดงการแจ้งเตือนดั้งเดิมของ Windows/macOS เมื่อผู้ช่วย AI ตอบกลับเสร็จสิ้น", "mainAgentTurnCompleteNotification": "การแจ้งเตือนเมนเอเจนต์เสร็จสิ้น", diff --git a/src/renderer/src/locales/zh/common/shell-workflow.json b/src/renderer/src/locales/zh/common/shell-workflow.json index aa731edc9..9bc46822a 100644 --- a/src/renderer/src/locales/zh/common/shell-workflow.json +++ b/src/renderer/src/locales/zh/common/shell-workflow.json @@ -657,6 +657,7 @@ "designCanvasConversationNew": "新建设计会话", "designCanvasConversationDragHint": "拖动移动面板;双击恢复默认位置", "designCanvasConversationResetPosition": "恢复默认位置", + "designCanvasConversationResize": "拖动调整面板大小", "designCanvasConversationRunning": "会话正在运行", "designCanvasConversationPanelLabel": "设计会话:{{title}}" } diff --git a/src/renderer/src/locales/zh/settings/navigation-providers.json b/src/renderer/src/locales/zh/settings/navigation-providers.json index 47704bf96..e726f9db0 100644 --- a/src/renderer/src/locales/zh/settings/navigation-providers.json +++ b/src/renderer/src/locales/zh/settings/navigation-providers.json @@ -284,6 +284,16 @@ "cursorSpotlightColorReset": "默认", "cursorSpotlightColorShade": "交互特效色阶 {{index}}", "cursorSpotlightColorTone": "交互特效深浅调色条", + "darkUiColorsTitle": "暗色界面颜色", + "darkUiColorsBackground": "背景", + "darkUiColorsBackgroundDesc": "应用、侧边栏、画布和透明卡片使用的基础颜色。", + "darkUiColorsBorder": "边框", + "darkUiColorsBorderDesc": "分隔线、轮廓和控件边框使用的基础颜色。", + "darkUiColorsPanel": "面板", + "darkUiColorsPanelDesc": "卡片、面板、标签和悬停表面使用的基础颜色。", + "darkUiColorsPreview": "预览", + "darkUiColorsDarkOnlyHint": "仅应用于 Kun 标准暗色主题;外观包继续使用自己的配色。", + "darkUiColorsReset": "重置全部颜色", "turnCompleteNotification": "回复完成通知", "turnCompleteNotificationDesc": "AI 助手回复完成时,显示 Windows/macOS 系统通知。", "mainAgentTurnCompleteNotification": "主代理完成通知", diff --git a/src/renderer/src/store/chat-projection-reducer-reconciliation.test.ts b/src/renderer/src/store/chat-projection-reducer-reconciliation.test.ts index 95bb6928e..4563d3ebc 100644 --- a/src/renderer/src/store/chat-projection-reducer-reconciliation.test.ts +++ b/src/renderer/src/store/chat-projection-reducer-reconciliation.test.ts @@ -310,6 +310,39 @@ describe('chat projection reducer', () => { expect(projected.busy).toBe(true) }) + it('discards a foreign live buffer instead of materializing another thread turn', () => { + const projected = project({ + ...state(), + busy: true, + currentTurnId: 'turn_b', + lastSeq: 20, + liveDeltaSeqFloor: 20, + liveAssistant: 'Stale answer from A', + liveAssistantItemId: 'assistant_a', + liveAssistantTurnId: 'turn_a', + liveAssistantCreatedAt: '2026-07-10T23:59:00.000Z', + blocks: [{ kind: 'user', id: 'user_b', turnId: 'turn_b', text: 'Continue B' }] + }, [{ + type: 'deltas_received', + deltas: [{ + seq: 21, + threadId: 'thread_1', + turnId: 'turn_b', + itemId: 'assistant_b', + kind: 'agent_message', + text: 'Fresh answer from B' + }] + }]) + + expect(projected.blocks).toEqual([ + { kind: 'user', id: 'user_b', turnId: 'turn_b', text: 'Continue B' } + ]) + expect(projected.liveAssistant).toBe('Fresh answer from B') + expect(projected.liveAssistantItemId).toBe('assistant_b') + expect(projected.liveAssistantTurnId).toBe('turn_b') + expect(projected.blocks.some((block) => block.turnId === 'turn_a')).toBe(false) + }) + it('does not settle a newer running turn when a terminal snapshot for an older turn is reconciled', () => { const projected = project({ ...state(), diff --git a/src/renderer/src/store/chat-projection-reducer.ts b/src/renderer/src/store/chat-projection-reducer.ts index b3c813c3b..dbda9faa9 100644 --- a/src/renderer/src/store/chat-projection-reducer.ts +++ b/src/renderer/src/store/chat-projection-reducer.ts @@ -64,6 +64,24 @@ export { toolEventChildId } from './chat-projection-reducer-support' +function liveBufferMatchesTurn(input: { + text?: string + itemId?: string + turnId?: string + targetTurnId?: string +}): boolean { + const text = input.text ?? '' + const hasState = Boolean(text.trim() || input.itemId || input.turnId) + if (!hasState) return true + return Boolean( + text.trim() && + input.itemId && + input.turnId && + input.targetTurnId && + input.turnId === input.targetTurnId + ) +} + /** Pure state projection for normalized actions; browser work is emitted elsewhere. */ export function reduceChatProjection( state: ChatState, @@ -156,6 +174,18 @@ export function reduceChatProjection( liveDeltaSeqFloor = delta.seq } if (delta.kind === 'agent_reasoning') { + const targetTurnId = delta.turnId ?? state.currentTurnId ?? undefined + if (!liveBufferMatchesTurn({ + text: liveReasoning, + itemId: liveReasoningItemId, + turnId: liveReasoningTurnId, + targetTurnId + })) { + liveReasoning = '' + liveReasoningItemId = undefined + liveReasoningTurnId = undefined + liveReasoningCreatedAt = undefined + } const text = unseenDeltaText( delta, blocks, @@ -174,6 +204,9 @@ export function reduceChatProjection( }) } liveReasoning = '' + liveReasoningItemId = undefined + liveReasoningTurnId = undefined + liveReasoningCreatedAt = undefined } liveReasoningItemId = delta.itemId ?? liveReasoningItemId liveReasoningTurnId = delta.turnId ?? liveReasoningTurnId ?? state.currentTurnId ?? undefined @@ -182,6 +215,18 @@ export function reduceChatProjection( sawReasoning = true sawUnseenDelta = true } else { + const targetTurnId = delta.turnId ?? state.currentTurnId ?? undefined + if (!liveBufferMatchesTurn({ + text: liveAssistant, + itemId: liveAssistantItemId, + turnId: liveAssistantTurnId, + targetTurnId + })) { + liveAssistant = '' + liveAssistantItemId = undefined + liveAssistantTurnId = undefined + liveAssistantCreatedAt = undefined + } const text = unseenDeltaText( delta, blocks, @@ -200,6 +245,9 @@ export function reduceChatProjection( }) } liveAssistant = '' + liveAssistantItemId = undefined + liveAssistantTurnId = undefined + liveAssistantCreatedAt = undefined } liveAssistantItemId = delta.itemId ?? liveAssistantItemId liveAssistantTurnId = delta.turnId ?? liveAssistantTurnId ?? state.currentTurnId ?? undefined diff --git a/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts b/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts index 627b31b6d..1b70e5a98 100644 --- a/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts +++ b/src/renderer/src/store/chat-store-app-actions-model-switching.test.ts @@ -102,6 +102,7 @@ function buildHarness(fetchModelsResult: FetchModelsResult): { applyChatContentMaxWidth: () => undefined, applyCursorSpotlight: () => undefined, applyCursorSpotlightColor: () => undefined, + applyDarkUiColors: () => undefined, applyWriteTypography: () => undefined, applyDocumentLocale: () => undefined, workspaceLabelFromPath: (workspaceRoot) => workspaceRoot, diff --git a/src/renderer/src/store/chat-store-app-actions.test.ts b/src/renderer/src/store/chat-store-app-actions.test.ts index e2d045025..1e42b6df4 100644 --- a/src/renderer/src/store/chat-store-app-actions.test.ts +++ b/src/renderer/src/store/chat-store-app-actions.test.ts @@ -102,6 +102,7 @@ function buildHarness(fetchModelsResult: FetchModelsResult): { applyChatContentMaxWidth: () => undefined, applyCursorSpotlight: () => undefined, applyCursorSpotlightColor: () => undefined, + applyDarkUiColors: () => undefined, applyWriteTypography: () => undefined, applyDocumentLocale: () => undefined, workspaceLabelFromPath: (workspaceRoot) => workspaceRoot, @@ -636,6 +637,7 @@ describe('chat-store app actions composer model loading', () => { applyChatContentMaxWidth: () => undefined, applyCursorSpotlight: () => undefined, applyCursorSpotlightColor: () => undefined, + applyDarkUiColors: () => undefined, applyWriteTypography: () => undefined, applyDocumentLocale: () => undefined, workspaceLabelFromPath: (workspaceRoot) => workspaceRoot, diff --git a/src/renderer/src/store/chat-store-app-actions.ts b/src/renderer/src/store/chat-store-app-actions.ts index 9c272a8f6..368e8d0ef 100644 --- a/src/renderer/src/store/chat-store-app-actions.ts +++ b/src/renderer/src/store/chat-store-app-actions.ts @@ -46,6 +46,7 @@ type CreateAppActionsOptions = { applyChatContentMaxWidth: (widthPx: AppSettingsV1['chatContentMaxWidthPx']) => void applyCursorSpotlight: (enabled: boolean) => void applyCursorSpotlightColor: (color: AppSettingsV1['cursorSpotlightColor']) => void + applyDarkUiColors: (colors: AppSettingsV1['darkUiColors']) => void applyWriteTypography: (typography: AppSettingsV1['write']['typography']) => void applyDocumentLocale: (locale: AppSettingsV1['locale']) => void workspaceLabelFromPath: (workspaceRoot: string) => string @@ -97,6 +98,7 @@ export function createAppActions(options: CreateAppActionsOptions): Pick< applyChatContentMaxWidth, applyCursorSpotlight, applyCursorSpotlightColor, + applyDarkUiColors, applyWriteTypography, applyDocumentLocale, workspaceLabelFromPath, @@ -361,6 +363,7 @@ export function createAppActions(options: CreateAppActionsOptions): Pick< applyChatContentMaxWidth(settings.chatContentMaxWidthPx) applyCursorSpotlight(settings.cursorSpotlight !== false) applyCursorSpotlightColor(settings.cursorSpotlightColor) + applyDarkUiColors(settings.darkUiColors) if (settings.write?.typography) applyWriteTypography(settings.write.typography) set({ workspaceRoot, diff --git a/src/renderer/src/store/chat-store-claw-actions.ts b/src/renderer/src/store/chat-store-claw-actions.ts index 398362c67..18fd1f080 100644 --- a/src/renderer/src/store/chat-store-claw-actions.ts +++ b/src/renderer/src/store/chat-store-claw-actions.ts @@ -9,6 +9,7 @@ import { } from '@shared/app-settings' import { rendererRuntimeClient } from '../agent/runtime-client' import type { ChatState, ChatStoreGet, ChatStoreSet } from './chat-store-types' +import { emptyLiveProjection } from './chat-store-live-projection' import type { ChatBlock, NormalizedThread } from '../agent/types' import { clawThreadTitleLooksManaged, clawThreadIdsFromChannels } from './chat-store-helpers' import { emitRendererSettingsChanged } from '../lib/keyboard-shortcut-settings' @@ -216,8 +217,7 @@ export function createClawActions(options: CreateClawActionsOptions): Pick< text: replyText } ], - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(state.lastSeq), error: null } }), diff --git a/src/renderer/src/store/chat-store-live-projection.ts b/src/renderer/src/store/chat-store-live-projection.ts new file mode 100644 index 000000000..afdff688e --- /dev/null +++ b/src/renderer/src/store/chat-store-live-projection.ts @@ -0,0 +1,98 @@ +import type { ChatState } from './chat-store-types' + +export type LiveProjectionState = Pick< + ChatState, + | 'liveDeltaSeqFloor' + | 'liveReasoning' + | 'liveReasoningItemId' + | 'liveReasoningTurnId' + | 'liveReasoningCreatedAt' + | 'liveAssistant' + | 'liveAssistantItemId' + | 'liveAssistantTurnId' + | 'liveAssistantCreatedAt' +> + +type LiveProjectionSource = LiveProjectionState & { + busy: boolean + currentTurnId: string | null +} + +export function emptyLiveProjection(liveDeltaSeqFloor = 0): LiveProjectionState { + return { + liveDeltaSeqFloor, + liveReasoning: '', + liveReasoningItemId: undefined, + liveReasoningTurnId: undefined, + liveReasoningCreatedAt: undefined, + liveAssistant: '', + liveAssistantItemId: undefined, + liveAssistantTurnId: undefined, + liveAssistantCreatedAt: undefined + } +} + +export function copyLiveProjection(source: LiveProjectionState): LiveProjectionState { + return { + liveDeltaSeqFloor: Number.isFinite(source.liveDeltaSeqFloor) + ? source.liveDeltaSeqFloor + : 0, + liveReasoning: source.liveReasoning ?? '', + liveReasoningItemId: source.liveReasoningItemId, + liveReasoningTurnId: source.liveReasoningTurnId, + liveReasoningCreatedAt: source.liveReasoningCreatedAt, + liveAssistant: source.liveAssistant ?? '', + liveAssistantItemId: source.liveAssistantItemId, + liveAssistantTurnId: source.liveAssistantTurnId, + liveAssistantCreatedAt: source.liveAssistantCreatedAt + } +} + +function liveBufferIsCoherent(input: { + text: string + itemId?: string + turnId?: string + createdAt?: string + currentTurnId: string | null +}): boolean { + if (!input.text.trim()) { + return !input.itemId && !input.turnId && !input.createdAt + } + return Boolean( + input.itemId && + input.turnId && + input.currentTurnId && + input.turnId === input.currentTurnId + ) +} + +/** A parked projection is safe to paint only when live text and identity agree. */ +export function liveProjectionIsCoherent(source: LiveProjectionSource): boolean { + if (!Number.isFinite(source.liveDeltaSeqFloor) || source.liveDeltaSeqFloor < 0) return false + const liveReasoning = source.liveReasoning ?? '' + const liveAssistant = source.liveAssistant ?? '' + const hasLiveState = Boolean( + liveReasoning.trim() || + source.liveReasoningItemId || + source.liveReasoningTurnId || + source.liveReasoningCreatedAt || + liveAssistant.trim() || + source.liveAssistantItemId || + source.liveAssistantTurnId || + source.liveAssistantCreatedAt + ) + if (!source.busy && hasLiveState) return false + return liveBufferIsCoherent({ + text: liveReasoning, + itemId: source.liveReasoningItemId, + turnId: source.liveReasoningTurnId, + createdAt: source.liveReasoningCreatedAt, + currentTurnId: source.currentTurnId + }) && liveBufferIsCoherent({ + text: liveAssistant, + itemId: source.liveAssistantItemId, + turnId: source.liveAssistantTurnId, + createdAt: source.liveAssistantCreatedAt, + currentTurnId: source.currentTurnId + }) +} diff --git a/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts b/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts index 17102e972..43bc39195 100644 --- a/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts +++ b/src/renderer/src/store/chat-store-maintenance-recovery-actions.ts @@ -39,6 +39,7 @@ import { saveQueuedMessagesForThread } from './queued-message-persistence' import { invalidateThreadSnapshot } from './thread-snapshot-cache' +import { emptyLiveProjection } from './chat-store-live-projection' import { invalidatePendingTurnStarts } from './turn-start-fence' /** @@ -479,8 +480,7 @@ export function createMaintenanceRecoveryActions( invalidateThreadSnapshot(state.activeThreadId) set({ blocks: trimmedBlocks, - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(state.lastSeq), currentTurnId: null, currentTurnOrchestration: null, currentTurnUserId: null, diff --git a/src/renderer/src/store/chat-store-navigation-runtime-actions.ts b/src/renderer/src/store/chat-store-navigation-runtime-actions.ts index ab53cc389..be361484e 100644 --- a/src/renderer/src/store/chat-store-navigation-runtime-actions.ts +++ b/src/renderer/src/store/chat-store-navigation-runtime-actions.ts @@ -6,6 +6,7 @@ import { applyChatContentMaxWidth, applyCursorSpotlight, applyCursorSpotlightColor, + applyDarkUiColors, applyTheme, applyUiFontScale, applyWriteTypography @@ -253,6 +254,7 @@ export function createNavigationRuntimeActions( applyChatContentMaxWidth(settings.chatContentMaxWidthPx) applyCursorSpotlight(settings.cursorSpotlight !== false) applyCursorSpotlightColor(settings.cursorSpotlightColor) + applyDarkUiColors(settings.darkUiColors) if (settings.write?.typography) applyWriteTypography(settings.write.typography) await get().applyI18nFromSettings(settings.locale) if (!runtimeStatusUnsubscribe && typeof window.kunGui.onRuntimeStatus === 'function') { diff --git a/src/renderer/src/store/chat-store-runtime-helpers.test.ts b/src/renderer/src/store/chat-store-runtime-helpers.test.ts index af5db285b..f749d8647 100644 --- a/src/renderer/src/store/chat-store-runtime-helpers.test.ts +++ b/src/renderer/src/store/chat-store-runtime-helpers.test.ts @@ -6,6 +6,7 @@ import { import type { ChatBlock } from '../agent/types' import { hasPendingRuntimeWork, + clearedThreadSelection, isOptimisticUserBlockId, reconcileOptimisticUserBlock, settlePendingRuntimeWorkAfterInterrupt, @@ -16,6 +17,20 @@ import { } from './chat-store-runtime-helpers' describe('chat store runtime helpers', () => { + it('clears live text and runtime identity in the same selection reset', () => { + expect(clearedThreadSelection()).toMatchObject({ + liveDeltaSeqFloor: 0, + liveReasoning: '', + liveReasoningItemId: undefined, + liveReasoningTurnId: undefined, + liveReasoningCreatedAt: undefined, + liveAssistant: '', + liveAssistantItemId: undefined, + liveAssistantTurnId: undefined, + liveAssistantCreatedAt: undefined + }) + }) + it('uses the latest turn as the execution-state authority', () => { expect(threadLooksRunning({ status: 'running', latestTurnStatus: 'completed' })).toBe(false) expect(threadLooksRunning({ status: 'running', latestTurnStatus: 'failed' })).toBe(false) diff --git a/src/renderer/src/store/chat-store-runtime-helpers.ts b/src/renderer/src/store/chat-store-runtime-helpers.ts index 69f0a34f3..f5f022b98 100644 --- a/src/renderer/src/store/chat-store-runtime-helpers.ts +++ b/src/renderer/src/store/chat-store-runtime-helpers.ts @@ -11,6 +11,7 @@ import { import { normalizeWorkspaceRoot } from '../lib/workspace-path' import { shouldAutoTitleThread } from '../lib/thread-title' import type { ChatState } from './chat-store-types' +import { emptyLiveProjection } from './chat-store-live-projection' type ThreadDetailProviderLike = { getThreadDetail: (threadId: string) => Promise<{ blocks: ChatBlock[] }> @@ -298,7 +299,13 @@ export function clearedThreadSelection(): Pick< | 'lastSeq' | 'liveDeltaSeqFloor' | 'liveReasoning' + | 'liveReasoningItemId' + | 'liveReasoningTurnId' + | 'liveReasoningCreatedAt' | 'liveAssistant' + | 'liveAssistantItemId' + | 'liveAssistantTurnId' + | 'liveAssistantCreatedAt' | 'busy' | 'busyUnconfirmed' | 'currentTurnId' @@ -324,9 +331,7 @@ export function clearedThreadSelection(): Pick< activeThreadTodos: null, blocks: [], lastSeq: 0, - liveDeltaSeqFloor: 0, - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(), busy: false, busyUnconfirmed: false, currentTurnId: null, diff --git a/src/renderer/src/store/chat-store-thread-creation-actions.ts b/src/renderer/src/store/chat-store-thread-creation-actions.ts index cdc635332..fe6a56062 100644 --- a/src/renderer/src/store/chat-store-thread-creation-actions.ts +++ b/src/renderer/src/store/chat-store-thread-creation-actions.ts @@ -82,6 +82,7 @@ import { threadSnapshotLooksRunning, threadBelongsToWorkspace } from './chat-store-runtime-helpers' +import { emptyLiveProjection } from './chat-store-live-projection' import { WRITE_ASSISTANT_THREAD_TITLE, activeWriteThreadForWorkspace, @@ -468,9 +469,7 @@ export function createThreadCreationActions( lastSeq: latestSeq, // Re-baseline the shared delta floor to this subscription's since_seq, // in lockstep with the liveAssistant reset below. - liveDeltaSeqFloor: latestSeq, - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(latestSeq), error: busy ? runtimeStreamRecoveringMessage() : null, busy, // Recovery re-read a persisted snapshot; its running claim stays diff --git a/src/renderer/src/store/chat-store-thread-review-actions.ts b/src/renderer/src/store/chat-store-thread-review-actions.ts index 0824af864..12769d707 100644 --- a/src/renderer/src/store/chat-store-thread-review-actions.ts +++ b/src/renderer/src/store/chat-store-thread-review-actions.ts @@ -82,6 +82,7 @@ import { threadSnapshotLooksRunning, threadBelongsToWorkspace } from './chat-store-runtime-helpers' +import { emptyLiveProjection } from './chat-store-live-projection' import { WRITE_ASSISTANT_THREAD_TITLE, activeWriteThreadForWorkspace, @@ -245,8 +246,7 @@ export function createThreadReviewActions( set({ busy: true, busyUnconfirmed: false, - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(seqAtSend), error: null, currentTurnId: null, currentTurnOrchestration: 'direct', @@ -271,7 +271,6 @@ export function createThreadReviewActions( // cannot make this thread look idle/completed while it streams. set((s) => ({ currentTurnId: turnId, - liveDeltaSeqFloor: seqAtSend, threads: s.threads.map((thread) => thread.id === activeThreadId ? { ...thread, diff --git a/src/renderer/src/store/chat-store-thread-running-resume.test.ts b/src/renderer/src/store/chat-store-thread-running-resume.test.ts index 7b7f967a4..7297f8db4 100644 --- a/src/renderer/src/store/chat-store-thread-running-resume.test.ts +++ b/src/renderer/src/store/chat-store-thread-running-resume.test.ts @@ -55,6 +55,9 @@ function buildHarness(busyUnconfirmed = false): { lastSeq: 11, liveDeltaSeqFloor: 11, liveReasoning: 'Still working', + liveReasoningItemId: 'reasoning_a', + liveReasoningTurnId: 'turn_a', + liveReasoningCreatedAt: '2026-08-23T00:00:01.000Z', liveAssistant: '', queuedMessages: [], recoverActiveTurn: vi.fn(async () => true), @@ -149,6 +152,8 @@ describe('running thread parked projection resume', () => { expect(state.busy).toBe(true) expect(state.busyUnconfirmed).toBe(false) expect(state.liveReasoning).toBe('Still working') + expect(state.liveReasoningItemId).toBe('reasoning_a') + expect(state.liveReasoningTurnId).toBe('turn_a') expect(getThreadDetail).toHaveBeenCalledTimes(1) await selecting expect(subscribeThreadEvents).toHaveBeenLastCalledWith( @@ -160,9 +165,17 @@ describe('running thread parked projection resume', () => { const resumedSink = sinks.get('thr_a') expect(resumedSink).toBeDefined() - resumedSink!.onDeltas([{ kind: 'agent_message', text: 'Caught up', seq: 12 }]) + resumedSink!.onDeltas([{ + kind: 'agent_message', + text: 'Caught up', + seq: 12, + itemId: 'assistant_a', + turnId: 'turn_a' + }]) resumedSink!.onSeq(12) expect(state.liveAssistant).toBe('Caught up') + expect(state.liveAssistantItemId).toBe('assistant_a') + expect(state.liveAssistantTurnId).toBe('turn_a') expect(state.lastSeq).toBe(12) resumedSink!.onTurnComplete({ @@ -194,4 +207,70 @@ describe('running thread parked projection resume', () => { expect(state.busy).toBe(true) expect(state.busyUnconfirmed).toBe(true) }) + + it('keeps live identity isolated while repeatedly switching running threads', async () => { + const sinks = new Map() + const getThreadDetail = vi.fn(async (id: string) => { + if (id !== 'thr_b') throw new Error(`unexpected detail request for ${id}`) + return { + blocks: [{ kind: 'user' as const, id: 'b-user', turnId: 'turn_b', text: 'Run B' }], + latestSeq: 22, + threadStatus: 'running', + latestTurnId: 'turn_b', + latestTurnStatus: 'running' + } + }) + registryMock.getProvider.mockReturnValue({ + getThreadDetail, + subscribeThreadEvents: vi.fn(async ( + id: string, + _sinceSeq: number, + sink: ThreadEventSink + ) => { sinks.set(id, sink) }) + }) + const { actions, state } = buildHarness() + state.threads = state.threads.map((candidate) => candidate.id === 'thr_b' + ? { + ...candidate, + status: 'running', + latestSeq: 22, + latestTurnId: 'turn_b', + latestTurnStatus: 'running' + } + : candidate) + + await actions.selectThread('thr_b') + sinks.get('thr_b')!.onDeltas([{ + kind: 'agent_message', + text: 'B is working', + seq: 23, + itemId: 'assistant_b', + turnId: 'turn_b' + }]) + + await actions.selectThread('thr_a') + expect(state.liveReasoning).toBe('Still working') + expect(state.liveReasoningItemId).toBe('reasoning_a') + expect(state.liveReasoningTurnId).toBe('turn_a') + expect(state.blocks.some((block) => block.turnId === 'turn_b')).toBe(false) + + sinks.get('thr_a')!.onDeltas([{ + kind: 'agent_reasoning', + text: ' on A', + seq: 12, + itemId: 'reasoning_a', + turnId: 'turn_a' + }]) + await actions.selectThread('thr_b') + expect(state.liveAssistant).toBe('B is working') + expect(state.liveAssistantItemId).toBe('assistant_b') + expect(state.liveAssistantTurnId).toBe('turn_b') + expect(state.blocks.some((block) => block.turnId === 'turn_a')).toBe(false) + + await actions.selectThread('thr_a') + expect(state.liveReasoning).toBe('Still working on A') + expect(state.liveReasoningItemId).toBe('reasoning_a') + expect(state.liveReasoningTurnId).toBe('turn_a') + expect(getThreadDetail).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/store/chat-store-thread-selection-actions.ts b/src/renderer/src/store/chat-store-thread-selection-actions.ts index 0006512fd..faa33b115 100644 --- a/src/renderer/src/store/chat-store-thread-selection-actions.ts +++ b/src/renderer/src/store/chat-store-thread-selection-actions.ts @@ -129,6 +129,7 @@ import { invalidateThreadSnapshot, snapshotThreadProjection } from './thread-snapshot-cache' +import { copyLiveProjection, emptyLiveProjection } from './chat-store-live-projection' import { getThreadPrewarmHandle, threadPrewarmHandleIsCurrent } from './thread-detail-prewarm' import { ensureRuntimeProviderForSend, @@ -258,9 +259,7 @@ export function createThreadSelectionActions( activeThreadTodos: cached.activeThreadTodos, blocks: cached.blocks, lastSeq: cached.lastSeq, - liveDeltaSeqFloor: cached.liveDeltaSeqFloor, - liveReasoning: cached.liveReasoning, - liveAssistant: cached.liveAssistant, + ...copyLiveProjection(cached), error: null, busy: cached.busy, // Preserve whether the parked projection already had live evidence. @@ -319,9 +318,7 @@ export function createThreadSelectionActions( activeThreadTodos: targetThread?.todos ?? null, blocks: [], lastSeq: 0, - liveDeltaSeqFloor: 0, - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(), busy: false, busyUnconfirmed: false, currentTurnId: null, @@ -446,9 +443,7 @@ export function createThreadSelectionActions( activeThreadTodos: todos ?? null, blocks, lastSeq: latestSeq, - liveDeltaSeqFloor: latestSeq, - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(latestSeq), error: null, busy, // The persisted snapshot's running claim may be stale (interrupted @@ -581,19 +576,24 @@ export function createThreadSelectionActions( threadHistoryLoading: false, blocks: keepExistingBlocks ? prevState.blocks : [], lastSeq: fallbackSinceSeq, - liveDeltaSeqFloor: fallbackSinceSeq, - liveReasoning: '', - liveAssistant: '', + ...(keepExistingBlocks + ? copyLiveProjection(prevState) + : emptyLiveProjection(fallbackSinceSeq)), unreadThreadIds: { ...prevState.unreadThreadIds, [targetThreadId]: false }, busy: true, - currentTurnId: null, + busyUnconfirmed: keepExistingBlocks ? prevState.busyUnconfirmed : true, + currentTurnId: + keepExistingBlocks && prevState.busy ? prevState.currentTurnId : null, currentTurnOrchestration: keepExistingBlocks && prevState.busy ? prevState.currentTurnOrchestration : null, - currentTurnUserId: null, - turnStartedAtByUserId: {}, - turnDurationByUserId: {}, - turnReasoningFirstAtByUserId: {}, - turnReasoningLastAtByUserId: {}, + currentTurnUserId: + keepExistingBlocks && prevState.busy ? prevState.currentTurnUserId : null, + turnStartedAtByUserId: keepExistingBlocks ? prevState.turnStartedAtByUserId : {}, + turnDurationByUserId: keepExistingBlocks ? prevState.turnDurationByUserId : {}, + turnReasoningFirstAtByUserId: + keepExistingBlocks ? prevState.turnReasoningFirstAtByUserId : {}, + turnReasoningLastAtByUserId: + keepExistingBlocks ? prevState.turnReasoningLastAtByUserId : {}, inspectorSelectedId: null, queuedMessages: keepExistingBlocks ? prevState.queuedMessages @@ -647,7 +647,7 @@ export function createThreadSelectionActions( threadHistoryLoading: false, blocks, lastSeq: latestSeq, - liveDeltaSeqFloor: latestSeq, + ...emptyLiveProjection(latestSeq), busy, // Persisted running claim is unconfirmed until live events arrive. busyUnconfirmed: busy, diff --git a/src/renderer/src/store/chat-store-thread-send-direct.ts b/src/renderer/src/store/chat-store-thread-send-direct.ts index 873c98945..5b1325bbc 100644 --- a/src/renderer/src/store/chat-store-thread-send-direct.ts +++ b/src/renderer/src/store/chat-store-thread-send-direct.ts @@ -44,6 +44,7 @@ import { withoutConsumedComposerContexts } from './chat-store-thread-actions-support' import type { PreparedThreadSend } from './chat-store-thread-send-direct-types' +import { copyLiveProjection, emptyLiveProjection } from './chat-store-live-projection' /** * A queued message freezes the model captured when it was enqueued. Draining @@ -112,8 +113,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom const previousCurrentTurnId = get().currentTurnId const previousCurrentTurnOrchestration = get().currentTurnOrchestration const previousCurrentTurnUserId = get().currentTurnUserId - const previousLiveReasoning = get().liveReasoning - const previousLiveAssistant = get().liveAssistant + const previousLiveProjection = copyLiveProjection(get()) const previousTurnStartedAtByUserId = get().turnStartedAtByUserId const previousTurnDurationByUserId = get().turnDurationByUserId const previousTurnReasoningFirstAtByUserId = get().turnReasoningFirstAtByUserId @@ -154,8 +154,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom : {}) } ], - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(s.lastSeq), error: null, currentTurnOrchestration: orchestration, currentTurnUserId: userBlockId, @@ -176,6 +175,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom blocks: previousBlocks, busy: false, busyUnconfirmed: false, + ...previousLiveProjection, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -255,6 +255,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom lastSeq: previousLastSeq, busy: false, busyUnconfirmed: false, + ...previousLiveProjection, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -313,8 +314,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom lastSeq: previousLastSeq, busy: false, busyUnconfirmed: false, - liveReasoning: previousLiveReasoning, - liveAssistant: previousLiveAssistant, + ...previousLiveProjection, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -561,6 +561,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom blocks: previousBlocks, busy: true, busyUnconfirmed: false, + ...previousLiveProjection, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -604,6 +605,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom blocks: previousBlocks, busy: false, busyUnconfirmed: false, + ...previousLiveProjection, currentTurnId: previousCurrentTurnId, currentTurnOrchestration: previousCurrentTurnOrchestration, currentTurnUserId: previousCurrentTurnUserId, @@ -661,6 +663,7 @@ export async function performPreparedThreadSend(input: PreparedThreadSend): Prom error: view.summary, busy: false, busyUnconfirmed: false, + ...emptyLiveProjection(state.lastSeq), currentTurnId: null, currentTurnOrchestration: null, queuedMessages: failQueuedSubmission( diff --git a/src/renderer/src/store/chat-store.ts b/src/renderer/src/store/chat-store.ts index 382a67501..640236bcf 100644 --- a/src/renderer/src/store/chat-store.ts +++ b/src/renderer/src/store/chat-store.ts @@ -7,6 +7,7 @@ import { applyChatContentMaxWidth, applyCursorSpotlight, applyCursorSpotlightColor, + applyDarkUiColors, applyDocumentLocale, applyTheme, applyUiFontScale, @@ -177,6 +178,7 @@ export const useChatStore = create((set, get) => ({ applyChatContentMaxWidth, applyCursorSpotlight, applyCursorSpotlightColor, + applyDarkUiColors, applyWriteTypography, applyDocumentLocale, workspaceLabelFromPath, diff --git a/src/renderer/src/store/thread-snapshot-cache.test.ts b/src/renderer/src/store/thread-snapshot-cache.test.ts index 8b2044245..7c6d25b9c 100644 --- a/src/renderer/src/store/thread-snapshot-cache.test.ts +++ b/src/renderer/src/store/thread-snapshot-cache.test.ts @@ -186,6 +186,51 @@ describe('thread snapshot cache', () => { }) }) + it('round-trips live buffers together with their runtime identity', () => { + const state = stateFor('thr_live_identity') + Object.assign(state, { + busy: true, + currentTurnId: 'turn_live', + liveDeltaSeqFloor: 7, + liveReasoning: 'Inspecting files', + liveReasoningItemId: 'reasoning_live', + liveReasoningTurnId: 'turn_live', + liveReasoningCreatedAt: '2026-08-23T00:00:01.000Z', + liveAssistant: 'Preparing the answer', + liveAssistantItemId: 'assistant_live', + liveAssistantTurnId: 'turn_live', + liveAssistantCreatedAt: '2026-08-23T00:00:02.000Z' + }) + + snapshotThreadProjection(state, 10) + + expect(getThreadSnapshot(state.activeThreadId!)).toMatchObject({ + liveDeltaSeqFloor: 7, + liveReasoning: 'Inspecting files', + liveReasoningItemId: 'reasoning_live', + liveReasoningTurnId: 'turn_live', + liveReasoningCreatedAt: '2026-08-23T00:00:01.000Z', + liveAssistant: 'Preparing the answer', + liveAssistantItemId: 'assistant_live', + liveAssistantTurnId: 'turn_live', + liveAssistantCreatedAt: '2026-08-23T00:00:02.000Z' + }) + }) + + it('rejects a parked running projection with live text but no matching identity', () => { + const state = stateFor('thr_incomplete_live') + Object.assign(state, { + busy: true, + currentTurnId: 'turn_live', + liveReasoning: 'Orphaned live text' + }) + + snapshotThreadProjection(state, 10) + + expect(getThreadSnapshot(state.activeThreadId!)).toBeNull() + expect(threadSnapshotCacheStats()).toEqual({ entries: 0, bytes: 0 }) + }) + it('rejects drifted running snapshots without matching live evidence', () => { const initial = thread('thr_guarded', { status: 'running', diff --git a/src/renderer/src/store/thread-snapshot-cache.ts b/src/renderer/src/store/thread-snapshot-cache.ts index 8d408388a..98e8f0718 100644 --- a/src/renderer/src/store/thread-snapshot-cache.ts +++ b/src/renderer/src/store/thread-snapshot-cache.ts @@ -6,6 +6,12 @@ import type { ThreadTodoList } from '../agent/types' import type { ChatState, QueuedUserMessage } from './chat-store-types' +import { + copyLiveProjection, + emptyLiveProjection, + liveProjectionIsCoherent, + type LiveProjectionState +} from './chat-store-live-projection' import { hydrateBlockModelLabels } from './chat-store-helpers' import { settlePendingRuntimeWorkAfterInterrupt, @@ -32,9 +38,6 @@ export type ThreadSnapshot = { lastSeq: number threadHistoryCursor: string | null threadHasMoreHistory: boolean - liveDeltaSeqFloor: number - liveReasoning: string - liveAssistant: string busy: boolean busyUnconfirmed: boolean currentTurnId: string | null @@ -50,7 +53,7 @@ export type ThreadSnapshot = { activeThreadTodos: ThreadTodoList | null queuedMessages: QueuedUserMessage[] payloadBytes: number -} +} & LiveProjectionState const snapshots = new Map() let totalBytes = 0 @@ -200,6 +203,10 @@ export function cacheThreadSnapshot( token?: ThreadSnapshotCacheToken ): boolean { if (token && !threadSnapshotCacheTokenIsCurrent(snapshot.threadId, token)) return false + if (!liveProjectionIsCoherent(snapshot)) { + invalidateThreadSnapshot(snapshot.threadId) + return false + } const payloadBytes = normalizedPayloadBytes(snapshot.payloadBytes) const bytes = Math.max(payloadBytes, estimateSnapshotBytes(snapshot)) if (bytes > THREAD_SNAPSHOT_CACHE_MAX_BYTES) { @@ -232,9 +239,7 @@ export function snapshotThreadProjection(state: ChatState, payloadBytes?: number lastSeq: state.lastSeq, threadHistoryCursor: state.threadHistoryCursor, threadHasMoreHistory: state.threadHasMoreHistory, - liveDeltaSeqFloor: state.liveDeltaSeqFloor, - liveReasoning: state.liveReasoning, - liveAssistant: state.liveAssistant, + ...copyLiveProjection(state), busy: state.busy, busyUnconfirmed: state.busyUnconfirmed, currentTurnId: state.currentTurnId, @@ -278,6 +283,10 @@ export function getThreadSnapshot( export function getThreadSnapshotForSelection(thread: NormalizedThread): ThreadSnapshot | null { const snapshot = snapshots.get(thread.id) if (!snapshot) return null + if (!liveProjectionIsCoherent(snapshot)) { + invalidateThreadSnapshot(thread.id) + return null + } const fingerprint = threadSnapshotFingerprint(thread) if (snapshot.fingerprint === fingerprint) { snapshots.delete(thread.id) @@ -336,9 +345,7 @@ export function buildPrefetchedThreadSnapshot( lastSeq: detail.latestSeq, threadHistoryCursor: detail.historyCursor ?? null, threadHasMoreHistory: detail.hasMoreHistory === true, - liveDeltaSeqFloor: detail.latestSeq, - liveReasoning: '', - liveAssistant: '', + ...emptyLiveProjection(detail.latestSeq), busy: false, busyUnconfirmed: false, currentTurnId: null, diff --git a/src/renderer/src/styles/base-shell/tokens-window-workspace.css b/src/renderer/src/styles/base-shell/tokens-window-workspace.css index aa7826488..788690bcb 100644 --- a/src/renderer/src/styles/base-shell/tokens-window-workspace.css +++ b/src/renderer/src/styles/base-shell/tokens-window-workspace.css @@ -151,15 +151,25 @@ } [data-theme='dark'] { - /* Dark host theme keeps the same paper hierarchy in warm charcoal. */ - --bg-app: #2a2828; - --bg-sidebar: #2a2828; - --bg-canvas: #2a2828; - --surface-1: #312f2f; - --surface-2: #312f2f; - --surface-3: #383636; - --border-soft: #434343; - --border-strong: #555353; + --kun-dark-ui-effective-background: var(--kun-dark-ui-background, #181818); + --kun-dark-ui-effective-border: var(--kun-dark-ui-border, #272727); + --kun-dark-ui-effective-panel: var(--kun-dark-ui-panel, #2c2c2c); + --kun-dark-ui-panel-subtle: color-mix(in srgb, var(--kun-dark-ui-effective-panel) 98%, white); + --kun-dark-ui-panel-hover: color-mix(in srgb, var(--kun-dark-ui-effective-panel) 96%, white); + --kun-dark-ui-border-muted: color-mix( + in srgb, + var(--kun-dark-ui-effective-border) 70%, + var(--kun-dark-ui-effective-background) + ); + --kun-dark-ui-border-strong: color-mix(in srgb, var(--kun-dark-ui-effective-border) 90%, white); + --bg-app: var(--kun-dark-ui-effective-background); + --bg-sidebar: var(--kun-dark-ui-effective-background); + --bg-canvas: var(--kun-dark-ui-effective-background); + --surface-1: var(--kun-dark-ui-effective-panel); + --surface-2: var(--kun-dark-ui-effective-panel); + --surface-3: var(--kun-dark-ui-panel-hover); + --border-soft: var(--kun-dark-ui-effective-border); + --border-strong: var(--kun-dark-ui-border-strong); --text-primary: #d4d4d4; --text-secondary: #bfc1c4; --text-tertiary: #999ba0; @@ -170,15 +180,15 @@ --ds-bg-canvas: var(--bg-canvas); --ds-surface-card: var(--surface-1); --ds-surface-elevated: var(--surface-2); - --ds-surface-subtle: #353333; - --ds-surface-hover: #3a3838; + --ds-surface-subtle: var(--kun-dark-ui-panel-subtle); + --ds-surface-hover: var(--kun-dark-ui-panel-hover); --ds-border: var(--border-soft); - --ds-border-muted: #3b3939; + --ds-border-muted: var(--kun-dark-ui-border-muted); --ds-border-strong: var(--border-strong); --ds-text: var(--text-primary); --ds-text-muted: var(--text-secondary); --ds-text-faint: var(--text-tertiary); - --ds-bubble-user: #312f2f; + --ds-bubble-user: var(--kun-dark-ui-effective-panel); --ds-bubble-user-fg: #d4d4d4; --ds-accent: #78a3ea; --ds-accent-soft: #353b46; @@ -207,35 +217,35 @@ --ds-sidebar-haze: none; --ds-sidebar-border: var(--ds-border); --ds-sidebar-shadow: none; - --ds-sidebar-row-hover: #3a3838; - --ds-sidebar-row-active: #312f2f; - --ds-sidebar-row-ring: #434343; - --ds-sidebar-field-bg: #353333; - --ds-sidebar-field-focus: #312f2f; - --ds-sidebar-divider: #434343; + --ds-sidebar-row-hover: var(--kun-dark-ui-panel-hover); + --ds-sidebar-row-active: var(--kun-dark-ui-effective-panel); + --ds-sidebar-row-ring: var(--kun-dark-ui-effective-border); + --ds-sidebar-field-bg: var(--kun-dark-ui-panel-subtle); + --ds-sidebar-field-focus: var(--kun-dark-ui-effective-panel); + --ds-sidebar-divider: var(--kun-dark-ui-effective-border); --ds-sidebar-surface-bg: var(--ds-bg-sidebar); --ds-sidebar-surface-chrome-bg: color-mix( in srgb, var(--ds-bg-sidebar) 94%, var(--ds-surface-card) 6% ); - --ds-card-soft: #312f2f; - --ds-card-strong: #312f2f; - --ds-card-muted: #353333; - --ds-card-ghost: #2a2828; - --ds-card-hover: #3a3838; - --ds-chip-bg: #312f2f; - --ds-chip-muted-bg: #353333; - --ds-chip-hover: #3a3838; - --ds-chip-border: #434343; - --ds-chip-active: #3a3838; - --ds-kbd-bg: #353333; + --ds-card-soft: var(--kun-dark-ui-effective-panel); + --ds-card-strong: var(--kun-dark-ui-effective-panel); + --ds-card-muted: var(--kun-dark-ui-panel-subtle); + --ds-card-ghost: var(--kun-dark-ui-effective-background); + --ds-card-hover: var(--kun-dark-ui-panel-hover); + --ds-chip-bg: var(--kun-dark-ui-effective-panel); + --ds-chip-muted-bg: var(--kun-dark-ui-panel-subtle); + --ds-chip-hover: var(--kun-dark-ui-panel-hover); + --ds-chip-border: var(--kun-dark-ui-effective-border); + --ds-chip-active: var(--kun-dark-ui-panel-hover); + --ds-kbd-bg: var(--kun-dark-ui-panel-subtle); --ds-code-bg: #353333; --ds-inline-code-bg: #3a3838; --ds-inline-code-hover-bg: #434141; --ds-pre-bg: #302e2e; --ds-code-block-bg: #252323; - --ds-table-head-bg: #353333; + --ds-table-head-bg: var(--kun-dark-ui-panel-subtle); --ds-scrollbar-thumb: rgba(212, 212, 212, 0.22); --ds-scrollbar-thumb-hover: rgba(212, 212, 212, 0.34); --ds-selection: rgba(120, 163, 234, 0.28); @@ -246,6 +256,13 @@ --ds-shadow-chip: none; } +[data-theme='dark'][data-ui-plugin], +[data-theme='dark'][data-ikun-mode='on'] { + --kun-dark-ui-effective-background: #181818; + --kun-dark-ui-effective-border: #272727; + --kun-dark-ui-effective-panel: #2c2c2c; +} + [data-theme='dark'] .ds-composer-shell::before { background: linear-gradient(180deg, rgba(151, 192, 235, 0.05), transparent 42%); } diff --git a/src/renderer/src/styles/kun-home-palette.test.ts b/src/renderer/src/styles/kun-home-palette.test.ts index 897ab000b..b6e179b29 100644 --- a/src/renderer/src/styles/kun-home-palette.test.ts +++ b/src/renderer/src/styles/kun-home-palette.test.ts @@ -28,3 +28,21 @@ describe('Kun default light palette', () => { ) }) }) + +describe('Kun customizable dark palette', () => { + it('uses Graphite source fallbacks and derives surface and border hierarchy', async () => { + const css = await readStylesheetBundle( + new URL('./base-shell/tokens-window-workspace.css', import.meta.url) + ) + + expect(css).toContain('--kun-dark-ui-effective-background: var(--kun-dark-ui-background, #181818)') + expect(css).toContain('--kun-dark-ui-effective-border: var(--kun-dark-ui-border, #272727)') + expect(css).toContain('--kun-dark-ui-effective-panel: var(--kun-dark-ui-panel, #2c2c2c)') + expect(css).toContain('var(--kun-dark-ui-effective-panel) 98%, white') + expect(css).toContain('var(--kun-dark-ui-effective-panel) 96%, white') + expect(css).toContain('var(--kun-dark-ui-effective-border) 70%') + expect(css).toContain('var(--kun-dark-ui-effective-border) 90%, white') + expect(css).toContain("[data-theme='dark'][data-ui-plugin]") + expect(css).toContain("[data-theme='dark'][data-ikun-mode='on']") + }) +}) diff --git a/src/renderer/src/styles/tray-provider-quota/shell-and-overview.css b/src/renderer/src/styles/tray-provider-quota/shell-and-overview.css index 645fca578..3139ad0ca 100644 --- a/src/renderer/src/styles/tray-provider-quota/shell-and-overview.css +++ b/src/renderer/src/styles/tray-provider-quota/shell-and-overview.css @@ -33,16 +33,16 @@ :root[data-theme="dark"] { color-scheme: dark; - --tray-bg: #2a2828; - --tray-panel: #312f2f; - --tray-panel-strong: #383636; - --tray-subtle: #353333; - --tray-hover: #3a3838; + --tray-bg: var(--kun-dark-ui-background, #181818); + --tray-panel: var(--kun-dark-ui-panel, #2c2c2c); + --tray-panel-strong: color-mix(in srgb, var(--kun-dark-ui-panel, #2c2c2c) 96%, white); + --tray-subtle: color-mix(in srgb, var(--kun-dark-ui-panel, #2c2c2c) 98%, white); + --tray-hover: color-mix(in srgb, var(--kun-dark-ui-panel, #2c2c2c) 96%, white); --tray-text: #d4d4d4; --tray-muted: #bfc1c4; --tray-faint: #999ba0; - --tray-border: #434343; - --tray-border-strong: #555353; + --tray-border: var(--kun-dark-ui-border, #272727); + --tray-border-strong: color-mix(in srgb, var(--kun-dark-ui-border, #272727) 90%, white); --tray-control: #eeeeee; --tray-control-hover: #ffffff; --tray-control-foreground: #252222; diff --git a/src/shared/app-settings-dark-ui.ts b/src/shared/app-settings-dark-ui.ts new file mode 100644 index 000000000..8d8ba40fa --- /dev/null +++ b/src/shared/app-settings-dark-ui.ts @@ -0,0 +1,33 @@ +import type { DarkUiColorsPatchV1, DarkUiColorsV1 } from './app-settings-types' + +export const DEFAULT_DARK_UI_COLORS: Readonly = Object.freeze({ + background: '#181818', + border: '#272727', + panel: '#2c2c2c' +}) + +const SIX_DIGIT_HEX_COLOR = /^#[0-9a-f]{6}$/i + +export function normalizeDarkUiHexColor(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback + const normalized = value.trim().toLowerCase() + return SIX_DIGIT_HEX_COLOR.test(normalized) ? normalized : fallback +} + +export function normalizeDarkUiColors( + value?: DarkUiColorsPatchV1 | null +): DarkUiColorsV1 { + return { + background: normalizeDarkUiHexColor(value?.background, DEFAULT_DARK_UI_COLORS.background), + border: normalizeDarkUiHexColor(value?.border, DEFAULT_DARK_UI_COLORS.border), + panel: normalizeDarkUiHexColor(value?.panel, DEFAULT_DARK_UI_COLORS.panel) + } +} + +export function mergeDarkUiColors( + current?: DarkUiColorsPatchV1 | null, + patch?: DarkUiColorsPatchV1 +): DarkUiColorsV1 { + if (!patch) return normalizeDarkUiColors(current) + return normalizeDarkUiColors({ ...current, ...patch }) +} diff --git a/src/shared/app-settings-domain.ts b/src/shared/app-settings-domain.ts index 1448bafa5..968a20d6d 100644 --- a/src/shared/app-settings-domain.ts +++ b/src/shared/app-settings-domain.ts @@ -7,7 +7,7 @@ export type SettingsFieldOwner = /** Compile-time complete inventory of every persisted top-level settings field. */ export const APP_SETTINGS_FIELD_OWNERS: { readonly [K in keyof AppSettingsV1]-?: SettingsFieldOwner } = { version: 'core', initialSetupCompleted: 'core', locale: 'core', theme: 'core', uiFontScale: 'core', chatContentMaxWidthPx: 'core', - composerSendKey: 'core', cursorSpotlight: 'core', cursorSpotlightColor: 'core', provider: 'provider', agents: 'kun', + composerSendKey: 'core', cursorSpotlight: 'core', cursorSpotlightColor: 'core', darkUiColors: 'core', provider: 'provider', agents: 'kun', workspaceRoot: 'core', conversationWorkspaceRoot: 'core', log: 'core', checkpointCleanup: 'core', gitBranchPrefix: 'core', notifications: 'core', appBehavior: 'core', keyboardShortcuts: 'keyboard', write: 'write', claw: 'claw', schedule: 'schedule', workflow: 'workflow', design: 'design', diff --git a/src/shared/app-settings-normalize.ts b/src/shared/app-settings-normalize.ts index a425900f3..59d30a028 100644 --- a/src/shared/app-settings-normalize.ts +++ b/src/shared/app-settings-normalize.ts @@ -52,6 +52,7 @@ import { normalizeWriteSettings } from './app-settings-write' import { normalizeCodeAgentPresets } from './app-settings-code-agents' import { normalizeDesignSettings } from './app-settings-design' import { normalizeTerminalSettings, type TerminalSettingsPatchV1 } from './app-settings-terminal' +import { normalizeDarkUiColors } from './app-settings-dark-ui' export function normalizeAppSettings(settings: AppSettingsV1): AppSettingsV1 { const migrated = shouldMigrateLegacySettings(settings) @@ -70,6 +71,7 @@ export function normalizeAppSettings(settings: AppSettingsV1): AppSettingsV1 { design?: DesignSettingsPatchV1 guiUpdate?: Partial terminal?: TerminalSettingsPatchV1 + darkUiColors?: Parameters[0] } const providerSettings = normalizeModelProviderSettings(maybeSettings.provider) const rawKun = maybeSettings.agents?.kun @@ -115,6 +117,7 @@ export function normalizeAppSettings(settings: AppSettingsV1): AppSettingsV1 { composerSendKey: normalizeComposerSendKey(maybeSettings.composerSendKey), cursorSpotlight: maybeSettings.cursorSpotlight !== false, cursorSpotlightColor: normalizeCursorSpotlightColor(maybeSettings.cursorSpotlightColor), + darkUiColors: normalizeDarkUiColors(maybeSettings.darkUiColors), provider: providerSettings, agents: kunSettingsEnvelope(mergeKunRuntimeSettings(defaultKunRuntimeSettings(), { ...runtime, diff --git a/src/shared/app-settings-types-product.ts b/src/shared/app-settings-types-product.ts index 89021b216..b964f3b22 100644 --- a/src/shared/app-settings-types-product.ts +++ b/src/shared/app-settings-types-product.ts @@ -542,6 +542,14 @@ export type TerminalSettingsPatchV1 = { colors?: Partial } +export type DarkUiColorsV1 = { + background: string + border: string + panel: string +} + +export type DarkUiColorsPatchV1 = Partial + export type AppSettingsV1 = { version: 1 /** Persisted independently from credentials so SDK/subscription providers do not reopen onboarding. */ @@ -554,6 +562,8 @@ export type AppSettingsV1 = { composerSendKey: ComposerSendKey cursorSpotlight?: boolean cursorSpotlightColor?: string + /** Legacy snapshots omit this field; normalized settings always populate it. */ + darkUiColors?: DarkUiColorsV1 provider: ModelProviderSettingsV1 agents: KunSettingsEnvelopeV1 workspaceRoot: string @@ -588,8 +598,9 @@ export type AppSettingsV1 = { } export type AppSettingsPatch = Partial< - Omit + Omit > & { + darkUiColors?: DarkUiColorsPatchV1 provider?: ModelProviderSettingsPatchV1 agents?: KunSettingsEnvelopePatchV1 log?: Partial diff --git a/src/shared/app-settings.test.ts b/src/shared/app-settings.test.ts index bd925a3e2..9cdfcf808 100644 --- a/src/shared/app-settings.test.ts +++ b/src/shared/app-settings.test.ts @@ -9,6 +9,7 @@ import { DEFAULT_KUN_STREAM_IDLE_TIMEOUT_MS, DEFAULT_LOG_RETENTION_DAYS, DEFAULT_CURSOR_SPOTLIGHT_COLOR, + DEFAULT_DARK_UI_COLORS, DEFAULT_GIT_BRANCH_PREFIX, DEFAULT_APPROVAL_POLICY, DEFAULT_SANDBOX_MODE, @@ -39,6 +40,8 @@ import { isKunRuntimeInsecure, migrateLegacyAppSettings, normalizeAppSettings, + normalizeDarkUiColors, + mergeDarkUiColors, KUN_RUNTIME_TUNING_DEFAULTS_VERSION, normalizeChatContentMaxWidth, normalizeChatWelcomeMessage, @@ -71,6 +74,7 @@ function settings(): AppSettingsV1 { uiFontScale: 0.82, chatContentMaxWidthPx: 896, composerSendKey: 'enter', + darkUiColors: { background: '#181818', border: '#272727', panel: '#2c2c2c' }, provider: defaultModelProviderSettings(), agents: { kun: defaultKunRuntimeSettings() @@ -107,6 +111,38 @@ describe('application locale settings', () => { }) }) +describe('dark UI color settings', () => { + it('normalizes valid values and falls back field by field to Graphite', () => { + expect(normalizeDarkUiColors({ + background: ' #ABCDEF ', + border: 'invalid', + panel: '#123456' + })).toEqual({ + background: '#abcdef', + border: DEFAULT_DARK_UI_COLORS.border, + panel: '#123456' + }) + expect(normalizeDarkUiColors()).toEqual(DEFAULT_DARK_UI_COLORS) + }) + + it('preserves untouched siblings when merging a partial patch', () => { + expect(mergeDarkUiColors({ + background: '#101010', + border: '#202020', + panel: '#303030' + }, { border: '#AABBCC' })).toEqual({ + background: '#101010', + border: '#aabbcc', + panel: '#303030' + }) + }) + + it('migrates legacy application snapshots to Graphite defaults', () => { + const legacy = { ...settings(), darkUiColors: undefined } as unknown as AppSettingsV1 + expect(normalizeAppSettings(legacy).darkUiColors).toEqual(DEFAULT_DARK_UI_COLORS) + }) +}) + describe('composer persona experiment settings', () => { it('keeps legacy snapshots enabled and preserves explicit disablement', () => { expect(normalizeAppSettings(settings()).codeAgentPersonaEnabled).toBe(true) diff --git a/src/shared/app-settings.ts b/src/shared/app-settings.ts index ecb5cc8a9..43d83a5d1 100644 --- a/src/shared/app-settings.ts +++ b/src/shared/app-settings.ts @@ -14,6 +14,7 @@ export * from './app-settings-write' export * from './app-settings-code-agents' export * from './app-settings-design' export * from './app-settings-terminal' +export * from './app-settings-dark-ui' export * from './app-settings-normalize' export * from './app-settings-domain' export * from './browser-use' diff --git a/src/shared/tray-provider-quota.ts b/src/shared/tray-provider-quota.ts index 4700cf22f..eede8a574 100644 --- a/src/shared/tray-provider-quota.ts +++ b/src/shared/tray-provider-quota.ts @@ -1,4 +1,5 @@ import type { AppLocale } from './app-locales' +import type { DarkUiColorsV1 } from './app-settings-types' import type { ProviderQuotaListResult } from './provider-quota' export const TRAY_PROVIDER_QUOTA_CHANNELS = { @@ -23,6 +24,7 @@ export type TrayProviderQuotaContext = { locale: AppLocale colorMode: 'light' | 'dark' platform: TrayProviderQuotaPlatform + darkUiColors: DarkUiColorsV1 } export type KunTrayProviderQuotaApi = { From 0d0a65bc03c96e5105b4c81232c135358d1de297 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 02:10:43 +0800 Subject: [PATCH 046/168] fix(retention): prevent prune from overwriting turns --- kun/src/adapters/file/file-thread-store.ts | 46 ++++++- .../adapters/hybrid/hybrid-thread-store.ts | 72 +++++------ kun/src/adapters/in-memory-thread-store.ts | 35 ++++-- kun/src/contracts/threads.ts | 2 + kun/src/domain/thread.ts | 1 + kun/src/manager/remote-data-stores.ts | 8 ++ .../manager/shared-data-store-contracts.ts | 3 +- .../shared-data-store-implementation.ts | 7 ++ kun/src/ports/thread-store.ts | 10 ++ kun/src/server/routes/thread-projection.ts | 14 +-- kun/src/services/thread-lifecycle-fence.ts | 15 ++- .../turn-service-compaction-operations.ts | 76 +++++++----- ...turn-service.retention-concurrency.test.ts | 114 ++++++++++++++++++ 13 files changed, 315 insertions(+), 88 deletions(-) create mode 100644 kun/src/services/turn-service.retention-concurrency.test.ts diff --git a/kun/src/adapters/file/file-thread-store.ts b/kun/src/adapters/file/file-thread-store.ts index f69f32e7a..ddf14806b 100644 --- a/kun/src/adapters/file/file-thread-store.ts +++ b/kun/src/adapters/file/file-thread-store.ts @@ -1,6 +1,10 @@ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' -import type { ThreadStore, ThreadStoreListOptions } from '../../ports/thread-store.js' +import type { + ThreadStore, + ThreadStoreConditionalWrite, + ThreadStoreListOptions +} from '../../ports/thread-store.js' import { ThreadSchema, ThreadSchemaReadable, @@ -27,6 +31,7 @@ export class FileThreadStore implements ThreadStore { private readonly dataDir: string private readonly now: () => Date private indexQueue: Promise = Promise.resolve() + private readonly threadQueues = new Map>() constructor(options: { dataDir: string; now?: () => Date }) { this.dataDir = resolve(options.dataDir, 'threads') @@ -62,11 +67,30 @@ export class FileThreadStore implements ThreadStore { } async upsert(thread: ThreadRecord): Promise { + return this.withThreadWrite(thread.id, async () => { + const current = await this.readThread(thread.id) + return this.writeThread({ ...thread, revision: (current?.revision ?? -1) + 1 }) + }) + } + + async upsertIfRevision( + thread: ThreadRecord, + expectedRevision: number + ): Promise { + return this.withThreadWrite(thread.id, async () => { + const current = await this.readThread(thread.id) + const revision = current?.revision ?? 0 + if (!current || revision !== expectedRevision) return { applied: false, revision } + const stored = await this.writeThread({ ...thread, revision: revision + 1 }) + return { applied: true, thread: stored, revision: stored.revision ?? revision + 1 } + }) + } + + private async writeThread(thread: ThreadRecord): Promise { const normalized = ThreadSchema.parse(thread) assertSafeThreadId(normalized.id) await this.ensureDir(this.threadDir(normalized.id)) - const path = this.threadFilePath(normalized.id) - await this.atomicWrite(path, JSON.stringify(normalized)) + await this.atomicWrite(this.threadFilePath(normalized.id), JSON.stringify(normalized)) await this.updateIndex((current) => { const next = new Set(current.order) next.add(normalized.id) @@ -75,6 +99,22 @@ export class FileThreadStore implements ThreadStore { return normalized } + private async readThread(threadId: string): Promise { + return this.get(threadId) + } + + private async withThreadWrite(threadId: string, operation: () => Promise): Promise { + const previous = this.threadQueues.get(threadId) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(operation) + const guard = run.then(() => undefined, () => undefined) + this.threadQueues.set(threadId, guard) + try { + return await run + } finally { + if (this.threadQueues.get(threadId) === guard) this.threadQueues.delete(threadId) + } + } + async delete(threadId: string): Promise { if (!isSafeThreadId(threadId)) return false const dir = this.threadDir(threadId) diff --git a/kun/src/adapters/hybrid/hybrid-thread-store.ts b/kun/src/adapters/hybrid/hybrid-thread-store.ts index bbe701c0a..eb115dd6b 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-store.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-store.ts @@ -7,7 +7,7 @@ import { type ThreadSummary } from '../../contracts/threads.js' import type { RuntimeEvent } from '../../contracts/events.js' -import type { ThreadStore, ThreadStoreListOptions, ThreadStoreListPage } from '../../ports/thread-store.js' +import type { ThreadStore, ThreadStoreConditionalWrite, ThreadStoreListOptions, ThreadStoreListPage } from '../../ports/thread-store.js' import type { SessionLatestUsageSnapshot, SessionUsageRecord } from '../../ports/session-store.js' import { legacyWorkThreadTitleMatches, resolveThreadAgentSurface, toThreadSummary } from '../../domain/thread.js' import { assertSafeThreadId, isSafeThreadId } from '../../contracts/thread-id.js' @@ -170,14 +170,26 @@ export class HybridThreadStore implements ThreadStore { } async upsert(thread: ThreadRecord): Promise { - const normalized = ThreadSchema.parse(thread) - assertSafeThreadId(normalized.id) - await this.ready() - await this.appendMetadata(normalized) - if (this.db) { - this.upsertIndexBestEffort(this.indexRecordForThread(normalized)) - } - return normalized + assertSafeThreadId(thread.id); await this.ready() + return this.withMetadataMutation(thread.id, async () => this.storeRevision(thread, (await this.documents.readMetadata(thread.id))?.revision ?? -1)) + } + + async upsertIfRevision(thread: ThreadRecord, expectedRevision: number): Promise { + assertSafeThreadId(thread.id); await this.ready() + return this.withMetadataMutation(thread.id, async () => { + const current = await this.documents.readMetadata(thread.id) + const revision = current?.revision ?? 0 + if (!current || revision !== expectedRevision) return { applied: false, revision } + const stored = await this.storeRevision(thread, revision) + return { applied: true, thread: stored, revision: stored.revision ?? revision + 1 } + }) + } + + private async storeRevision(thread: ThreadRecord, revision: number): Promise { + const stored = ThreadSchema.parse({ ...thread, revision: revision + 1 }) + await this.appendMetadataNow(stored) + this.upsertIndexBestEffort(this.indexRecordForThread(stored)) + return stored } async delete(threadId: string): Promise { @@ -525,36 +537,24 @@ export class HybridThreadStore implements ThreadStore { } private async appendMetadata(thread: ThreadRecord): Promise { - const previous = this.metadataQueues.get(thread.id) ?? Promise.resolve() - const run = previous.catch(() => undefined).then(async () => { - await mkdir(this.threadDir(thread.id), { recursive: true }) - const line: ThreadMetadataLine = { - kind: 'thread_metadata', - version: 1, - timestamp: this.nowIso(), - thread: stripThreadItemBodies(thread) - } - await appendJsonlLine(this.metadataPath(thread.id), line) - await this.maybeCompactMetadata(thread.id) - }) + await this.withMetadataMutation(thread.id, () => this.appendMetadataNow(thread)) + } + + private async appendMetadataNow(thread: ThreadRecord): Promise { + await mkdir(this.threadDir(thread.id), { recursive: true }); await appendJsonlLine(this.metadataPath(thread.id), { + kind: 'thread_metadata', version: 1, timestamp: this.nowIso(), thread: stripThreadItemBodies(thread) + }); await this.maybeCompactMetadata(thread.id) + } + + private async withMetadataMutation(threadId: string, operation: () => Promise): Promise { + const previous = this.metadataQueues.get(threadId) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(operation) const guard = run.then(() => undefined, () => undefined) - this.metadataQueues.set(thread.id, guard) - try { - await run - } finally { - if (this.metadataQueues.get(thread.id) === guard) { - this.metadataQueues.delete(thread.id) - } - } + this.metadataQueues.set(threadId, guard) + try { return await run } finally { if (this.metadataQueues.get(threadId) === guard) this.metadataQueues.delete(threadId) } } - /** - * Every upsert appends a full thread snapshot, so metadata.jsonl grows - * quadratically with turn activity (observed: 4.2MB for an 8-turn thread - * whose latest snapshot is 6KB). Once the file passes the threshold it is - * rewritten as a single normalized snapshot. Runs inside the per-thread - * metadata queue, so no append can interleave with the rewrite. - */ + /** Compacts append-only metadata snapshots inside the per-thread queue. */ private async maybeCompactMetadata(threadId: string): Promise { const path = this.metadataPath(threadId) const tmpPath = `${path}.compact.tmp` diff --git a/kun/src/adapters/in-memory-thread-store.ts b/kun/src/adapters/in-memory-thread-store.ts index 8302f6bcd..4a54d57ef 100644 --- a/kun/src/adapters/in-memory-thread-store.ts +++ b/kun/src/adapters/in-memory-thread-store.ts @@ -1,4 +1,8 @@ -import type { ThreadStore, ThreadStoreListOptions } from '../ports/thread-store.js' +import type { + ThreadStore, + ThreadStoreConditionalWrite, + ThreadStoreListOptions +} from '../ports/thread-store.js' import { ThreadSchema, ThreadSchemaReadable, @@ -25,20 +29,33 @@ export class InMemoryThreadStore implements ThreadStore { } async upsert(thread: ThreadRecord): Promise { + const current = this.threads.get(thread.id) + const normalized = this.normalize({ ...thread, revision: (current?.revision ?? -1) + 1 }) + this.threads.set(normalized.id, normalized) + return normalized + } + + async upsertIfRevision( + thread: ThreadRecord, + expectedRevision: number + ): Promise { + const current = this.threads.get(thread.id) + const revision = current?.revision ?? 0 + if (!current || revision !== expectedRevision) return { applied: false, revision } + const normalized = this.normalize({ ...thread, revision: revision + 1 }) + this.threads.set(normalized.id, normalized) + return { applied: true, thread: normalized, revision: normalized.revision ?? revision + 1 } + } + + private normalize(thread: ThreadRecord): ThreadRecord { const strict = ThreadSchema.safeParse(thread) - if (strict.success) { - this.threads.set(strict.data.id, strict.data) - return strict.data - } + if (strict.success) return strict.data // Legacy half-bound plan-build records are tolerated for read/repair // paths exactly like the file and hybrid stores: a test (or a migration // import) may need to seed the pre-fix malformed shape to exercise the // CAS backfill flow. New writes still fail via the service-layer callers. const readable = ThreadSchemaReadable.safeParse(thread) - if (readable.success) { - this.threads.set(readable.data.id, readable.data) - return readable.data - } + if (readable.success) return readable.data throw strict.error } diff --git a/kun/src/contracts/threads.ts b/kun/src/contracts/threads.ts index 11c044fb0..9e6e2058c 100644 --- a/kun/src/contracts/threads.ts +++ b/kun/src/contracts/threads.ts @@ -346,6 +346,8 @@ export type DesignCloneOperation = z.infer export const ThreadSchemaBase = z.object({ id: z.string().min(1), + /** Internal optimistic-concurrency version; defaults for legacy records. */ + revision: z.number().int().nonnegative().optional(), title: z.string(), /** * Whether the current title was auto-derived (client-side first-message diff --git a/kun/src/domain/thread.ts b/kun/src/domain/thread.ts index 6c353f3de..c9effa679 100644 --- a/kun/src/domain/thread.ts +++ b/kun/src/domain/thread.ts @@ -89,6 +89,7 @@ export function createThreadRecord(input: { const now = input.createdAt ?? new Date().toISOString() return { id: input.id, + revision: 0, title: input.title, ...(input.titleAuto !== undefined ? { titleAuto: input.titleAuto } : {}), workspace: input.workspace, diff --git a/kun/src/manager/remote-data-stores.ts b/kun/src/manager/remote-data-stores.ts index c5c16fc2e..6537f3633 100644 --- a/kun/src/manager/remote-data-stores.ts +++ b/kun/src/manager/remote-data-stores.ts @@ -204,6 +204,14 @@ export class ManagerRemoteThreadStore implements ThreadStore { return ThreadSchema.parse(await this.call('upsert', { thread })) } + async upsertIfRevision(thread: ThreadRecord, expectedRevision: number) { + return z.object({ + applied: z.boolean(), + thread: ThreadSchema.optional(), + revision: z.number().int().nonnegative() + }).strict().parse(await this.call('upsertIfRevision', { thread, expectedRevision })) + } + async delete(threadId: string) { return z.boolean().parse(await this.call('delete', { threadId })) } diff --git a/kun/src/manager/shared-data-store-contracts.ts b/kun/src/manager/shared-data-store-contracts.ts index 505e4df5c..15b680eaa 100644 --- a/kun/src/manager/shared-data-store-contracts.ts +++ b/kun/src/manager/shared-data-store-contracts.ts @@ -100,6 +100,7 @@ export type ManagerThreadStoreOperation = | 'getMetadata' | 'touch' | 'upsert' + | 'upsertIfRevision' | 'delete' export type ManagerSessionStoreOperation = @@ -212,7 +213,7 @@ export function mutationThreadId(value: unknown): string | null { } export function isThreadMutation(operation: ManagerThreadStoreOperation): boolean { - return operation === 'touch' || operation === 'upsert' || operation === 'delete' + return operation === 'touch' || operation === 'upsert' || operation === 'upsertIfRevision' || operation === 'delete' } export function isSessionMutation(operation: ManagerSessionStoreOperation): boolean { diff --git a/kun/src/manager/shared-data-store-implementation.ts b/kun/src/manager/shared-data-store-implementation.ts index 57d8c0a38..4d4508f5a 100644 --- a/kun/src/manager/shared-data-store-implementation.ts +++ b/kun/src/manager/shared-data-store-implementation.ts @@ -130,6 +130,13 @@ export class ManagerSharedDataStore extends ManagerSharedDataStoreCore { } case 'upsert': return this.threadStore.upsert(ThreadSchema.parse(z.object({ thread: z.unknown() }).parse(value).thread)) + case 'upsertIfRevision': { + const body = z.object({ + thread: z.unknown(), + expectedRevision: z.number().int().nonnegative() + }).strict().parse(value) + return this.threadStore.upsertIfRevision!(ThreadSchema.parse(body.thread), body.expectedRevision) + } case 'delete': { const { threadId } = parseThreadId(value) this.seqFloors.delete(threadId) diff --git a/kun/src/ports/thread-store.ts b/kun/src/ports/thread-store.ts index 653cfff8d..582c98a36 100644 --- a/kun/src/ports/thread-store.ts +++ b/kun/src/ports/thread-store.ts @@ -1,5 +1,13 @@ import type { ThreadRecord, ThreadSummary } from '../contracts/threads.js' +export type ThreadStoreConditionalWrite = { + applied: boolean + /** Durable record after a successful conditional write. */ + thread?: ThreadRecord + /** Durable revision observed when the expected revision was stale. */ + revision: number +} + export type ThreadStoreListOptions = { limit?: number search?: string @@ -39,5 +47,7 @@ export interface ThreadStore { /** Update only rebuildable Thread metadata, without hydrating item history. */ touch?(threadId: string, updatedAt: string): Promise upsert(thread: ThreadRecord): Promise + /** Atomically replace a record only when its durable revision still matches. */ + upsertIfRevision?(thread: ThreadRecord, expectedRevision: number): Promise delete(threadId: string): Promise } diff --git a/kun/src/server/routes/thread-projection.ts b/kun/src/server/routes/thread-projection.ts index 4a430bdd7..93d2ed4c0 100644 --- a/kun/src/server/routes/thread-projection.ts +++ b/kun/src/server/routes/thread-projection.ts @@ -196,12 +196,10 @@ export function hydrateThreadItemsFromSession( /** Defense in depth for every HTTP endpoint that returns a ThreadRecord. */ export function projectPublicThreadRecord(thread: ThreadRecord): ThreadRecord { - let changed = false - const turns = thread.turns.map((turn): Turn => { - const items = turn.items.filter(isPublicTurnItem) - if (items.length === turn.items.length) return turn - changed = true - return { ...turn, items } - }) - return changed ? { ...thread, turns } : thread + const { revision: _revision, ...publicThread } = thread + const turns = thread.turns.map((turn): Turn => ({ + ...turn, + items: turn.items.filter(isPublicTurnItem) + })) + return { ...publicThread, turns } } diff --git a/kun/src/services/thread-lifecycle-fence.ts b/kun/src/services/thread-lifecycle-fence.ts index d8bc3c041..2e049548b 100644 --- a/kun/src/services/thread-lifecycle-fence.ts +++ b/kun/src/services/thread-lifecycle-fence.ts @@ -8,7 +8,12 @@ import type { SessionLatestUsageSnapshot, SessionUsageRecord } from '../ports/session-store.js' -import type { ThreadStore, ThreadStoreListOptions, ThreadStoreListPage } from '../ports/thread-store.js' +import type { + ThreadStore, + ThreadStoreConditionalWrite, + ThreadStoreListOptions, + ThreadStoreListPage +} from '../ports/thread-store.js' import type { ThreadRecord, ThreadSummary } from '../contracts/threads.js' /** @@ -183,6 +188,14 @@ export class LifecycleFencedThreadStore implements ThreadStore { } } + async upsertIfRevision( + thread: ThreadRecord, + expectedRevision: number + ): Promise { + return this.write(thread.id, { applied: false, revision: expectedRevision }, () => + this.raw.upsertIfRevision!(thread, expectedRevision)) + } + /** * ThreadService must use `raw.delete()` after closing and draining the * fence. This passthrough exists only because ThreadStore has a delete diff --git a/kun/src/services/turn-service-compaction-operations.ts b/kun/src/services/turn-service-compaction-operations.ts index 4e10a4bad..145e64c34 100644 --- a/kun/src/services/turn-service-compaction-operations.ts +++ b/kun/src/services/turn-service-compaction-operations.ts @@ -50,6 +50,7 @@ import type { UsageService } from './usage-service.js' import { createImmutablePrefix } from '../cache/immutable-prefix.js' import { rewriteItemHistoryWithRetry } from './history-commit-coordinator.js' import { withThreadStoreMutation } from './thread-mutation-coordinator.js' +import { withManagerDataMutex } from '../manager/data-mutex.js' import type { ThreadLifecycleFence } from './thread-lifecycle-fence.js' import { ThreadItemProjectionService } from './thread-item-projection.js' import { ComposerContextAttachmentSchema } from '../contracts/composer-context.js' @@ -399,37 +400,52 @@ async pruneThread(this: TurnService, input: { threadId: string request: PruneThreadRequest }): Promise { - const current = await this['deps'].threadStore.get(input.threadId) - if (!current) throw new Error(`thread not found: ${input.threadId}`) - if (current.turns.some(isActiveTurn)) throw new TurnConflictError('thread has an active turn') - const policy = input.request - const cutoffTurnId = selectRetentionCutoff(current, policy, this['deps'].nowIso()) - const compacted = cutoffTurnId - ? await this.compact({ - threadId: input.threadId, - request: { - cutoffTurnId, - reason: 'thread retention policy', - archiveBeforePrune: policy.archiveBeforePrune - } - }) - : undefined - const latest = await this['deps'].threadStore.get(input.threadId) - if (!latest) throw new Error(`thread not found: ${input.threadId}`) - await this['deps'].threadStore.upsert({ - ...latest, - retentionPolicy: policy, - updatedAt: this['deps'].nowIso() + return withManagerDataMutex(`thread:${input.threadId}`, async () => { + const policy = input.request + const cutoffTurnId = await this['withThreadMutation'](input.threadId, async () => { + const current = await this['deps'].threadStore.get(input.threadId) + if (!current) throw new Error(`thread not found: ${input.threadId}`) + if (current.turns.some(isActiveTurn)) throw new TurnConflictError('thread has an active turn') + return selectRetentionCutoff(current, policy, this['deps'].nowIso()) + }) + const compacted = cutoffTurnId + ? await this.compact({ + threadId: input.threadId, + request: { + cutoffTurnId, + reason: 'thread retention policy', + archiveBeforePrune: policy.archiveBeforePrune + } + }) + : undefined + await this['withThreadMutation'](input.threadId, async () => { + // The manager lease prevents other runtimes from starting a turn in + // this transaction; CAS also rejects any stale durable snapshot. + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = await this['deps'].threadStore.get(input.threadId) + if (!latest) throw new Error(`thread not found: ${input.threadId}`) + if (latest.turns.some(isActiveTurn)) throw new TurnConflictError('thread has an active turn') + const conditionalWrite = this['deps'].threadStore.upsertIfRevision + if (!conditionalWrite) throw new Error('thread store does not support conditional writes') + const committed = await conditionalWrite.call(this['deps'].threadStore, { + ...latest, + retentionPolicy: policy, + updatedAt: this['deps'].nowIso() + }, latest.revision ?? 0) + if (committed.applied) return + } + throw new TurnConflictError('thread changed while retention policy was being committed') + }) + return { + threadId: input.threadId, + policy, + pruned: Boolean(compacted), + ...(cutoffTurnId ? { cutoffTurnId } : {}), + archivedItems: compacted?.archivedItems ?? 0, + retainedItems: compacted?.retainedItems ?? (await this['deps'].sessionStore.loadItems(input.threadId)).length, + ...(compacted?.archivePath ? { archivePath: compacted.archivePath } : {}) + } }) - return { - threadId: input.threadId, - policy, - pruned: Boolean(compacted), - ...(cutoffTurnId ? { cutoffTurnId } : {}), - archivedItems: compacted?.archivedItems ?? 0, - retainedItems: compacted?.retainedItems ?? (await this['deps'].sessionStore.loadItems(input.threadId)).length, - ...(compacted?.archivePath ? { archivePath: compacted.archivePath } : {}) - } }, /** diff --git a/kun/src/services/turn-service.retention-concurrency.test.ts b/kun/src/services/turn-service.retention-concurrency.test.ts new file mode 100644 index 000000000..93896670d --- /dev/null +++ b/kun/src/services/turn-service.retention-concurrency.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { InMemoryEventBus } from '../adapters/in-memory-event-bus.js' +import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' +import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' +import { createThreadRecord } from '../domain/thread.js' +import { ContextCompactor } from '../loop/context-compactor.js' +import { InflightTracker } from '../loop/inflight-tracker.js' +import { SteeringQueue } from '../loop/steering-queue.js' +import { SequentialIdGenerator } from '../ports/id-generator.js' +import type { ThreadRecord } from '../contracts/threads.js' +import type { ThreadStoreConditionalWrite } from '../ports/thread-store.js' +import { RuntimeEventRecorder } from './runtime-event-recorder.js' +import { TurnService } from './turn-service.js' + +class BlockingCasThreadStore extends InMemoryThreadStore { + readonly casStarted: Promise + private readonly casRelease: Promise + private resolveCasStarted!: () => void + private resolveCasRelease!: () => void + private block = true + + constructor() { + super() + this.casStarted = new Promise((resolve) => { this.resolveCasStarted = resolve }) + this.casRelease = new Promise((resolve) => { this.resolveCasRelease = resolve }) + } + + releaseCas(): void { this.resolveCasRelease() } + + override async upsertIfRevision( + thread: ThreadRecord, + expectedRevision: number + ): Promise { + if (this.block) { + this.block = false + this.resolveCasStarted() + await this.casRelease + } + return super.upsertIfRevision(thread, expectedRevision) + } +} + +function service(threadStore: InMemoryThreadStore, sessionStore = new InMemorySessionStore()): TurnService { + const eventBus = new InMemoryEventBus() + const nowIso = () => '2026-08-24T00:00:00.000Z' + return new TurnService({ + threadStore, + sessionStore, + events: new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }), + inflight: new InflightTracker(), + steering: new SteeringQueue(), + compactor: new ContextCompactor(), + ids: new SequentialIdGenerator(), + nowIso + }) +} + +describe('ThreadStore conditional writes', () => { + it('rejects a stale snapshot without replacing the durable record', async () => { + const store = new InMemoryThreadStore() + const initial = await store.upsert(createThreadRecord({ + id: 'thr_cas', title: 'Initial', workspace: '/tmp', model: 'test' + })) + const first = await store.upsertIfRevision({ ...initial, title: 'Fresh' }, initial.revision ?? 0) + const stale = await store.upsertIfRevision({ ...initial, title: 'Stale' }, initial.revision ?? 0) + + expect(first).toMatchObject({ applied: true, revision: 1 }) + expect(stale).toEqual({ applied: false, revision: 1 }) + expect((await store.get('thr_cas'))?.title).toBe('Fresh') + }) +}) + +describe('TurnService retention pruning', () => { + it('serializes retention CAS with startTurn and preserves the admitted turn', async () => { + const threadStore = new BlockingCasThreadStore() + const sessionStore = new InMemorySessionStore() + const turns = service(threadStore, sessionStore) + const threadId = 'thr_retention_race' + await threadStore.upsert(createThreadRecord({ + id: threadId, title: 'Retention', workspace: '/tmp', model: 'test' + })) + + const pruning = turns.pruneThread({ + threadId, + request: { keepLastTurns: 1, archiveBeforePrune: true } + }) + await threadStore.casStarted + let started = false + const starting = turns.startTurn({ threadId, request: { prompt: 'must survive pruning' } }) + .then((value) => { started = true; return value }) + await Promise.resolve() + expect(started).toBe(false) + + threadStore.releaseCas() + await pruning + const accepted = await starting + const record = await threadStore.get(threadId) + + expect(record?.retentionPolicy).toEqual({ keepLastTurns: 1, archiveBeforePrune: true }) + expect(record?.turns).toHaveLength(1) + expect(record?.turns[0]).toMatchObject({ id: accepted.turnId, status: 'running' }) + expect(await sessionStore.loadItems(threadId)).toContainEqual(expect.objectContaining({ + id: accepted.userMessageItemId, + kind: 'user_message' + })) + await expect(turns.finishTurn({ threadId, turnId: accepted.turnId, status: 'completed' })) + .resolves.toMatchObject({ kind: 'applied' }) + }) +}) From 3865074f9af0d93fe49bdb35e09ebc38e08e6edf Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 03:30:19 +0800 Subject: [PATCH 047/168] fix(storage): recover listings and bound history queries --- kun/src/adapters/file/file-thread-store.ts | 296 ++++++++++++------ .../hybrid/hybrid-filesystem-summary-cache.ts | 70 +++++ .../adapters/hybrid/hybrid-session-store.ts | 3 +- .../hybrid/hybrid-sqlite-degraded-state.ts | 24 ++ .../adapters/hybrid/hybrid-thread-index.ts | 28 +- .../hybrid/hybrid-thread-list-page.ts | 53 ++-- .../hybrid/hybrid-thread-store.test.ts | 58 ++++ .../adapters/hybrid/hybrid-thread-store.ts | 90 +++--- kun/src/adapters/hybrid/hybrid-usage-query.ts | 43 +++ kun/src/domain/thread-list-query.ts | 100 ++++++ kun/src/manager/remote-data-stores.ts | 3 +- kun/src/manager/service-manager.test.ts | 16 + .../manager/shared-data-store-contracts.ts | 23 ++ .../shared-data-store-implementation.ts | 3 +- kun/src/ports/session-store.ts | 10 +- kun/src/server/routes/usage.test.ts | 21 +- kun/src/server/routes/usage.ts | 13 +- .../thread-service-metadata-operations.ts | 44 +-- kun/src/services/usage-history.test.ts | 21 ++ kun/src/services/usage-history.ts | 57 +++- kun/src/services/usage-service-query.ts | 41 +++ kun/src/services/usage-service.test.ts | 15 + kun/src/services/usage-service.ts | 2 +- kun/tests/file-thread-store.test.ts | 153 ++++++--- .../src/lib/shared-business-storage.test.ts | 90 ++++++ .../src/lib/shared-business-storage.ts | 16 +- 26 files changed, 1026 insertions(+), 267 deletions(-) create mode 100644 kun/src/adapters/hybrid/hybrid-filesystem-summary-cache.ts create mode 100644 kun/src/adapters/hybrid/hybrid-sqlite-degraded-state.ts create mode 100644 kun/src/adapters/hybrid/hybrid-usage-query.ts create mode 100644 kun/src/domain/thread-list-query.ts diff --git a/kun/src/adapters/file/file-thread-store.ts b/kun/src/adapters/file/file-thread-store.ts index ddf14806b..ab7b949e1 100644 --- a/kun/src/adapters/file/file-thread-store.ts +++ b/kun/src/adapters/file/file-thread-store.ts @@ -3,7 +3,8 @@ import { join, resolve } from 'node:path' import type { ThreadStore, ThreadStoreConditionalWrite, - ThreadStoreListOptions + ThreadStoreListOptions, + ThreadStoreListPage } from '../../ports/thread-store.js' import { ThreadSchema, @@ -13,56 +14,69 @@ import { } from '../../contracts/threads.js' import { assertSafeThreadId, isSafeThreadId } from '../../contracts/thread-id.js' import { toThreadSummary } from '../../domain/thread.js' +import { + applyThreadCursor, + filterThreadSummaries, + queryThreadSummaryPage +} from '../../domain/thread-list-query.js' import { atomicWriteFile } from './atomic-write.js' import { isPathBelowDirectory } from './path-containment.js' -/** - * File-backed thread store. Writes small JSON state files via atomic - * `rename` and keeps a compact index.json to make `list` cheap. - * - * Layout: - * {dataDir}/threads/index.json - * {dataDir}/threads/{threadId}/thread.json - * {dataDir}/threads/{threadId}/messages.jsonl - * {dataDir}/threads/{threadId}/events.jsonl - * {dataDir}/threads/{threadId}/usage.json - */ +type ThreadIndex = { order: string[]; updatedAt: string } +type IndexRead = + | { kind: 'ok'; index: ThreadIndex } + | { kind: 'missing' } + | { kind: 'corrupt'; error: unknown } + +type FileThreadStoreOptions = { + dataDir: string + now?: () => Date + writeFile?: (path: string, contents: string) => Promise +} + +/** File-backed thread store with a rebuildable, backed-up listing index. */ export class FileThreadStore implements ThreadStore { private readonly dataDir: string private readonly now: () => Date + private readonly writeFile: (path: string, contents: string) => Promise private indexQueue: Promise = Promise.resolve() private readonly threadQueues = new Map>() + private reconciliation: Promise | null = null - constructor(options: { dataDir: string; now?: () => Date }) { + constructor(options: FileThreadStoreOptions) { this.dataDir = resolve(options.dataDir, 'threads') this.now = options.now ?? (() => new Date()) + this.writeFile = options.writeFile ?? atomicWriteFile } - async list(_options?: ThreadStoreListOptions): Promise { - await this.ensureDir(this.dataDir) - const index = await this.readIndex() - const summaries: ThreadSummary[] = [] - for (const threadId of index.order) { - try { - const path = this.threadFilePath(threadId) - const raw = await readFile(path, 'utf-8') - const thread = ThreadSchemaReadable.safeParse(JSON.parse(raw)) - if (thread.success) summaries.push(toThreadSummary(thread.data)) - } catch { - // Skip broken entries rather than failing the whole list. - } - } - return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + async list(options: ThreadStoreListOptions = {}): Promise { + const summaries = filterThreadSummaries(await this.readIndexedSummaries(), options) + const afterCursor = applyThreadCursor(summaries, options.cursor) + return typeof options.limit === 'number' + ? afterCursor.slice(0, Math.max(1, Math.floor(options.limit))) + : afterCursor + } + + async listPage(options: ThreadStoreListOptions = {}): Promise { + return queryThreadSummaryPage(await this.readIndexedSummaries(), options) } async get(threadId: string): Promise { if (!isSafeThreadId(threadId)) return null + const path = this.threadFilePath(threadId) + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch (error) { + if (isErrno(error, 'ENOENT')) return null + throw fileError(`read thread ${threadId}`, path, error) + } try { - const raw = await readFile(this.threadFilePath(threadId), 'utf-8') const parsed = ThreadSchemaReadable.safeParse(JSON.parse(raw)) - return parsed.success ? parsed.data : null - } catch { - return null + if (!parsed.success) throw parsed.error + return parsed.data + } catch (error) { + throw fileError(`parse thread ${threadId}`, path, error) } } @@ -90,12 +104,17 @@ export class FileThreadStore implements ThreadStore { const normalized = ThreadSchema.parse(thread) assertSafeThreadId(normalized.id) await this.ensureDir(this.threadDir(normalized.id)) - await this.atomicWrite(this.threadFilePath(normalized.id), JSON.stringify(normalized)) - await this.updateIndex((current) => { - const next = new Set(current.order) - next.add(normalized.id) - return { order: [...next], updatedAt: this.now().toISOString() } - }) + await this.writeFile(this.threadFilePath(normalized.id), JSON.stringify(normalized)) + await this.ensureReconciled() + try { + await this.updateIndex((current) => ({ + order: current.order.includes(normalized.id) ? current.order : [...current.order, normalized.id], + updatedAt: this.now().toISOString() + })) + } catch (error) { + this.reconciliation = null + throw error + } return normalized } @@ -120,73 +139,180 @@ export class FileThreadStore implements ThreadStore { const dir = this.threadDir(threadId) try { await stat(dir) - } catch { - return false + } catch (error) { + if (isErrno(error, 'ENOENT')) return false + throw fileError(`stat thread ${threadId}`, dir, error) } await rm(dir, { recursive: true, force: true }) - await this.updateIndex((current) => { - const order = current.order.filter((id) => id !== threadId) - return { order, updatedAt: this.now().toISOString() } - }) + await this.ensureReconciled() + await this.updateIndex((current) => ({ + order: current.order.filter((id) => id !== threadId), + updatedAt: this.now().toISOString() + })) return true } - private async readIndex(): Promise<{ order: string[]; updatedAt: string }> { + private async readIndexedSummaries(): Promise { + await this.ensureDir(this.dataDir) + await this.ensureReconciled() + let current = await this.readIndexFile(this.indexPath()) + if (current.kind !== 'ok') { + this.reconciliation = null + await this.ensureReconciled() + current = await this.readIndexFile(this.indexPath()) + } + if (current.kind !== 'ok') throw new Error('thread index unavailable after reconciliation') + const summaries: ThreadSummary[] = [] + for (const threadId of current.index.order) { + const thread = await this.readThreadForListing(threadId) + if (thread) summaries.push(toThreadSummary(thread)) + } + return summaries + } + + private ensureReconciled(): Promise { + if (this.reconciliation) return this.reconciliation + const run = this.enqueueIndex(async () => this.reconcileIndex()) + this.reconciliation = run.catch((error) => { + this.reconciliation = null + throw error + }) + return this.reconciliation + } + + private async reconcileIndex(): Promise { + await this.ensureDir(this.dataDir) + const primary = await this.readIndexFile(this.indexPath()) + const backup = primary.kind === 'ok' + ? null + : await this.readIndexFile(this.indexBackupPath()) + if (primary.kind === 'corrupt') warnFileStore(`index is corrupt; rebuilding`, this.indexPath(), primary.error) + if (primary.kind !== 'ok' && backup?.kind === 'ok') { + console.warn('[kun] file thread index recovered from index.json.bak and filesystem reconciliation') + } + + const seed = primary.kind === 'ok' + ? primary.index + : backup?.kind === 'ok' + ? backup.index + : emptyIndex(this.now()) + const diskOrder: string[] = [] + const entries = await readdir(this.dataDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || !isSafeThreadId(entry.name)) continue + if (await this.readThreadForListing(entry.name)) diskOrder.push(entry.name) + } + const available = new Set(diskOrder) + const order = [ + ...seed.order.filter((id) => available.has(id)), + ...diskOrder.filter((id) => !seed.order.includes(id)) + ] + const changed = primary.kind !== 'ok' || !sameOrder(order, seed.order) + if (!changed) return + const next = { order, updatedAt: this.now().toISOString() } + await this.writeIndex(next, primary.kind === 'ok' ? primary.index : null) + } + + private async readThreadForListing(threadId: string): Promise { + const path = this.threadFilePath(threadId) + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch (error) { + if (isErrno(error, 'ENOENT')) return null + throw fileError(`read thread ${threadId}`, path, error) + } try { - const raw = await readFile(this.indexPath(), 'utf-8') - const parsed = JSON.parse(raw) as { order?: string[]; updatedAt?: string } + const parsed = ThreadSchemaReadable.safeParse(JSON.parse(raw)) + if (!parsed.success) throw parsed.error + if (parsed.data.id !== threadId) throw new Error(`record id ${parsed.data.id} does not match directory`) + return parsed.data + } catch (error) { + warnFileStore(`skipping corrupt thread ${threadId}`, path, error) + return null + } + } + + private async readIndexFile(path: string): Promise { + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch (error) { + if (isErrno(error, 'ENOENT')) return { kind: 'missing' } + throw fileError('read thread index', path, error) + } + try { + const value = JSON.parse(raw) as unknown + if (!value || typeof value !== 'object') throw new Error('index must be an object') + const candidate = value as { order?: unknown; updatedAt?: unknown } + if (!Array.isArray(candidate.order) || typeof candidate.updatedAt !== 'string') { + throw new Error('index requires order[] and updatedAt') + } + if (!candidate.order.every((id) => typeof id === 'string' && isSafeThreadId(id))) { + throw new Error('index contains an unsafe thread id') + } return { - order: Array.isArray(parsed.order) ? parsed.order.filter(isSafeThreadId) : [], - updatedAt: parsed.updatedAt ?? this.now().toISOString() + kind: 'ok', + index: { order: [...new Set(candidate.order)], updatedAt: candidate.updatedAt } } - } catch { - return { order: [], updatedAt: this.now().toISOString() } + } catch (error) { + return { kind: 'corrupt', error } } } - private async updateIndex( - mutator: (current: { order: string[]; updatedAt: string }) => { order: string[]; updatedAt: string } - ): Promise { - const run = this.indexQueue.catch(() => undefined).then(async () => { - const current = await this.readIndex() - const next = mutator(current) - await this.ensureDir(this.dataDir) - await this.atomicWrite(this.indexPath(), JSON.stringify(next)) + private async updateIndex(mutator: (current: ThreadIndex) => ThreadIndex): Promise { + await this.enqueueIndex(async () => { + const current = await this.readIndexFile(this.indexPath()) + if (current.kind !== 'ok') throw new Error('thread index unavailable during update') + await this.writeIndex(mutator(current.index), current.index) }) + } + + private async writeIndex(next: ThreadIndex, previous: ThreadIndex | null): Promise { + await this.ensureDir(this.dataDir) + if (previous) await this.writeFile(this.indexBackupPath(), JSON.stringify(previous)) + await this.writeFile(this.indexPath(), JSON.stringify(next)) + } + + private enqueueIndex(task: () => Promise): Promise { + const run = this.indexQueue.catch(() => undefined).then(task) this.indexQueue = run.then(() => undefined, () => undefined) - await run + return run } private threadDir(threadId: string): string { assertSafeThreadId(threadId) const path = resolve(this.dataDir, threadId) - if (!isPathBelowDirectory(this.dataDir, path)) { - throw new Error(`thread path escapes data directory: ${threadId}`) - } + if (!isPathBelowDirectory(this.dataDir, path)) throw new Error(`thread path escapes data directory: ${threadId}`) return path } - private threadFilePath(threadId: string): string { - return join(this.threadDir(threadId), 'thread.json') - } - - private indexPath(): string { - return join(this.dataDir, 'index.json') - } - - private async ensureDir(path: string): Promise { - await mkdir(path, { recursive: true, mode: 0o700 }) - } + private threadFilePath(threadId: string): string { return join(this.threadDir(threadId), 'thread.json') } + private indexPath(): string { return join(this.dataDir, 'index.json') } + private indexBackupPath(): string { return join(this.dataDir, 'index.json.bak') } + private async ensureDir(path: string): Promise { await mkdir(path, { recursive: true, mode: 0o700 }) } +} - private async atomicWrite(path: string, contents: string): Promise { - await atomicWriteFile(path, contents) - } +function emptyIndex(now: Date): ThreadIndex { return { order: [], updatedAt: now.toISOString() } } +function sameOrder(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((id, index) => id === right[index]) +} +function isErrno(error: unknown, code: string): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === code +} +function fileError(action: string, path: string, error: unknown): Error { + const message = error instanceof Error ? error.message : String(error) + const wrapped = new Error(`${action} failed for ${path}: ${message}`, { cause: error }) + const source = error as NodeJS.ErrnoException | undefined + if (source?.code) Object.assign(wrapped, { code: source.code }) + return wrapped +} +function warnFileStore(action: string, path: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + console.warn(`[kun] file thread store ${action} at ${path}: ${message}`) } -/** - * Helper used by the JSONL event store to enumerate disk content - * during replay. Exposed for tests and the file session store. - */ +/** Helper used by the JSONL event store to enumerate disk content. */ export async function readJsonl(path: string): Promise { try { const content = await readFile(path, 'utf-8') @@ -194,12 +320,7 @@ export async function readJsonl(path: string): Promise { for (const line of content.split('\n')) { const trimmed = line.trim() if (!trimmed) continue - try { - out.push(JSON.parse(trimmed) as T) - } catch { - // Skip malformed lines so a single bad record does not poison - // the whole replay. - } + try { out.push(JSON.parse(trimmed) as T) } catch { /* skip malformed records */ } } return out } catch { @@ -207,5 +328,4 @@ export async function readJsonl(path: string): Promise { } } -/** Re-export so other files in the package can import through a single path. */ export { readdir } diff --git a/kun/src/adapters/hybrid/hybrid-filesystem-summary-cache.ts b/kun/src/adapters/hybrid/hybrid-filesystem-summary-cache.ts new file mode 100644 index 000000000..8e3e33fcc --- /dev/null +++ b/kun/src/adapters/hybrid/hybrid-filesystem-summary-cache.ts @@ -0,0 +1,70 @@ +import type { ThreadRecord, ThreadSummary } from '../../contracts/threads.js' +import { compareThreadSummaries } from '../../domain/thread-list-query.js' +import { toThreadSummary } from '../../domain/thread.js' +import { requiresLegacyWorkThreadHydration } from './hybrid-thread-legacy-surface.js' + +export type HybridFilesystemSummarySource = { + threadIds(): Promise + readMetadata(threadId: string): Promise + readThread(threadId: string): Promise + warn(threadId: string, error: unknown): void +} + +export class HybridFilesystemSummaryCache { + private cache: { summaries: ThreadSummary[]; expiresAt: number; generation: number } | null = null + private load: { generation: number; promise: Promise } | null = null + private generation = 0 + + constructor( + private readonly source: HybridFilesystemSummarySource, + private readonly ttlMs = 30_000, + private readonly concurrency = 8 + ) {} + + invalidate(): void { + this.generation += 1 + this.cache = null + } + + async list(): Promise { + const cached = this.cache + if (cached && cached.expiresAt > Date.now() && cached.generation === this.generation) { + return [...cached.summaries] + } + if (this.load?.generation === this.generation) return [...await this.load.promise] + const generation = this.generation + const promise = this.scan().then((summaries) => { + if (generation === this.generation) { + this.cache = { summaries, expiresAt: Date.now() + this.ttlMs, generation } + } + return summaries + }).finally(() => { + if (this.load?.promise === promise) this.load = null + }) + this.load = { generation, promise } + return [...await promise] + } + + private async scan(): Promise { + const threadIds = await this.source.threadIds() + const summaries: ThreadSummary[] = [] + let nextIndex = 0 + const workerCount = Math.min(this.concurrency, threadIds.length) + await Promise.all(Array.from({ length: workerCount }, async () => { + while (nextIndex < threadIds.length) { + const threadId = threadIds[nextIndex] + nextIndex += 1 + try { + const metadata = await this.source.readMetadata(threadId) + const thread = metadata && requiresLegacyWorkThreadHydration(metadata) + ? await this.source.readThread(threadId) ?? metadata + : metadata + if (thread) summaries.push(toThreadSummary(thread)) + } catch (error) { + this.source.warn(threadId, error) + } + } + })) + return summaries.sort(compareThreadSummaries) + } +} diff --git a/kun/src/adapters/hybrid/hybrid-session-store.ts b/kun/src/adapters/hybrid/hybrid-session-store.ts index efea2c528..f1b62689c 100644 --- a/kun/src/adapters/hybrid/hybrid-session-store.ts +++ b/kun/src/adapters/hybrid/hybrid-session-store.ts @@ -10,6 +10,7 @@ import type { ItemTextSearchOptions, SessionLatestUsageSnapshot, SessionStore, + SessionUsageQueryOptions, SessionUsageRecord } from '../../ports/session-store.js' import { FileSessionStore } from '../file/file-session-store.js' @@ -136,7 +137,7 @@ export class HybridSessionStore implements SessionStore { return Math.max(indexed ?? 0, durable) } - async loadUsageRecords(options?: { threadId?: string }): Promise { + async loadUsageRecords(options?: SessionUsageQueryOptions): Promise { return this.index.loadUsageRecords(options) } diff --git a/kun/src/adapters/hybrid/hybrid-sqlite-degraded-state.ts b/kun/src/adapters/hybrid/hybrid-sqlite-degraded-state.ts new file mode 100644 index 000000000..101919fc4 --- /dev/null +++ b/kun/src/adapters/hybrid/hybrid-sqlite-degraded-state.ts @@ -0,0 +1,24 @@ +import { warnSqlite } from './hybrid-thread-support.js' + +export class HybridSqliteDegradedState { + private degradedUntil = 0 + private degraded = false + + available(hasDatabase: boolean): boolean { + return hasDatabase && Date.now() >= this.degradedUntil + } + + fail(action: string, error: unknown): void { + this.degradedUntil = Date.now() + 30_000 + if (!this.degraded) { + this.degraded = true + warnSqlite(`${action}; entering 30s degraded cooldown`, error) + } + } + + recover(): void { + if (this.degraded) console.warn('[kun] hybrid sqlite recovered; leaving filesystem fallback') + this.degraded = false + this.degradedUntil = 0 + } +} diff --git a/kun/src/adapters/hybrid/hybrid-thread-index.ts b/kun/src/adapters/hybrid/hybrid-thread-index.ts index b32c501bd..7080434a7 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-index.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-index.ts @@ -1,5 +1,6 @@ import type { Database as BetterSqliteDatabase } from 'better-sqlite3' import type { ThreadStoreListOptions } from '../../ports/thread-store.js' +import { decodeThreadCursor } from '../../domain/thread-list-query.js' import { rowFromIndexRecord, type ThreadIndexRecord, @@ -15,7 +16,7 @@ export class HybridThreadIndexRepository { query(options: ThreadStoreListOptions): ThreadRow[] { const { where, params } = this.buildWhere(options) - const cursor = decodeKeysetCursor(options.cursor) + const cursor = decodeThreadCursor(options.cursor) if (cursor) { where.push('(updated_at_ms < @cursorMs OR (updated_at_ms = @cursorMs AND id < @cursorId))') params.cursorMs = cursor.updatedAtMs @@ -119,29 +120,4 @@ export class HybridThreadIndexRepository { } catch (error) { this.warn('delete index row', error) } } } - function escapeLike(value: string): string { return value.replace(/[%_]/g, (match) => `\\${match}`) } - -type KeysetCursor = { updatedAtMs: number; id: string } - -/** - * Cursor encoding: base64url of `JSON.stringify([updatedAtMs, id])`. The id - * tiebreaker keeps the key unique for the `(updated_at_ms DESC, id DESC)` sort. - */ -export function encodeKeysetCursor(updatedAt: string, id: string): string { - const updatedAtMs = Number.isFinite(Date.parse(updatedAt)) ? Date.parse(updatedAt) : 0 - return Buffer.from(JSON.stringify([updatedAtMs, id])).toString('base64url') -} - -export function decodeKeysetCursor(cursor: string | undefined): KeysetCursor | null { - if (!cursor) return null - try { - const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown - if (!Array.isArray(parsed) || parsed.length !== 2) return null - const [updatedAtMs, id] = parsed as [unknown, unknown] - if (typeof updatedAtMs !== 'number' || typeof id !== 'string' || !id) return null - return { updatedAtMs, id } - } catch { - return null - } -} diff --git a/kun/src/adapters/hybrid/hybrid-thread-list-page.ts b/kun/src/adapters/hybrid/hybrid-thread-list-page.ts index 584f14144..d45b5ef2e 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-list-page.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-list-page.ts @@ -1,9 +1,12 @@ import type { ThreadStoreListOptions, ThreadStoreListPage } from '../../ports/thread-store.js' import type { ThreadSummary } from '../../contracts/threads.js' import type { ThreadRow } from './hybrid-thread-index-mapping.js' -import { decodeKeysetCursor, encodeKeysetCursor } from './hybrid-thread-index.js' -import { filterThreadSummaries, summaryFromRow } from './hybrid-thread-index-mapping.js' -import { warnSqlite } from './hybrid-thread-support.js' +import { + applyThreadCursor, + encodeThreadCursor, + filterThreadSummaries +} from '../../domain/thread-list-query.js' +import { summaryFromRow } from './hybrid-thread-index-mapping.js' /** * Internal access surface for keyset pagination. The HybridThreadStore keeps @@ -18,6 +21,8 @@ export interface HybridThreadListPageSource { deleteIndexRow(threadId: string): void listFromFilesystem(): Promise indexCount(options: ThreadStoreListOptions): number | undefined + markSqliteDegraded(action: string, error: unknown): void + markSqliteHealthy(): void } /** Hydrate readable index rows into summaries, dropping stale index rows. */ @@ -50,7 +55,7 @@ function pageFromSummaries( const last = page[page.length - 1] return { threads: page, - ...(hasMore && last ? { nextCursor: encodeKeysetCursor(last.updatedAt, last.id) } : {}), + ...(hasMore && last ? { nextCursor: encodeThreadCursor(last.updatedAt, last.id) } : {}), hasMore, ...(options.cursor ? {} : { total: total ? total() : summaries.length }) } @@ -64,33 +69,37 @@ export async function hybridThreadStoreListPage( if (source.hasDb()) { try { const pageSize = typeof options.limit === 'number' ? Math.max(1, Math.floor(options.limit)) : 0 - // Fetch one extra row to decide `hasMore` without a second query. - const rows = source.queryThreadRows({ - ...options, - ...(pageSize > 0 ? { limit: pageSize + 1 } : {}) - }) - return pageFromSummaries( - await summariesFromRows(source, rows), + const wanted = pageSize > 0 ? pageSize + 1 : 0 + const readable: ThreadSummary[] = [] + let cursor = options.cursor + while (true) { + const rows = source.queryThreadRows({ + ...options, + cursor, + ...(wanted > 0 ? { limit: wanted - readable.length } : {}) + }) + readable.push(...await summariesFromRows(source, rows)) + if (wanted === 0 || readable.length >= wanted || rows.length === 0) break + const lastRow = rows.at(-1) + if (!lastRow) break + cursor = encodeThreadCursor(lastRow.updated_at, lastRow.id) + } + const result = pageFromSummaries( + readable, options, () => source.indexCount(options) ) + source.markSqliteHealthy() + return result } catch (error) { - warnSqlite('listPage', error) + source.markSqliteDegraded('listPage', error) } } - const cursor = decodeKeysetCursor(options.cursor) - let summaries = filterThreadSummaries( + const filtered = filterThreadSummaries( await source.listFromFilesystem(), { ...options, limit: undefined } ) - if (cursor) { - summaries = summaries.filter((thread) => { - const updatedAtMs = Number.isFinite(Date.parse(thread.updatedAt)) ? Date.parse(thread.updatedAt) : 0 - return updatedAtMs < cursor.updatedAtMs || - (updatedAtMs === cursor.updatedAtMs && thread.id < cursor.id) - }) - } - return pageFromSummaries(summaries, options) + return pageFromSummaries(applyThreadCursor(filtered, options.cursor), options, () => filtered.length) } /** Structural assertion from the store to the pagination access surface. */ diff --git a/kun/src/adapters/hybrid/hybrid-thread-store.test.ts b/kun/src/adapters/hybrid/hybrid-thread-store.test.ts index 062f11840..03b816550 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-store.test.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-store.test.ts @@ -135,6 +135,39 @@ describe('HybridThreadStore usage timing persistence', () => { } }) + it('keeps the pre-range cumulative snapshot as the differential baseline', async () => { + const { store } = await createStore() + try { + await store.noteEvent(usageEvent(1, { + promptTokens: 100, + completionTokens: 10, + totalTokens: 110, + cacheHitRate: null, + turns: 1 + })) + await store.noteEvent(usageEvent(2, { + promptTokens: 140, + completionTokens: 15, + totalTokens: 155, + cacheHitRate: null, + turns: 2 + })) + + const records = await store.loadUsageRecords({ + fromInclusive: '2026-08-08T00:00:02.000Z', + toExclusive: '2026-08-08T00:00:03.000Z' + }) + + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ + turnId: 'turn-2', + usage: { promptTokens: 40, completionTokens: 5, totalTokens: 45, turns: 1 } + }) + } finally { + store.close() + } + }) + it('defaults timing aggregates to null when snapshots omit them', async () => { const { store } = await createStore() try { @@ -184,6 +217,31 @@ describe('HybridThreadStore filesystem surface fallback', () => { store.close() } }) + + it('reuses one filesystem scan across cursor pages', async () => { + const { root, store } = await createStore() + const records = [ + legacyWorkThread('thread_cache_c', 'Cache C'), + legacyWorkThread('thread_cache_b', 'Cache B') + ] + await Promise.all(records.map((record) => writeThreadDocument(root, record))) + await store.ready() + store.close() + const source = store as unknown as { threadIdsFromFilesystem(): Promise } + const scan = vi.spyOn(source, 'threadIdsFromFilesystem') + + const first = await store.listPage({ includeArchived: true, limit: 1 }) + const second = await store.listPage({ + includeArchived: true, + limit: 1, + cursor: first.nextCursor + }) + + expect(first).toMatchObject({ hasMore: true, total: 2 }) + expect(second).toMatchObject({ hasMore: false }) + expect([...first.threads, ...second.threads]).toHaveLength(2) + expect(scan).toHaveBeenCalledTimes(1) + }) }) describe('HybridThreadStore SQLite pagination', () => { diff --git a/kun/src/adapters/hybrid/hybrid-thread-store.ts b/kun/src/adapters/hybrid/hybrid-thread-store.ts index eb115dd6b..44a4872ba 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-store.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-store.ts @@ -1,24 +1,19 @@ import { mkdir, open, readdir, rename, rm, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import type { Database as BetterSqliteDatabase, Statement } from 'better-sqlite3' -import { - ThreadSchema, - type ThreadRecord, - type ThreadSummary -} from '../../contracts/threads.js' +import { ThreadSchema, type ThreadRecord, type ThreadSummary } from '../../contracts/threads.js' import type { RuntimeEvent } from '../../contracts/events.js' import type { ThreadStore, ThreadStoreConditionalWrite, ThreadStoreListOptions, ThreadStoreListPage } from '../../ports/thread-store.js' -import type { SessionLatestUsageSnapshot, SessionUsageRecord } from '../../ports/session-store.js' -import { legacyWorkThreadTitleMatches, resolveThreadAgentSurface, toThreadSummary } from '../../domain/thread.js' +import type { SessionLatestUsageSnapshot, SessionUsageQueryOptions, SessionUsageRecord } from '../../ports/session-store.js' +import { legacyWorkThreadTitleMatches, resolveThreadAgentSurface } from '../../domain/thread.js' +import { filterThreadSummaries } from '../../domain/thread-list-query.js' import { assertSafeThreadId, isSafeThreadId } from '../../contracts/thread-id.js' import { readJsonl } from '../file/file-thread-store.js' import { stripThreadItemBodies, type ThreadMetadataLine } from './hybrid-thread-projection.js' import { HybridThreadDocumentRepository } from './hybrid-thread-documents.js' -import { - filterThreadSummaries, - type ThreadIndexRecord, - type ThreadRow -} from './hybrid-thread-index-mapping.js' +import { HybridFilesystemSummaryCache } from './hybrid-filesystem-summary-cache.js' +import { HybridSqliteDegradedState } from './hybrid-sqlite-degraded-state.js' +import { type ThreadIndexRecord, type ThreadRow } from './hybrid-thread-index-mapping.js' import { requiresLegacyWorkThreadHydration } from './hybrid-thread-legacy-surface.js' import { HybridThreadIndexRepository } from './hybrid-thread-index.js' import { hybridThreadStoreListPage, summariesFromRows } from './hybrid-thread-list-page.js' @@ -30,13 +25,13 @@ import { latestUsageSnapshotsFromRows, pathExists, previewFromItems, - usageRecordsFromRows, usageRowFromEvent, warnSqlite, yieldToEventLoop, type UsageRow, type UsageRuntimeEvent } from './hybrid-thread-support.js' +import { loadIndexedUsageRecords } from './hybrid-usage-query.js' export { describeSqliteAbiMismatch } from './hybrid-thread-support.js' @@ -61,15 +56,23 @@ export class HybridThreadStore implements ThreadStore { // Per-thread floor that keeps metadata compaction from re-running on every // append when a single snapshot is already larger than the threshold. private readonly metadataCompactFloor = new Map() - + private readonly filesystemSummaries: HybridFilesystemSummaryCache + private readonly sqliteState = new HybridSqliteDegradedState() constructor(options: { dataDir: string; sqlitePath?: string; nowIso?: () => string }) { this.dataDir = resolve(options.dataDir, 'threads') this.documents = new HybridThreadDocumentRepository(options.dataDir) this.sqlitePath = resolve(options.sqlitePath ?? join(options.dataDir, 'index.sqlite3')) this.nowIso = options.nowIso ?? (() => new Date().toISOString()) + this.filesystemSummaries = new HybridFilesystemSummaryCache({ + threadIds: () => this.threadIdsFromFilesystem(), + readMetadata: (threadId) => this.readThreadMetadataFromDisk(threadId), + readThread: (threadId) => this.readThreadFromDisk(threadId), + warn: (threadId, error) => console.warn( + `[kun] skipping unreadable filesystem thread ${threadId}: ${error instanceof Error ? error.message : String(error)}` + ) + }) this.readyPromise = this.initialize() } - async ready(): Promise { await this.readyPromise } @@ -84,20 +87,25 @@ export class HybridThreadStore implements ThreadStore { this.statementCache.clear() } } - async shutdown(): Promise { await this.ready() this.backfill?.stop() await this.backfill?.wait() this.close() } - async waitForBackfill(): Promise { await this.ready() await this.backfill?.wait() } + private hasDb(): boolean { return this.sqliteState.available(this.db !== null) } - private hasDb(): boolean { return this.db !== null } + private markSqliteDegraded(action: string, error: unknown): void { + this.sqliteState.fail(action, error) + } + + private markSqliteHealthy(): void { + this.sqliteState.recover() + } async list(options: ThreadStoreListOptions = {}): Promise { await this.ready() @@ -105,11 +113,13 @@ export class HybridThreadStore implements ThreadStore { // canonical JSONL metadata before the first list response. Usage/event // backfill remains in the background so large histories stay responsive. await this.backfill?.waitForIndex() - if (this.db) { + if (this.hasDb()) { try { - return summariesFromRows(this, this.queryThreadRows(options)) + const summaries = await summariesFromRows(this, this.queryThreadRows(options)) + this.markSqliteHealthy() + return summaries } catch (error) { - warnSqlite('list', error) + this.markSqliteDegraded('list', error) } } return filterThreadSummaries(await this.listFromFilesystem(), options) @@ -151,6 +161,7 @@ export class HybridThreadStore implements ThreadStore { if (!current) return false const next = ThreadSchema.parse({ ...current, updatedAt }) await this.appendMetadata(next) + this.invalidateFilesystemCache() if (this.db) { try { this.cachedStatement(` @@ -188,6 +199,7 @@ export class HybridThreadStore implements ThreadStore { private async storeRevision(thread: ThreadRecord, revision: number): Promise { const stored = ThreadSchema.parse({ ...thread, revision: revision + 1 }) await this.appendMetadataNow(stored) + this.invalidateFilesystemCache() this.upsertIndexBestEffort(this.indexRecordForThread(stored)) return stored } @@ -205,6 +217,7 @@ export class HybridThreadStore implements ThreadStore { this.deleteIndexRow(threadId) this.documents.invalidate(threadId) this.metadataCompactFloor.delete(threadId) + this.invalidateFilesystemCache() return true } @@ -250,23 +263,11 @@ export class HybridThreadStore implements ThreadStore { } } - async loadUsageRecords(options: { threadId?: string } = {}): Promise { + async loadUsageRecords(options: SessionUsageQueryOptions = {}): Promise { await this.ready() if (!this.db) throw new Error('hybrid sqlite unavailable') try { - const threadId = options.threadId?.trim() - const rows = threadId - ? this.db - .prepare(` - SELECT * FROM usage_events - WHERE thread_id = @thread_id - ORDER BY thread_id ASC, seq ASC - `) - .all({ thread_id: threadId }) as UsageRow[] - : this.db - .prepare('SELECT * FROM usage_events ORDER BY thread_id ASC, seq ASC') - .all() as UsageRow[] - return usageRecordsFromRows(rows) + return loadIndexedUsageRecords(this.db, options) } catch (error) { warnSqlite('load usage records', error) throw error @@ -637,22 +638,21 @@ export class HybridThreadStore implements ThreadStore { } } - private async listFromFilesystem(): Promise { - const summaries: ThreadSummary[] = [] - for (const threadId of await this.threadIdsFromFilesystem()) { - const metadata = await this.readThreadMetadataFromDisk(threadId) - const thread = metadata && requiresLegacyWorkThreadHydration(metadata) ? await this.readThreadFromDisk(threadId) ?? metadata : metadata - if (thread) summaries.push(toThreadSummary(thread)) - } - return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + private invalidateFilesystemCache(): void { + this.filesystemSummaries.invalidate() + } + + private listFromFilesystem(): Promise { + return this.filesystemSummaries.list() } private async threadIdsFromFilesystem(): Promise { try { const entries = await readdir(this.dataDir, { withFileTypes: true }) return entries.filter((entry) => entry.isDirectory() && isSafeThreadId(entry.name)).map((entry) => entry.name) - } catch { - return [] + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error } } diff --git a/kun/src/adapters/hybrid/hybrid-usage-query.ts b/kun/src/adapters/hybrid/hybrid-usage-query.ts new file mode 100644 index 000000000..d799b220b --- /dev/null +++ b/kun/src/adapters/hybrid/hybrid-usage-query.ts @@ -0,0 +1,43 @@ +import type { Database as BetterSqliteDatabase } from 'better-sqlite3' +import type { SessionUsageQueryOptions, SessionUsageRecord } from '../../ports/session-store.js' +import { usageRecordsFromRows, type UsageRow } from './hybrid-thread-support.js' + +export function loadIndexedUsageRecords( + db: BetterSqliteDatabase, + options: SessionUsageQueryOptions +): SessionUsageRecord[] { + const threadId = options.threadId?.trim() + const range = options.fromInclusive && options.toExclusive + ? { from: options.fromInclusive, to: options.toExclusive } + : null + const threadClause = threadId ? 'AND thread_id = @thread_id' : '' + const params = { thread_id: threadId, from: range?.from, to: range?.to } + const rows = range + ? db.prepare(` + SELECT * FROM ( + SELECT * FROM usage_events + WHERE timestamp >= @from AND timestamp < @to ${threadClause} + UNION ALL + SELECT u.* FROM usage_events u + JOIN ( + SELECT thread_id, MAX(seq) AS seq + FROM usage_events + WHERE timestamp < @from ${threadClause} + GROUP BY thread_id + ) baseline + ON baseline.thread_id = u.thread_id AND baseline.seq = u.seq + ) + ORDER BY thread_id ASC, seq ASC + `).all(params) as UsageRow[] + : threadId + ? db.prepare(` + SELECT * FROM usage_events + WHERE thread_id = @thread_id + ORDER BY thread_id ASC, seq ASC + `).all(params) as UsageRow[] + : db.prepare('SELECT * FROM usage_events ORDER BY thread_id ASC, seq ASC').all() as UsageRow[] + const records = usageRecordsFromRows(rows) + return range + ? records.filter((record) => record.completedAt >= range.from && record.completedAt < range.to) + : records +} diff --git a/kun/src/domain/thread-list-query.ts b/kun/src/domain/thread-list-query.ts new file mode 100644 index 000000000..4c63ec231 --- /dev/null +++ b/kun/src/domain/thread-list-query.ts @@ -0,0 +1,100 @@ +import type { ThreadSummary } from '../contracts/threads.js' +import type { ThreadStoreListOptions, ThreadStoreListPage } from '../ports/thread-store.js' + +type KeysetCursor = { updatedAtMs: number; id: string } + +export function threadUpdatedAtMs(thread: Pick): number { + const parsed = Date.parse(thread.updatedAt) + return Number.isFinite(parsed) ? parsed : 0 +} + +export function compareThreadSummaries(left: ThreadSummary, right: ThreadSummary): number { + return threadUpdatedAtMs(right) - threadUpdatedAtMs(left) || right.id.localeCompare(left.id) +} + +export function encodeThreadCursor(updatedAt: string, id: string): string { + const updatedAtMs = Number.isFinite(Date.parse(updatedAt)) ? Date.parse(updatedAt) : 0 + return Buffer.from(JSON.stringify([updatedAtMs, id])).toString('base64url') +} + +export function decodeThreadCursor(cursor: string | undefined): KeysetCursor | null { + if (!cursor) return null + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown + if (!Array.isArray(parsed) || parsed.length !== 2) return null + const [updatedAtMs, id] = parsed as [unknown, unknown] + if (typeof updatedAtMs !== 'number' || !Number.isFinite(updatedAtMs) || typeof id !== 'string' || !id) return null + return { updatedAtMs, id } + } catch { + return null + } +} + +export function filterThreadSummaries( + summaries: readonly ThreadSummary[], + options: ThreadStoreListOptions = {} +): ThreadSummary[] { + const query = options.search?.trim().toLowerCase() + let out = options.archivedOnly + ? summaries.filter((thread) => thread.status === 'archived') + : options.includeArchived + ? [...summaries] + : summaries.filter((thread) => thread.status !== 'archived' && thread.status !== 'deleted') + if (!options.includeSide) out = out.filter((thread) => (thread.relation ?? 'primary') !== 'side') + if (options.workspace) out = out.filter((thread) => thread.workspace === options.workspace) + if (query) out = out.filter((thread) => threadSearchText(thread).includes(query)) + return out.sort(compareThreadSummaries) +} + +export function applyThreadCursor( + summaries: readonly ThreadSummary[], + cursorValue: string | undefined +): ThreadSummary[] { + const cursor = decodeThreadCursor(cursorValue) + if (!cursor) return [...summaries] + return summaries.filter((thread) => { + const updatedAtMs = threadUpdatedAtMs(thread) + return updatedAtMs < cursor.updatedAtMs || + (updatedAtMs === cursor.updatedAtMs && thread.id < cursor.id) + }) +} + +export function pageThreadSummaries( + summaries: readonly ThreadSummary[], + options: ThreadStoreListOptions = {}, + total = summaries.length +): ThreadStoreListPage { + const pageSize = typeof options.limit === 'number' + ? Math.max(1, Math.floor(options.limit)) + : summaries.length + const hasMore = summaries.length > pageSize + const threads = hasMore ? summaries.slice(0, pageSize) : [...summaries] + const last = threads.at(-1) + return { + threads, + ...(hasMore && last ? { nextCursor: encodeThreadCursor(last.updatedAt, last.id) } : {}), + hasMore, + ...(options.cursor ? {} : { total }) + } +} + +export function queryThreadSummaryPage( + summaries: readonly ThreadSummary[], + options: ThreadStoreListOptions = {} +): ThreadStoreListPage { + const filtered = filterThreadSummaries(summaries, options) + return pageThreadSummaries(applyThreadCursor(filtered, options.cursor), options, filtered.length) +} + +function threadSearchText(thread: ThreadSummary): string { + return [ + thread.id, + thread.title, + thread.workspace, + thread.model, + thread.mode, + thread.forkedFromTitle, + thread.forkedFromThreadId, + ...(thread.todos?.items.map((item) => item.content) ?? []) + ].filter(Boolean).join('\n').toLowerCase() +} diff --git a/kun/src/manager/remote-data-stores.ts b/kun/src/manager/remote-data-stores.ts index 6537f3633..8d27c25f4 100644 --- a/kun/src/manager/remote-data-stores.ts +++ b/kun/src/manager/remote-data-stores.ts @@ -56,6 +56,7 @@ import type { ItemTextSearchOptions, SessionLatestUsageSnapshot, SessionStore, + SessionUsageQueryOptions, SessionUsageRecord } from '../ports/session-store.js' import type { @@ -339,7 +340,7 @@ export class ManagerRemoteSessionStore implements SessionStore { return z.number().int().nonnegative().parse(await this.call('highestSeq', { threadId })) } - async loadUsageRecords(options: { threadId?: string } = {}): Promise { + async loadUsageRecords(options: SessionUsageQueryOptions = {}): Promise { return UsageRecordSchema.array().parse(await this.call('loadUsageRecords', options)) as SessionUsageRecord[] } diff --git a/kun/src/manager/service-manager.test.ts b/kun/src/manager/service-manager.test.ts index 0bd82b671..1d5d0784f 100644 --- a/kun/src/manager/service-manager.test.ts +++ b/kun/src/manager/service-manager.test.ts @@ -11,6 +11,7 @@ import { type ServiceManagerConnection } from './manager-client.js' import { ManagerRemoteGraphRunStore } from './remote-data-stores.js' +import { SessionUsageQuerySchema } from './shared-data-store-contracts.js' import type { ManagerSharedDataStore } from './shared-data-store.js' import { buildServiceManagerRouter, @@ -19,6 +20,21 @@ import { ThreadLeaseBusyError } from './service-manager.js' +describe('manager usage query contract', () => { + it('accepts complete UTC ranges and rejects partial ranges', () => { + expect(SessionUsageQuerySchema.parse({ + fromInclusive: '2026-08-01T00:00:00.000Z', + toExclusive: '2026-08-02T00:00:00.000Z' + })).toEqual({ + fromInclusive: '2026-08-01T00:00:00.000Z', + toExclusive: '2026-08-02T00:00:00.000Z' + }) + expect(() => SessionUsageQuerySchema.parse({ + fromInclusive: '2026-08-01T00:00:00.000Z' + })).toThrow('usage range requires both boundaries') + }) +}) + afterEach(() => { vi.unstubAllGlobals() }) diff --git a/kun/src/manager/shared-data-store-contracts.ts b/kun/src/manager/shared-data-store-contracts.ts index 15b680eaa..a737b21f4 100644 --- a/kun/src/manager/shared-data-store-contracts.ts +++ b/kun/src/manager/shared-data-store-contracts.ts @@ -93,6 +93,29 @@ export const AgentSessionSchema = z.object({ closed: z.boolean() }) +export const SessionUsageQuerySchema = z.object({ + threadId: ThreadIdSchema.optional(), + fromInclusive: z.string().datetime({ offset: true }).optional(), + toExclusive: z.string().datetime({ offset: true }).optional() +}).strict().transform((input, context) => { + if (Boolean(input.fromInclusive) !== Boolean(input.toExclusive)) { + context.addIssue({ code: 'custom', message: 'usage range requires both boundaries' }) + return z.NEVER + } + if (!input.fromInclusive || !input.toExclusive) return input + const fromMs = Date.parse(input.fromInclusive) + const toMs = Date.parse(input.toExclusive) + if (fromMs >= toMs) { + context.addIssue({ code: 'custom', message: 'usage range must be increasing' }) + return z.NEVER + } + return { + ...input, + fromInclusive: new Date(fromMs).toISOString(), + toExclusive: new Date(toMs).toISOString() + } +}) + export type ManagerThreadStoreOperation = | 'list' | 'listPage' diff --git a/kun/src/manager/shared-data-store-implementation.ts b/kun/src/manager/shared-data-store-implementation.ts index 4d4508f5a..b1d605364 100644 --- a/kun/src/manager/shared-data-store-implementation.ts +++ b/kun/src/manager/shared-data-store-implementation.ts @@ -62,6 +62,7 @@ import { buildPublicItemHistoryPage } from '../services/item-history-page.js' import { ManagerSharedDataStoreCore } from './shared-data-store-core.js' import { AgentSessionSchema, + SessionUsageQuerySchema, ThreadIdSchema, ThreadStoreListOptionsSchema, attachmentScopeRequest, @@ -543,7 +544,7 @@ export class ManagerSharedDataStore extends ManagerSharedDataStoreCore { return this.allocateEventSeq(threadId) } case 'loadUsageRecords': { - const body = z.object({ threadId: ThreadIdSchema.optional() }).strict().parse(value ?? {}) + const body = SessionUsageQuerySchema.parse(value ?? {}) return this.sessionStore.loadUsageRecords?.(body) ?? [] } case 'loadLatestUsageSnapshots': { diff --git a/kun/src/ports/session-store.ts b/kun/src/ports/session-store.ts index d547edc77..4fcbf437f 100644 --- a/kun/src/ports/session-store.ts +++ b/kun/src/ports/session-store.ts @@ -3,6 +3,14 @@ import type { RuntimeEvent } from '../contracts/events.js' import type { TurnItem } from '../contracts/items.js' import type { UsageSnapshot } from '../contracts/usage.js' +export type SessionUsageQueryOptions = { + threadId?: string + /** Inclusive ISO-8601 UTC timestamp boundary. Requires `toExclusive`. */ + fromInclusive?: string + /** Exclusive ISO-8601 UTC timestamp boundary. Requires `fromInclusive`. */ + toExclusive?: string +} + export type SessionUsageRecord = { threadId: string turnId?: string @@ -189,7 +197,7 @@ export interface SessionStore { * Optional indexed usage query. Implementations may return per-event * usage deltas without replaying the full event log. */ - loadUsageRecords?(options?: { threadId?: string }): Promise + loadUsageRecords?(options?: SessionUsageQueryOptions): Promise /** Optional indexed latest cumulative usage snapshot query. */ loadLatestUsageSnapshots?(options?: { threadIds?: string[] }): Promise /** Forget the per-thread in-memory state without touching disk. */ diff --git a/kun/src/server/routes/usage.test.ts b/kun/src/server/routes/usage.test.ts index 47ed6b418..7977888d1 100644 --- a/kun/src/server/routes/usage.test.ts +++ b/kun/src/server/routes/usage.test.ts @@ -4,6 +4,25 @@ import type { ServerRuntime } from './server-runtime.js' import { usageJsonResponse } from './usage.js' describe('usageJsonResponse', () => { + it('validates day queries before loading history and forwards the UTC range', async () => { + const loadUsageRecords = vi.fn(async () => []) + const runtime = runtimeFixture({ list: vi.fn(async () => []), loadUsageRecords }) + + const invalid = await usageJsonResponse( + new Request('http://kun.local/v1/usage?group_by=day&from=bad&to=2026-08-09&timezone=UTC'), + runtime + ) + expect(invalid.status).toBe(400) + expect(loadUsageRecords).not.toHaveBeenCalled() + + const valid = await usageJsonResponse(request('day', '2026-08-01', '2026-08-09'), runtime) + expect(valid.status).toBe(200) + expect(loadUsageRecords).toHaveBeenCalledWith({ + fromInclusive: '2026-08-01T00:00:00.000Z', + toExclusive: '2026-08-10T00:00:00.000Z' + }) + }) + it('returns persisted latest cache telemetry when reopening a thread', async () => { const usage = { ...emptyUsageSnapshot(), @@ -341,7 +360,7 @@ function runtimeFixture(overrides: { get?: (threadId: string) => Promise list: (options?: unknown) => Promise loadEventsSince?: (threadId: string, sinceSeq: number) => Promise - loadUsageRecords: () => Promise + loadUsageRecords: (options?: unknown) => Promise }): ServerRuntime { return { threadService: { diff --git a/kun/src/server/routes/usage.ts b/kun/src/server/routes/usage.ts index 6af32736a..134c98658 100644 --- a/kun/src/server/routes/usage.ts +++ b/kun/src/server/routes/usage.ts @@ -9,6 +9,7 @@ import { parseDailyUsageQuery, parseModelUsageQuery, parseTurnUsageQuery, + usageQueryUtcRange, UsageValidationError } from '../../services/usage-service.js' import type { ServerRuntime } from './server-runtime.js' @@ -44,13 +45,21 @@ export async function usageJsonResponse( }))) } if (groupBy === 'day') { + const dayQuery = parseDailyUsageQuery(query) return jsonResponse( - buildDailyUsageResponse(await loadUsageHistory(runtime), parseDailyUsageQuery(query)) + buildDailyUsageResponse( + await loadUsageHistory(runtime, usageQueryUtcRange(dayQuery)), + dayQuery + ) ) } if (groupBy === 'model') { + const modelQuery = parseModelUsageQuery(query) return jsonResponse( - buildModelUsageResponse(await loadUsageHistory(runtime), parseModelUsageQuery(query)) + buildModelUsageResponse( + await loadUsageHistory(runtime, usageQueryUtcRange(modelQuery)), + modelQuery + ) ) } if (groupBy === 'turn') { diff --git a/kun/src/services/thread-service-metadata-operations.ts b/kun/src/services/thread-service-metadata-operations.ts index cd1232284..c7fdab02d 100644 --- a/kun/src/services/thread-service-metadata-operations.ts +++ b/kun/src/services/thread-service-metadata-operations.ts @@ -27,6 +27,11 @@ import type { SandboxMode } from '../contracts/policy.js' import type { Turn } from '../contracts/turns.js' +import { + applyThreadCursor, + filterThreadSummaries, + pageThreadSummaries +} from '../domain/thread-list-query.js' import { isPublicTurnItem, type TurnItem } from '../contracts/items.js' import { createThreadRecord, @@ -83,6 +88,9 @@ async list(this: ThreadService, options: ListThreadsOptions = {}): Promise (thread.relation ?? 'primary') !== 'side') } + if (options.workspace) { + threads = threads.filter((thread) => thread.workspace === options.workspace) + } if (query) { threads = threads.filter((thread) => matchesThreadSearch(thread, query)) } @@ -100,30 +108,22 @@ async listPage(this: ThreadService, options: ListThreadsOptions = {}): Promise thread.status === 'archived') - } else if (!options.includeArchived) { - threads = threads.filter((thread) => thread.status !== 'archived' && thread.status !== 'deleted') - } - if (!options.includeSide) { - threads = threads.filter((thread) => (thread.relation ?? 'primary') !== 'side') - } - if (query) { - threads = threads.filter((thread) => matchesThreadSearch(thread, query)) - } - const total = threads.length - const pageSize = options.limit ?? total - const page = threads.slice(0, pageSize) - return { - threads: page, - hasMore: page.length < total, - ...(options.cursor ? {} : { total }) - } + const filtered = filterThreadSummaries(allThreads, storeOptions) + return pageThreadSummaries( + applyThreadCursor(filtered, options.cursor), + storeOptions, + filtered.length + ) }, async get(this: ThreadService, threadId: string): Promise { diff --git a/kun/src/services/usage-history.test.ts b/kun/src/services/usage-history.test.ts index c9e919ed7..12c49a2e0 100644 --- a/kun/src/services/usage-history.test.ts +++ b/kun/src/services/usage-history.test.ts @@ -101,6 +101,27 @@ describe('loadUsageHistory provider attribution', () => { }) }) + it('filters JSONL fallback only after computing cumulative deltas', async () => { + const source = makeSwitchedThreadSource({ + loadUsageRecords: vi.fn(async () => { throw new Error('index unavailable') }) + }) + source.sessionStore.loadEventsSince = vi.fn(async () => [ + jsonlUsageEvent(1, 'turn-1', 1_000, 100), + jsonlUsageEvent(2, 'turn-2', 1_200, 140) + ]) + + const records = await loadUsageHistory(source as never, { + fromInclusive: '2026-08-23T00:00:02.000Z', + toExclusive: '2026-08-23T00:00:03.000Z' + }) + + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ + turnId: 'turn-2', + usage: { promptTokens: 200, completionTokens: 40, totalTokens: 240 } + }) + }) + it('hydrates full threads in the JSONL fallback path too', async () => { const source = makeSwitchedThreadSource({ loadUsageRecords: vi.fn(async () => { diff --git a/kun/src/services/usage-history.ts b/kun/src/services/usage-history.ts index ddf083a36..2cab9b33a 100644 --- a/kun/src/services/usage-history.ts +++ b/kun/src/services/usage-history.ts @@ -3,7 +3,7 @@ import type { UsageEvent } from '../contracts/events.js' import { emptyUsageSnapshot } from '../contracts/usage.js' import type { ThreadRecord, ThreadSummary } from '../contracts/threads.js' import { diffUsage, hasUsage } from '../domain/usage.js' -import type { SessionStore } from '../ports/session-store.js' +import type { SessionStore, SessionUsageQueryOptions } from '../ports/session-store.js' import type { UsageService } from './usage-service.js' import type { ThreadUsageRecord } from './usage-service-query.js' @@ -70,16 +70,24 @@ function writeHydratedThreadMemo(threadId: string, record: ThreadRecord | null): */ export async function loadUsageHistory( source: UsageHistorySource, - options: { threadId?: string } = {} + options: SessionUsageQueryOptions = {} ): Promise { const threadId = options.threadId?.trim() - const key = threadId ? `thread:${threadId}` : 'all' + const key = JSON.stringify({ + threadId: threadId || null, + fromInclusive: options.fromInclusive ?? null, + toExclusive: options.toExclusive ?? null + }) const loads = usageRecordLoads.get(source) ?? new Map>() usageRecordLoads.set(source, loads) const active = loads.get(key) if (active) return active let load: Promise - load = loadUsageRecords(source, { ...(threadId ? { threadId } : {}) }).finally(() => { + load = loadUsageRecords(source, { + ...(threadId ? { threadId } : {}), + ...(options.fromInclusive ? { fromInclusive: options.fromInclusive } : {}), + ...(options.toExclusive ? { toExclusive: options.toExclusive } : {}) + }).finally(() => { if (loads.get(key) === load) loads.delete(key) if (loads.size === 0) usageRecordLoads.delete(source) }) @@ -89,7 +97,7 @@ export async function loadUsageHistory( async function loadUsageRecords( source: UsageHistorySource, - options: { threadId?: string } + options: SessionUsageQueryOptions ): Promise { const explicitThread = options.threadId ? await source.threadService.get(options.threadId) @@ -138,7 +146,7 @@ async function loadUsageRecords( const allowedThreadIds = new Set( options.threadId ? [options.threadId] : threadSummaries.map((thread) => thread.id) ) - const indexedRaw = await source.sessionStore.loadUsageRecords({ threadId: options.threadId }) + const indexedRaw = await source.sessionStore.loadUsageRecords(options) // Legacy indexed rows carry no persisted providerId; without a hydrate // they would be attributed to the thread's *current* provider. const hydrationIds: string[] = [] @@ -150,7 +158,9 @@ async function loadUsageRecords( } const hydrated = await hydrateThreadsWithBounds(hydrationIds, hydrateThread) const records: ThreadUsageRecord[] = indexedRaw - .filter((record) => allowedThreadIds.has(record.threadId)) + .filter((record) => + allowedThreadIds.has(record.threadId) && timestampInUsageRange(record.completedAt, options) + ) .map((record) => { const thread = explicitThread?.id === record.threadId ? explicitThread @@ -186,6 +196,8 @@ async function loadUsageRecords( ? explicitThread : await hydrateThread(threadId) ?? summariesById.get(threadId) if (!thread) continue + const completedAt = thread.updatedAt || source.nowIso() + if (!timestampInUsageRange(completedAt, options)) continue const turnId = latestTurnId(thread) records.push({ threadId, @@ -194,7 +206,7 @@ async function loadUsageRecords( ...(usageRecordProvider(thread, { turnId }) ? { providerId: usageRecordProvider(thread, { turnId }) } : {}), - completedAt: thread.updatedAt || source.nowIso(), + completedAt, usage: liveRemainder }) } @@ -208,7 +220,7 @@ async function loadUsageRecords( const sources: UsageThreadSource[] = explicitThread ? [{ id: explicitThread.id, thread: explicitThread }] : threadSummaries.map((thread) => ({ id: thread.id, summary: thread })) - return loadUsageRecordsFromSources(source, sources, hydrateThread) + return loadUsageRecordsFromSources(source, sources, hydrateThread, options) } async function hydrateThreadsWithBounds( @@ -232,7 +244,8 @@ async function hydrateThreadsWithBounds( async function loadUsageRecordsFromSources( source: UsageHistorySource, sources: UsageThreadSource[], - hydrateThread: ThreadHydrator + hydrateThread: ThreadHydrator, + options: SessionUsageQueryOptions ): Promise { const recordsBySource: ThreadUsageRecord[][] = Array.from({ length: sources.length }) let nextIndex = 0 @@ -241,7 +254,12 @@ async function loadUsageRecordsFromSources( while (nextIndex < sources.length) { const index = nextIndex nextIndex += 1 - recordsBySource[index] = await loadUsageRecordsForSource(source, sources[index], hydrateThread) + recordsBySource[index] = await loadUsageRecordsForSource( + source, + sources[index], + hydrateThread, + options + ) } })) return recordsBySource.flat() @@ -250,7 +268,8 @@ async function loadUsageRecordsFromSources( async function loadUsageRecordsForSource( source: UsageHistorySource, item: UsageThreadSource, - hydrateThread: ThreadHydrator + hydrateThread: ThreadHydrator, + options: SessionUsageQueryOptions ): Promise { // Hydrate the full record before falling back to the summary: the summary // lacks `turns`, so provider attribution on it would use the thread's @@ -275,7 +294,7 @@ async function loadUsageRecordsForSource( for (const event of usageEvents) { const delta = diffUsage(event.usage, latestPersisted) latestPersisted = event.usage - if (!hasUsage(delta)) continue + if (!hasUsage(delta) || !timestampInUsageRange(event.timestamp, options)) continue records.push({ threadId: thread.id, ...(event.turnId ? { turnId: event.turnId } : {}), @@ -289,7 +308,8 @@ async function loadUsageRecordsForSource( } const liveRemainder = diffUsage(source.usageService.forThread(thread.id), latestPersisted) - if (hasUsage(liveRemainder)) { + const liveTimestamp = thread.updatedAt || source.nowIso() + if (hasUsage(liveRemainder) && timestampInUsageRange(liveTimestamp, options)) { const turnId = latestTurnId(thread) records.push({ threadId: thread.id, @@ -305,6 +325,15 @@ async function loadUsageRecordsForSource( return records } +function timestampInUsageRange(timestamp: string, options: SessionUsageQueryOptions): boolean { + if (!options.fromInclusive && !options.toExclusive) return true + const value = Date.parse(timestamp) + if (!Number.isFinite(value)) return false + if (options.fromInclusive && value < Date.parse(options.fromInclusive)) return false + if (options.toExclusive && value >= Date.parse(options.toExclusive)) return false + return true +} + function latestTurnId(thread: unknown): string | undefined { if (!thread || typeof thread !== 'object') return undefined const turns = (thread as { turns?: unknown }).turns diff --git a/kun/src/services/usage-service-query.ts b/kun/src/services/usage-service-query.ts index 612a6267e..483a482cd 100644 --- a/kun/src/services/usage-service-query.ts +++ b/kun/src/services/usage-service-query.ts @@ -195,6 +195,47 @@ export function parseModelUsageQuery( return { groupBy: 'model', from, to, timezone } } +export type UsageUtcRange = { + fromInclusive: string + toExclusive: string +} + +export function usageQueryUtcRange(query: DailyUsageQuery | ModelUsageQuery): UsageUtcRange { + const from = zonedMidnightUtc(query.from, query.timezone, 'from') + const dayAfterTo = dateString(addUtcDays(parseDateString(query.to, 'to'), 1)) + const to = zonedMidnightUtc(dayAfterTo, query.timezone, 'to') + if (from.getTime() >= to.getTime()) { + throw new UsageValidationError('usage range must have a positive UTC duration') + } + return { fromInclusive: from.toISOString(), toExclusive: to.toISOString() } +} + +function zonedMidnightUtc(dateValue: string, timezone: string, field: string): Date { + const localDate = parseDateString(dateValue, field) + const targetMs = localDate.getTime() + let candidateMs = targetMs + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }) + for (let attempt = 0; attempt < 4; attempt += 1) { + const parts = formatter.formatToParts(new Date(candidateMs)) + const part = (type: Intl.DateTimeFormatPartTypes): number => + Number(parts.find((entry) => entry.type === type)?.value) + const observedMs = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second')) + const correction = targetMs - observedMs + if (correction === 0) return new Date(candidateMs) + candidateMs += correction + } + throw new UsageValidationError(`${field} cannot be represented in timezone: ${timezone}`) +} + export function resolveUsageWindow( input: Record, timezone: string, diff --git a/kun/src/services/usage-service.test.ts b/kun/src/services/usage-service.test.ts index 06722a52e..4e873549c 100644 --- a/kun/src/services/usage-service.test.ts +++ b/kun/src/services/usage-service.test.ts @@ -4,6 +4,7 @@ import { buildThreadUsageResponse, buildTurnUsageResponse, type ThreadUsageRecord, + usageQueryUtcRange, UsageService } from './usage-service.js' @@ -16,6 +17,20 @@ const signature = { activeSkillIds: ['skill-a'] } +describe('usage UTC query ranges', () => { + it('uses DST-aware half-open UTC boundaries', () => { + expect(usageQueryUtcRange({ + groupBy: 'day', + from: '2026-03-08', + to: '2026-03-08', + timezone: 'America/New_York' + })).toEqual({ + fromInclusive: '2026-03-08T05:00:00.000Z', + toExclusive: '2026-03-09T04:00:00.000Z' + }) + }) +}) + describe('usage cache diagnostics', () => { it('attaches cache diagnostics to recorded usage snapshots', () => { const usage = new UsageService() diff --git a/kun/src/services/usage-service.ts b/kun/src/services/usage-service.ts index d779dcbed..a08e51c2c 100644 --- a/kun/src/services/usage-service.ts +++ b/kun/src/services/usage-service.ts @@ -1,4 +1,4 @@ export { UsageService, MAX_DAILY_USAGE_DAYS } from './usage-service-core.js' -export { UsageValidationError, type DailyUsageQuery, type ModelUsageQuery, type TurnUsageQuery, type ThreadUsageRecord, parseDailyUsageQuery, parseModelUsageQuery, parseTurnUsageQuery, formatDateInTimezone } from './usage-service-query.js' +export { UsageValidationError, type DailyUsageQuery, type ModelUsageQuery, type TurnUsageQuery, type ThreadUsageRecord, type UsageUtcRange, parseDailyUsageQuery, parseModelUsageQuery, parseTurnUsageQuery, formatDateInTimezone, usageQueryUtcRange } from './usage-service-query.js' export { buildThreadUsageResponse, buildDailyUsageResponse, buildModelUsageResponse, buildTurnUsageResponse } from './usage-service-responses.js' export { loadUsageHistory, type UsageHistorySource } from './usage-history.js' diff --git a/kun/tests/file-thread-store.test.ts b/kun/tests/file-thread-store.test.ts index 352d69014..fa4b7428e 100644 --- a/kun/tests/file-thread-store.test.ts +++ b/kun/tests/file-thread-store.test.ts @@ -1,54 +1,135 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { FileThreadStore } from '../src/adapters/file/file-thread-store.js' +import { atomicWriteFile } from '../src/adapters/file/atomic-write.js' import { createThreadRecord } from '../src/domain/thread.js' -describe('FileThreadStore permission migration', () => { - const cleanup: string[] = [] +const cleanup: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function tempDir(label: string): Promise { + const path = await mkdtemp(join(tmpdir(), label)) + cleanup.push(path) + return path +} + +async function writeThread(dataDir: string, thread: ReturnType): Promise { + const dir = join(dataDir, 'threads', thread.id) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'thread.json'), JSON.stringify(thread), 'utf8') +} + +describe('FileThreadStore recovery', () => { + it('rebuilds a missing index from thread directories', async () => { + const dataDir = await tempDir('kun-file-thread-rebuild-') + const first = createThreadRecord({ id: 'thr_first', title: 'First', workspace: '/tmp/a', model: 'test' }) + const second = createThreadRecord({ id: 'thr_second', title: 'Second', workspace: '/tmp/b', model: 'test' }) + await Promise.all([writeThread(dataDir, first), writeThread(dataDir, second)]) + + const store = new FileThreadStore({ dataDir }) + await expect(store.list({ includeArchived: true, includeSide: true })).resolves.toHaveLength(2) + const index = JSON.parse(await readFile(join(dataDir, 'threads', 'index.json'), 'utf8')) as { order: string[] } + expect(index.order).toEqual(expect.arrayContaining([first.id, second.id])) + }) + + it('recovers a corrupt index from backup and reconciles newer disk threads', async () => { + const dataDir = await tempDir('kun-file-thread-backup-') + const first = createThreadRecord({ id: 'thr_backup', title: 'Backup', workspace: '/tmp/a', model: 'test' }) + const second = createThreadRecord({ id: 'thr_newer', title: 'Newer', workspace: '/tmp/a', model: 'test' }) + await Promise.all([writeThread(dataDir, first), writeThread(dataDir, second)]) + await writeFile(join(dataDir, 'threads', 'index.json'), '{"order":', 'utf8') + await writeFile(join(dataDir, 'threads', 'index.json.bak'), JSON.stringify({ + order: [first.id], updatedAt: first.updatedAt + }), 'utf8') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const store = new FileThreadStore({ dataDir }) + const listed = await store.list({ includeArchived: true, includeSide: true }) + + expect(listed.map((thread) => thread.id)).toEqual(expect.arrayContaining([first.id, second.id])) + expect(warning).toHaveBeenCalled() + expect(await readFile(join(dataDir, 'threads', 'index.json.bak'), 'utf8')).toContain(first.id) + }) + + it('does not hide a thread when the index write fails after its document was saved', async () => { + const dataDir = await tempDir('kun-file-thread-fault-') + let writes = 0 + const store = new FileThreadStore({ + dataDir, + writeFile: async (path, contents) => { + writes += 1 + if (writes === 2) throw Object.assign(new Error('injected index failure'), { code: 'EIO' }) + await atomicWriteFile(path, contents) + } + }) + const thread = createThreadRecord({ id: 'thr_interrupted', title: 'Interrupted', workspace: '/tmp/a', model: 'test' }) + + await expect(store.upsert(thread)).rejects.toThrow('injected index failure') + await expect(readFile(join(dataDir, 'threads', thread.id, 'thread.json'), 'utf8')).resolves.toContain(thread.id) + await expect(store.list({ includeArchived: true })).resolves.toEqual([ + expect.objectContaining({ id: thread.id }) + ]) + await expect(new FileThreadStore({ dataDir }).list({ includeArchived: true })).resolves.toEqual([ + expect.objectContaining({ id: thread.id }) + ]) + }) + + it('returns null only for a missing thread and rejects corrupt data', async () => { + const dataDir = await tempDir('kun-file-thread-get-') + const store = new FileThreadStore({ dataDir }) + await expect(store.get('thr_missing')).resolves.toBeNull() + await mkdir(join(dataDir, 'threads', 'thr_corrupt'), { recursive: true }) + await writeFile(join(dataDir, 'threads', 'thr_corrupt', 'thread.json'), '{broken', 'utf8') + await expect(store.get('thr_corrupt')).rejects.toThrow('parse thread thr_corrupt') + }) +}) + +describe('FileThreadStore pagination', () => { + it('filters by workspace and returns stable cursor pages', async () => { + const dataDir = await tempDir('kun-file-thread-page-') + const createdAt = '2026-08-01T00:00:00.000Z' + const records = [ + createThreadRecord({ id: 'thr_c', title: 'Alpha c', workspace: '/tmp/a', model: 'test', createdAt }), + createThreadRecord({ id: 'thr_b', title: 'Alpha b', workspace: '/tmp/a', model: 'test', createdAt }), + createThreadRecord({ id: 'thr_other', title: 'Alpha other', workspace: '/tmp/b', model: 'test', createdAt }) + ] + const store = new FileThreadStore({ dataDir }) + for (const record of records) await store.upsert(record) - afterEach(async () => { - await Promise.all( - cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) + const first = await store.listPage({ workspace: '/tmp/a', search: 'alpha', includeArchived: true, limit: 1 }) + expect(first).toMatchObject({ total: 2, hasMore: true }) + expect(first.nextCursor).toEqual(expect.any(String)) + const second = await store.listPage({ + workspace: '/tmp/a', search: 'alpha', includeArchived: true, limit: 1, cursor: first.nextCursor + }) + expect(second).toMatchObject({ hasMore: false }) + expect(second).not.toHaveProperty('total') + expect([...first.threads, ...second.threads].map((thread) => thread.id)).toEqual(['thr_c', 'thr_b']) }) +}) +describe('FileThreadStore permission migration', () => { it('normalizes a legacy thread without a reviewer to user without widening its policy', async () => { - const dataDir = await mkdtemp(join(tmpdir(), 'kun-file-thread-reviewer-')) - cleanup.push(dataDir) + const dataDir = await tempDir('kun-file-thread-reviewer-') const thread = createThreadRecord({ - id: 'thr_legacy_reviewer', - title: 'Legacy reviewer', - workspace: '/tmp/project', - model: 'deepseek-chat', - approvalPolicy: 'never', - sandboxMode: 'read-only', - createdAt: '2026-07-29T00:00:00.000Z' + id: 'thr_legacy_reviewer', title: 'Legacy reviewer', workspace: '/tmp/project', model: 'deepseek-chat', + approvalPolicy: 'never', sandboxMode: 'read-only', createdAt: '2026-07-29T00:00:00.000Z' }) const { approvalReviewer: _reviewer, ...legacy } = thread - const threadDir = join(dataDir, 'threads', thread.id) - await mkdir(threadDir, { recursive: true }) - await writeFile(join(threadDir, 'thread.json'), JSON.stringify(legacy), 'utf8') - await writeFile( - join(dataDir, 'threads', 'index.json'), - JSON.stringify({ order: [thread.id], updatedAt: thread.updatedAt }), - 'utf8' - ) + await writeThread(dataDir, legacy as typeof thread) + await writeFile(join(dataDir, 'threads', 'index.json'), JSON.stringify({ + order: [thread.id], updatedAt: thread.updatedAt + }), 'utf8') const store = new FileThreadStore({ dataDir }) await expect(store.get(thread.id)).resolves.toMatchObject({ - approvalPolicy: 'never', - sandboxMode: 'read-only', - approvalReviewer: 'user' + approvalPolicy: 'never', sandboxMode: 'read-only', approvalReviewer: 'user' }) - await expect(store.list({ includeArchived: true })).resolves.toMatchObject([ - { - id: thread.id, - approvalPolicy: 'never', - sandboxMode: 'read-only', - approvalReviewer: 'user' - } - ]) }) }) diff --git a/src/renderer/src/lib/shared-business-storage.test.ts b/src/renderer/src/lib/shared-business-storage.test.ts index bfd9e7d26..3a73da702 100644 --- a/src/renderer/src/lib/shared-business-storage.test.ts +++ b/src/renderer/src/lib/shared-business-storage.test.ts @@ -256,6 +256,96 @@ describe('shared business storage synchronization', () => { delete (window as unknown as { kunGui?: unknown }).kunGui }) + it('runs the immediate follow-up after clearing the completed in-flight sync', async () => { + const storage = new MemoryStorage() + const remoteRegistry = '{"version":1,"workspaces":{}}' + const firstLocalRegistry = '{"version":1,"workspaces":{"drawing-1":{}}}' + const latestLocalRegistry = '{"version":1,"workspaces":{"drawing-2":{}}}' + storage.setItem(DESIGN_REGISTRY_KEY, firstLocalRegistry) + vi.stubGlobal('localStorage', storage) + writeSharedBusinessStorageJournal({ + version: 1, + acknowledgedRevision: 3, + acknowledgedEntries: { [DESIGN_REGISTRY_KEY]: remoteRegistry }, + dirtyKeys: [DESIGN_REGISTRY_KEY] + }) + + let remote: { revision: number; value: Record } = { + revision: 3, + value: { [DESIGN_REGISTRY_KEY]: remoteRegistry } + } + const firstWrite = deferred() + const write = vi + .fn<(revision: number, value: Record) => Promise>() + .mockImplementationOnce(() => firstWrite.promise) + .mockImplementation(async (_revision, value) => { + remote = { revision: remote.revision + 1, value: { ...value } } + return remote + }) + ;(window as unknown as { kunGui: unknown }).kunGui = { + sharedClientState: { + read: vi.fn(async () => remote), + write + }, + appEnvironment: { flavor: 'development' } + } + + const installing = installSharedBusinessStorage() + await vi.waitFor(() => expect(write).toHaveBeenCalledOnce()) + storage.setItem(DESIGN_REGISTRY_KEY, latestLocalRegistry) + remote = { + revision: 4, + value: { [DESIGN_REGISTRY_KEY]: firstLocalRegistry } + } + firstWrite.resolve(remote) + + await installing + await vi.waitFor(() => expect(write).toHaveBeenCalledTimes(2)) + + expect(write).toHaveBeenLastCalledWith(4, { + [DESIGN_REGISTRY_KEY]: latestLocalRegistry + }) + expect(storage.getItem(DESIGN_REGISTRY_KEY)).toBe(latestLocalRegistry) + delete (window as unknown as { kunGui?: unknown }).kunGui + }) + + it('bounds immediate retries when writes keep failing', async () => { + const storage = new MemoryStorage() + const remoteRegistry = '{"remote":true}' + const localRegistry = '{"local":true}' + storage.setItem(DESIGN_REGISTRY_KEY, localRegistry) + vi.stubGlobal('localStorage', storage) + writeSharedBusinessStorageJournal({ + version: 1, + acknowledgedRevision: 6, + acknowledgedEntries: { [DESIGN_REGISTRY_KEY]: remoteRegistry }, + dirtyKeys: [DESIGN_REGISTRY_KEY] + }) + const write = vi.fn().mockRejectedValue(new Error('write unavailable')) + ;(window as unknown as { kunGui: unknown }).kunGui = { + sharedClientState: { + read: vi.fn(async () => ({ + revision: 6, + value: { [DESIGN_REGISTRY_KEY]: remoteRegistry } + })), + write + }, + appEnvironment: { flavor: 'development' } + } + + await installSharedBusinessStorage() + await vi.waitFor(() => expect(write).toHaveBeenCalledTimes(6)) + await Promise.resolve() + await Promise.resolve() + + expect(write).toHaveBeenCalledTimes(6) + expect(storage.getItem(DESIGN_REGISTRY_KEY)).toBe(localRegistry) + expect(JSON.parse(storage.getItem(SHARED_BUSINESS_STORAGE_JOURNAL_KEY) ?? '{}')).toMatchObject({ + dirtyKeys: [DESIGN_REGISTRY_KEY] + }) + delete (window as unknown as { kunGui?: unknown }).kunGui + }) + it('protects a newer local registry written while its previous value is being committed', async () => { const storage = new MemoryStorage() const remoteRegistry = '{"version":1,"workspaces":{}}' diff --git a/src/renderer/src/lib/shared-business-storage.ts b/src/renderer/src/lib/shared-business-storage.ts index 82b527361..0a2846e66 100644 --- a/src/renderer/src/lib/shared-business-storage.ts +++ b/src/renderer/src/lib/shared-business-storage.ts @@ -109,21 +109,25 @@ async function doInstallSharedBusinessStorage(): Promise { let revision = journal.acknowledgedRevision let syncing: Promise | null = null - const sync = (): Promise => { + const sync = (allowImmediateRetry = true): Promise => { if (syncing) return syncing - syncing = (async () => { + let retry = false + const current = (async () => { try { const result = await syncSharedBusinessStorageOnce(api, { baseline, revision }) baseline = result.baseline revision = result.revision - if (result.retry) queueMicrotask(() => void sync()) + retry = result.retry } catch { // Keep the profile-local mirror and durable journal intact for retry. } - })().finally(() => { - syncing = null + })() + syncing = current + void current.finally(() => { + if (syncing === current) syncing = null + if (retry && allowImmediateRetry) queueMicrotask(() => void sync(false)) }) - return syncing + return current } activeFlush = sync From dad2ea67a9b5298e5192a250d71ff2e744b6290b Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 04:00:00 +0800 Subject: [PATCH 048/168] chore(hygiene): restore file-size gate and web typecheck --- scripts/check-file-lines.mjs | 5 + .../src/agent/kun-runtime-test-support.ts | 74 +++++++ src/renderer/src/agent/kun-runtime.test.ts | 190 +----------------- .../agent/kun-runtime.thread-items.test.ts | 125 ++++++++++++ src/renderer/src/components/Workbench.tsx | 11 - .../chat/SidebarThreadOrdering.test.ts | 8 +- 6 files changed, 211 insertions(+), 202 deletions(-) create mode 100644 src/renderer/src/agent/kun-runtime-test-support.ts create mode 100644 src/renderer/src/agent/kun-runtime.thread-items.test.ts diff --git a/scripts/check-file-lines.mjs b/scripts/check-file-lines.mjs index 2efe29afd..3674d4a34 100644 --- a/scripts/check-file-lines.mjs +++ b/scripts/check-file-lines.mjs @@ -114,6 +114,11 @@ export async function inspectTrackedFiles({ root, maxLines = DEFAULT_MAX_LINES, missingTrackedFiles += 1 continue } + // Tracked symlinks can point at directories (skill aliases). They have + // no physical lines of their own, so skip them instead of failing. + if (error && typeof error === 'object' && error.code === 'EISDIR') { + continue + } throw error } diff --git a/src/renderer/src/agent/kun-runtime-test-support.ts b/src/renderer/src/agent/kun-runtime-test-support.ts new file mode 100644 index 000000000..8efa4f972 --- /dev/null +++ b/src/renderer/src/agent/kun-runtime-test-support.ts @@ -0,0 +1,74 @@ +import { vi } from 'vitest' +import { + defaultClawSettings, + defaultDesignSettings, + defaultKeyboardShortcuts, + defaultKunRuntimeSettings, + defaultModelProviderSettings, + defaultScheduleSettings, + defaultWorkflowSettings, + defaultWriteSettings, + defaultTerminalSettings, + type AppSettingsV1 +} from '@shared/app-settings' + +export const DEFAULT_EXECUTION_SETTINGS = { + approvalPolicy: 'auto', + sandboxMode: 'danger-full-access', + approvalReviewer: 'user' +} as const + +export function settings(): AppSettingsV1 { + return { + version: 1, + locale: 'en', + theme: 'system', + uiFontScale: 0.82, + chatContentMaxWidthPx: 896, + composerSendKey: 'enter', + provider: defaultModelProviderSettings(), + agents: { + kun: defaultKunRuntimeSettings() + }, + workspaceRoot: '/tmp/workspace', + conversationWorkspaceRoot: '~/Documents/Kun', + log: { enabled: false, retentionDays: 7 }, + checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, + notifications: { turnComplete: true }, + appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, + keyboardShortcuts: defaultKeyboardShortcuts(), + write: defaultWriteSettings(), + claw: defaultClawSettings(), + schedule: defaultScheduleSettings(), + workflow: defaultWorkflowSettings(), + design: defaultDesignSettings(), + terminal: defaultTerminalSettings(), + guiUpdate: { channel: 'stable' }, + codePromptPrefix: '', + chatWelcomeMessage: '', + codeAgentPresets: [], + disabledSkillIds: [] + } +} + +export function installDsGui(overrides: Partial): void { + vi.stubGlobal('window', { + kunGui: { + getSettings: vi.fn(async () => settings()), + runtimeRequest: vi.fn(async () => ({ ok: true, status: 200, body: '{}' })), + resolveKunApproval: vi.fn(async () => ({ + confirmed: true, + response: { ok: true, status: 200, body: '{}' } + })), + startSse: vi.fn(async (_threadId: string, _sinceSeq: number, streamId?: string) => ({ + streamId: streamId ?? 'stream-1' + })), + stopSse: vi.fn(async () => true), + ackSse: vi.fn(async () => true), + onSseEvent: vi.fn(() => () => undefined), + onSseEnd: vi.fn(() => () => undefined), + onSseError: vi.fn(() => () => undefined), + ...overrides + } + }) +} diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index 0800fb72b..de40b6748 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -1,81 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { - defaultClawSettings, - defaultDesignSettings, - defaultKeyboardShortcuts, - defaultKunRuntimeSettings, - defaultModelProviderSettings, - defaultScheduleSettings, - defaultWorkflowSettings, - defaultWriteSettings, - defaultTerminalSettings, - type AppSettingsV1 -} from '@shared/app-settings' import { KunRuntimeProvider } from './kun-runtime' -import { getProvider, resetProviderCacheForTests } from './registry' +import { resetProviderCacheForTests } from './registry' import { rendererRuntimeClient } from './runtime-client' -import type { ThreadEventSink } from './types' - -const DEFAULT_EXECUTION_SETTINGS = { - approvalPolicy: 'auto', - sandboxMode: 'danger-full-access', - approvalReviewer: 'user' -} as const - -function settings(): AppSettingsV1 { - return { - version: 1, - locale: 'en', - theme: 'system', - uiFontScale: 0.82, - chatContentMaxWidthPx: 896, - composerSendKey: 'enter', - provider: defaultModelProviderSettings(), - agents: { - kun: defaultKunRuntimeSettings() - }, - workspaceRoot: '/tmp/workspace', - conversationWorkspaceRoot: '~/Documents/Kun', - log: { enabled: false, retentionDays: 7 }, - checkpointCleanup: { createEnabled: false, enabled: false, intervalDays: 3 }, - notifications: { turnComplete: true }, - appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, - keyboardShortcuts: defaultKeyboardShortcuts(), - write: defaultWriteSettings(), - claw: defaultClawSettings(), - schedule: defaultScheduleSettings(), - workflow: defaultWorkflowSettings(), - design: defaultDesignSettings(), - terminal: defaultTerminalSettings(), - guiUpdate: { channel: 'stable' }, - codePromptPrefix: '', - chatWelcomeMessage: '', - codeAgentPresets: [], - disabledSkillIds: [] - } -} - -function installDsGui(overrides: Partial): void { - vi.stubGlobal('window', { - kunGui: { - getSettings: vi.fn(async () => settings()), - runtimeRequest: vi.fn(async () => ({ ok: true, status: 200, body: '{}' })), - resolveKunApproval: vi.fn(async () => ({ - confirmed: true, - response: { ok: true, status: 200, body: '{}' } - })), - startSse: vi.fn(async (_threadId: string, _sinceSeq: number, streamId?: string) => ({ - streamId: streamId ?? 'stream-1' - })), - stopSse: vi.fn(async () => true), - ackSse: vi.fn(async () => true), - onSseEvent: vi.fn(() => () => undefined), - onSseEnd: vi.fn(() => () => undefined), - onSseError: vi.fn(() => () => undefined), - ...overrides - } - }) -} +import { installDsGui } from './kun-runtime-test-support' afterEach(() => { rendererRuntimeClient.invalidateSettings() @@ -601,117 +528,4 @@ describe('KunRuntimeProvider', () => { expect(staleBlock?.kind === 'user_input' && staleBlock.live).toBeFalsy() }) - it('expires a recovered approval when the runtime approval gate no longer awaits it', async () => { - const threadBody = (pendingApprovalIds: string[]): string => - JSON.stringify({ - id: 'thr_approval', - title: 'Demo', - workspace: '/tmp', - model: 'deepseek-chat', - mode: 'agent', - status: 'running', - createdAt: 't0', - updatedAt: 't1', - latestSeq: 12, - pendingApprovalIds, - turns: [{ - id: 'turn_approval', - threadId: 'thr_approval', - status: 'running', - prompt: 'run command', - createdAt: 't0', - items: [{ - id: 'item_approval', - turnId: 'turn_approval', - threadId: 'thr_approval', - role: 'tool', - status: 'pending', - createdAt: 't1', - kind: 'approval', - approvalId: 'approval_live', - toolName: 'bash', - summary: 'Run tests' - }] - }] - }) - - installDsGui({ - runtimeRequest: vi.fn(async () => ({ ok: true, status: 200, body: threadBody(['approval_live']) })) - }) - const liveDetail = await new KunRuntimeProvider().getThreadDetail('thr_approval') - expect(liveDetail.blocks.find((block) => block.kind === 'approval')) - .toMatchObject({ status: 'pending' }) - - resetProviderCacheForTests() - installDsGui({ - runtimeRequest: vi.fn(async () => ({ ok: true, status: 200, body: threadBody([]) })) - }) - const staleDetail = await new KunRuntimeProvider().getThreadDetail('thr_approval') - expect(staleDetail.blocks.find((block) => block.kind === 'approval')) - .toMatchObject({ status: 'expired' }) - }) - - it('coalesces tool_call and tool_result pairs into one tool block on thread load', async () => { - installDsGui({ - runtimeRequest: vi.fn(async () => ({ - ok: true, - status: 200, - body: JSON.stringify({ - id: 'thr_1', - title: 'Demo', - workspace: '/tmp', - model: 'deepseek-chat', - mode: 'agent', - status: 'idle', - createdAt: 't0', - updatedAt: 't1', - latestSeq: 9, - turns: [ - { - id: 'turn_1', - threadId: 'thr_1', - status: 'completed', - prompt: 'run echo', - createdAt: 't0', - items: [ - { - id: 'item_call', - turnId: 'turn_1', - threadId: 'thr_1', - role: 'tool', - status: 'pending', - createdAt: 't0', - kind: 'tool_call', - toolName: 'echo', - callId: 'call_1', - arguments: { text: 'hi' } - }, - { - id: 'item_result', - turnId: 'turn_1', - threadId: 'thr_1', - role: 'tool', - status: 'completed', - createdAt: 't1', - kind: 'tool_result', - toolName: 'echo', - callId: 'call_1', - output: { echoed: 'hi' } - } - ] - } - ] - }) - })) - }) - const provider = new KunRuntimeProvider() - const detail = await provider.getThreadDetail('thr_1') - expect(detail.blocks).toHaveLength(1) - expect(detail.blocks[0]).toMatchObject({ - kind: 'tool', - id: 'tool_call_1', - status: 'success' - }) - }) - }) diff --git a/src/renderer/src/agent/kun-runtime.thread-items.test.ts b/src/renderer/src/agent/kun-runtime.thread-items.test.ts new file mode 100644 index 000000000..4ee749a2e --- /dev/null +++ b/src/renderer/src/agent/kun-runtime.thread-items.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { KunRuntimeProvider } from './kun-runtime' +import { resetProviderCacheForTests } from './registry' +import { rendererRuntimeClient } from './runtime-client' +import { installDsGui } from './kun-runtime-test-support' + +afterEach(() => { + rendererRuntimeClient.invalidateSettings() + vi.unstubAllGlobals() +}) + +describe('KunRuntimeProvider thread item recovery', () => { + it('expires a recovered approval when the runtime approval gate no longer awaits it', async () => { + const threadBody = (pendingApprovalIds: string[]): string => + JSON.stringify({ + id: 'thr_approval', + title: 'Demo', + workspace: '/tmp', + model: 'deepseek-chat', + mode: 'agent', + status: 'running', + createdAt: 't0', + updatedAt: 't1', + latestSeq: 12, + pendingApprovalIds, + turns: [{ + id: 'turn_approval', + threadId: 'thr_approval', + status: 'running', + prompt: 'run command', + createdAt: 't0', + items: [{ + id: 'item_approval', + turnId: 'turn_approval', + threadId: 'thr_approval', + role: 'tool', + status: 'pending', + createdAt: 't1', + kind: 'approval', + approvalId: 'approval_live', + toolName: 'bash', + summary: 'Run tests' + }] + }] + }) + + installDsGui({ + runtimeRequest: vi.fn(async () => ({ ok: true, status: 200, body: threadBody(['approval_live']) })) + }) + const liveDetail = await new KunRuntimeProvider().getThreadDetail('thr_approval') + expect(liveDetail.blocks.find((block) => block.kind === 'approval')) + .toMatchObject({ status: 'pending' }) + + resetProviderCacheForTests() + installDsGui({ + runtimeRequest: vi.fn(async () => ({ ok: true, status: 200, body: threadBody([]) })) + }) + const staleDetail = await new KunRuntimeProvider().getThreadDetail('thr_approval') + expect(staleDetail.blocks.find((block) => block.kind === 'approval')) + .toMatchObject({ status: 'expired' }) + }) + + it('coalesces tool_call and tool_result pairs into one tool block on thread load', async () => { + installDsGui({ + runtimeRequest: vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_1', + title: 'Demo', + workspace: '/tmp', + model: 'deepseek-chat', + mode: 'agent', + status: 'idle', + createdAt: 't0', + updatedAt: 't1', + latestSeq: 9, + turns: [ + { + id: 'turn_1', + threadId: 'thr_1', + status: 'completed', + prompt: 'run echo', + createdAt: 't0', + items: [ + { + id: 'item_call', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'pending', + createdAt: 't0', + kind: 'tool_call', + toolName: 'echo', + callId: 'call_1', + arguments: { text: 'hi' } + }, + { + id: 'item_result', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'completed', + createdAt: 't1', + kind: 'tool_result', + toolName: 'echo', + callId: 'call_1', + output: { echoed: 'hi' } + } + ] + } + ] + }) + })) + }) + const provider = new KunRuntimeProvider() + const detail = await provider.getThreadDetail('thr_1') + expect(detail.blocks).toHaveLength(1) + expect(detail.blocks[0]).toMatchObject({ + kind: 'tool', + id: 'tool_call_1', + status: 'success' + }) + }) +}) diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index 0acae74e5..5f40c6de8 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -45,11 +45,6 @@ import { useDesignWorkspaceStore } from '../design/design-workspace-store' import { useCodeCanvasDesignSurface } from '../design/code-canvas-design-surface' import { useWorkbenchPptWhiteboardRouter } from './workbench/useWorkbenchPptWhiteboardRouter' import { designDocumentComposerFileReferences } from '../design/design-document-file-reference' -import { - readBrowserStorageItem, - removeBrowserStorageItem, - writeBrowserStorageItem -} from '../lib/browser-storage' import { BUILTIN_RIGHT_PANEL_IDS, isExtensionContributionId, @@ -98,12 +93,6 @@ import { createDevPreviewComposerContextAttachment } from '../lib/dev-preview-co import { useWorkbenchFocusedCanvasController } from './workbench/useWorkbenchFocusedCanvasController' import { useWorkbenchGraphRuntimeState } from './workbench/useWorkbenchGraphRuntimeState' -const extensionSurfaceLayoutStorage = { - getItem: readBrowserStorageItem, - setItem: writeBrowserStorageItem, - removeItem: removeBrowserStorageItem -} - export function Workbench(): ReactElement { const { t, i18n } = useTranslation('common') const { diff --git a/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts b/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts index b4423072d..b27dbed41 100644 --- a/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts +++ b/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts @@ -110,7 +110,9 @@ describe('sidebar thread ordering integration', () => { 'background', 'thread-1', 'thread-2', 'thread-3', 'thread-4' ]) } finally { - renderer?.unmount() + // The renderer is assigned inside the act() closure; keep the union + // explicit so control-flow analysis cannot narrow it to `never`. + ;(renderer as ReactTestRenderer | null)?.unmount() vi.unstubAllGlobals() } }) @@ -145,7 +147,7 @@ describe('sidebar thread ordering integration', () => { expect(visibleThreadTitles(renderer!, ['newer', 'waiting'])).toEqual(['waiting', 'newer']) expect(renderer!.root.findAll((node) => node.props.title === 'Folder').length).toBeGreaterThan(0) } finally { - renderer?.unmount() + ;(renderer as ReactTestRenderer | null)?.unmount() vi.unstubAllGlobals() } }) @@ -201,7 +203,7 @@ describe('sidebar thread ordering integration', () => { 'waiting-conversation', 'newer-conversation' ]) } finally { - renderer?.unmount() + ;(renderer as ReactTestRenderer | null)?.unmount() useChatStore.setState(originalState, true) vi.unstubAllGlobals() } From b4270549982f99405775f5eea9d81443ddfae231 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 04:25:00 +0800 Subject: [PATCH 049/168] fix(loop): stop goal continuation for user-directed questions The goal no-tool repetition guard treated a legitimate user question as repetitive filler: the model asked the user in prose and ended the turn, the loop forced another continuation, the similar question was flagged as near-identical repetition, and the user saw goal_repetition_stop. Share the user-directed question/blocker classifier with the goal guard, stop the turn normally for such replies (goal stays active, resume waits for the user), and extend the stop message with resume guidance. --- .../loop/continuation-instructions.test.ts | 17 ++++- kun/src/loop/continuation-instructions.ts | 25 ++++++-- kun/src/loop/round-outcome-recovery-phase.ts | 16 ++++- kun/tests/goal-repetition-guard.test.ts | 63 +++++++++++++++++++ 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/kun/src/loop/continuation-instructions.test.ts b/kun/src/loop/continuation-instructions.test.ts index 540994a20..a9bc48947 100644 --- a/kun/src/loop/continuation-instructions.test.ts +++ b/kun/src/loop/continuation-instructions.test.ts @@ -5,7 +5,8 @@ import { filterGoalContextsForActiveGoal, filterGoalContextsForGoalKey, goalContextKey, - isPostToolFailureProgressText + isPostToolFailureProgressText, + isUserDirectedNoToolText } from './continuation-instructions.js' function activeGoal(overrides: Partial = {}): ThreadGoal { @@ -138,3 +139,17 @@ describe('post-tool-failure progress classifier', () => { expect(isPostToolFailureProgressText('搜索失败:工作区不存在,无法继续。')).toBe(false) }) }) + +describe('user-directed no-tool classifier', () => { + it('recognizes Chinese and English questions and wait-for-user replies', () => { + expect(isUserDirectedNoToolText('请问你选择哪个方案?')).toBe(true) + expect(isUserDirectedNoToolText('Which option should I use?')).toBe(true) + expect(isUserDirectedNoToolText('需要你确认后才能继续。')).toBe(true) + }) + + it('does not classify ordinary progress announcements as user-directed', () => { + expect(isUserDirectedNoToolText('I will run the build now.')).toBe(false) + expect(isUserDirectedNoToolText('下一步我会运行构建')).toBe(false) + expect(isUserDirectedNoToolText(' ')).toBe(false) + }) +}) diff --git a/kun/src/loop/continuation-instructions.ts b/kun/src/loop/continuation-instructions.ts index 27f87ab50..18a7daade 100644 --- a/kun/src/loop/continuation-instructions.ts +++ b/kun/src/loop/continuation-instructions.ts @@ -229,12 +229,13 @@ export function postToolFailureRecoveryInstruction(recoveryStep: number): string } /** - * Conservative classifier for "progress announcement" text produced after a - * tool failure. Questions directed at the user and explicit blocker/final - * reports are excluded so a legitimate answer is never forced into another - * round. + * Conservative classifier for text that is directed at the user: a question + * or an explicit blocker/waiting report. Shared by the post-tool-failure + * progress classifier (which additionally requires commitment wording) and + * the goal continuation no-tool guard, so a legitimate user-directed reply + * is never forced into another model round. */ -const POST_TOOL_FAILURE_QUESTION_OR_BLOCKER_PATTERNS: RegExp[] = [ +const USER_DIRECTED_QUESTION_OR_BLOCKER_PATTERNS: RegExp[] = [ /[??]/, /请问|是否|能不能|可不可以|麻烦你|请(你|先|确认|提供|补充|告诉|检查|调整|修复|重试|修改|选择|决定|告诉我|再看看)/, /需要(你|用户|手动|人工)/, @@ -271,12 +272,24 @@ const POST_TOOL_FAILURE_COMMITMENT_PATTERNS: RegExp[] = [ export function isPostToolFailureProgressText(text: string): boolean { const trimmed = text.trim() if (!trimmed) return false - if (POST_TOOL_FAILURE_QUESTION_OR_BLOCKER_PATTERNS.some((pattern) => pattern.test(trimmed))) { + if (USER_DIRECTED_QUESTION_OR_BLOCKER_PATTERNS.some((pattern) => pattern.test(trimmed))) { return false } return POST_TOOL_FAILURE_COMMITMENT_PATTERNS.some((pattern) => pattern.test(trimmed)) } +/** + * True when a no-tool assistant reply is asking the user something or + * explicitly waiting on user input. The goal continuation guard stops the + * turn for such replies instead of counting them as repetition: the model + * followed the documented "ask in prose and end the turn" guidance. + */ +export function isUserDirectedNoToolText(text: string): boolean { + const trimmed = text.trim() + if (!trimmed) return false + return USER_DIRECTED_QUESTION_OR_BLOCKER_PATTERNS.some((pattern) => pattern.test(trimmed)) +} + /** * Goal continuation re-prompts the model whenever it stops without tool * calls, which can spin forever on "I will do X next" filler that never diff --git a/kun/src/loop/round-outcome-recovery-phase.ts b/kun/src/loop/round-outcome-recovery-phase.ts index 301e76738..0945f3ffe 100644 --- a/kun/src/loop/round-outcome-recovery-phase.ts +++ b/kun/src/loop/round-outcome-recovery-phase.ts @@ -13,7 +13,8 @@ import { GOAL_NO_TOOL_REPEAT_MAX_RECOVERY_STEPS, POST_TOOL_FAILURE_MAX_RECOVERY_STEPS, TOOL_SUPPRESSION_FINAL_ANSWER_RECOVERY_STEP, - isRepeatedNoToolAssistantText + isRepeatedNoToolAssistantText, + isUserDirectedNoToolText } from './continuation-instructions.js' import type { SvgArtifactCompletionState } from './svg-artifact-completion.js' import type { @@ -286,6 +287,16 @@ export abstract class RoundOutcomeRecoveryPhase extends RoundOutcomeRequiredTool input: RoundOutcomeInput, assistantText: string ): Promise { + // A user-directed question or explicit wait-for-user reply is a legitimate + // terminal outcome, not repetition. Stop normally (goal stays active, but + // resume waits for the user's answer) so the question is never swallowed + // by another forced continuation round. + if (isUserDirectedNoToolText(assistantText)) { + this.lastNoToolTextByTurn.delete(input.turnId) + this.goalNoToolRecoveryStepsByTurn.delete(input.turnId) + this.deps.suppressGoalResume(input.turnId) + return 'stop' + } const previousText = this.lastNoToolTextByTurn.get(input.turnId) if (isRepeatedNoToolAssistantText(previousText, assistantText)) { const recoverySteps = (this.goalNoToolRecoveryStepsByTurn.get(input.turnId) ?? 0) + 1 @@ -295,7 +306,8 @@ export abstract class RoundOutcomeRecoveryPhase extends RoundOutcomeRequiredTool return 'continue' } const message = - 'Goal continuation stopped: the model kept repeating near-identical replies without calling tools or updating the goal.' + 'Goal continuation stopped: the model kept repeating near-identical replies without calling tools or updating the goal. ' + + 'The goal is still active; send a message to continue it, or ask to change or clear the goal.' await this.deps.turns.applyItem( input.threadId, makeErrorItem({ diff --git a/kun/tests/goal-repetition-guard.test.ts b/kun/tests/goal-repetition-guard.test.ts index 4525a9312..628812e9d 100644 --- a/kun/tests/goal-repetition-guard.test.ts +++ b/kun/tests/goal-repetition-guard.test.ts @@ -244,4 +244,67 @@ describe('goal continuation repetition guard', () => { expect(calls).toBe(7) expect(await loadRepetitionStops(h)).toHaveLength(1) }) + + it('stops immediately when the first no-tool reply asks the user a question', async () => { + let h: Harness + let calls = 0 + const requests: ModelRequest[] = [] + h = makeHarness( + { + provider: 'goal-question', + model: 'goal-question', + async *stream(request): AsyncIterable { + requests.push(request) + calls += 1 + yield { + kind: 'assistant_text_delta', + text: 'I found two viable approaches. Which option should I use for the release?' + } + yield { kind: 'completed', stopReason: 'stop' } + } + }, + { tools: [...buildDefaultLocalTools(), ...makeGoalTools(() => h)] } + ) + await bootstrapThread(h, { request: { prompt: 'prepare the release' } }) + await h.threads.setGoal(h.threadId, { objective: 'prepare the release', status: 'active' }) + + const status = await h.loop.runTurn(h.threadId, h.turnId) + + expect(status).toBe('completed') + expect(calls).toBe(1) + expect((await h.threads.getGoal(h.threadId))?.status).toBe('active') + expect(requests.some((request) => + modelRequestContextText(request).includes('Goal continuation recovery:') + )).toBe(false) + expect(await loadRepetitionStops(h)).toHaveLength(0) + }) + + it('stops a repeated user question instead of entering recovery', async () => { + let h: Harness + let calls = 0 + h = makeHarness( + { + provider: 'goal-repeat-question', + model: 'goal-repeat-question', + async *stream(): AsyncIterable { + calls += 1 + yield { + kind: 'assistant_text_delta', + text: 'Before I continue, which option should I use?' + } + yield { kind: 'completed', stopReason: 'stop' } + } + }, + { tools: [...buildDefaultLocalTools(), ...makeGoalTools(() => h)] } + ) + await bootstrapThread(h, { request: { prompt: 'ship the feature' } }) + await h.threads.setGoal(h.threadId, { objective: 'ship the feature', status: 'active' }) + + const status = await h.loop.runTurn(h.threadId, h.turnId) + + expect(status).toBe('completed') + expect(calls).toBe(1) + expect((await h.threads.getGoal(h.threadId))?.status).toBe('active') + expect(await loadRepetitionStops(h)).toHaveLength(0) + }) }) From e3aa2f68eea50a29987e18acb3db2161747132f9 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 04:51:00 +0800 Subject: [PATCH 050/168] fix(sidebar): demote viewed completed threads --- .../chat/SidebarProjectsContent.tsx | 3 +- .../chat/SidebarThreadOrdering.test.ts | 118 ++++++++++++++++++ .../chat/sidebar-sort-anchor.test.ts | 90 +++++++++++++ .../chat/sidebar-thread-order-tracker.ts | 51 ++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/chat/SidebarProjectsContent.tsx b/src/renderer/src/components/chat/SidebarProjectsContent.tsx index cb2bf0b28..618890186 100644 --- a/src/renderer/src/components/chat/SidebarProjectsContent.tsx +++ b/src/renderer/src/components/chat/SidebarProjectsContent.tsx @@ -315,7 +315,8 @@ export function SidebarProjectsContent(props: SidebarProjectsContentProps): Reac const visibleSelection = sidebarProjectVisibleItems( rootThreads, visibleThreadCount, - (thread) => sidebarThreadActivity(thread, sidebarThreadActivityContext) === 'running' + (thread) => thread.id === activeThreadId + || sidebarThreadActivity(thread, sidebarThreadActivityContext) === 'running' ) const visibleThreads = visibleSelection.items const hiddenThreadCount = visibleSelection.hiddenCount diff --git a/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts b/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts index b27dbed41..272f5aacf 100644 --- a/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts +++ b/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts @@ -208,4 +208,122 @@ describe('sidebar thread ordering integration', () => { vi.unstubAllGlobals() } }) + + it('demotes a viewed completed project row behind loading rows and keeps it visible past the first batch', async () => { + vi.stubGlobal('localStorage', storage()) + const threads = [ + thread('load-a', '/project', '2026-08-20T00:00:09.000Z'), + thread('load-b', '/project', '2026-08-20T00:00:08.000Z'), + thread('load-c', '/project', '2026-08-20T00:00:07.000Z'), + thread('load-d', '/project', '2026-08-20T00:00:06.000Z'), + thread('load-e', '/project', '2026-08-20T00:00:05.000Z'), + thread('done', '/project', '2026-08-20T00:00:01.000Z') + ] + const ids = threads.map((item) => item.id) + const runningWatches = { 'load-a': true, 'load-b': true, 'load-c': true, 'load-d': true, 'load-e': true } + let renderer: ReactTestRenderer | null = null + try { + await act(async () => { + renderer = createRenderer(createElement(SidebarProjectsSection, projectProps(threads, { + watchTurnCompletion: { ...runningWatches, done: true } + }))) + }) + await act(async () => { + renderer!.update(createElement(SidebarProjectsSection, projectProps(threads, { + unreadThreadIds: { done: 'completed' }, + watchTurnCompletion: runningWatches + }))) + }) + expect(visibleThreadTitles(renderer!, ids)).toEqual([ + 'done', 'load-a', 'load-b', 'load-c', 'load-d', 'load-e' + ]) + + await act(async () => { + renderer!.update(createElement(SidebarProjectsSection, projectProps(threads, { + activeThreadId: 'done', + unreadThreadIds: {}, + watchTurnCompletion: runningWatches + }))) + }) + expect(visibleThreadTitles(renderer!, ids)).toEqual([ + 'load-a', 'load-b', 'load-c', 'load-d', 'load-e', 'done' + ]) + } finally { + ;(renderer as ReactTestRenderer | null)?.unmount() + vi.unstubAllGlobals() + } + }) + + it('demotes a viewed completed conversation row behind a still-running conversation', async () => { + vi.stubGlobal('localStorage', storage()) + const originalState = useChatStore.getState() + const items = [ + thread('conv-load', '/conversations/load', '2026-08-20T00:00:09.000Z'), + thread('conv-done', '/conversations/done', '2026-08-20T00:00:01.000Z') + ] + const noOp = vi.fn() + let renderer: ReactTestRenderer | null = null + try { + useChatStore.setState({ + activeThreadId: null, + busy: false, + watchTurnCompletion: { 'conv-load': true, 'conv-done': true }, + unreadThreadIds: {}, + scheduledThreadActivities: {}, + awaitingUserInputThreadIds: {} + }) + await act(async () => { + renderer = createRenderer(createElement(SidebarConversationsSection, { + threads: items, + activeThreadId: null, + runtimeReady: true, + conversationRoot: '/conversations', + onNewConversation: noOp, + onSelectThread: noOp, + onRenameThread: vi.fn(async () => undefined), + onPinThread: vi.fn(async () => undefined), + onArchiveThread: vi.fn(async () => undefined), + onDeleteThread: vi.fn(async () => undefined), + onRestoreThread: vi.fn(async () => undefined), + t: (key: string) => key + })) + }) + const toggle = renderer!.root.find((node) => node.type === 'button' && node.props.title === 'sidebarConversations') + await act(async () => toggle.props.onClick()) + await act(async () => useChatStore.setState({ + watchTurnCompletion: { 'conv-load': true }, + unreadThreadIds: { 'conv-done': 'completed' } + })) + expect(visibleThreadTitles(renderer!, items.map((item) => item.id))).toEqual([ + 'conv-done', 'conv-load' + ]) + await act(async () => { + useChatStore.setState({ + activeThreadId: 'conv-done', + unreadThreadIds: {} + }) + renderer!.update(createElement(SidebarConversationsSection, { + threads: items, + activeThreadId: 'conv-done', + runtimeReady: true, + conversationRoot: '/conversations', + onNewConversation: noOp, + onSelectThread: noOp, + onRenameThread: vi.fn(async () => undefined), + onPinThread: vi.fn(async () => undefined), + onArchiveThread: vi.fn(async () => undefined), + onDeleteThread: vi.fn(async () => undefined), + onRestoreThread: vi.fn(async () => undefined), + t: (key: string) => key + })) + }) + expect(visibleThreadTitles(renderer!, items.map((item) => item.id))).toEqual([ + 'conv-load', 'conv-done' + ]) + } finally { + ;(renderer as ReactTestRenderer | null)?.unmount() + useChatStore.setState(originalState, true) + vi.unstubAllGlobals() + } + }) }) diff --git a/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts b/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts index c8503c016..d394fdc11 100644 --- a/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts +++ b/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts @@ -154,4 +154,94 @@ describe('sidebar stable thread ordering', () => { awaitingUserInputThreadIds: { 'manual-waiting': true } }, 'manual-v2')).toEqual(['manual-other', 'manual-waiting']) }) + + it('demotes a viewed completed result after the last still-running thread', () => { + const tracker = createSidebarThreadOrderTracker() + const runningA = thread('running-a', { updatedAt: '2026-08-20T00:00:09.000Z' }) + const runningB = thread('running-b', { updatedAt: '2026-08-20T00:00:08.000Z' }) + const result = thread('result', { updatedAt: '2026-08-20T00:00:01.000Z' }) + const newer = thread('newer-read', { updatedAt: '2026-08-20T00:00:05.000Z' }) + const running = { + ...settledContext, + watchTurnCompletion: { 'running-a': true, 'running-b': true, result: true } + } + reconcile(tracker, [runningA, runningB, newer, result], running) + expect(reconcile(tracker, [runningA, runningB, newer, result], { + ...settledContext, + watchTurnCompletion: { 'running-a': true, 'running-b': true }, + unreadThreadIds: { result: 'completed' } + })).toEqual(['result', 'running-a', 'running-b', 'newer-read']) + + expect(reconcile(tracker, [runningA, runningB, newer, result], { + ...settledContext, + activeThreadId: 'result', + watchTurnCompletion: { 'running-a': true, 'running-b': true } + })).toEqual(['running-a', 'running-b', 'result', 'newer-read']) + }) + + it('demotes a viewed failed result the same way', () => { + const tracker = createSidebarThreadOrderTracker() + const runningA = thread('failed-running', { updatedAt: '2026-08-20T00:00:09.000Z' }) + const failed = thread('failed', { updatedAt: '2026-08-20T00:00:01.000Z' }) + const running = { ...settledContext, watchTurnCompletion: { 'failed-running': true, failed: true } } + reconcile(tracker, [runningA, failed], running) + expect(reconcile(tracker, [runningA, failed], { + ...settledContext, + watchTurnCompletion: { 'failed-running': true }, + unreadThreadIds: { failed: 'failed' } + })).toEqual(['failed', 'failed-running']) + + expect(reconcile(tracker, [runningA, failed], { + ...settledContext, + activeThreadId: 'failed', + watchTurnCompletion: { 'failed-running': true } + })).toEqual(['failed-running', 'failed']) + }) + + it('keeps a viewed result in place when nothing is running', () => { + const tracker = createSidebarThreadOrderTracker() + const result = thread('idle-result', { updatedAt: '2026-08-20T00:00:01.000Z' }) + const newer = thread('idle-newer', { updatedAt: '2026-08-20T00:00:05.000Z' }) + reconcile(tracker, [newer, result], { + ...settledContext, + watchTurnCompletion: { 'idle-result': true } + }) + expect(reconcile(tracker, [newer, result], { + ...settledContext, + unreadThreadIds: { 'idle-result': 'completed' } + })).toEqual(['idle-result', 'idle-newer']) + expect(reconcile(tracker, [newer, result], { + ...settledContext, + activeThreadId: 'idle-result' + })).toEqual(['idle-result', 'idle-newer']) + }) + + it('keeps a viewed pinned result pinned', () => { + const tracker = createSidebarThreadOrderTracker() + const runningA = thread('pin-running', { updatedAt: '2026-08-20T00:00:09.000Z' }) + const pinnedResult = thread('pin-result', { pinned: true, updatedAt: '2026-08-20T00:00:01.000Z' }) + const running = { ...settledContext, watchTurnCompletion: { 'pin-running': true, 'pin-result': true } } + reconcile(tracker, [runningA, pinnedResult], running) + expect(reconcile(tracker, [runningA, pinnedResult], { + ...settledContext, + unreadThreadIds: { 'pin-result': 'completed' } + })).toEqual(['pin-result', 'pin-running']) + + expect(reconcile(tracker, [runningA, pinnedResult], { + ...settledContext, + activeThreadId: 'pin-result', + watchTurnCompletion: { 'pin-running': true } + })).toEqual(['pin-result', 'pin-running']) + }) + + it('does not move a plain read row when it is merely selected', () => { + const tracker = createSidebarThreadOrderTracker() + const top = thread('plain-top', { updatedAt: '2026-08-20T00:00:09.000Z' }) + const bottom = thread('plain-bottom', { updatedAt: '2026-08-20T00:00:01.000Z' }) + reconcile(tracker, [top, bottom], settledContext) + expect(reconcile(tracker, [top, bottom], { + ...settledContext, + activeThreadId: 'plain-bottom' + })).toEqual(['plain-top', 'plain-bottom']) + }) }) diff --git a/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts b/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts index 14865fd57..f09a3d79e 100644 --- a/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts +++ b/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts @@ -83,6 +83,43 @@ function shouldPromote( return previous === 'running' && current !== 'running' } +/** + * The user just opened an unread/failed result. Demote only that row so it + * lands after the threads that are still working; no other row moves. + */ +function shouldDemoteAfterViewing( + previous: SidebarThreadActivity | undefined, + current: SidebarThreadActivity, + threadId: string, + context: SidebarThreadActivityContext +): boolean { + if (previous !== 'unread' && previous !== 'failed') return false + if (current !== 'read') return false + return context.activeThreadId === threadId +} + +function demoteAfterLastRunning(options: { + activityById: Map + id: string + order: string[] +}): string[] { + const order = [...options.order] + const from = order.indexOf(options.id) + if (from < 0) return order + let insertAt = -1 + for (let index = order.length - 1; index >= 0; index -= 1) { + if (order[index] !== options.id && options.activityById.get(order[index]!) === 'running') { + insertAt = index + 1 + break + } + } + if (insertAt < 0 || insertAt === from) return order + const [moved] = order.splice(from, 1) + if (moved === undefined) return order + order.splice(insertAt > from ? insertAt - 1 : insertAt, 0, moved) + return order +} + function parsedUpdatedAt(thread: NormalizedThread): number { const value = Date.parse(thread.updatedAt) return Number.isFinite(value) ? value : 0 @@ -141,6 +178,16 @@ export function createSidebarThreadOrderTracker(): SidebarThreadOrderTracker { const promotedIds = baselineChanged ? [] : baseIds.filter((id) => shouldPromote(previous?.activityById.get(id), activityById.get(id)!)) + const demotedViewedIds = baselineChanged + ? [] + : baseIds.filter((id) => + shouldDemoteAfterViewing( + previous?.activityById.get(id), + activityById.get(id)!, + id, + context + ) + ) order = promoteAttentionRows({ byId, @@ -148,6 +195,10 @@ export function createSidebarThreadOrderTracker(): SidebarThreadOrderTracker { previousOrder: previous?.order ?? baseIds, promotedIds }) + for (const id of demotedViewedIds) { + if (byId.get(id)?.pinned === true) continue + order = demoteAfterLastRunning({ activityById, id, order }) + } snapshots.set(containerKey, { activityById, baselineKey, From 66bed862432ac72000fbbbb949a406362d9e8b29 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 05:16:00 +0800 Subject: [PATCH 051/168] fix(updater): serialize channel changes --- src/main/gui-updater-operation.test.ts | 45 +++ src/main/gui-updater-operation.ts | 94 +++++ src/main/gui-updater-release-notes.ts | 54 +++ src/main/gui-updater.test.ts | 49 +++ src/main/gui-updater.ts | 362 +++++++++++------- src/main/main-ready-ipc.ts | 8 +- .../settings-section-updates.test.ts | 42 ++ .../components/settings-section-updates.tsx | 3 + .../src/components/use-settings-gui-update.ts | 21 +- 9 files changed, 525 insertions(+), 153 deletions(-) create mode 100644 src/main/gui-updater-operation.test.ts create mode 100644 src/main/gui-updater-operation.ts create mode 100644 src/main/gui-updater-release-notes.ts create mode 100644 src/renderer/src/components/settings-section-updates.test.ts diff --git a/src/main/gui-updater-operation.test.ts b/src/main/gui-updater-operation.test.ts new file mode 100644 index 000000000..98630f3fe --- /dev/null +++ b/src/main/gui-updater-operation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { GuiUpdateOperationCoordinator } from './gui-updater-operation' + +describe('GuiUpdateOperationCoordinator', () => { + it('invalidates a stale download and refuses its install qualification', () => { + const coordinator = new GuiUpdateOperationCoordinator() + const stable = coordinator.begin('download', 'stable', 'https://updates.test/stable/') + stable.targetVersion = '0.2.0' + + coordinator.invalidate() + + expect(coordinator.isCurrent(stable)).toBe(false) + expect(coordinator.markDownloaded(stable, '0.2.0')).toBe(false) + expect(coordinator.downloadedFor('frontier', 'https://updates.test/frontier/', '0.3.0')).toBe(false) + }) + + it('requires generation, channel, feed and version to match a download', () => { + const coordinator = new GuiUpdateOperationCoordinator() + const frontier = coordinator.begin('download', 'frontier', 'https://updates.test/frontier/') + frontier.targetVersion = '0.3.0' + + expect(coordinator.markDownloaded(frontier, '0.3.0')).toBe(true) + expect(coordinator.downloadedFor('frontier', 'https://updates.test/frontier/', '0.3.0')).toBe(true) + expect(coordinator.downloadedFor('stable', 'https://updates.test/stable/', '0.3.0')).toBe(false) + expect(coordinator.downloadedFor('frontier', 'https://updates.test/frontier/', '0.2.0')).toBe(false) + }) + + it('serializes updater work in FIFO order', async () => { + const coordinator = new GuiUpdateOperationCoordinator() + const steps: string[] = [] + let releaseFirst = (): void => undefined + const first = coordinator.run(async () => { + steps.push('first-start') + await new Promise((resolve) => { releaseFirst = resolve }) + steps.push('first-end') + }) + const second = coordinator.run(async () => { steps.push('second') }) + + await Promise.resolve() + expect(steps).toEqual(['first-start']) + releaseFirst() + await Promise.all([first, second]) + expect(steps).toEqual(['first-start', 'first-end', 'second']) + }) +}) diff --git a/src/main/gui-updater-operation.ts b/src/main/gui-updater-operation.ts new file mode 100644 index 000000000..192110fad --- /dev/null +++ b/src/main/gui-updater-operation.ts @@ -0,0 +1,94 @@ +import type { GuiUpdateChannel } from '../shared/gui-update' + +export type GuiUpdateOperationKind = 'check' | 'download' + +export type GuiUpdateOperation = { + generation: number + kind: GuiUpdateOperationKind + channel: GuiUpdateChannel + feedUrl: string + targetVersion?: string + startedAt: number + invalidated: boolean +} + +export type DownloadedGuiUpdate = { + generation: number + channel: GuiUpdateChannel + feedUrl: string + version: string +} + +export class GuiUpdateOperationCoordinator { + private generation = 0 + private lane: Promise = Promise.resolve() + private active: GuiUpdateOperation | null = null + private downloaded: DownloadedGuiUpdate | null = null + + invalidate(): number { + this.generation += 1 + if (this.active) this.active.invalidated = true + this.downloaded = null + return this.generation + } + + currentGeneration(): number { + return this.generation + } + + isGenerationCurrent(generation: number): boolean { + return generation === this.generation + } + + currentOperation(): GuiUpdateOperation | null { + return this.active + } + + isCurrent(operation: GuiUpdateOperation | null | undefined): operation is GuiUpdateOperation { + return Boolean(operation && !operation.invalidated && operation.generation === this.generation) + } + + begin(kind: GuiUpdateOperationKind, channel: GuiUpdateChannel, feedUrl: string): GuiUpdateOperation { + const operation = { + generation: this.generation, + kind, + channel, + feedUrl, + startedAt: Date.now(), + invalidated: false + } + this.active = operation + return operation + } + + end(operation: GuiUpdateOperation): void { + if (this.active === operation) this.active = null + } + + run(task: () => Promise): Promise { + const next = this.lane.then(task, task) + this.lane = next.then(() => undefined, () => undefined) + return next + } + + markDownloaded(operation: GuiUpdateOperation, version: string): boolean { + if (!this.isCurrent(operation) || !version || operation.targetVersion !== version) return false + this.downloaded = { generation: operation.generation, channel: operation.channel, feedUrl: operation.feedUrl, version } + return true + } + + downloadedFor(channel: GuiUpdateChannel, feedUrl: string, version: string): boolean { + const downloaded = this.downloaded + return Boolean( + downloaded && + downloaded.generation === this.generation && + downloaded.channel === channel && + downloaded.feedUrl === feedUrl && + downloaded.version === version + ) + } + + clearDownloaded(): void { + this.downloaded = null + } +} diff --git a/src/main/gui-updater-release-notes.ts b/src/main/gui-updater-release-notes.ts new file mode 100644 index 000000000..10a5e388c --- /dev/null +++ b/src/main/gui-updater-release-notes.ts @@ -0,0 +1,54 @@ +import { app, BrowserWindow, dialog, shell } from 'electron' +import type { MessageBoxOptions } from 'electron' +import type { AppLocale } from '../shared/app-locales' +import { + changelogUrl, + DEVELOPMENT_APP_FLAVOR, + isVersionGreater, + readGuiVersionState, + writeGuiVersionState +} from './gui-updater-support' + +export async function showGuiUpdateReleaseNotes( + getMainWindow: (() => BrowserWindow | null) | null, + getSelectedLocale: (() => AppLocale | Promise) | null +): Promise { + if (DEVELOPMENT_APP_FLAVOR || !app.isPackaged) return + const currentVersion = app.getVersion().trim() + const state = await readGuiVersionState() + if (!state.lastSeenVersion) { + await writeGuiVersionState({ ...state, lastSeenVersion: currentVersion }) + return + } + if (state.lastSeenVersion === currentVersion || !isVersionGreater(currentVersion, state.lastSeenVersion)) return + const pendingUpdate = state.pendingUpdate?.version === currentVersion ? state.pendingUpdate : undefined + await writeGuiVersionState({ lastSeenVersion: currentVersion }) + const isZh = await selectedLocale(getSelectedLocale) === 'zh' + const options: MessageBoxOptions = { + type: 'info', + title: isZh ? 'Kun 已更新' : 'Kun updated', + message: isZh ? `已更新到 Kun ${currentVersion}` : `Kun has been updated to ${currentVersion}`, + detail: pendingUpdate?.releaseNotes ?? (isZh + ? '此版本的完整更新内容可在 Kun 更新日志中查看。' + : 'See the Kun changelog for the complete release notes.'), + buttons: isZh ? ['查看更新日志', '稍后'] : ['View changelog', 'Later'], + defaultId: 0, + cancelId: 1, + noLink: true + } + const window = getMainWindow?.() + const result = window && !window.isDestroyed() + ? await dialog.showMessageBox(window, options) + : await dialog.showMessageBox(options) + if (result.response === 0) await shell.openExternal(changelogUrl(currentVersion)) +} + +async function selectedLocale( + getSelectedLocale: (() => AppLocale | Promise) | null +): Promise<'en' | 'zh'> { + try { + return (await getSelectedLocale?.()) === 'zh' ? 'zh' : 'en' + } catch { + return app.getLocale().toLowerCase().startsWith('zh') ? 'zh' : 'en' + } +} diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts index b6ba3f2c8..84a40bb1f 100644 --- a/src/main/gui-updater.test.ts +++ b/src/main/gui-updater.test.ts @@ -514,6 +514,55 @@ describe('downloadGuiUpdate recovery', () => { }) expect(updater.downloadUpdate).toHaveBeenCalledTimes(2) }) + + it('ignores a stale download completion after switching from stable to frontier', async () => { + process.env.KUN_UPDATE_URL_STABLE = 'https://updates.example.test/stable/' + process.env.KUN_UPDATE_URL_FRONTIER = 'https://updates.example.test/frontier/' + process.env.DEEPSEEK_GUI_ALLOW_UNSIGNED_UPDATES = '1' + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + let finishDownload = (): void => undefined + updater.downloadUpdate.mockImplementation(() => new Promise((resolve) => { + finishDownload = () => resolve(['C:\\Temp\\Kun-0.2.0.exe']) + })) + const module = await import('./gui-updater') + module.initializeGuiUpdater(() => null, () => 'stable') + updater.emit('update-available', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + + const downloading = module.downloadGuiUpdate('stable') + for (let index = 0; index < 4; index += 1) await Promise.resolve() + expect(updater.downloadUpdate).toHaveBeenCalledOnce() + module.setGuiUpdateChannel('frontier') + updater.emit('download-progress', { percent: 100 }) + updater.emit('update-downloaded', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + finishDownload() + + await expect(downloading).resolves.toMatchObject({ ok: false, code: 'download_failed' }) + expect(module.getGuiUpdateState()).toEqual({ status: 'idle' }) + await expect(module.installGuiUpdate()).resolves.toMatchObject({ ok: false, code: 'install_failed' }) + expect(updater.quitAndInstall).not.toHaveBeenCalled() + }) + + it('ignores a stale check result after switching from stable to frontier', async () => { + process.env.KUN_UPDATE_URL_STABLE = 'https://updates.example.test/stable/' + process.env.KUN_UPDATE_URL_FRONTIER = 'https://updates.example.test/frontier/' + process.env.DEEPSEEK_GUI_ALLOW_UNSIGNED_UPDATES = '1' + let finishCheck = (_value: unknown): void => undefined + updater.checkForUpdates.mockImplementation(() => new Promise((resolve) => { + finishCheck = resolve + })) + const module = await import('./gui-updater') + module.initializeGuiUpdater(() => null, () => 'stable') + + const checking = module.checkGuiUpdate('stable') + for (let index = 0; index < 6; index += 1) await Promise.resolve() + expect(updater.checkForUpdates).toHaveBeenCalledOnce() + module.setGuiUpdateChannel('frontier') + updater.emit('update-available', { version: '0.2.0', releaseDate: '2026-06-06T00:00:00.000Z' }) + finishCheck({ updateInfo: { version: '0.2.0' }, isUpdateAvailable: true }) + + await expect(checking).resolves.toMatchObject({ ok: false, channel: 'stable' }) + expect(module.getGuiUpdateState()).toEqual({ status: 'idle' }) + }) }) describe('showPostUpdateReleaseNotes', () => { diff --git a/src/main/gui-updater.ts b/src/main/gui-updater.ts index 4afb8b1d2..251c6057c 100644 --- a/src/main/gui-updater.ts +++ b/src/main/gui-updater.ts @@ -1,5 +1,4 @@ -import { app, autoUpdater as nativeAutoUpdater, BrowserWindow, dialog, shell } from 'electron' -import type { MessageBoxOptions } from 'electron' +import { app, autoUpdater as nativeAutoUpdater, BrowserWindow } from 'electron' import type { ProgressInfo, UpdateDownloadedEvent, UpdateInfo } from 'electron-updater' import type { GuiUpdateChannel, @@ -12,9 +11,10 @@ import type { import { nextGuiUpdateCheckDelay } from '../shared/gui-update-schedule' import { DEFAULT_GUI_UPDATE_CHANNEL, normalizeGuiUpdateChannel } from '../shared/gui-update' import type { AppLocale } from '../shared/app-locales' +import { GuiUpdateOperationCoordinator, type GuiUpdateOperation } from './gui-updater-operation' +import { showGuiUpdateReleaseNotes } from './gui-updater-release-notes' import { autoUpdater, - changelogUrl, DEVELOPMENT_APP_FLAVOR, DEVELOPMENT_UPDATE_MESSAGE, downloadPageUrl, @@ -22,7 +22,6 @@ import { isVersionGreater, macAutoUpdateAllowed, parseYamlScalar, - readGuiVersionState, readLastScheduledCheckAt, recordPendingUpdate, releaseUrlForVersion, @@ -32,18 +31,18 @@ import { unsupportedMessage, updateFeedManifestUrl, updateFeedUrl, - writeGuiVersionState, writeLastScheduledCheckAt } from './gui-updater-support' export { setWindowsInstallerUpdateSource } from './gui-updater-support' - let initialized = false let getMainWindow: (() => BrowserWindow | null) | null = null let lastInfo: Extract | null = null let lastState: GuiUpdateState = { status: 'idle' } let downloaded = false let downloadPromise: Promise | null = null +const operations = new GuiUpdateOperationCoordinator() +let eventOperation: GuiUpdateOperation | null = null let configuredChannel: GuiUpdateChannel = normalizeGuiUpdateChannel( envWithLegacyFallback('KUN_UPDATE_CHANNEL', 'DEEPSEEK_GUI_UPDATE_CHANNEL') || undefined ) @@ -66,36 +65,33 @@ let updateInstallAttemptActive = false let updateInstallRecoveryNeeded = false let updateInstallRecoveryScheduled = false let restoreInstallerUpdateSourceAfterFailure: (() => void) | null = null - -async function selectedLocale(): Promise<'en' | 'zh'> { - try { - return (await getSelectedLocale?.()) === 'zh' ? 'zh' : 'en' - } catch { - return app.getLocale().toLowerCase().startsWith('zh') ? 'zh' : 'en' - } -} -function toGuiInfo(updateInfo: UpdateInfo, hasUpdate: boolean, manualOnly = false): Extract { +function toGuiInfo( + updateInfo: UpdateInfo, + hasUpdate: boolean, + operation: GuiUpdateOperation | null = null, + manualOnly = false +): Extract { const latestVersion = updateInfo.version.trim() return { ok: true, currentVersion: app.getVersion(), latestVersion, hasUpdate, - releaseUrl: releaseUrlForVersion(latestVersion, configuredChannel), + releaseUrl: releaseUrlForVersion(latestVersion, operation?.channel ?? configuredChannel), releaseDate: updateInfo.releaseDate, - channel: configuredChannel, + channel: operation?.channel ?? configuredChannel, manualOnly, - downloaded + downloaded: operation + ? operations.downloadedFor(operation.channel, operation.feedUrl, latestVersion) + : downloaded } } - function emitGuiUpdateState(state: GuiUpdateState): void { lastState = state const win = getMainWindow?.() if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return win.webContents.send('gui:update-state', state) } - function runBeforeInstallUpdate(): Promise { if (beforeInstallUpdatePrepared) return Promise.resolve() if (!beforeInstallUpdate) return Promise.resolve() @@ -111,13 +107,11 @@ function runBeforeInstallUpdate(): Promise { } return beforeInstallUpdatePromise } - function markUpdateInstallQuitting(active: boolean): void { if (updateInstallQuitting === active) return updateInstallQuitting = active setUpdateInstallQuitting?.(active) } - function clearBeforeInstallUpdatePreparation(): void { beforeInstallUpdatePrepared = false } @@ -210,34 +204,55 @@ async function resolveUpdateChannel(requested?: GuiUpdateChannel): Promise { - configureUpdaterChannel(channel, await resolveUpdateFeedUrl(channel)) +async function resolveConfiguredUpdateChannel( + channel: GuiUpdateChannel, + requestGeneration: number +): Promise { + const feedUrl = await resolveUpdateFeedUrl(channel) + if (!operations.isGenerationCurrent(requestGeneration)) return false + configureUpdaterChannel(channel, feedUrl) + return true } export function setGuiUpdateChannel(channel: GuiUpdateChannel): void { if (DEVELOPMENT_APP_FLAVOR) return - configureUpdaterChannel(channel) + const nextChannel = normalizeGuiUpdateChannel(channel) + const nextFeedUrl = updateFeedUrl(nextChannel) + configureUpdaterChannel(nextChannel, nextFeedUrl, false) + void operations.run(async () => { + if (configuredChannel !== nextChannel || configuredFeedUrl !== nextFeedUrl) return + autoUpdater.allowPrerelease = nextChannel === 'frontier' + autoUpdater.allowDowngrade = false + autoUpdater.setFeedURL({ provider: 'generic', url: nextFeedUrl }) + }) } async function checkManualUpdate( channel: GuiUpdateChannel, - code: GuiUpdateFailureCode = 'unsupported' + code: GuiUpdateFailureCode = 'unsupported', + operation?: GuiUpdateOperation ): Promise { const currentVersion = app.getVersion() try { @@ -262,6 +277,15 @@ async function checkManualUpdate( } } const text = await res.text() + if (operation && !operations.isCurrent(operation)) { + return { + ok: false, + currentVersion, + channel, + code: 'unknown', + message: 'The update channel changed while checking for updates.' + } + } const latestVersion = parseYamlScalar(text, 'version') if (!latestVersion) { return { @@ -319,6 +343,7 @@ export function initializeGuiUpdater( autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = false configureUpdaterChannel(configuredChannel) + eventOperation = operations.begin('check', configuredChannel, configuredFeedUrl) if (!app.isPackaged) { autoUpdater.forceDevUpdateConfig = true } @@ -330,30 +355,38 @@ export function initializeGuiUpdater( } autoUpdater.on('checking-for-update', () => { + if (!operations.isCurrent(eventOperation)) return emitGuiUpdateState({ status: 'checking', info: lastInfo ?? undefined }) }) autoUpdater.on('update-available', (updateInfo: UpdateInfo) => { + if (!operations.isCurrent(eventOperation)) return downloaded = false - const info = toGuiInfo(updateInfo, true) + eventOperation.targetVersion = updateInfo.version.trim() + const info = toGuiInfo(updateInfo, true, eventOperation) lastInfo = info emitGuiUpdateState({ status: 'available', info }) }) autoUpdater.on('update-not-available', (updateInfo: UpdateInfo) => { + if (!operations.isCurrent(eventOperation)) return downloaded = false - const info = toGuiInfo(updateInfo, false) + const info = toGuiInfo(updateInfo, false, eventOperation) lastInfo = info emitGuiUpdateState({ status: 'not_available', info }) }) autoUpdater.on('download-progress', (progress: ProgressInfo) => { + if (!operations.isCurrent(eventOperation)) return emitGuiUpdateState({ status: 'downloading', info: lastInfo ?? undefined, progress }) }) autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => { + if (!eventOperation || !operations.isCurrent(eventOperation)) return + eventOperation.targetVersion ??= event.version.trim() + if (!operations.markDownloaded(eventOperation, event.version.trim())) return downloaded = true - const info = toGuiInfo(event, true) + const info = toGuiInfo(event, true, eventOperation) lastInfo = info pendingVersionStateWrite = recordPendingUpdate(event) .catch((error) => { @@ -372,11 +405,14 @@ export function initializeGuiUpdater( updateInstallLaunchError = asError(error) scheduleFailedUpdateInstallRecovery() } - const downloadFailed = !installFailed && (downloadPromise !== null || lastState.status === 'downloading') + const downloadFailed = !installFailed && operations.isCurrent(eventOperation) && + (downloadPromise !== null || lastState.status === 'downloading') if (downloadFailed) { downloaded = false + operations.clearDownloaded() downloadPromise = null } + if (!installFailed && !downloadFailed && !operations.isCurrent(eventOperation)) return emitGuiUpdateState({ status: 'error', info: lastInfo ?? undefined, @@ -402,46 +438,7 @@ export function initializeGuiUpdater( } export async function showPostUpdateReleaseNotes(): Promise { - if (DEVELOPMENT_APP_FLAVOR) return - if (!app.isPackaged) return - - const currentVersion = app.getVersion().trim() - const state = await readGuiVersionState() - if (!state.lastSeenVersion) { - await writeGuiVersionState({ ...state, lastSeenVersion: currentVersion }) - return - } - if (state.lastSeenVersion === currentVersion) return - if (!isVersionGreater(currentVersion, state.lastSeenVersion)) return - - const pendingUpdate = - state.pendingUpdate?.version === currentVersion ? state.pendingUpdate : undefined - await writeGuiVersionState({ lastSeenVersion: currentVersion }) - - const locale = await selectedLocale() - const isZh = locale === 'zh' - const options: MessageBoxOptions = { - type: 'info', - title: isZh ? 'Kun 已更新' : 'Kun updated', - message: isZh ? `已更新到 Kun ${currentVersion}` : `Kun has been updated to ${currentVersion}`, - detail: - pendingUpdate?.releaseNotes ?? - (isZh - ? '此版本的完整更新内容可在 Kun 更新日志中查看。' - : 'See the Kun changelog for the complete release notes.'), - buttons: isZh ? ['查看更新日志', '稍后'] : ['View changelog', 'Later'], - defaultId: 0, - cancelId: 1, - noLink: true - } - const window = getMainWindow?.() - const result = - window && !window.isDestroyed() - ? await dialog.showMessageBox(window, options) - : await dialog.showMessageBox(options) - if (result.response === 0) { - await shell.openExternal(changelogUrl(currentVersion)) - } + await showGuiUpdateReleaseNotes(getMainWindow, getSelectedLocale) } export function getGuiUpdateState(): GuiUpdateState { @@ -459,95 +456,158 @@ export async function checkGuiUpdate(channel?: GuiUpdateChannel): Promise { + if (!operations.isGenerationCurrent(requestGeneration)) { + return { + ok: false, + currentVersion: app.getVersion(), + channel: selectedChannel, + code: 'unknown', + message: 'The update channel changed before checking for updates.' + } } - const info = toGuiInfo(result.updateInfo, result.isUpdateAvailable) - lastInfo = info - emitGuiUpdateState(info.hasUpdate ? { status: 'available', info } : { status: 'not_available', info }) - return info - } catch (e) { - const message = sanitizeUpdaterError(e instanceof Error ? e.message : String(e), selectedChannel) - const info: GuiUpdateInfo = { - ok: false, - currentVersion: app.getVersion(), - message, - code: 'unknown', - releaseUrl: downloadPageUrl(configuredChannel), - channel: selectedChannel + if (!await resolveConfiguredUpdateChannel(selectedChannel, requestGeneration)) { + return { + ok: false, + currentVersion: app.getVersion(), + channel: selectedChannel, + code: 'unknown', + message: 'The update channel changed before checking for updates.' + } } - emitGuiUpdateState({ status: 'error', info, message, code: 'unknown' }) - return info - } + const operation = operations.begin('check', selectedChannel, configuredFeedUrl) + eventOperation = operation + try { + if (!macAutoUpdateAllowed()) return await checkManualUpdate(selectedChannel, 'unsupported', operation) + emitGuiUpdateState({ status: 'checking', info: lastInfo ?? undefined }) + const result = await autoUpdater.checkForUpdates() + if (!operations.isCurrent(operation)) { + return { + ok: false, + currentVersion: app.getVersion(), + channel: selectedChannel, + code: 'unknown', + message: 'The update channel changed while checking for updates.' + } + } + if (!result) return await checkManualUpdate(selectedChannel, 'not_configured', operation) + operation.targetVersion = result.updateInfo.version.trim() + const info = toGuiInfo(result.updateInfo, result.isUpdateAvailable, operation) + lastInfo = info + emitGuiUpdateState(info.hasUpdate ? { status: 'available', info } : { status: 'not_available', info }) + return info + } catch (e) { + const message = sanitizeUpdaterError(e instanceof Error ? e.message : String(e), selectedChannel) + const info: GuiUpdateInfo = { + ok: false, + currentVersion: app.getVersion(), + message, + code: 'unknown', + releaseUrl: downloadPageUrl(selectedChannel), + channel: selectedChannel + } + if (operations.isCurrent(operation)) emitGuiUpdateState({ status: 'error', info, message, code: 'unknown' }) + return info + } finally { + if (eventOperation === operation) eventOperation = null + operations.end(operation) + } + }) } export async function downloadGuiUpdate(channel?: GuiUpdateChannel): Promise { const selectedChannel = await resolveUpdateChannel(channel) if (DEVELOPMENT_APP_FLAVOR) { - return { - ok: false, - currentVersion: app.getVersion(), - code: 'unsupported', - message: DEVELOPMENT_UPDATE_MESSAGE - } + return { ok: false, currentVersion: app.getVersion(), code: 'unsupported', message: DEVELOPMENT_UPDATE_MESSAGE } } - await configureReachableUpdaterChannel(selectedChannel) - - if (!macAutoUpdateAllowed()) { - return { - ok: false, - currentVersion: app.getVersion(), - code: 'unsupported', - message: unsupportedMessage() + if (!lastInfo?.hasUpdate || lastInfo.channel !== selectedChannel) { + const checked = await checkGuiUpdate(selectedChannel) + if (!checked.ok) return checked + if (!checked.hasUpdate || checked.manualOnly) { + return { + ok: false, + currentVersion: app.getVersion(), + code: checked.manualOnly ? 'unsupported' : 'unknown', + message: checked.manualOnly ? unsupportedMessage() : 'No downloadable GUI update is available.' + } } } - - try { - if (!lastInfo?.hasUpdate || lastInfo.channel !== selectedChannel) { - const checked = await checkGuiUpdate(selectedChannel) - if (!checked.ok) return checked - if (!checked.hasUpdate || checked.manualOnly) { - return { - ok: false, - currentVersion: app.getVersion(), - code: checked.manualOnly ? 'unsupported' : 'unknown', - message: checked.manualOnly - ? unsupportedMessage() - : 'No downloadable GUI update is available.' - } + const requestGeneration = operations.currentGeneration() + return operations.run(async () => { + if (!operations.isGenerationCurrent(requestGeneration)) { + return { + ok: false, + currentVersion: app.getVersion(), + code: 'download_failed', + message: 'The update channel changed before download.' } } - - if (!downloadPromise) { + if (!await resolveConfiguredUpdateChannel(selectedChannel, requestGeneration)) { + return { + ok: false, + currentVersion: app.getVersion(), + code: 'download_failed', + message: 'The update channel changed before download.' + } + } + if (!macAutoUpdateAllowed()) { + return { ok: false, currentVersion: app.getVersion(), code: 'unsupported', message: unsupportedMessage() } + } + if (!lastInfo?.hasUpdate || lastInfo.channel !== selectedChannel) { + return { ok: false, currentVersion: app.getVersion(), code: 'unknown', message: 'The update channel changed before download.' } + } + const operation = operations.begin('download', selectedChannel, configuredFeedUrl) + operation.targetVersion = lastInfo.latestVersion + eventOperation = operation + downloaded = false + try { let tracked: Promise tracked = autoUpdater.downloadUpdate().finally(() => { if (downloadPromise === tracked) downloadPromise = null }) downloadPromise = tracked + const paths = await tracked + if (operations.isCurrent(operation) && !operations.downloadedFor( + operation.channel, + operation.feedUrl, + operation.targetVersion + )) { + operations.markDownloaded(operation, operation.targetVersion) + downloaded = true + } + if (!operations.isCurrent(operation) || !operations.downloadedFor( + operation.channel, + operation.feedUrl, + operation.targetVersion + )) { + return { + ok: false, + currentVersion: app.getVersion(), + code: 'download_failed', + message: 'The update channel changed before the download completed.' + } + } + return { ok: true, paths } + } catch (e) { + if (operations.isCurrent(operation)) { + downloaded = false + operations.clearDownloaded() + const message = e instanceof Error ? e.message : String(e) + emitGuiUpdateState({ status: 'error', info: lastInfo ?? undefined, message, code: 'download_failed' }) + return { ok: false, currentVersion: app.getVersion(), code: 'download_failed', message } + } + return { + ok: false, + currentVersion: app.getVersion(), + code: 'download_failed', + message: 'The update channel changed before the download completed.' + } + } finally { + if (eventOperation === operation) eventOperation = null + operations.end(operation) } - const paths = await downloadPromise - return { ok: true, paths } - } catch (e) { - downloaded = false - downloadPromise = null - const message = e instanceof Error ? e.message : String(e) - emitGuiUpdateState({ status: 'error', info: lastInfo ?? undefined, message, code: 'download_failed' }) - return { - ok: false, - currentVersion: app.getVersion(), - code: 'download_failed', - message - } - } + }) } export function installGuiUpdate(): Promise { @@ -580,7 +640,13 @@ async function installGuiUpdateOnce(): Promise { let updateInstallQuitMarked = false let restoreInstallerUpdateSource = (): void => undefined try { - if (!downloaded) { + if (!downloaded || !lastInfo || !operations.downloadedFor( + configuredChannel, + configuredFeedUrl, + lastInfo.latestVersion + )) { + downloaded = false + operations.clearDownloaded() return { ok: false, currentVersion: app.getVersion(), diff --git a/src/main/main-ready-ipc.ts b/src/main/main-ready-ipc.ts index 0551afad8..71fa7997a 100644 --- a/src/main/main-ready-ipc.ts +++ b/src/main/main-ready-ipc.ts @@ -178,7 +178,13 @@ export function registerMainIpc(services: MainServices): void { } updateComputerUseHostSettings(saved) if (previous.guiUpdate.channel !== saved.guiUpdate.channel && mainState.guiUpdaterModulePromise) { - void mainState.guiUpdaterModulePromise.then((module) => module.setGuiUpdateChannel(saved.guiUpdate.channel)) + void mainState.guiUpdaterModulePromise + .then((module) => module.setGuiUpdateChannel(saved.guiUpdate.channel)) + .catch((error) => { + logWarn('gui-updater', 'failed to apply the saved GUI update channel', { + message: error instanceof Error ? error.message : String(error) + }) + }) } try { mainState.scheduleRuntime?.sync(saved) diff --git a/src/renderer/src/components/settings-section-updates.test.ts b/src/renderer/src/components/settings-section-updates.test.ts new file mode 100644 index 000000000..eab274c56 --- /dev/null +++ b/src/renderer/src/components/settings-section-updates.test.ts @@ -0,0 +1,42 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { UpdatesSettingsSection } from './settings-section-updates' + +function render(busy: 'idle' | 'checking' | 'downloading' | 'installing'): string { + const checking = busy === 'checking' + const downloading = busy === 'downloading' + const installing = busy === 'installing' + return renderToStaticMarkup(createElement(UpdatesSettingsSection, { + ctx: { + t: (key: string) => key, + form: { guiUpdate: { channel: 'stable' } }, + update: () => undefined, + selectControlClass: 'select', + guiUpdateInfo: null, + checkingGuiUpdate: checking, + downloadingGuiUpdate: downloading, + installingGuiUpdate: installing, + guiUpdateDownloaded: false, + guiUpdateProgress: null, + guiUpdateError: null, + checkGuiUpdate: async () => undefined, + downloadGuiUpdate: async () => undefined, + installGuiUpdate: async () => undefined + } + })) +} + +describe('UpdatesSettingsSection', () => { + it.each(['checking', 'downloading', 'installing'] as const)('disables channel switching while %s', (busy) => { + expect(render(busy)).toContain(' }): R downloadGuiUpdate, installGuiUpdate } = ctx + const channelBusy = checkingGuiUpdate || downloadingGuiUpdate || installingGuiUpdate return ( @@ -30,6 +31,8 @@ export function UpdatesSettingsSection({ ctx }: { ctx: Record }): R onProfileChange?.({ - outputMedium: event.target.value as DesignTaskOutputMedium - })} - className={compactSelectClass()} - title={ - effectiveOutputMedium === 'image' && - imageGenerationStateKnown && - !imageGenerationAvailable - ? imageGenerationReason - : undefined - } - > - - {showImageGenerationOption ? ( - - ) : null} - - - - {effectiveOutputMedium === 'html' - ? - : } - - - {profile.target === 'web' - ? - : } - - {profile.presetSource === 'root-design-md' ? ( - - - - {t('designStyleProjectSource', { - defaultValue: 'Project DESIGN.md: {{name}}', - name: profile.styleSourceName || 'DESIGN.md' - })} - - - ) : null} - {effectiveOutputMedium === 'image' && - imageGenerationStateKnown && - !imageGenerationAvailable ? ( - - ) : null} -
- ) : null} - - {surface === 'design' && profileLocked ? ( - - - {t('designProfileLocked', { defaultValue: 'Design profile locked' })} - + ) : null} ) From 5c9fc72ce1ae8a7f0e82c334b088f112a0e6dca7 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 08:13:00 +0800 Subject: [PATCH 058/168] fix(context): classify active skill context tokens --- kun/src/loop/model-request-estimator.test.ts | 96 +++++++++++++++++++- kun/src/loop/model-request-estimator.ts | 35 ++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/kun/src/loop/model-request-estimator.test.ts b/kun/src/loop/model-request-estimator.test.ts index 112f13afb..db4ffc002 100644 --- a/kun/src/loop/model-request-estimator.test.ts +++ b/kun/src/loop/model-request-estimator.test.ts @@ -3,7 +3,7 @@ import { estimateModelRequestInputTokenBreakdown, estimateModelRequestInputTokens } from './model-request-estimator.js' -import { makeUserItem } from '../domain/item.js' +import { makeModelContextItem, makeUserItem } from '../domain/item.js' import type { ModelRequest } from '../ports/model-client.js' describe('estimateModelRequestInputTokens', () => { @@ -118,4 +118,98 @@ describe('estimateModelRequestInputTokens', () => { ) expect(estimateModelRequestInputTokens(request)).toBe(breakdown.total) }) + it('moves active skill context updates from messages to skills without changing the total', () => { + const activeSkillContext = makeModelContextItem({ + id: 'context_active_skill', + turnId: 'turn_context_skill', + threadId: 'thr_context_skill', + stepIndex: 0, + contentDigest: 'active_skill', + blocks: [{ + key: 'skill-instruction:skill:0', + kind: 'skill-instruction', + authority: 'skill', + state: 'active', + digest: 'skill' + }], + text: [ + 'Kun append-only model context update (format 1).', + '', + 's'.repeat(400), + '' + ].join('\n') + }) + const inactiveSkillContext = makeModelContextItem({ + id: 'context_inactive_skill', + turnId: 'turn_context_skill', + threadId: 'thr_context_skill', + stepIndex: 0, + contentDigest: 'inactive_skill', + blocks: [{ + key: 'skill-instruction:skill:0', + kind: 'skill-instruction', + authority: 'skill', + state: 'inactive' + }], + text: [ + 'Kun append-only model context update (format 1).', + '', + 's'.repeat(400), + '' + ].join('\n') + }) + const request: ModelRequest = { + threadId: 'thr_context_skill', + turnId: 'turn_context_skill', + model: 'model', + systemPrompt: 'system', + prefix: [], + history: [activeSkillContext], + tools: [], + abortSignal: new AbortController().signal + } + + const active = estimateModelRequestInputTokenBreakdown(request) + const inactive = estimateModelRequestInputTokenBreakdown({ + ...request, + history: [inactiveSkillContext] + }) + + expect(active.skills).toBeGreaterThan(0) + expect(inactive.skills).toBe(0) + expect(active.messages).toBeLessThan(inactive.messages) + expect(active.total).toBe(inactive.total) + }) + + it('keeps model context without active skills in the messages category', () => { + const request: ModelRequest = { + threadId: 'thr_context_runtime', + turnId: 'turn_context_runtime', + model: 'model', + systemPrompt: 'system', + prefix: [], + history: [makeModelContextItem({ + id: 'context_runtime', + turnId: 'turn_context_runtime', + threadId: 'thr_context_runtime', + stepIndex: 0, + contentDigest: 'runtime', + blocks: [{ + key: 'runtime:runtime:0', + kind: 'runtime', + authority: 'runtime', + state: 'active', + digest: 'runtime' + }], + text: 'runtime' + })], + tools: [], + abortSignal: new AbortController().signal + } + + const breakdown = estimateModelRequestInputTokenBreakdown(request) + + expect(breakdown.skills).toBe(0) + expect(breakdown.messages).toBeGreaterThan(0) + }) }) diff --git a/kun/src/loop/model-request-estimator.ts b/kun/src/loop/model-request-estimator.ts index 2816ca2da..4587e93a6 100644 --- a/kun/src/loop/model-request-estimator.ts +++ b/kun/src/loop/model-request-estimator.ts @@ -38,14 +38,16 @@ export function estimateModelRequestInputTokenBreakdown( options?.skillContextInstructions ) const contextInstructions = estimateText(request.contextInstructions?.join('\n')) - const skills = Math.min(contextInstructions, estimateText(skill.join('\n'))) - const nonSkillContext = contextInstructions - skills + const requestSkillTokens = Math.min(contextInstructions, estimateText(skill.join('\n'))) + const skillContextItemTokens = estimateActiveSkillContextItems(request.history) + const skills = requestSkillTokens + skillContextItemTokens + const nonSkillContext = contextInstructions - requestSkillTokens const system = estimateText(request.systemPrompt) + estimateText(request.threadProfileInstruction) + estimateText(request.modeInstruction) + nonSkillContext - const messages = estimateItems(request.prefix) + estimateItems(request.history) + const messages = Math.max(0, estimateItems(request.prefix) + estimateItems(request.history) - skillContextItemTokens) const tools = estimateTools(request.tools) const other = estimateTextFallbacks(request.attachmentTextFallbacks) + @@ -103,6 +105,33 @@ export function estimateRequestOverheadTokens(input: { return Math.max(0, tokens) } +function estimateActiveSkillContextItems(items?: TurnItem[]): number { + if (!items?.length) return 0 + return items.reduce((total, item) => { + if (item.kind !== 'model_context') return total + const skillTokens = activeSkillContextSections(item.text).reduce( + (sectionTotal, section) => sectionTotal + estimateText(section), + 0 + ) + return total + Math.min(estimateItems([item]), skillTokens) + }, 0) +} + +function activeSkillContextSections(text: string): string[] { + const sections: string[] = [] + const pattern = /]*)>[\s\S]*?<\/kun_context_update>/g + for (const match of text.matchAll(pattern)) { + const attributes = match[1] ?? '' + if ( + /\bauthority="skill"/.test(attributes) && + /\bstate="active"/.test(attributes) + ) { + sections.push(match[0]) + } + } + return sections +} + function estimateItems(items?: TurnItem[]): number { return items && items.length > 0 ? estimator.estimateItems(items) : 0 } From 237d0ad6e03c67dcfe207b59c4bafcb94680c522 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 08:38:00 +0800 Subject: [PATCH 059/168] feat(manager): implement ManagerThreadExecutionLeaseClient with renewal logic and tests --- .../manager-client-lease-renewal.test.ts | 121 +++++++++++ kun/src/manager/manager-client.ts | 111 +--------- .../manager-thread-execution-lease-client.ts | 189 ++++++++++++++++++ .../chat/SidebarThreadOrdering.test.ts | 8 +- .../chat/sidebar-sort-anchor.test.ts | 41 +++- .../chat/sidebar-thread-order-tracker.ts | 80 +++----- 6 files changed, 374 insertions(+), 176 deletions(-) create mode 100644 kun/src/manager/manager-client-lease-renewal.test.ts create mode 100644 kun/src/manager/manager-thread-execution-lease-client.ts diff --git a/kun/src/manager/manager-client-lease-renewal.test.ts b/kun/src/manager/manager-client-lease-renewal.test.ts new file mode 100644 index 000000000..e3e52bdc5 --- /dev/null +++ b/kun/src/manager/manager-client-lease-renewal.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ManagerThreadExecutionLeaseClient, + type ServiceManagerConnection +} from './manager-client.js' + +const manager = { + discovery: { + baseUrl: 'http://127.0.0.1:19001', + managerToken: 'manager-token' + } +} as ServiceManagerConnection + +describe('ManagerThreadExecutionLeaseClient renewal', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('retries a transient renewal failure instead of aborting the live turn', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let renewAttempts = 0 + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = String(input) + if (url.endsWith('/acquire')) return leaseResponse(0) + if (url.endsWith('/renew')) { + renewAttempts += 1 + if (renewAttempts === 1) throw new Error('temporary manager timeout') + return leaseResponse(10) + } + throw new Error(`unexpected request: ${url}`) + })) + const client = new ManagerThreadExecutionLeaseClient(manager, 'production', 'runtime-1') + const leaseLost = vi.fn() + client.setLeaseLostHandler(leaseLost) + + await client.acquire('thread-1', 'turn-1') + await vi.advanceTimersByTimeAsync(5_000) + expect(renewAttempts).toBe(1) + expect(leaseLost).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(500) + expect(renewAttempts).toBe(2) + expect(leaseLost).not.toHaveBeenCalled() + client.shutdown() + }) + + it('aborts only after the manager definitively rejects the renewal', async () => { + vi.useFakeTimers() + let renewAttempts = 0 + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = String(input) + if (url.endsWith('/acquire')) return leaseResponse(0) + if (url.endsWith('/renew')) { + renewAttempts += 1 + return new Response(JSON.stringify({ code: 'thread_lease_lost' }), { + status: 409, + headers: { 'content-type': 'application/json' } + }) + } + throw new Error(`unexpected request: ${url}`) + })) + const client = new ManagerThreadExecutionLeaseClient(manager, 'production', 'runtime-1') + const leaseLost = vi.fn() + client.setLeaseLostHandler(leaseLost) + + const lease = await client.acquire('thread-1', 'turn-1') + await vi.advanceTimersByTimeAsync(5_000) + + expect(leaseLost).toHaveBeenCalledOnce() + expect(leaseLost).toHaveBeenCalledWith(lease) + await vi.advanceTimersByTimeAsync(10_000) + expect(renewAttempts).toBe(1) + client.shutdown() + }) + + it('does not overlap renewals while a slow manager request is still pending', async () => { + vi.useFakeTimers() + let resolveRenewal!: (response: Response) => void + const pendingRenewal = new Promise((resolve) => { resolveRenewal = resolve }) + let renewAttempts = 0 + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = String(input) + if (url.endsWith('/acquire')) return leaseResponse(0) + if (url.endsWith('/renew')) { + renewAttempts += 1 + return pendingRenewal + } + throw new Error(`unexpected request: ${url}`) + })) + const client = new ManagerThreadExecutionLeaseClient(manager, 'production', 'runtime-1') + + await client.acquire('thread-1', 'turn-1') + await vi.advanceTimersByTimeAsync(10_000) + expect(renewAttempts).toBe(1) + + resolveRenewal(leaseResponse(10)) + await vi.advanceTimersByTimeAsync(0) + client.shutdown() + }) +}) + +function leaseResponse(seconds: number): Response { + const acquiredAt = '2026-08-24T00:00:00.000Z' + const expiresAt = new Date(Date.parse(acquiredAt) + (seconds + 15) * 1_000).toISOString() + return new Response(JSON.stringify({ + lease: { + threadId: 'thread-1', + turnId: 'turn-1', + ownerFlavor: 'production', + ownerInstanceId: 'runtime-1', + acquiredAt, + expiresAt + } + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) +} diff --git a/kun/src/manager/manager-client.ts b/kun/src/manager/manager-client.ts index ea7743922..192170f2c 100644 --- a/kun/src/manager/manager-client.ts +++ b/kun/src/manager/manager-client.ts @@ -9,10 +9,6 @@ import { type RuntimeRegistration, type ThreadExecutionLease } from '../contracts/runtime-flavor.js' -import { - ThreadExecutionBusyError, - type ThreadExecutionLeasePort -} from '../ports/thread-execution-lease.js' import { GraphRunConflictError } from '../graph/graph-run-store.js' import { isLoopbackHost } from '../server/loopback-host.js' import { @@ -49,6 +45,7 @@ const LEGACY_HANDOVER_TIMEOUT_MS = 5 * 60_000 export type ServiceManagerConnection = { discovery: ManagerDiscoveryRecord } +export { ManagerThreadExecutionLeaseClient } from './manager-thread-execution-lease-client.js' export class ManagerRevisionConflictError extends Error { constructor(readonly currentRevision: number) { @@ -469,112 +466,6 @@ export async function readManagerRuntime( return z.object({ registration: RuntimeRegistrationSchema.nullable() }).parse(response).registration } -export class ManagerThreadExecutionLeaseClient implements ThreadExecutionLeasePort { - private readonly renewals = new Map - }>() - private onLeaseLost: ((lease: ThreadExecutionLease) => void) | undefined - - constructor( - private readonly manager: ServiceManagerConnection, - private readonly flavor: RuntimeFlavor, - private readonly instanceId: string - ) {} - - setLeaseLostHandler(handler: (lease: ThreadExecutionLease) => void): void { - this.onLeaseLost = handler - } - - async acquire(threadId: string, turnId: string): Promise { - const response = await requestManagerResponse( - this.manager, - `/v1/leases/threads/${encodeURIComponent(threadId)}/acquire`, - { - method: 'POST', - body: { turnId, ownerFlavor: this.flavor, ownerInstanceId: this.instanceId } - } - ) - if (response.status === 409) { - const body = await response.json().catch(() => null) - const owner = z.object({ owner: ThreadExecutionLeaseSchema }).safeParse(body) - if (owner.success) throw new ThreadExecutionBusyError(owner.data.owner) - } - const parsed = z.object({ lease: ThreadExecutionLeaseSchema }).parse( - await requireManagerJson(response) - ) - this.startRenewal(parsed.lease) - return parsed.lease - } - - async release(threadId: string, turnId: string): Promise { - this.stopRenewal(threadId, turnId) - await requestManagerJson( - this.manager, - `/v1/leases/threads/${encodeURIComponent(threadId)}/release`, - { - method: 'POST', - body: { turnId, ownerFlavor: this.flavor, ownerInstanceId: this.instanceId } - } - ) - } - - async owner(threadId: string): Promise { - const body = await requestManagerJson( - this.manager, - `/v1/leases/threads/${encodeURIComponent(threadId)}`, - {} - ) - return z.object({ lease: ThreadExecutionLeaseSchema.nullable() }).parse(body).lease - } - - shutdown(): void { - for (const { timer } of this.renewals.values()) clearInterval(timer) - this.renewals.clear() - } - - private startRenewal(lease: ThreadExecutionLease): void { - this.stopRenewal(lease.threadId) - const timer = setInterval(() => void this.renew(lease.threadId), 5_000) - timer.unref?.() - this.renewals.set(lease.threadId, { lease, timer }) - } - - private async renew(threadId: string): Promise { - const current = this.renewals.get(threadId) - if (!current) return - try { - const response = await requestManagerResponse( - this.manager, - `/v1/leases/threads/${encodeURIComponent(threadId)}/renew`, - { - method: 'POST', - body: { - turnId: current.lease.turnId, - ownerFlavor: this.flavor, - ownerInstanceId: this.instanceId - } - } - ) - const parsed = z.object({ lease: ThreadExecutionLeaseSchema }).parse( - await requireManagerJson(response) - ) - const latest = this.renewals.get(threadId) - if (latest?.lease.turnId === current.lease.turnId) latest.lease = parsed.lease - } catch { - this.stopRenewal(threadId, current.lease.turnId) - this.onLeaseLost?.(current.lease) - } - } - - private stopRenewal(threadId: string, turnId?: string): void { - const current = this.renewals.get(threadId) - if (!current || (turnId && current.lease.turnId !== turnId)) return - clearInterval(current.timer) - this.renewals.delete(threadId) - } -} - export async function forwardRequestToExecutionOwner(input: { manager: ServiceManagerConnection currentInstanceId: string diff --git a/kun/src/manager/manager-thread-execution-lease-client.ts b/kun/src/manager/manager-thread-execution-lease-client.ts new file mode 100644 index 000000000..9c45e2b8a --- /dev/null +++ b/kun/src/manager/manager-thread-execution-lease-client.ts @@ -0,0 +1,189 @@ +import { z } from 'zod' +import { + ThreadExecutionLeaseSchema, + type RuntimeFlavor, + type ThreadExecutionLease +} from '../contracts/runtime-flavor.js' +import { + ThreadExecutionBusyError, + type ThreadExecutionLeasePort +} from '../ports/thread-execution-lease.js' +import { + requestManagerJson, + requestManagerResponse, + requireManagerJson +} from './manager-client-support.js' +import type { ServiceManagerConnection } from './manager-client.js' + +type ActiveRenewal = { + lease: ThreadExecutionLease + timer: ReturnType + retryTimer?: ReturnType + renewing: boolean + transientFailures: number +} + +export class ManagerThreadExecutionLeaseClient implements ThreadExecutionLeasePort { + private readonly renewals = new Map() + private onLeaseLost: ((lease: ThreadExecutionLease) => void) | undefined + + constructor( + private readonly manager: ServiceManagerConnection, + private readonly flavor: RuntimeFlavor, + private readonly instanceId: string + ) {} + + setLeaseLostHandler(handler: (lease: ThreadExecutionLease) => void): void { + this.onLeaseLost = handler + } + + async acquire(threadId: string, turnId: string): Promise { + const response = await requestManagerResponse( + this.manager, + `/v1/leases/threads/${encodeURIComponent(threadId)}/acquire`, + { + method: 'POST', + body: { turnId, ownerFlavor: this.flavor, ownerInstanceId: this.instanceId } + } + ) + if (response.status === 409) { + const body = await response.json().catch(() => null) + const owner = z.object({ owner: ThreadExecutionLeaseSchema }).safeParse(body) + if (owner.success) throw new ThreadExecutionBusyError(owner.data.owner) + } + const parsed = z.object({ lease: ThreadExecutionLeaseSchema }).parse( + await requireManagerJson(response) + ) + this.startRenewal(parsed.lease) + return parsed.lease + } + + async release(threadId: string, turnId: string): Promise { + this.stopRenewal(threadId, turnId) + await requestManagerJson( + this.manager, + `/v1/leases/threads/${encodeURIComponent(threadId)}/release`, + { + method: 'POST', + body: { turnId, ownerFlavor: this.flavor, ownerInstanceId: this.instanceId } + } + ) + } + + async owner(threadId: string): Promise { + const body = await requestManagerJson( + this.manager, + `/v1/leases/threads/${encodeURIComponent(threadId)}`, + {} + ) + return z.object({ lease: ThreadExecutionLeaseSchema.nullable() }).parse(body).lease + } + + shutdown(): void { + for (const renewal of this.renewals.values()) { + clearInterval(renewal.timer) + if (renewal.retryTimer) clearTimeout(renewal.retryTimer) + } + this.renewals.clear() + } + + private startRenewal(lease: ThreadExecutionLease): void { + this.stopRenewal(lease.threadId) + const timer = setInterval(() => void this.renew(lease.threadId), 5_000) + timer.unref?.() + this.renewals.set(lease.threadId, { + lease, + timer, + renewing: false, + transientFailures: 0 + }) + } + + private async renew(threadId: string): Promise { + const current = this.renewals.get(threadId) + if (!current || current.renewing) return + if (current.retryTimer) { + clearTimeout(current.retryTimer) + current.retryTimer = undefined + } + current.renewing = true + try { + const response = await requestManagerResponse( + this.manager, + `/v1/leases/threads/${encodeURIComponent(threadId)}/renew`, + { + method: 'POST', + body: { + turnId: current.lease.turnId, + ownerFlavor: this.flavor, + ownerInstanceId: this.instanceId + } + } + ) + if (response.status === 409) { + await response.body?.cancel().catch(() => undefined) + this.loseRenewal(current.lease) + return + } + const parsed = z.object({ lease: ThreadExecutionLeaseSchema }).parse( + await requireManagerJson(response) + ) + this.recordRenewal(current, parsed.lease) + } catch (error) { + this.recordTransientFailure(current, error) + } finally { + const latest = this.renewals.get(threadId) + if (latest?.lease.turnId === current.lease.turnId) latest.renewing = false + } + } + + private recordRenewal(current: ActiveRenewal, lease: ThreadExecutionLease): void { + const latest = this.renewals.get(current.lease.threadId) + if (latest?.lease.turnId !== current.lease.turnId) return + if (latest.transientFailures > 0) { + console.warn( + `[kun] thread lease renewal recovered thread=${current.lease.threadId} ` + + `turn=${current.lease.turnId} attempts=${latest.transientFailures + 1}` + ) + } + latest.lease = lease + latest.transientFailures = 0 + } + + private recordTransientFailure(current: ActiveRenewal, error: unknown): void { + const latest = this.renewals.get(current.lease.threadId) + if (latest?.lease.turnId !== current.lease.turnId) return + latest.transientFailures += 1 + if (latest.transientFailures === 1 || latest.transientFailures % 3 === 0) { + console.warn( + `[kun] thread lease renewal delayed thread=${current.lease.threadId} ` + + `turn=${current.lease.turnId} failures=${latest.transientFailures}: ` + + `${error instanceof Error ? error.message : String(error)}` + ) + } + this.scheduleRenewalRetry(latest) + } + + private loseRenewal(lease: ThreadExecutionLease): void { + this.stopRenewal(lease.threadId, lease.turnId) + this.onLeaseLost?.(lease) + } + + private scheduleRenewalRetry(current: ActiveRenewal): void { + if (current.retryTimer) return + const retryMs = Math.min(500 * (2 ** Math.min(current.transientFailures - 1, 3)), 5_000) + current.retryTimer = setTimeout(() => { + current.retryTimer = undefined + void this.renew(current.lease.threadId) + }, retryMs) + current.retryTimer.unref?.() + } + + private stopRenewal(threadId: string, turnId?: string): void { + const current = this.renewals.get(threadId) + if (!current || (turnId && current.lease.turnId !== turnId)) return + clearInterval(current.timer) + if (current.retryTimer) clearTimeout(current.retryTimer) + this.renewals.delete(threadId) + } +} diff --git a/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts b/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts index 272f5aacf..b8dfd79ca 100644 --- a/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts +++ b/src/renderer/src/components/chat/SidebarThreadOrdering.test.ts @@ -72,7 +72,7 @@ function visibleThreadTitles(renderer: ReactTestRenderer, ids: string[]): string } describe('sidebar thread ordering integration', () => { - it('keeps a late-discovered running project row fixed, then promotes awaiting input into the first five', async () => { + it('moves a late-discovered running project row above viewed rows and into the first five', async () => { vi.stubGlobal('localStorage', storage()) const times = ['07', '06', '05', '04', '03', '02', '01'] const threads = times.map((suffix, index) => @@ -97,7 +97,7 @@ describe('sidebar thread ordering integration', () => { }))) }) expect(visibleThreadTitles(renderer!, ids)).toEqual([ - 'thread-1', 'thread-2', 'thread-3', 'thread-4', 'thread-5', 'background' + 'background', 'thread-1', 'thread-2', 'thread-3', 'thread-4' ]) await act(async () => { @@ -137,7 +137,7 @@ describe('sidebar thread ordering integration', () => { watchTurnCompletion: { waiting: true } }))) }) - expect(visibleThreadTitles(renderer!, ['newer', 'waiting'])).toEqual(['newer', 'waiting']) + expect(visibleThreadTitles(renderer!, ['newer', 'waiting'])).toEqual(['waiting', 'newer']) await act(async () => { renderer!.update(createElement(SidebarProjectsSection, projectProps(items, { watchTurnCompletion: { waiting: true }, @@ -189,7 +189,7 @@ describe('sidebar thread ordering integration', () => { const toggle = renderer!.root.find((node) => node.type === 'button' && node.props.title === 'sidebarConversations') await act(async () => toggle.props.onClick()) expect(visibleThreadTitles(renderer!, items.map((item) => item.id))).toEqual([ - 'newer-conversation', 'waiting-conversation' + 'waiting-conversation', 'newer-conversation' ]) await act(async () => useChatStore.setState({ diff --git a/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts b/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts index d394fdc11..91bd6b894 100644 --- a/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts +++ b/src/renderer/src/components/chat/sidebar-sort-anchor.test.ts @@ -41,7 +41,7 @@ function reconcile( } describe('sidebar stable thread ordering', () => { - it('keeps the prior position when running and updatedAt arrive together', () => { + it('moves a newly running row above read rows when running and updatedAt arrive together', () => { const tracker = createSidebarThreadOrderTracker() const settled = thread('settled', { updatedAt: '2026-08-20T00:00:05.000Z' }) const background = thread('background', { updatedAt: '2026-08-20T00:00:01.000Z' }) @@ -56,20 +56,20 @@ describe('sidebar stable thread ordering', () => { watchTurnCompletion: { background: true } } expect(reconcile(tracker, [refreshed, settled], runningContext)).toEqual([ - 'settled', - 'background' + 'background', + 'settled' ]) expect(reconcile(tracker, [ thread('background', { updatedAt: '2026-08-20T00:00:12.000Z' }), settled - ], runningContext)).toEqual(['settled', 'background']) + ], runningContext)).toEqual(['background', 'settled']) }) - it('uses the normal base order for a running row first discovered at startup', () => { + it('places a running row above newer read rows when first discovered at startup', () => { const tracker = createSidebarThreadOrderTracker() const context = { ...settledContext, watchTurnCompletion: { running: true } } expect(reconcile(tracker, [ - thread('running', { updatedAt: '2026-08-20T00:00:09.000Z' }), + thread('running', { updatedAt: '2026-08-20T00:00:01.000Z' }), thread('settled', { updatedAt: '2026-08-20T00:00:05.000Z' }) ], context)).toEqual(['running', 'settled']) }) @@ -79,7 +79,7 @@ describe('sidebar stable thread ordering', () => { const newer = thread('newer', { updatedAt: '2026-08-20T00:00:05.000Z' }) const waiting = thread('waiting', { updatedAt: '2026-08-20T00:00:01.000Z' }) const running = { ...settledContext, watchTurnCompletion: { waiting: true } } - expect(reconcile(tracker, [waiting, newer], running)).toEqual(['newer', 'waiting']) + expect(reconcile(tracker, [waiting, newer], running)).toEqual(['waiting', 'newer']) const awaiting = { ...running, @@ -131,17 +131,17 @@ describe('sidebar stable thread ordering', () => { const tracker = createSidebarThreadOrderTracker() const pinned = thread('pinned', { pinned: true }) const waiting = thread('waiting-under-pin') - reconcile(tracker, [waiting, pinned], { + expect(reconcile(tracker, [waiting, pinned], { ...settledContext, watchTurnCompletion: { 'waiting-under-pin': true } - }) + })).toEqual(['pinned', 'waiting-under-pin']) expect(reconcile(tracker, [waiting, pinned], { ...settledContext, awaitingUserInputThreadIds: { 'waiting-under-pin': true } })).toEqual(['pinned', 'waiting-under-pin']) }) - it('treats a changed manual-order key as a new explicit baseline', () => { + it('keeps attention priority above a changed manual-order baseline', () => { const tracker = createSidebarThreadOrderTracker() const waiting = thread('manual-waiting') const other = thread('manual-other') @@ -152,7 +152,26 @@ describe('sidebar stable thread ordering', () => { expect(reconcile(tracker, [other, waiting], { ...settledContext, awaitingUserInputThreadIds: { 'manual-waiting': true } - }, 'manual-v2')).toEqual(['manual-other', 'manual-waiting']) + }, 'manual-v2')).toEqual(['manual-waiting', 'manual-other']) + }) + + it('keeps the previous relative order while running timestamps refresh', () => { + const tracker = createSidebarThreadOrderTracker() + const running = { + ...settledContext, + watchTurnCompletion: { 'running-a': true, 'running-b': true } + } + expect(reconcile(tracker, [ + thread('read', { updatedAt: '2026-08-20T00:00:09.000Z' }), + thread('running-a', { updatedAt: '2026-08-20T00:00:05.000Z' }), + thread('running-b', { updatedAt: '2026-08-20T00:00:04.000Z' }) + ], running)).toEqual(['running-a', 'running-b', 'read']) + + expect(reconcile(tracker, [ + thread('running-b', { updatedAt: '2026-08-20T00:00:12.000Z' }), + thread('running-a', { updatedAt: '2026-08-20T00:00:11.000Z' }), + thread('read', { updatedAt: '2026-08-20T00:00:09.000Z' }) + ], running)).toEqual(['running-a', 'running-b', 'read']) }) it('demotes a viewed completed result after the last still-running thread', () => { diff --git a/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts b/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts index f09a3d79e..7b8625aac 100644 --- a/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts +++ b/src/renderer/src/components/chat/sidebar-thread-order-tracker.ts @@ -71,6 +71,34 @@ function partitionPinned(order: string[], byId: Map): return [...pinned, ...unpinned] } +function activityPartition(activity: SidebarThreadActivity): 0 | 1 | 2 { + if (isPersistentAttention(activity)) return 0 + if (activity === 'running') return 1 + return 2 +} + +/** + * Keep activity priority visible without letting timestamp refreshes disturb + * the relative order inside any partition. + */ +function partitionByActivity(options: { + activityById: Map + byId: Map + order: string[] +}): string[] { + const pinned: string[] = [] + const activityPartitions: [string[], string[], string[]] = [[], [], []] + for (const id of options.order) { + if (options.byId.get(id)?.pinned === true) { + pinned.push(id) + continue + } + const activity = options.activityById.get(id) ?? 'read' + activityPartitions[activityPartition(activity)].push(id) + } + return [...pinned, ...activityPartitions[0], ...activityPartitions[1], ...activityPartitions[2]] +} + function isPersistentAttention(activity: SidebarThreadActivity): boolean { return activity === 'awaiting-input' || activity === 'failed' || activity === 'unread' } @@ -83,43 +111,6 @@ function shouldPromote( return previous === 'running' && current !== 'running' } -/** - * The user just opened an unread/failed result. Demote only that row so it - * lands after the threads that are still working; no other row moves. - */ -function shouldDemoteAfterViewing( - previous: SidebarThreadActivity | undefined, - current: SidebarThreadActivity, - threadId: string, - context: SidebarThreadActivityContext -): boolean { - if (previous !== 'unread' && previous !== 'failed') return false - if (current !== 'read') return false - return context.activeThreadId === threadId -} - -function demoteAfterLastRunning(options: { - activityById: Map - id: string - order: string[] -}): string[] { - const order = [...options.order] - const from = order.indexOf(options.id) - if (from < 0) return order - let insertAt = -1 - for (let index = order.length - 1; index >= 0; index -= 1) { - if (order[index] !== options.id && options.activityById.get(order[index]!) === 'running') { - insertAt = index + 1 - break - } - } - if (insertAt < 0 || insertAt === from) return order - const [moved] = order.splice(from, 1) - if (moved === undefined) return order - order.splice(insertAt > from ? insertAt - 1 : insertAt, 0, moved) - return order -} - function parsedUpdatedAt(thread: NormalizedThread): number { const value = Date.parse(thread.updatedAt) return Number.isFinite(value) ? value : 0 @@ -178,16 +169,6 @@ export function createSidebarThreadOrderTracker(): SidebarThreadOrderTracker { const promotedIds = baselineChanged ? [] : baseIds.filter((id) => shouldPromote(previous?.activityById.get(id), activityById.get(id)!)) - const demotedViewedIds = baselineChanged - ? [] - : baseIds.filter((id) => - shouldDemoteAfterViewing( - previous?.activityById.get(id), - activityById.get(id)!, - id, - context - ) - ) order = promoteAttentionRows({ byId, @@ -195,10 +176,7 @@ export function createSidebarThreadOrderTracker(): SidebarThreadOrderTracker { previousOrder: previous?.order ?? baseIds, promotedIds }) - for (const id of demotedViewedIds) { - if (byId.get(id)?.pinned === true) continue - order = demoteAfterLastRunning({ activityById, id, order }) - } + order = partitionByActivity({ activityById, byId, order }) snapshots.set(containerKey, { activityById, baselineKey, From 9a81597d0f8d940754d0602104ee4435ecb68bbd Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 09:04:00 +0800 Subject: [PATCH 060/168] feat(create-plan-tool): enhance plan title handling and add test for fallback session id --- kun/src/adapters/tool/create-plan-tool.ts | 32 ++++++++++++++++------- kun/tests/create-plan-tool.test.ts | 30 ++++++++++++++++++--- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/kun/src/adapters/tool/create-plan-tool.ts b/kun/src/adapters/tool/create-plan-tool.ts index 2b44f1687..7af554def 100644 --- a/kun/src/adapters/tool/create-plan-tool.ts +++ b/kun/src/adapters/tool/create-plan-tool.ts @@ -49,7 +49,7 @@ export const CREATE_PLAN_INPUT_SCHEMA: Record = { }, title: { type: 'string', - description: 'Short display title for the plan.' + description: 'Concise plan summary used for the display title and Markdown filename. Prefer the plan title or main section name; omit only when title generation fails so the reserved session-id filename is kept.' }, operation: { type: 'string', @@ -274,7 +274,7 @@ export async function executeCreatePlanTool( } const resolved = context.guiPlan - ? resolveReservedTarget(input, context) + ? await resolveReservedTarget(input, context, options) : await resolveFreeFormTarget(input, context, options) if ('error' in resolved) { return { output: { error: resolved.error }, isError: true } @@ -357,10 +357,11 @@ export async function executeCreatePlanTool( * host-owned operation, with parity checks on workspace, id, and * explicit path overrides. */ -function resolveReservedTarget( +async function resolveReservedTarget( input: Partial, - context: ToolHostContext -): ResolvedPlanTarget | { error: string } { + context: ToolHostContext, + options: CreatePlanAdapterOptions +): Promise { const contextPlan = context.guiPlan if (!contextPlan) { return { error: 'create_plan requires an active reserved plan context' } @@ -368,11 +369,11 @@ function resolveReservedTarget( if (!guiPlanWorkspaceMatches(context.workspace, contextPlan.workspaceRoot)) { return { error: 'tool workspace does not match the active plan workspace' } } - const relativePath = toRelativePath(contextPlan.relativePath) - if (!relativePath || !isGuiPlanRelativePath(relativePath)) { + const reservedRelativePath = toRelativePath(contextPlan.relativePath) + if (!reservedRelativePath || !isGuiPlanRelativePath(reservedRelativePath)) { return { error: 'plan_relative_path must be a direct Markdown file under .kunsdd/plan' } } - if (contextPlan.operation === 'draft' && !isGuiPlanCurrentRelativePath(relativePath)) { + if (contextPlan.operation === 'draft' && !isGuiPlanCurrentRelativePath(reservedRelativePath)) { return { error: 'legacy .deepseekgui/plan paths can only be refined' } } if (input.plan_relative_path && toRelativePath(input.plan_relative_path) !== contextPlan.relativePath) { @@ -385,13 +386,24 @@ function resolveReservedTarget( if (!workspaceRoot) { return { error: 'workspace root is required' } } + let relativePath = reservedRelativePath + if (contextPlan.operation === 'draft') { + // The renderer reserves a provisional path before the model responds. Use + // the model's concise plan title when available; only fall back to the + // stable session id when the title call/tool argument is unavailable. + const featureName = deriveFeatureName(input.title?.trim() || context.threadId) + const existing = await listExistingPlanRelativePaths(workspaceRoot, options) + relativePath = nextAvailablePlanRelativePath(featureName, existing) + } return { workspaceRoot, relativePath, - planId: contextPlan.planId ?? input.plan_id ?? buildGuiPlanId(workspaceRoot, relativePath), + planId: relativePath === reservedRelativePath + ? contextPlan.planId ?? input.plan_id ?? buildGuiPlanId(workspaceRoot, relativePath) + : buildGuiPlanId(workspaceRoot, relativePath), operation: contextPlan.operation, sourceRequest: contextPlan.sourceRequest, - title: contextPlan.title + title: input.title ?? contextPlan.title } } diff --git a/kun/tests/create-plan-tool.test.ts b/kun/tests/create-plan-tool.test.ts index 70cee32f8..fecf55baf 100644 --- a/kun/tests/create-plan-tool.test.ts +++ b/kun/tests/create-plan-tool.test.ts @@ -460,12 +460,12 @@ describe('create_plan tool: success and atomic write', () => { byte_size: number saved_at: string } - expect(output.relative_path).toBe('.kunsdd/plan/login.md') + expect(output.relative_path).toBe('.kunsdd/plan/login-flow.md') expect(output.operation).toBe('draft') - expect(output.summary).toContain('.kunsdd/plan/login.md') + expect(output.summary).toContain('.kunsdd/plan/login-flow.md') expect(output.content_hash).toMatch(/^[a-f0-9]{16}$/) expect(output.byte_size).toBe(Buffer.byteLength('# Login plan\n\n- step 1', 'utf8')) - expect(output.absolute_path).toBe(join(workspace, '.kunsdd/plan/login.md')) + expect(output.absolute_path).toBe(join(workspace, '.kunsdd/plan/login-flow.md')) const persisted = await readFile(output.absolute_path, 'utf8') expect(persisted).toBe('# Login plan\n\n- step 1') }) @@ -519,6 +519,30 @@ describe('create_plan tool: success and atomic write', () => { expect(JSON.stringify(result.output)).toMatch(/legacy/) }) + it('uses the reserved session id when a draft has no generated title', async () => { + const result = await executeCreatePlanTool( + { markdown: '# fallback' }, + buildContext({ + threadId: 'thr_plan_fallback', + threadMode: 'plan', + workspace, + guiPlan: { + operation: 'draft', + workspaceRoot: workspace, + relativePath: '.kunsdd/plan/raw-user-message.md', + planId: `${workspace}:.kunsdd/plan/raw-user-message.md`, + sourceRequest: 'A long follow-up user message that should not become the filename' + } + }) + ) + + expect(result.isError).toBeFalsy() + expect(result.output).toMatchObject({ + relative_path: '.kunsdd/plan/thr-plan-fallback.md', + plan_id: `${workspace}:.kunsdd/plan/thr-plan-fallback.md` + }) + }) + it('overwrites an existing plan when the same reserved path is reused', async () => { const result = await executeCreatePlanTool( { markdown: '# refined', operation: 'refine' }, From a04a7d4735826bb8bce7d297c380f64b28cbb66d Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Mon, 24 Aug 2026 22:39:00 +0800 Subject: [PATCH 061/168] feat(plan-panel): add copy path functionality for active plan and update localization strings --- .../src/components/plan/PlanPanel.tsx | 64 ++++++++++++++++++- .../src/locales/en/common/commands-sdd.json | 1 + .../src/locales/hi/common/commands-sdd.json | 1 + .../src/locales/ja/common/commands-sdd.json | 1 + .../src/locales/ko/common/commands-sdd.json | 1 + .../src/locales/ru/common/commands-sdd.json | 1 + .../src/locales/th/common/commands-sdd.json | 1 + .../src/locales/zh/common/commands-sdd.json | 1 + 8 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/plan/PlanPanel.tsx b/src/renderer/src/components/plan/PlanPanel.tsx index 5b1d7a48c..1d15187b1 100644 --- a/src/renderer/src/components/plan/PlanPanel.tsx +++ b/src/renderer/src/components/plan/PlanPanel.tsx @@ -1,6 +1,8 @@ -import { useEffect, type ReactElement } from 'react' +import { useEffect, useRef, useState, type ReactElement } from 'react' import { + Check, ClipboardList, + Copy, ExternalLink, Loader2, PanelRightClose, @@ -241,6 +243,39 @@ export function PlanPanel({ ) } + const [copyPathStatus, setCopyPathStatus] = useState<'idle' | 'success' | 'error'>('idle') + const copyPathResetRef = useRef(null) + + useEffect( + () => () => { + if (copyPathResetRef.current !== null) window.clearTimeout(copyPathResetRef.current) + }, + [] + ) + + const handleCopyPath = async (): Promise => { + if (!activePlan) return + try { + if (!navigator?.clipboard?.writeText) throw new Error('Clipboard unavailable') + await navigator.clipboard.writeText(activePlan.relativePath) + setCopyPathStatus('success') + } catch { + setCopyPathStatus('error') + } + if (copyPathResetRef.current !== null) window.clearTimeout(copyPathResetRef.current) + copyPathResetRef.current = window.setTimeout(() => { + setCopyPathStatus('idle') + copyPathResetRef.current = null + }, 1600) + } + + const copyPathLabel = + copyPathStatus === 'success' + ? t('copySuccess') + : copyPathStatus === 'error' + ? t('copyFailed') + : t('planCopyPath') + return (