@geekbeer/minion 4.2.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -133,6 +133,7 @@ function save(session) {
133
133
  * Add a message to a session.
134
134
  * Creates a new session if none exists for the given session_id.
135
135
  * Past sessions are preserved (not deleted).
136
+ * Wrapped in a transaction so partial saves can't happen on failure.
136
137
  * @param {string} sessionId - Claude CLI session ID
137
138
  * @param {{ role: string, content: string }} msg - Message to add
138
139
  * @param {number} [turnCount] - Optional turn count to add
@@ -140,40 +141,71 @@ function save(session) {
140
141
  */
141
142
  function addMessage(sessionId, msg, turnCount, workspaceId) {
142
143
  const db = getDb()
143
- const existing = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(sessionId)
144
+ const tx = db.transaction(() => {
145
+ const existing = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(sessionId)
146
+
147
+ if (!existing) {
148
+ const now = Date.now()
149
+ db.prepare(
150
+ 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
151
+ ).run(sessionId, workspaceId || null, 0, now, now)
152
+ }
144
153
 
145
- // If session doesn't exist, create a new one (past sessions remain in DB)
146
- if (!existing) {
147
- const now = Date.now()
148
154
  db.prepare(
149
- 'INSERT INTO chat_sessions (session_id, workspace_id, turn_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
150
- ).run(sessionId, workspaceId || null, 0, now, now)
151
- }
155
+ 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
156
+ ).run(sessionId, msg.role, msg.content, Date.now())
152
157
 
153
- // Insert message
154
- db.prepare(
155
- 'INSERT INTO chat_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
156
- ).run(sessionId, msg.role, msg.content, Date.now())
157
-
158
- // Update session metadata
159
- const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
160
- db.prepare(`
161
- UPDATE chat_sessions
162
- SET updated_at = ?,
163
- turn_count = turn_count + ?
164
- WHERE session_id = ?
165
- `).run(Date.now(), newTurnCount, sessionId)
166
-
167
- // Prune old messages for this session (keep only MAX_MESSAGES most recent)
168
- const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
169
- if (count > MAX_MESSAGES) {
158
+ const newTurnCount = (turnCount && turnCount > 0) ? turnCount : 0
170
159
  db.prepare(`
171
- DELETE FROM chat_messages WHERE id IN (
172
- SELECT id FROM chat_messages WHERE session_id = ?
173
- ORDER BY id ASC LIMIT ?
174
- )
175
- `).run(sessionId, count - MAX_MESSAGES)
176
- }
160
+ UPDATE chat_sessions
161
+ SET updated_at = ?,
162
+ turn_count = turn_count + ?
163
+ WHERE session_id = ?
164
+ `).run(Date.now(), newTurnCount, sessionId)
165
+
166
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM chat_messages WHERE session_id = ?').get(sessionId).cnt
167
+ if (count > MAX_MESSAGES) {
168
+ db.prepare(`
169
+ DELETE FROM chat_messages WHERE id IN (
170
+ SELECT id FROM chat_messages WHERE session_id = ?
171
+ ORDER BY id ASC LIMIT ?
172
+ )
173
+ `).run(sessionId, count - MAX_MESSAGES)
174
+ }
175
+ })
176
+ tx()
177
+ }
178
+
179
+ /**
180
+ * Rename a session's ID, carrying over all messages.
181
+ * Used when a pending session ID (generated locally before the LLM call) is
182
+ * replaced by the real session_id returned by Claude CLI.
183
+ * If `newSessionId` already exists, messages from `oldSessionId` are merged
184
+ * into it and the old session row is dropped.
185
+ * @param {string} oldSessionId
186
+ * @param {string} newSessionId
187
+ * @returns {boolean} true if any rows were touched
188
+ */
189
+ function rekeySession(oldSessionId, newSessionId) {
190
+ if (!oldSessionId || !newSessionId || oldSessionId === newSessionId) return false
191
+ const db = getDb()
192
+ const tx = db.transaction(() => {
193
+ const oldSession = db.prepare('SELECT * FROM chat_sessions WHERE session_id = ?').get(oldSessionId)
194
+ if (!oldSession) return false
195
+
196
+ const existingNew = db.prepare('SELECT session_id FROM chat_sessions WHERE session_id = ?').get(newSessionId)
197
+ if (existingNew) {
198
+ db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
199
+ db.prepare('UPDATE chat_sessions SET updated_at = ?, turn_count = turn_count + ? WHERE session_id = ?')
200
+ .run(Date.now(), oldSession.turn_count || 0, newSessionId)
201
+ db.prepare('DELETE FROM chat_sessions WHERE session_id = ?').run(oldSessionId)
202
+ } else {
203
+ db.prepare('UPDATE chat_sessions SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
204
+ db.prepare('UPDATE chat_messages SET session_id = ? WHERE session_id = ?').run(newSessionId, oldSessionId)
205
+ }
206
+ return true
207
+ })
208
+ return tx() === true
177
209
  }
178
210
 
179
211
  /**
@@ -208,4 +240,4 @@ function deleteSession(sessionId) {
208
240
  return result.changes > 0
209
241
  }
210
242
 
211
- module.exports = { load, loadById, listSessions, save, addMessage, clear, deleteSession }
243
+ module.exports = { load, loadById, listSessions, save, addMessage, rekeySession, clear, deleteSession }
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const { spawn } = require('child_process')
20
+ const crypto = require('crypto')
20
21
  const fs = require('fs')
21
22
  const path = require('path')
22
23
  const { verifyToken } = require('../../core/lib/auth')
@@ -57,10 +58,16 @@ async function chatRoutes(fastify) {
57
58
  // so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
58
59
  const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
59
60
 
60
- // Store user message
61
+ // Persist the user message BEFORE invoking the LLM so that crashes,
62
+ // timeouts, or unparseable CLI output can't lose it. For new sessions we
63
+ // mint a local pending ID; it gets rekeyed to the real Claude CLI session
64
+ // ID once that comes back on the SSE stream.
61
65
  const currentSessionId = session_id || null
62
- if (currentSessionId) {
63
- await chatStore.addMessage(currentSessionId, { role: 'user', content: message }, undefined, workspaceId)
66
+ const pendingSessionId = currentSessionId || `pending-${crypto.randomUUID()}`
67
+ try {
68
+ await chatStore.addMessage(pendingSessionId, { role: 'user', content: message }, undefined, workspaceId)
69
+ } catch (err) {
70
+ console.error('[Chat] failed to persist user message:', err.message)
64
71
  }
65
72
 
66
73
  // Take over response handling from Fastify for SSE streaming
@@ -74,7 +81,7 @@ async function chatRoutes(fastify) {
74
81
  reply.raw.flushHeaders()
75
82
 
76
83
  try {
77
- await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
84
+ await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
78
85
  } catch (err) {
79
86
  console.error('[Chat] stream error:', err.message)
80
87
  const errorEvent = JSON.stringify({ type: 'error', error: err.message })
@@ -541,16 +548,16 @@ function getLlmBinary() {
541
548
  * Tracks block types to correctly forward tool_use vs text events
542
549
  * and counts turns for session management.
543
550
  */
544
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
551
+ async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
545
552
  // Plugin system path: Primary is set → delegate to plugin
546
553
  const primary = getActivePrimary()
547
554
  if (primary) {
548
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
555
+ return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
549
556
  }
550
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
557
+ return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
551
558
  }
552
559
 
553
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
560
+ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
554
561
  const input = { prompt }
555
562
  const activeRef = { current: null }
556
563
  activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
@@ -576,6 +583,7 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
576
583
 
577
584
  res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
578
585
 
586
+ let pluginError = null
579
587
  try {
580
588
  let output
581
589
  if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
@@ -592,25 +600,42 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
592
600
  }
593
601
  }
594
602
  resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
603
+ } catch (err) {
604
+ // Swallow here so we can persist any partial response first; rethrow below.
605
+ pluginError = err
595
606
  } finally {
596
607
  activeChatChild = null
597
608
  }
598
609
 
599
- if (resolvedSessionId) {
600
- if (!sessionId) {
601
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
610
+ // For new sessions, the user message was persisted under pendingSessionId
611
+ // before the plugin call. Rekey it to the real session ID now that we
612
+ // know it. If the plugin never reported a session ID, leave the message
613
+ // under the pending key so the history isn't lost.
614
+ const persistSessionId = resolvedSessionId || pendingSessionId
615
+ try {
616
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
617
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
602
618
  }
603
- if (fullResponse) {
604
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
619
+ if (fullResponse && persistSessionId) {
620
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
605
621
  }
622
+ } catch (err) {
623
+ console.error('[Chat] failed to persist assistant message:', err.message)
606
624
  }
607
625
 
608
- const session = await chatStore.load(workspaceId)
609
- const totalTurnCount = session?.turn_count || turnCount
626
+ if (pluginError) throw pluginError
627
+
628
+ let totalTurnCount = turnCount
629
+ try {
630
+ const session = await chatStore.load(workspaceId)
631
+ totalTurnCount = session?.turn_count || turnCount
632
+ } catch (err) {
633
+ console.error('[Chat] failed to load session for done event:', err.message)
634
+ }
610
635
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
611
636
  }
612
637
 
613
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
638
+ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
614
639
  return new Promise((resolve, reject) => {
615
640
  const binaryName = getLlmBinary()
616
641
  if (!binaryName) {
@@ -786,16 +811,21 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
786
811
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
787
812
  }
788
813
 
789
- // Store messages in chat-store
790
- if (resolvedSessionId) {
791
- // If this was a new session, also store the user message now
792
- if (!sessionId) {
793
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
814
+ // For new sessions, the user message was already persisted under
815
+ // pendingSessionId before spawn. Rekey it to the real session ID when
816
+ // Claude CLI reported one; otherwise leave the message under the
817
+ // pending key so the history is never lost on crash.
818
+ const persistSessionId = resolvedSessionId || pendingSessionId
819
+ try {
820
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
821
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
794
822
  }
795
- // Store assistant response with turn count
796
- if (fullResponse) {
797
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
823
+ // Persist any partial response we managed to collect, even on error
824
+ if (fullResponse && persistSessionId) {
825
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
798
826
  }
827
+ } catch (err) {
828
+ console.error('[Chat] failed to persist assistant message:', err.message)
799
829
  }
800
830
 
801
831
  if (code !== 0) {
@@ -811,8 +841,13 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
811
841
  }
812
842
 
813
843
  // Load current turn count from session for the done event
814
- const session = await chatStore.load(workspaceId)
815
- const totalTurnCount = session?.turn_count || turnCount
844
+ let totalTurnCount = turnCount
845
+ try {
846
+ const session = await chatStore.load(workspaceId)
847
+ totalTurnCount = session?.turn_count || turnCount
848
+ } catch (err) {
849
+ console.error('[Chat] failed to load session for done event:', err.message)
850
+ }
816
851
 
817
852
  const doneEvent = JSON.stringify({
818
853
  type: 'done',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  const { spawn } = require('child_process')
16
+ const crypto = require('crypto')
16
17
  const fs = require('fs')
17
18
  const path = require('path')
18
19
  const http = require('http')
@@ -130,8 +131,19 @@ async function chatRoutes(fastify) {
130
131
  const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
131
132
  const currentSessionId = session_id || null
132
133
 
133
- if (currentSessionId) {
134
- await chatStore.addMessage(currentSessionId, { role: 'user', content: message }, undefined, workspaceId)
134
+ // Persist the user message BEFORE invoking the LLM so that crashes,
135
+ // timeouts, or unparseable CLI output can't lose it. New sessions get a
136
+ // local pending ID; it gets rekeyed to the real Claude CLI session ID
137
+ // once that comes back on the SSE stream.
138
+ // In WSL mode the upstream WSL session server keeps its own store, so we
139
+ // only persist locally for existing sessions (matching prior behavior).
140
+ const pendingSessionId = currentSessionId || `pending-${crypto.randomUUID()}`
141
+ if (!wsl_mode || currentSessionId) {
142
+ try {
143
+ await chatStore.addMessage(pendingSessionId, { role: 'user', content: message }, undefined, workspaceId)
144
+ } catch (err) {
145
+ console.error('[Chat] failed to persist user message:', err.message)
146
+ }
135
147
  }
136
148
 
137
149
  reply.hijack()
@@ -149,7 +161,7 @@ async function chatRoutes(fastify) {
149
161
  await proxyWslChat(reply.raw, prompt, currentSessionId)
150
162
  wslModeActive = false
151
163
  } else {
152
- await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message)
164
+ await streamLlmResponse(reply.raw, prompt, currentSessionId, workspaceId, message, pendingSessionId)
153
165
  }
154
166
  } catch (err) {
155
167
  wslModeActive = false
@@ -594,15 +606,15 @@ function getLlmBinary() {
594
606
  * Tracks block types to correctly forward tool_use vs text events
595
607
  * and counts turns for session management.
596
608
  */
597
- async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
609
+ async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
598
610
  const primary = getActivePrimary()
599
611
  if (primary) {
600
- return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
612
+ return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
601
613
  }
602
- return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
614
+ return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId)
603
615
  }
604
616
 
605
- async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
617
+ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
606
618
  const input = { prompt }
607
619
  const activeRef = { current: null }
608
620
 
@@ -627,37 +639,57 @@ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, orig
627
639
 
628
640
  res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
629
641
 
630
- let output
631
- if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
632
- output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
633
- } else {
634
- output = await plugin.invoke(input)
635
- if (output.text) {
636
- fullResponse = output.text
637
- res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
638
- turnCount = 1
639
- }
640
- if (output.error) {
641
- res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
642
+ let pluginError = null
643
+ try {
644
+ let output
645
+ if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
646
+ output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
647
+ } else {
648
+ output = await plugin.invoke(input)
649
+ if (output.text) {
650
+ fullResponse = output.text
651
+ res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
652
+ turnCount = 1
653
+ }
654
+ if (output.error) {
655
+ res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
656
+ }
642
657
  }
658
+ resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
659
+ } catch (err) {
660
+ // Swallow here so we can persist any partial response first; rethrow below.
661
+ pluginError = err
643
662
  }
644
- resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
645
663
 
646
- if (resolvedSessionId) {
647
- if (!sessionId) {
648
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
664
+ // For new sessions, the user message was persisted under pendingSessionId
665
+ // before the plugin call. Rekey it to the real session ID now that we
666
+ // know it. If the plugin never reported a session ID, leave the message
667
+ // under the pending key so the history isn't lost.
668
+ const persistSessionId = resolvedSessionId || pendingSessionId
669
+ try {
670
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
671
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
649
672
  }
650
- if (fullResponse) {
651
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
673
+ if (fullResponse && persistSessionId) {
674
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
652
675
  }
676
+ } catch (err) {
677
+ console.error('[Chat] failed to persist assistant message:', err.message)
653
678
  }
654
679
 
655
- const session = await chatStore.load(workspaceId)
656
- const totalTurnCount = session?.turn_count || turnCount
680
+ if (pluginError) throw pluginError
681
+
682
+ let totalTurnCount = turnCount
683
+ try {
684
+ const session = await chatStore.load(workspaceId)
685
+ totalTurnCount = session?.turn_count || turnCount
686
+ } catch (err) {
687
+ console.error('[Chat] failed to load session for done event:', err.message)
688
+ }
657
689
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
658
690
  }
659
691
 
660
- function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
692
+ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage, pendingSessionId) {
661
693
  return new Promise((resolve, reject) => {
662
694
  const binaryName = getLlmBinary()
663
695
  if (!binaryName) {
@@ -809,14 +841,22 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
809
841
  console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
810
842
  }
811
843
 
812
- if (resolvedSessionId) {
813
- if (!sessionId) {
814
- await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
844
+ // For new sessions, the user message was already persisted under
845
+ // pendingSessionId before spawn. Rekey it to the real session ID when
846
+ // Claude CLI reported one; otherwise leave the message under the
847
+ // pending key so the history is never lost on crash.
848
+ const persistSessionId = resolvedSessionId || pendingSessionId
849
+ try {
850
+ if (!sessionId && resolvedSessionId && pendingSessionId && pendingSessionId !== resolvedSessionId) {
851
+ chatStore.rekeySession(pendingSessionId, resolvedSessionId)
815
852
  }
816
- if (fullResponse) {
817
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
853
+ if (fullResponse && persistSessionId) {
854
+ await chatStore.addMessage(persistSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
818
855
  }
856
+ } catch (err) {
857
+ console.error('[Chat] failed to persist assistant message:', err.message)
819
858
  }
859
+
820
860
  if (code !== 0) {
821
861
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
822
862
  console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
@@ -828,8 +868,13 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
828
868
  })}\n\n`)
829
869
  }
830
870
 
831
- const session = await chatStore.load(workspaceId)
832
- const totalTurnCount = session?.turn_count || turnCount
871
+ let totalTurnCount = turnCount
872
+ try {
873
+ const session = await chatStore.load(workspaceId)
874
+ totalTurnCount = session?.turn_count || turnCount
875
+ } catch (err) {
876
+ console.error('[Chat] failed to load session for done event:', err.message)
877
+ }
833
878
 
834
879
  res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
835
880
  resolve()