fix(topics): coordinate session lifecycle across runtimes

This commit is contained in:
digua committed 2026-08-10 16:52:19 +08:00
1 parent e00b74a330
commit 73601ebe26
14 files changed
+808 -149

No files matched your search

@@ -673,6 +673,28 @@ describe('registerSharedRoutes smoke tests', () => {
assert.deepEqual(lifecycle, ['close:chat-1', 'delete:chat-1'])
})
it('DELETE /_web/sessions/:id preserves a topic generation conflict without deleting the session', async () => {
const db = createSessionDb()
const dbs = new Map<string, DatabaseAdapter>([['chat-1', db]])
const routeApp = Fastify()
const ctx = createTestContext(dbs)
ctx.sessionAdapter.deleteSessionFile = () => {
throw Object.assign(new Error('Chat topics are being generated for this session in another runtime'), {
statusCode: 409,
})
}
registerSessionRoutes(routeApp, ctx)
await routeApp.ready()
const resp = await routeApp.inject({ method: 'DELETE', url: '/_web/sessions/chat-1' })
await routeApp.close()
db.close()
assert.equal(resp.statusCode, 409)
assert.equal(dbs.has('chat-1'), true)
})
it('owner profile routes select, apply and dismiss across sessions', async () => {
const prefDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-owner-routes-'))
const current = createSessionDb()
@@ -29,6 +29,7 @@ test('chat topic routes expose persisted run state and reject generation without
registerAiChatTopicRoutes(app, {
sessionAdapter: {} as SessionRuntimeAdapter,
pathProvider,
runtimeIdentity: { version: '0.35.1', kind: 'cli' },
nativeBinding,
})
await app.ready()
@@ -9,15 +9,20 @@ import type { CreateChatTopicsRequest } from '@openchatlab/shared-types'
import type { AiRouteContext } from '../../context/ai'
import type { RuntimeRouteContext } from '../../context/runtime'
type ChatTopicRouteContext = Pick<RuntimeRouteContext, 'sessionAdapter' | 'pathProvider' | 'nativeBinding'> &
type ChatTopicRouteContext = Pick<
RuntimeRouteContext,
'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding'
> &
Pick<AiRouteContext, 'llmConfigStore'>
export function registerAiChatTopicRoutes(server: FastifyInstance, ctx: ChatTopicRouteContext): void {
let service: ChatTopicService | null = null
const getService = () => {
if (!ctx.runtimeIdentity) throw new Error('Chat topic routes require a runtime identity')
service ??= createChatTopicService({
runtime: ctx.sessionAdapter,
userDataDir: ctx.pathProvider.getUserDataDir(),
pathProvider: ctx.pathProvider,
runtimeIdentity: ctx.runtimeIdentity,
nativeBinding: ctx.nativeBinding,
getModelClient() {
const config = ctx.llmConfigStore?.getFastModelConfig()
@@ -15,7 +15,7 @@ import { registerAiSummaryRoutes } from './ai-summaries'
import { registerAiToolRoutes } from './ai-tools'
export type AiRoutesContext = AiRouteContext &
Pick<RuntimeRouteContext, 'dbManager' | 'sessionAdapter' | 'pathProvider' | 'nativeBinding'> &
Pick<RuntimeRouteContext, 'dbManager' | 'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding'> &
Pick<StorageRouteContext, 'showInFolder'>
export interface AiRouteOptions {
@@ -49,17 +49,13 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
server.delete<{ Params: { id: string } }>('/_web/sessions/:id', async (request, reply) => {
const { id } = request.params
try {
await chatTopicWorkCoordinator.prepareSessionDelete(id)
await ctx.beforeDeleteSession?.(id)
const deleted = sessionService.deleteSession(adapter, id)
if (!deleted) {
return reply.code(404).send({ success: false, error: 'File not found' })
}
return { success: true }
} catch (err) {
return reply.code(500).send({ success: false, error: String(err) })
await chatTopicWorkCoordinator.prepareSessionDelete(id)
await ctx.beforeDeleteSession?.(id)
const deleted = sessionService.deleteSession(adapter, id)
if (!deleted) {
return reply.code(404).send({ success: false, error: 'File not found' })
}
return { success: true }
})
server.patch<{ Params: { id: string }; Body: { name: string } }>('/_web/sessions/:id/name', async (request) => {
@@ -131,6 +131,12 @@ export function assertDataDirCompatible(
}
}
export function isRuntimeVersionAtLeast(runtimeVersion: string, minimumVersion: string): boolean {
const normalizedRuntimeVersion = normalizeRuntimeStableVersion(runtimeVersion)
if (!normalizedRuntimeVersion || !isStableSemver(minimumVersion)) return false
return compareStableSemver(normalizedRuntimeVersion, minimumVersion) >= 0
}
export function raiseDataDirMinRuntimeVersion(
pathProvider: PathProvider,
input: RaiseDataDirCompatibilityInput
@@ -46,6 +46,36 @@ function assertAnalysisToolIndexes(db: Database.Database): void {
assert.ok(indexes.includes('idx_message_type_ts'))
}
function seedTopicRun(userDataDir: string, sessionId: string, runId = 'topic-run'): void {
const topicStore = new ChatTopicStore(getChatTopicsDbPath(userDataDir), { nativeBinding })
topicStore.createRun({
id: runId,
sessionId,
rangeKind: 'today',
timezone: 'Asia/Shanghai',
locale: 'zh-CN',
startDay: '2026-08-09',
endDay: '2026-08-09',
status: 'paused',
totalDays: 1,
completedDays: 0,
totalBlocks: 1,
completedBlocks: 0,
currentDay: '2026-08-09',
currentBlockIndex: 0,
modelId: 'test-model',
promptVersion: 'topics-v1',
algorithmVersion: 'topics-v1',
inputTokens: 0,
outputTokens: 0,
modelCalls: 0,
lastError: null,
createdAt: 1,
updatedAt: 1,
})
topicStore.close()
}
test('constructor rejects missing runtime unless the test-only bypass is explicit', () => {
const root = makeTempDir()
@@ -1188,33 +1218,7 @@ test('deleting a session also removes its derived topic data', () => {
fs.mkdirSync(paths.getDatabaseDir(), { recursive: true })
fs.writeFileSync(path.join(paths.getDatabaseDir(), 'session-with-topics.db'), 'placeholder')
const topicStore = new ChatTopicStore(getChatTopicsDbPath(paths.getUserDataDir()), { nativeBinding })
topicStore.createRun({
id: 'topic-run',
sessionId: 'session-with-topics',
rangeKind: 'today',
timezone: 'Asia/Shanghai',
locale: 'zh-CN',
startDay: '2026-08-09',
endDay: '2026-08-09',
status: 'paused',
totalDays: 1,
completedDays: 0,
totalBlocks: 1,
completedBlocks: 0,
currentDay: '2026-08-09',
currentBlockIndex: 0,
modelId: 'test-model',
promptVersion: 'topics-v1',
algorithmVersion: 'topics-v1',
inputTokens: 0,
outputTokens: 0,
modelCalls: 0,
lastError: null,
createdAt: 1,
updatedAt: 1,
})
topicStore.close()
seedTopicRun(paths.getUserDataDir(), 'session-with-topics')
const manager = new DatabaseManager(paths, { nativeBinding, allowMissingRuntimeForTests: true })
assert.equal(manager.deleteSessionDatabaseFiles('session-with-topics'), true)
@@ -1226,3 +1230,51 @@ test('deleting a session also removes its derived topic data', () => {
reopened.close()
}
})
test('failed primary database deletion preserves derived topic data', () => {
const root = makeTempDir()
const paths = createPathProvider(root)
const dbPath = path.join(paths.getDatabaseDir(), 'blocked.db')
fs.mkdirSync(dbPath, { recursive: true })
seedTopicRun(paths.getUserDataDir(), 'blocked', 'blocked-topic-run')
const manager = new DatabaseManager(paths, { nativeBinding, allowMissingRuntimeForTests: true })
assert.throws(() => manager.deleteSessionDatabaseFiles('blocked'))
assert.equal(fs.existsSync(dbPath), true)
const reopened = new ChatTopicStore(getChatTopicsDbPath(paths.getUserDataDir()), { nativeBinding })
try {
assert.equal(reopened.getRun('blocked-topic-run')?.sessionId, 'blocked')
} finally {
reopened.close()
}
})
test('session deletion rejects a live topic execution owned by another runtime', () => {
const root = makeTempDir()
const paths = createPathProvider(root)
const dbPath = path.join(paths.getDatabaseDir(), 'active-topics.db')
fs.mkdirSync(paths.getDatabaseDir(), { recursive: true })
fs.writeFileSync(dbPath, 'placeholder')
seedTopicRun(paths.getUserDataDir(), 'active-topics', 'active-topic-run')
const topicStore = new ChatTopicStore(getChatTopicsDbPath(paths.getUserDataDir()), { nativeBinding })
const run = topicStore.getRun('active-topic-run')!
topicStore.updateRun({ ...run, status: 'running' })
assert.equal(topicStore.tryAcquireExecutionLease(run.id, 'other-runtime', Date.now(), Date.now() + 30_000), true)
topicStore.close()
const manager = new DatabaseManager(paths, { nativeBinding, allowMissingRuntimeForTests: true })
assert.throws(
() => manager.deleteSessionDatabaseFiles('active-topics'),
(error: unknown) => (error as { code?: string }).code === 'TOPIC_EXECUTION_IN_PROGRESS'
)
assert.equal(fs.existsSync(dbPath), true)
const reopened = new ChatTopicStore(getChatTopicsDbPath(paths.getUserDataDir()), { nativeBinding })
try {
assert.equal(reopened.getRun('active-topic-run')?.status, 'running')
} finally {
reopened.close()
}
})
@@ -25,7 +25,7 @@ import { getContactsFactsCacheDir } from './services/contacts/paths'
import { getGlobalInsightDir, getGlobalInsightFactsCacheDir } from './services/global-insight/paths'
import { deleteAnnualSummarySnapshots } from './services/global-insight/snapshot'
import { getPeopleRelationshipsFactsCacheDir } from './services/people/relationships/paths'
import { deleteSessionChatTopics } from './services/topics'
import { assertSessionChatTopicsIdle, deleteSessionChatTopics } from './services/topics'
interface DatabaseManagerOptions {
nativeBinding?: string
@@ -248,14 +248,14 @@ export class DatabaseManager {
* SQLite WAL 模式会产生 sidecar 文件,前端查询还会写 JSON 缓存;只删主库会让列表和后续同步读到脏状态。
*/
deleteSessionDatabaseFiles(sessionId: string): boolean {
assertSessionChatTopicsIdle(this.pathProvider.getUserDataDir(), sessionId, { nativeBinding: this.nativeBinding })
this.close(sessionId)
// 话题是独立派生库中的会话数据,先删除它;失败时保留主库,避免出现“界面已删但派生数据残留”。
deleteSessionChatTopics(this.pathProvider.getUserDataDir(), sessionId, { nativeBinding: this.nativeBinding })
const dbPath = this.getDbPath(sessionId)
const existed = ['', '-wal', '-shm'].some((suffix) => fs.existsSync(dbPath + suffix))
for (const suffix of ['', '-wal', '-shm']) {
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath)
for (const suffix of ['-wal', '-shm']) {
try {
const filePath = dbPath + suffix
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
@@ -263,6 +263,10 @@ export class DatabaseManager {
/* ignore cleanup failures */
}
}
// Derived data is removed only after the primary database is gone. If deleting the main file fails, callers get
// the original filesystem error and can retry without losing paid topic snapshots or other cached results.
deleteSessionChatTopics(this.pathProvider.getUserDataDir(), sessionId, { nativeBinding: this.nativeBinding })
const cacheDir = this.pathProvider.getCacheDir()
deleteSessionCache(sessionId, cacheDir)
deleteSessionCache(sessionId, path.join(cacheDir, 'query'))
+1
View File
@@ -149,6 +149,7 @@ export type {
export {
assertDataDirCompatible,
DataDirCompatibilityError,
isRuntimeVersionAtLeast,
raiseDataDirMinRuntimeVersion,
readDataDirCompatibilityMeta,
} from './data-dir-compat'
@@ -31,7 +31,7 @@ export {
loadTopicSourceDay,
} from './source'
export type { TopicChatType, TopicSourceBlock, TopicSourceDay, TopicSourceMessage } from './source'
export { ChatTopicStore, deleteSessionChatTopics } from './store'
export { ChatTopicStore, assertSessionChatTopicsIdle, deleteSessionChatTopics } from './store'
export type { FinalizeTopicDayInput, TopicDayCheckpoint } from './store'
export {
assertValidTimezone,
@@ -7,6 +7,7 @@ import Database from 'better-sqlite3'
import type { PathProvider } from '@openchatlab/core'
import { CHAT_DB_SCHEMA } from '@openchatlab/core'
import { DatabaseManager } from '../../database-manager'
import { assertDataDirCompatible, DataDirCompatibilityError, readDataDirCompatibilityMeta } from '../../data-dir-compat'
import { createDatabaseManagerAdapter } from '../adapters'
import type { ChatTopicModelClient } from './model-client'
import { createChatTopicService, type ChatTopicService } from './service'
@@ -69,10 +70,12 @@ function createHarness(
let nextRunId = 1
createSession(root, messageCount, chatType)
const paths = createPathProvider(root)
const manager = new DatabaseManager(paths, { nativeBinding, allowMissingRuntimeForTests: true })
const runtimeIdentity = { version: '0.35.1', kind: 'cli' } as const
const manager = new DatabaseManager(paths, { nativeBinding, runtime: runtimeIdentity })
const service = createChatTopicService({
runtime: createDatabaseManagerAdapter(manager),
userDataDir: paths.getUserDataDir(),
pathProvider: paths,
runtimeIdentity,
nativeBinding,
getModelClient: () => modelClient,
now: () => Date.parse('2026-08-09T12:00:00.000Z'),
@@ -447,6 +450,47 @@ test('a paused model request resumes from the persisted day checkpoint', async (
}
})
test('a persisted paused run can be cancelled without blocking the next generation', async () => {
let calls = 0
const modelClient: ChatTopicModelClient = {
modelId: 'test/model',
complete(prompts, options) {
calls += 1
if (calls === 1) {
return new Promise((_, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
})
}
return Promise.resolve(successfulTopicResult(prompts))
},
}
const { service, manager } = createHarness(modelClient, 1)
try {
const pausedRun = service.start('group', {
rangeKind: 'today',
timezone: 'Asia/Shanghai',
locale: 'zh-CN',
})
await waitUntil(() => calls === 1)
service.pause('group', pausedRun.id)
await waitUntil(() => service.getRun('group', pausedRun.id)?.status === 'paused')
await new Promise<void>((resolve) => setImmediate(resolve))
assert.equal(service.cancel('group', pausedRun.id).status, 'cancelled')
const nextRun = service.start('group', {
rangeKind: 'today',
timezone: 'Asia/Shanghai',
locale: 'zh-CN',
})
assert.equal((await waitForRun(service, 'group', nextRun.id, 'completed')).status, 'completed')
} finally {
service.close()
manager.closeAll()
}
})
test('interactive AI work resumes topic generation after another session is deleted', async () => {
let calls = 0
const modelClient: ChatTopicModelClient = {
@@ -507,7 +551,148 @@ test('interactive AI work resumes topic generation after another session is dele
}
})
test('session deletion waits for active topic work and removes its derived state', async () => {
test('a second runtime cannot recover or control a live topic execution', async () => {
let firstRuntimeCalled = false
const firstModelClient: ChatTopicModelClient = {
modelId: 'test/model',
complete(_prompts, options) {
firstRuntimeCalled = true
return new Promise((_, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('first runtime stopped')), { once: true })
})
},
}
const { root, service: firstService, manager: firstManager } = createHarness(firstModelClient, 1)
const paths = createPathProvider(root)
let secondManager: DatabaseManager | null = null
let secondService: ChatTopicService | null = null
try {
const run = firstService.start('group', {
rangeKind: 'today',
timezone: 'Asia/Shanghai',
locale: 'zh-CN',
})
await waitUntil(() => firstRuntimeCalled)
const runtimeIdentity = { version: '0.35.1', kind: 'desktop' } as const
secondManager = new DatabaseManager(paths, { nativeBinding, runtime: runtimeIdentity })
const runtimeService = createChatTopicService({
runtime: createDatabaseManagerAdapter(secondManager),
pathProvider: paths,
runtimeIdentity,
nativeBinding,
getModelClient: () => firstModelClient,
now: () => Date.parse('2026-08-09T12:00:00.000Z'),
generateId: () => 'second-runtime-run',
})
assert.equal(secondService.getRun('group', run.id)?.status, 'running')
assert.throws(
() => secondService.pause('group', run.id),
(error: unknown) => (error as { statusCode?: number }).statusCode === 409
)
assert.throws(
() => secondService.cancel('group', run.id),
(error: unknown) => (error as { statusCode?: number }).statusCode === 409
)
assert.equal(firstService.getRun('group', run.id)?.status, 'running')
} finally {
firstService.close()
secondService?.close()
firstManager.closeAll()
secondManager?.closeAll()
}
})
test('session deletion preparation preserves completed topic data when primary deletion aborts', async () => {
const modelClient: ChatTopicModelClient = {
modelId: 'test/model',
async complete(prompt) {
return successfulTopicResult(prompt)
},
}
const { service, manager } = createHarness(modelClient, 1)
try {
const run = service.start('group', { rangeKind: 'today', timezone: 'Asia/Shanghai', locale: 'zh-CN' })
await waitForRun(service, 'group', run.id, 'completed')
await chatTopicWorkCoordinator.prepareSessionDelete('group')
assert.equal(service.getRun('group', run.id)?.status, 'completed')
assert.equal(service.getDay('group', '2026-08-09', 'Asia/Shanghai')?.status, 'ready')
} finally {
service.close()
manager.closeAll()
}
})
test('topic storage rejects older runtimes while supported runtimes can delete all session data', async () => {
const modelClient: ChatTopicModelClient = {
modelId: 'test/model',
async complete(prompt) {
return successfulTopicResult(prompt)
},
}
const { root, service, manager } = createHarness(modelClient, 1)
const pathProvider = createPathProvider(root)
try {
const meta = readDataDirCompatibilityMeta(pathProvider.getUserDataDir())
assert.equal(meta?.minRuntimeVersion, '0.35.1')
assert.equal(meta?.dataCompatibilityVersion, 2)
assert.ok(meta?.reasons.includes('chat-topics-store'))
assert.throws(
() => assertDataDirCompatible(pathProvider, { version: '0.35.0', kind: 'cli' }),
(error: unknown) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME'
)
assert.doesNotThrow(() => assertDataDirCompatible(pathProvider, { version: '0.35.1', kind: 'desktop' }))
const run = service.start('group', { rangeKind: 'today', timezone: 'Asia/Shanghai', locale: 'zh-CN' })
await waitForRun(service, 'group', run.id, 'completed')
await chatTopicWorkCoordinator.prepareSessionDelete('group')
assert.equal(manager.deleteSessionDatabaseFiles('group'), true)
assert.equal(service.getDay('group', '2026-08-09', 'Asia/Shanghai'), null)
} finally {
service.close()
manager.closeAll()
}
})
test('session deletion preparation cancels a persisted paused run', async () => {
let called = false
const modelClient: ChatTopicModelClient = {
modelId: 'test/model',
complete(_prompts, options) {
called = true
return new Promise((_, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('paused before deletion')), { once: true })
})
},
}
const { service, manager } = createHarness(modelClient, 1)
try {
const run = service.start('group', { rangeKind: 'today', timezone: 'Asia/Shanghai', locale: 'zh-CN' })
await waitUntil(() => called)
service.pause('group', run.id)
await waitUntil(() => service.getRun('group', run.id)?.status === 'paused')
await new Promise<void>((resolve) => setImmediate(resolve))
await chatTopicWorkCoordinator.prepareSessionDelete('group')
assert.equal(service.getRun('group', run.id)?.status, 'cancelled')
assert.equal(manager.deleteSessionDatabaseFiles('group'), true)
assert.equal(service.getRun('group', run.id), null)
} finally {
service.close()
manager.closeAll()
}
})
test('session deletion preparation waits for active topic work before primary deletion', async () => {
let called = false
const modelClient: ChatTopicModelClient = {
modelId: 'test/model',
@@ -524,6 +709,9 @@ test('session deletion waits for active topic work and removes its derived state
const run = service.start('group', { rangeKind: 'today', timezone: 'Asia/Shanghai', locale: 'zh-CN' })
await waitUntil(() => called)
await chatTopicWorkCoordinator.prepareSessionDelete('group')
assert.equal(service.getRun('group', run.id)?.status, 'cancelled')
assert.equal(manager.deleteSessionDatabaseFiles('group'), true)
assert.equal(service.getRun('group', run.id), null)
assert.equal(service.getLatestRun('group'), null)
} finally {
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'
import type { PathProvider } from '@openchatlab/core'
import type { ChatTopicDay, ChatTopicPreflight, ChatTopicRun, CreateChatTopicsRequest } from '@openchatlab/shared-types'
import { appLogger } from '../../logging/app-logger'
import { isRuntimeVersionAtLeast, raiseDataDirMinRuntimeVersion, type RuntimeIdentity } from '../../data-dir-compat'
import type { SessionRuntimeAdapter } from '../adapters'
import {
applyTopicFinalization,
@@ -28,11 +30,13 @@ import { chatTopicWorkCoordinator } from './work-coordinator'
export interface ChatTopicServiceDeps {
runtime: SessionRuntimeAdapter
userDataDir: string
pathProvider: PathProvider
runtimeIdentity: RuntimeIdentity
getModelClient(): ChatTopicModelClient | null
nativeBinding?: string
now?: () => number
generateId?: () => string
runtimeId?: string
}
export interface ChatTopicService {
@@ -53,6 +57,7 @@ interface ActiveExecution {
runId: string
controller: AbortController
requestedStatus: 'paused' | 'cancelled' | 'preempted' | null
leaseLost: boolean
}
interface PlannedDay {
@@ -62,15 +67,30 @@ interface PlannedDay {
skip: boolean
}
const TOPIC_EXECUTION_LEASE_DURATION_MS = 30_000
const TOPIC_EXECUTION_HEARTBEAT_MS = 10_000
const CHAT_TOPICS_MIN_RUNTIME_VERSION = '0.35.1'
const CHAT_TOPICS_DATA_COMPATIBILITY_VERSION = 2
export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicService {
const now = deps.now ?? Date.now
const generateId = deps.generateId ?? randomUUID
const store = new ChatTopicStore(getChatTopicsDbPath(deps.userDataDir), { nativeBinding: deps.nativeBinding })
const runtimeId = deps.runtimeId ?? randomUUID()
const store = new ChatTopicStore(getChatTopicsDbPath(deps.pathProvider.getUserDataDir()), {
nativeBinding: deps.nativeBinding,
})
try {
raiseChatTopicsCompatibilityGate(deps.pathProvider, deps.runtimeIdentity)
} catch (error) {
store.close()
throw error
}
store.recoverInterruptedRuns(now())
let activeExecution: ActiveExecution | null = null
let closed = false
let storeClosed = false
let preemptedRunId: string | null = null
let leaseHeartbeat: { runId: string; timer: ReturnType<typeof setInterval> } | null = null
const unsubscribeCoordinator = chatTopicWorkCoordinator.subscribe(handleInteractiveStateChange)
const unsubscribeSessionDelete = chatTopicWorkCoordinator.subscribeSessionDelete(prepareSessionDelete)
const executionWaiters: Array<{ runId: string; resolve: () => void }> = []
@@ -132,6 +152,7 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
totalBlocks: number
modelId: string
}): ChatTopicRun {
store.recoverInterruptedRuns(now())
const active = store.getActiveRun()
if (active) {
throw Object.assign(new Error(`A chat topic run is already ${active.status}`), {
@@ -166,22 +187,36 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
updatedAt: timestamp,
}
store.createRun(run)
if (run.status === 'pending') launch(run.id)
if (run.status === 'pending') {
if (!acquireExecutionLease(run.id)) {
store.updateRun({ ...run, status: 'cancelled', lastError: 'Another runtime owns topic generation' })
const competing = store.getActiveRun()
throw Object.assign(new Error('Another ChatLab runtime is already generating chat topics'), {
statusCode: 409,
activeRun: competing,
})
}
launch(run.id)
}
return run
}
function getRun(sessionId: string, runId: string): ChatTopicRun | null {
store.recoverInterruptedRuns(now())
const run = store.getRun(runId)
return run?.sessionId === sessionId ? run : null
}
function getLatestRun(sessionId: string): ChatTopicRun | null {
store.recoverInterruptedRuns(now())
return store.getLatestRun(sessionId)
}
function pause(sessionId: string, runId: string): ChatTopicRun {
const run = requireRun(sessionId, runId)
if (run.status === 'pending' && activeExecution?.runId !== runId) {
requireExecutionLease(runId)
if (preemptedRunId === runId) preemptedRunId = null
return updateRun(run, { status: 'paused' })
}
if (run.status !== 'running' && run.status !== 'pending') return run
@@ -191,6 +226,7 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
activeExecution.controller.abort()
return run
}
requireExecutionLease(runId)
return updateRun(run, { status: 'paused' })
}
@@ -202,9 +238,17 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
if (active && active.id !== runId) {
throw Object.assign(new Error(`A chat topic run is already ${active.status}`), { statusCode: 409 })
}
const resumed = updateRun(run, { status: 'pending', lastError: null })
launch(runId)
return resumed
if (!acquireExecutionLease(runId)) {
throw Object.assign(new Error('Another ChatLab runtime is already generating chat topics'), { statusCode: 409 })
}
try {
const resumed = updateRun(run, { status: 'pending', lastError: null })
launch(runId)
return resumed
} catch (error) {
releaseExecutionLease(runId)
throw error
}
}
function cancel(sessionId: string, runId: string): ChatTopicRun {
@@ -214,6 +258,10 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
preemptedRunId = null
activeExecution.requestedStatus = 'cancelled'
activeExecution.controller.abort()
return run
}
if (!acquireExecutionLease(runId)) {
throw Object.assign(new Error('Another ChatLab runtime is already generating chat topics'), { statusCode: 409 })
}
return updateRun(run, { status: 'cancelled' })
}
@@ -240,12 +288,19 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
closed = true
unsubscribeCoordinator()
unsubscribeSessionDelete()
preemptedRunId = null
if (activeExecution) {
activeExecution.requestedStatus = 'paused'
activeExecution.controller.abort()
return
}
if (preemptedRunId) {
const pending = store.getRun(preemptedRunId)
if (pending && store.ownsExecutionLease(pending.id, runtimeId, now())) {
updateRun(pending, { status: 'paused' })
}
preemptedRunId = null
}
stopLeaseHeartbeat()
closeStore()
}
@@ -255,7 +310,12 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
preemptedRunId = runId
return
}
const execution: ActiveExecution = { runId, controller: new AbortController(), requestedStatus: null }
const execution: ActiveExecution = {
runId,
controller: new AbortController(),
requestedStatus: null,
leaseLost: false,
}
activeExecution = execution
queueMicrotask(() => {
void executeRun(execution).finally(() => {
@@ -270,6 +330,7 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
async function executeRun(execution: ActiveExecution): Promise<void> {
let run = store.getRun(execution.runId)
if (!run) return
if (execution.leaseLost) return
if (execution.controller.signal.aborted) {
updateRun(run, {
status: execution.requestedStatus === 'preempted' ? 'pending' : (execution.requestedStatus ?? 'paused'),
@@ -321,6 +382,12 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
modelCalls: run.modelCalls,
})
} catch (error) {
if (execution.leaseLost || isExecutionLeaseLostError(error)) {
appLogger.warn('chat-topics', 'chat topic generation stopped after execution lease was lost', {
runId: execution.runId,
})
return
}
const latest = store.getRun(run.id) ?? run
const requestedStatus = execution.requestedStatus
const status = requestedStatus === 'preempted' ? 'pending' : (requestedStatus ?? 'failed')
@@ -427,22 +494,25 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
if (currentSource.sourceSignature !== source.sourceSignature) {
throw new Error('Chat messages changed during topic generation; retry is required')
}
store.finalizeDay({
sessionId: run.sessionId,
dayKey: source.dayKey,
timezone: source.timezone,
sourceSignature: source.sourceSignature,
sourceMessageCount: source.messages.length,
sourceFirstTs: source.messages[0]!.timestamp,
sourceLastTs: source.messages.at(-1)!.timestamp,
runId: run.id,
modelId: modelClient.modelId,
promptVersion: CHAT_TOPICS_PROMPT_VERSION,
algorithmVersion: CHAT_TOPICS_ALGORITHM_VERSION,
overview: finalResult.value.finalization.overview,
topics: materializeChatTopics(finalResult.value.ledger, source.messages),
generatedAt: now(),
})
store.finalizeDay(
{
sessionId: run.sessionId,
dayKey: source.dayKey,
timezone: source.timezone,
sourceSignature: source.sourceSignature,
sourceMessageCount: source.messages.length,
sourceFirstTs: source.messages[0]!.timestamp,
sourceLastTs: source.messages.at(-1)!.timestamp,
runId: run.id,
modelId: modelClient.modelId,
promptVersion: CHAT_TOPICS_PROMPT_VERSION,
algorithmVersion: CHAT_TOPICS_ALGORITHM_VERSION,
overview: finalResult.value.finalization.overview,
topics: materializeChatTopics(finalResult.value.ledger, source.messages),
generatedAt: now(),
},
executionLeaseGuard()
)
run = updateRun(run, {
completedDays: run.completedDays + 1,
currentDay: null,
@@ -496,31 +566,34 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
status: TopicDayCheckpoint['status'],
lastError: string | null = null
): void {
store.saveCheckpoint({
sessionId: run.sessionId,
dayKey: source.dayKey,
timezone: source.timezone,
status,
sourceSignature: source.sourceSignature,
sourceMessageCount: source.messages.length,
sourceFirstTs: source.messages[0]!.timestamp,
sourceLastTs: source.messages.at(-1)!.timestamp,
runId: run.id,
totalBlocks: source.blocks.length,
completedBlockIndex,
ledgerJson: serializeTopicLedger(ledger),
modelId: run.modelId,
promptVersion: CHAT_TOPICS_PROMPT_VERSION,
algorithmVersion: CHAT_TOPICS_ALGORITHM_VERSION,
lastError,
updatedAt: now(),
})
store.saveCheckpoint(
{
sessionId: run.sessionId,
dayKey: source.dayKey,
timezone: source.timezone,
status,
sourceSignature: source.sourceSignature,
sourceMessageCount: source.messages.length,
sourceFirstTs: source.messages[0]!.timestamp,
sourceLastTs: source.messages.at(-1)!.timestamp,
runId: run.id,
totalBlocks: source.blocks.length,
completedBlockIndex,
ledgerJson: serializeTopicLedger(ledger),
modelId: run.modelId,
promptVersion: CHAT_TOPICS_PROMPT_VERSION,
algorithmVersion: CHAT_TOPICS_ALGORITHM_VERSION,
lastError,
updatedAt: now(),
},
executionLeaseGuard()
)
}
function markCurrentCheckpointFailed(run: ChatTopicRun, lastError: string | null): void {
const checkpoint = run.currentDay ? store.getCheckpoint(run.sessionId, run.currentDay) : null
if (!checkpoint) return
store.saveCheckpoint({ ...checkpoint, status: 'failed', lastError, updatedAt: now() })
store.saveCheckpoint({ ...checkpoint, status: 'failed', lastError, updatedAt: now() }, executionLeaseGuard())
}
function isReusableCheckpoint(
@@ -539,9 +612,57 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
)
}
function acquireExecutionLease(runId: string): boolean {
const acquired = store.tryAcquireExecutionLease(runId, runtimeId, now(), now() + TOPIC_EXECUTION_LEASE_DURATION_MS)
if (acquired) startLeaseHeartbeat(runId)
return acquired
}
function startLeaseHeartbeat(runId: string): void {
stopLeaseHeartbeat()
const timer = setInterval(() => {
if (closed || storeClosed) return
const renewed = store.renewExecutionLease(runId, runtimeId, now() + TOPIC_EXECUTION_LEASE_DURATION_MS)
if (renewed) return
stopLeaseHeartbeat(runId)
if (preemptedRunId === runId) preemptedRunId = null
if (activeExecution?.runId === runId) {
activeExecution.leaseLost = true
activeExecution.controller.abort()
}
}, TOPIC_EXECUTION_HEARTBEAT_MS)
timer.unref?.()
leaseHeartbeat = { runId, timer }
}
function stopLeaseHeartbeat(runId?: string): void {
if (!leaseHeartbeat || (runId && leaseHeartbeat.runId !== runId)) return
clearInterval(leaseHeartbeat.timer)
leaseHeartbeat = null
}
function releaseExecutionLease(runId: string): void {
stopLeaseHeartbeat(runId)
store.releaseExecutionLease(runId, runtimeId)
}
function requireExecutionLease(runId: string): void {
if (store.ownsExecutionLease(runId, runtimeId, now())) return
throw Object.assign(new Error('This chat topic run is active in another ChatLab runtime'), { statusCode: 409 })
}
function executionLeaseGuard() {
return { ownerId: runtimeId, now: now() }
}
function updateRun(run: ChatTopicRun, updates: Partial<ChatTopicRun>): ChatTopicRun {
const updated = { ...run, ...updates, updatedAt: now() }
store.updateRun(updated)
if (!store.updateRunIfOwned(updated, executionLeaseGuard())) {
throw Object.assign(new Error('Chat topic execution lease was lost'), { code: 'TOPIC_EXECUTION_LEASE_LOST' })
}
if (['completed', 'failed', 'paused', 'cancelled'].includes(updated.status)) {
releaseExecutionLease(updated.id)
}
return updated
}
@@ -564,6 +685,7 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
function closeStore(): void {
if (storeClosed) return
stopLeaseHeartbeat()
storeClosed = true
store.close()
}
@@ -585,7 +707,7 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
if (closed || chatTopicWorkCoordinator.isInteractiveActive || activeExecution || !preemptedRunId) return
const runId = preemptedRunId
const pending = store.getRun(runId)
if (pending?.status !== 'pending') {
if (pending?.status !== 'pending' || !store.ownsExecutionLease(runId, runtimeId, now())) {
preemptedRunId = null
return
}
@@ -605,8 +727,9 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
}
}
const activeRun = store.getActiveRun()
if (activeRun?.sessionId === sessionId) updateRun(activeRun, { status: 'cancelled' })
store.deleteSession(sessionId)
if (activeRun?.sessionId === sessionId) {
cancel(sessionId, activeRun.id)
}
}
function waitForExecution(runId: string): Promise<void> {
@@ -638,6 +761,23 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer
}
}
function raiseChatTopicsCompatibilityGate(pathProvider: PathProvider, runtime: RuntimeIdentity): void {
// The source branch still reports the last released version until the release workflow bumps package metadata.
// Do not make local development builds block themselves; the first publishable runtime is 0.35.1.
if (!isRuntimeVersionAtLeast(runtime.version, CHAT_TOPICS_MIN_RUNTIME_VERSION)) return
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: CHAT_TOPICS_MIN_RUNTIME_VERSION,
dataCompatibilityVersion: CHAT_TOPICS_DATA_COMPATIBILITY_VERSION,
reason: 'chat-topics-store',
runtime,
module: 'chat-topics',
})
}
function isExecutionLeaseLostError(error: unknown): boolean {
return (error as { code?: string } | null)?.code === 'TOPIC_EXECUTION_LEASE_LOST'
}
function validationErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.replace(/[\r\n]+/g, ' ').slice(0, 500)
@@ -223,6 +223,67 @@ test('deleting a session removes its snapshots, checkpoints and runs only', () =
}
})
test('execution leases preserve live runs and recover them after expiry', () => {
const root = makeTempDir()
const store = new ChatTopicStore(getChatTopicsDbPath(root), { nativeBinding })
const now = 1_786_205_000_000
try {
const run = createRun({ updatedAt: now })
store.createRun(run)
assert.equal(store.tryAcquireExecutionLease(run.id, 'runtime-a', now, now + 30_000), true)
assert.equal(store.tryAcquireExecutionLease(run.id, 'runtime-b', now + 1, now + 30_001), false)
assert.equal(store.recoverInterruptedRuns(now + 10_000), 0)
assert.equal(store.getRun(run.id)?.status, 'running')
assert.equal(
store.updateRunIfOwned({ ...run, completedBlocks: 1 }, { ownerId: 'runtime-b', now: now + 10_000 }),
false
)
assert.equal(store.recoverInterruptedRuns(now + 30_001), 1)
assert.equal(store.getRun(run.id)?.status, 'paused')
assert.equal(store.tryAcquireExecutionLease(run.id, 'runtime-b', now + 30_001, now + 60_001), true)
assert.equal(
store.updateRunIfOwned({ ...run, status: 'running' }, { ownerId: 'runtime-a', now: now + 30_002 }),
false
)
} finally {
store.close()
}
})
test('schema v2 stores add execution leases without losing existing runs', () => {
const root = makeTempDir()
const dbPath = getChatTopicsDbPath(root)
const initial = new ChatTopicStore(dbPath, { nativeBinding })
initial.createRun(createRun({ status: 'completed' }))
initial.close()
const legacy = new Database(dbPath, { nativeBinding })
legacy.exec("UPDATE topic_meta SET value = '2' WHERE key = 'schema_version'; DROP TABLE topic_execution_lease;")
legacy.close()
const migrated = new ChatTopicStore(dbPath, { nativeBinding })
try {
assert.equal(migrated.getRun('run-1')?.status, 'completed')
const raw = new Database(dbPath, { nativeBinding })
try {
assert.equal(raw.prepare("SELECT value FROM topic_meta WHERE key = 'schema_version'").pluck().get(), '3')
assert.equal(
raw
.prepare("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'topic_execution_lease'")
.pluck()
.get(),
1
)
} finally {
raw.close()
}
} finally {
migrated.close()
}
})
test('schema v1 snapshots migrate without losing their legacy range fallback', () => {
const root = makeTempDir()
const dbPath = getChatTopicsDbPath(root)
@@ -291,7 +352,14 @@ test('schema v1 snapshots migrate without losing their legacy range fallback', (
assert.deepEqual(topic?.messageIds, [10, 12])
const migrated = new Database(dbPath, { nativeBinding })
try {
assert.equal(migrated.prepare("SELECT value FROM topic_meta WHERE key = 'schema_version'").pluck().get(), '2')
assert.equal(migrated.prepare("SELECT value FROM topic_meta WHERE key = 'schema_version'").pluck().get(), '3')
assert.equal(
migrated
.prepare("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'topic_execution_lease'")
.pluck()
.get(),
1
)
} finally {
migrated.close()
}
@@ -11,7 +11,7 @@ import type {
import { openBetterSqliteDatabase } from '../../better-sqlite3-adapter'
import { getChatTopicsDbPath } from './paths'
const CHAT_TOPICS_SCHEMA_VERSION = 2
const CHAT_TOPICS_SCHEMA_VERSION = 3
interface TopicStoreOptions {
nativeBinding?: string
@@ -39,6 +39,11 @@ export interface TopicDayCheckpoint {
export type StoredTopicDayCheckpoint = TopicDayCheckpoint
export interface TopicExecutionLeaseGuard {
ownerId: string
now: number
}
export interface FinalizeTopicDayInput {
sessionId: string
dayKey: string
@@ -199,6 +204,105 @@ export class ChatTopicStore {
)
}
updateRunIfOwned(run: ChatTopicRun, guard: TopicExecutionLeaseGuard): boolean {
return (
this.db
.prepare(
`UPDATE topic_run SET
status = ?, total_days = ?, completed_days = ?, total_blocks = ?, completed_blocks = ?,
current_day = ?, current_block_index = ?,
model_id = ?, input_tokens = ?, output_tokens = ?, model_calls = ?, last_error = ?, updated_at = ?
WHERE id = ?
AND EXISTS (
SELECT 1 FROM topic_execution_lease
WHERE singleton = 1 AND run_id = ? AND owner_id = ? AND expires_at > ?
)`
)
.run(
run.status,
run.totalDays,
run.completedDays,
run.totalBlocks,
run.completedBlocks,
run.currentDay,
run.currentBlockIndex,
run.modelId,
run.inputTokens,
run.outputTokens,
run.modelCalls,
run.lastError,
run.updatedAt,
run.id,
run.id,
guard.ownerId,
guard.now
).changes > 0
)
}
tryAcquireExecutionLease(runId: string, ownerId: string, now: number, expiresAt: number): boolean {
return (
this.db
.prepare(
`INSERT INTO topic_execution_lease (singleton, run_id, owner_id, expires_at)
VALUES (1, ?, ?, ?)
ON CONFLICT(singleton) DO UPDATE SET
run_id = excluded.run_id,
owner_id = excluded.owner_id,
expires_at = excluded.expires_at
WHERE topic_execution_lease.owner_id = excluded.owner_id
OR topic_execution_lease.expires_at <= ?`
)
.run(runId, ownerId, expiresAt, now).changes > 0
)
}
renewExecutionLease(runId: string, ownerId: string, expiresAt: number): boolean {
return (
this.db
.prepare(
`UPDATE topic_execution_lease SET expires_at = ?
WHERE singleton = 1 AND run_id = ? AND owner_id = ?`
)
.run(expiresAt, runId, ownerId).changes > 0
)
}
ownsExecutionLease(runId: string, ownerId: string, now: number): boolean {
return Boolean(
this.db
.prepare(
`SELECT 1 FROM topic_execution_lease
WHERE singleton = 1 AND run_id = ? AND owner_id = ? AND expires_at > ?`
)
.get(runId, ownerId, now)
)
}
hasLiveExecutionForSession(sessionId: string, now: number): boolean {
return Boolean(
this.db
.prepare(
`SELECT 1
FROM topic_execution_lease lease
JOIN topic_run run ON run.id = lease.run_id
WHERE lease.singleton = 1
AND lease.expires_at > ?
AND run.session_id = ?
AND run.status IN ('pending', 'running')`
)
.get(now, sessionId)
)
}
releaseExecutionLease(runId: string, ownerId: string): boolean {
return (
this.db
.prepare('DELETE FROM topic_execution_lease WHERE singleton = 1 AND run_id = ? AND owner_id = ?')
.run(runId, ownerId).changes > 0
)
}
getRun(runId: string): ChatTopicRun | null {
const row = this.db
.prepare(
@@ -257,62 +361,88 @@ export class ChatTopicStore {
this.db
.prepare(
`UPDATE topic_day_work SET status = 'failed', last_error = 'Generation interrupted by application restart', updated_at = ?
WHERE status IN ('pending', 'running')`
WHERE status IN ('pending', 'running')
AND NOT EXISTS (
SELECT 1 FROM topic_execution_lease
WHERE singleton = 1
AND run_id = topic_day_work.run_id
AND expires_at > ?
)`
)
.run(updatedAt)
return this.db
.run(updatedAt, updatedAt)
const recovered = this.db
.prepare(
`UPDATE topic_run SET status = 'paused', last_error = 'Generation interrupted by application restart', updated_at = ?
WHERE status IN ('pending', 'running')`
WHERE status IN ('pending', 'running')
AND NOT EXISTS (
SELECT 1 FROM topic_execution_lease
WHERE singleton = 1
AND run_id = topic_run.id
AND expires_at > ?
)`
)
.run(updatedAt).changes
.run(updatedAt, updatedAt).changes
this.db
.prepare(
`DELETE FROM topic_execution_lease
WHERE expires_at <= ?
OR NOT EXISTS (
SELECT 1 FROM topic_run
WHERE id = topic_execution_lease.run_id AND status IN ('pending', 'running')
)`
)
.run(updatedAt)
return recovered
})
}
saveCheckpoint(checkpoint: TopicDayCheckpoint): void {
this.db
.prepare(
`INSERT INTO topic_day_work (
session_id, day_key, timezone, status, source_signature, source_message_count,
source_first_ts, source_last_ts, run_id, total_blocks, completed_block_index,
ledger_json, model_id, prompt_version, algorithm_version, last_error, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, day_key) DO UPDATE SET
timezone = excluded.timezone,
status = excluded.status,
source_signature = excluded.source_signature,
source_message_count = excluded.source_message_count,
source_first_ts = excluded.source_first_ts,
source_last_ts = excluded.source_last_ts,
run_id = excluded.run_id,
total_blocks = excluded.total_blocks,
completed_block_index = excluded.completed_block_index,
ledger_json = excluded.ledger_json,
model_id = excluded.model_id,
prompt_version = excluded.prompt_version,
algorithm_version = excluded.algorithm_version,
last_error = excluded.last_error,
updated_at = excluded.updated_at`
)
.run(
checkpoint.sessionId,
checkpoint.dayKey,
checkpoint.timezone,
checkpoint.status,
checkpoint.sourceSignature,
checkpoint.sourceMessageCount,
checkpoint.sourceFirstTs,
checkpoint.sourceLastTs,
checkpoint.runId,
checkpoint.totalBlocks,
checkpoint.completedBlockIndex,
checkpoint.ledgerJson,
checkpoint.modelId,
checkpoint.promptVersion,
checkpoint.algorithmVersion,
checkpoint.lastError ?? null,
checkpoint.updatedAt
)
saveCheckpoint(checkpoint: TopicDayCheckpoint, guard?: TopicExecutionLeaseGuard): void {
this.db.transaction(() => {
if (guard) this.assertExecutionLease(checkpoint.runId, guard)
this.db
.prepare(
`INSERT INTO topic_day_work (
session_id, day_key, timezone, status, source_signature, source_message_count,
source_first_ts, source_last_ts, run_id, total_blocks, completed_block_index,
ledger_json, model_id, prompt_version, algorithm_version, last_error, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, day_key) DO UPDATE SET
timezone = excluded.timezone,
status = excluded.status,
source_signature = excluded.source_signature,
source_message_count = excluded.source_message_count,
source_first_ts = excluded.source_first_ts,
source_last_ts = excluded.source_last_ts,
run_id = excluded.run_id,
total_blocks = excluded.total_blocks,
completed_block_index = excluded.completed_block_index,
ledger_json = excluded.ledger_json,
model_id = excluded.model_id,
prompt_version = excluded.prompt_version,
algorithm_version = excluded.algorithm_version,
last_error = excluded.last_error,
updated_at = excluded.updated_at`
)
.run(
checkpoint.sessionId,
checkpoint.dayKey,
checkpoint.timezone,
checkpoint.status,
checkpoint.sourceSignature,
checkpoint.sourceMessageCount,
checkpoint.sourceFirstTs,
checkpoint.sourceLastTs,
checkpoint.runId,
checkpoint.totalBlocks,
checkpoint.completedBlockIndex,
checkpoint.ledgerJson,
checkpoint.modelId,
checkpoint.promptVersion,
checkpoint.algorithmVersion,
checkpoint.lastError ?? null,
checkpoint.updatedAt
)
})
}
getCheckpoint(sessionId: string, dayKey: string): StoredTopicDayCheckpoint | null {
@@ -330,8 +460,9 @@ export class ChatTopicStore {
return row ?? null
}
finalizeDay(input: FinalizeTopicDayInput): void {
finalizeDay(input: FinalizeTopicDayInput, guard?: TopicExecutionLeaseGuard): void {
this.db.transaction(() => {
if (guard) this.assertExecutionLease(input.runId, guard)
this.db.prepare('DELETE FROM topic WHERE session_id = ? AND day_key = ?').run(input.sessionId, input.dayKey)
// 成功快照与进行中的 checkpoint 分离。刷新失败时仍可继续展示上一次成功结果。
@@ -531,6 +662,17 @@ export class ChatTopicStore {
})
}
private assertExecutionLease(runId: string, guard: TopicExecutionLeaseGuard): void {
const owned = this.db
.prepare(
`UPDATE topic_execution_lease SET expires_at = expires_at
WHERE singleton = 1 AND run_id = ? AND owner_id = ? AND expires_at > ?`
)
.run(runId, guard.ownerId, guard.now).changes
if (owned > 0) return
throw Object.assign(new Error('Chat topic execution lease was lost'), { code: 'TOPIC_EXECUTION_LEASE_LOST' })
}
private initialize(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS topic_meta (
@@ -575,6 +717,14 @@ export class ChatTopicStore {
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS topic_execution_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
run_id TEXT NOT NULL,
owner_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (run_id) REFERENCES topic_run(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS topic_day (
session_id TEXT NOT NULL,
day_key TEXT NOT NULL,
@@ -672,6 +822,11 @@ export class ChatTopicStore {
);
CREATE INDEX IF NOT EXISTS idx_topic_message_message ON topic_message(message_id);
`)
if (version < CHAT_TOPICS_SCHEMA_VERSION) {
this.db
.prepare("UPDATE topic_meta SET value = ? WHERE key = 'schema_version'")
.run(String(CHAT_TOPICS_SCHEMA_VERSION))
}
}
}
}
@@ -691,6 +846,27 @@ export function deleteSessionChatTopics(
}
}
export function assertSessionChatTopicsIdle(
userDataDir: string,
sessionId: string,
options: TopicStoreOptions = {}
): void {
const dbPath = getChatTopicsDbPath(userDataDir)
if (!fs.existsSync(dbPath)) return
const store = new ChatTopicStore(dbPath, options)
try {
const timestamp = Date.now()
store.recoverInterruptedRuns(timestamp)
if (!store.hasLiveExecutionForSession(sessionId, timestamp)) return
throw Object.assign(new Error('Chat topics are being generated for this session in another runtime'), {
code: 'TOPIC_EXECUTION_IN_PROGRESS',
statusCode: 409,
})
} finally {
store.close()
}
}
function parseStringArray(value: string): string[] {
const parsed = JSON.parse(value) as unknown
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string')) {