@shawnstack/quickforge 1.7.7 → 1.7.8

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.
Files changed (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -29,6 +29,7 @@ import {
29
29
  } from './agent-profile-schema.mjs'
30
30
  import { projectContextFromId, defaultGlobalWorkspaceContext, readProjectConfig } from './project-config.mjs'
31
31
  import { ensureStorage, readStore, atomicUpdate, atomicSessionMetadataUpdate, readSessionValue, writeSessionValue, deleteSessionValue, tempAgentsDir } from './storage.mjs'
32
+ import { isSessionStateAuthoritative, readSessionStateRecord, saveSessionStatePair, deleteSessionState, storedMessagesState, sessionMessagesTailDigest } from './session-state-service.mjs'
32
33
  import { logger } from './utils/logger.mjs'
33
34
  import { publishChannelSessionChanged } from './channels/event-relay.mjs'
34
35
  import { withSessionPersistenceLock } from './session-persistence-lock.mjs'
@@ -494,8 +495,61 @@ function emitSessionEvent(session, event) {
494
495
  && event.contextUsage === undefined
495
496
  ? { ...event, contextUsage: getSessionContextUsage(session), stateVersion }
496
497
  : { ...event, stateVersion }
497
- session.eventBus.emit('agent_event', enrichedEvent)
498
- agentEvents.emit('agent_event', { sessionId: session.sessionId, ...enrichedEvent })
498
+ session.eventBus.emit('agent_event', transformSplitSessionEvent(session, enrichedEvent))
499
+ agentEvents.emit('agent_event', { sessionId: session.sessionId, ...transformSplitSessionEvent(session, enrichedEvent) })
500
+ }
501
+
502
+ /**
503
+ * F9 split-message SSE frames: split sessions never ship the full `messages`
504
+ * array over SSE. `state` frames carry a lightweight `messagesSummary`
505
+ * ({ count }) instead; message_end/agent_end/messages_replaced frames carry
506
+ * only the tail that the client has not yet seen (`messagesAfter` +
507
+ * `messages` + `messagesIncremental`), with a `messagesSummary` for the total.
508
+ * Non-split sessions keep the legacy full-array payloads byte-for-byte, so
509
+ * older clients and non-split sessions are unaffected. `stateVersion` is
510
+ * never modified here.
511
+ */
512
+ function transformSplitSessionEvent(session, event) {
513
+ if (session.persistedMessageStorage !== 'split' || !event || typeof event !== 'object') return event
514
+ if (event.type === 'state') {
515
+ if (!Array.isArray(event.messages)) return event
516
+ const next = { ...event }
517
+ const count = next.messages.length
518
+ delete next.messages
519
+ next.messagesSummary = { count }
520
+ return next
521
+ }
522
+ if (event.type === 'message_end' || event.type === 'agent_end' || event.type === 'messages_replaced') {
523
+ if (!Array.isArray(event.messages)) return event
524
+ const after = session.persistedMessageCount ?? 0
525
+ const tail = event.messages.slice(after)
526
+ const count = event.messages.length
527
+ const next = { ...event }
528
+ delete next.messages
529
+ if (tail.length > 0) {
530
+ next.messages = tail
531
+ next.messagesAfter = after
532
+ next.messagesIncremental = true
533
+ }
534
+ next.messagesSummary = { count }
535
+ return next
536
+ }
537
+ return event
538
+ }
539
+
540
+ /**
541
+ * Strip full messages from a session state snapshot destined for the wire
542
+ * (GET /state, POST /restore, SSE initial state frame) when the session is
543
+ * split. Internal consumers (shared conversations, ACP) keep the full state.
544
+ */
545
+ export function stripSplitSessionState(state) {
546
+ if (!state || state.messageStorage !== 'split') return state
547
+ if (!Array.isArray(state.messages)) return state
548
+ const next = { ...state }
549
+ const count = next.messages.length
550
+ delete next.messages
551
+ next.messagesSummary = { count }
552
+ return next
499
553
  }
500
554
 
501
555
  function addToolTimingToEvent(session, event) {
@@ -1247,6 +1301,11 @@ async function resolveSubagentProfile(parentSession, requestedSubagent) {
1247
1301
  return getAgentProfile(requestedSubagent, { workspaceRoot: parentSession.projectContext?.workspaceRoot })
1248
1302
  }
1249
1303
 
1304
+ function formatTimeoutMinutes(timeoutMs) {
1305
+ const minutes = Number((timeoutMs / (60 * 1000)).toFixed(2))
1306
+ return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`
1307
+ }
1308
+
1250
1309
  async function runSubagent(parentSession, params, parentSignal, onUpdate) {
1251
1310
  const profile = await resolveSubagentProfile(parentSession, params?.subagent)
1252
1311
  if (!profile || !profile.enabledAsSubagent) {
@@ -1424,7 +1483,7 @@ async function runSubagent(parentSession, params, parentSignal, onUpdate) {
1424
1483
 
1425
1484
  try {
1426
1485
  await subagent.prompt(userMessage)
1427
- if (timedOut) throw new Error(`Subagent ${definition.name} timed out after ${timeoutMs}ms.`)
1486
+ if (timedOut) throw new Error(`Subagent ${definition.name} timed out after ${formatTimeoutMinutes(timeoutMs)}.`)
1428
1487
  if (parentSignal?.aborted) throw new Error(`Subagent ${definition.name} aborted with parent run.`)
1429
1488
  } finally {
1430
1489
  clearTimeout(timeout)
@@ -1898,6 +1957,16 @@ export async function createAgent(sessionId, config = {}) {
1898
1957
  lastTransformedContextMessages: null,
1899
1958
  autoCompacting: false,
1900
1959
  stateVersion: Number.isFinite(stateVersion) ? Math.max(0, stateVersion) : 0,
1960
+ persistedStateVersion: Number.isFinite(config.persistedStateVersion) ? Math.max(0, config.persistedStateVersion) : null,
1961
+ persistedStorageRevision: Number.isFinite(config.persistedStorageRevision) ? Math.max(0, config.persistedStorageRevision) : null,
1962
+ persistedStateJson: typeof config.persistedStateJson === 'string' ? config.persistedStateJson : null,
1963
+ // F9 split-message bookkeeping: the count and tail-message digest of the
1964
+ // last authoritative persist, plus the storage representation marker. Used
1965
+ // for split-representation conflict detection and lightweight SSE frames.
1966
+ persistedMessageStorage: config.persistedMessageStorage === 'split' ? 'split' : null,
1967
+ persistedMessageCount: Number.isInteger(config.persistedMessageCount) && config.persistedMessageCount >= 0 ? config.persistedMessageCount : null,
1968
+ persistedTailDigest: typeof config.persistedTailDigest === 'string' ? config.persistedTailDigest : null,
1969
+ persistConflictCount: 0,
1901
1970
  lastAutoCompactAt: null,
1902
1971
  lastAutoCompactRejected: null,
1903
1972
  memoryEnabled: initialMemoryEnabled,
@@ -2044,6 +2113,123 @@ function sessionLastModifiedFromMessages(messages, fallback) {
2044
2113
  return Number.isNaN(fallbackMs) ? new Date().toISOString() : new Date(fallbackMs).toISOString()
2045
2114
  }
2046
2115
 
2116
+ // Fields the agent does not own; a concurrent sidebar pin/archive update is the
2117
+ // only legitimate way these change between the agent's read and its CAS save.
2118
+ const SESSION_STORAGE_OWNED_STATE_FIELDS = ['pinnedAt', 'archivedAt']
2119
+
2120
+ function stripStorageOwnedStateFields(state) {
2121
+ const next = { ...state }
2122
+ for (const field of SESSION_STORAGE_OWNED_STATE_FIELDS) delete next[field]
2123
+ return next
2124
+ }
2125
+
2126
+ /**
2127
+ * Split-representation conflict check: does the currently stored session match
2128
+ * the message state this session last persisted? Non-split sessions carry
2129
+ * messages inline in the body, which the caller already compares via
2130
+ * persistedStateJson; split sessions need the row count + tail digest because
2131
+ * their body excludes messages.
2132
+ */
2133
+ function storedMessagesMatchLastPersist(session) {
2134
+ if (session.persistedMessageStorage !== 'split') return true
2135
+ if (session.persistedMessageCount === null) return true
2136
+ const stored = storedMessagesState(session.sessionId)
2137
+ if (!stored) return true
2138
+ return stored.count === session.persistedMessageCount
2139
+ && stored.tailDigest === session.persistedTailDigest
2140
+ }
2141
+
2142
+ function canonicalStateJson(value) {
2143
+ if (Array.isArray(value)) return JSON.stringify(value.map(canonicalStateJson))
2144
+ if (value && typeof value === 'object') {
2145
+ return JSON.stringify(
2146
+ Object.fromEntries(
2147
+ Object.keys(value).sort()
2148
+ .filter((key) => value[key] !== undefined)
2149
+ .map((key) => [key, canonicalStateJson(value[key])]),
2150
+ ),
2151
+ )
2152
+ }
2153
+ return JSON.stringify(value)
2154
+ }
2155
+
2156
+ /**
2157
+ * Persist a full session record in one authoritative SQLite transaction with
2158
+ * revision CAS. When the CAS fails because a concurrent metadata-only change
2159
+ * (pin/archive via the sidebar) bumped the revision, re-read the current row
2160
+ * and verify the only difference from what this session last persisted is
2161
+ * storage-owned fields; then merge and retry (bounded). If the storage row
2162
+ * changed in agent-owned fields, record the conflict and refuse to overwrite
2163
+ * the other writer — never fake success by clobbering it.
2164
+ * @returns {{state: object, metadata: object, revision: number, stateVersion: number} | null}
2165
+ */
2166
+ async function persistAuthoritativeSessionState(session, sessionData, metadata) {
2167
+ const sessionId = session.sessionId
2168
+ const maxAttempts = 3
2169
+ let lastConflict = null
2170
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
2171
+ const existing = readSessionStateRecord(sessionId)
2172
+ const existingMetadata = existing?.metadata
2173
+ const mergedMetadata = {
2174
+ ...existingMetadata,
2175
+ ...metadata,
2176
+ ...(existingMetadata?.archivedAt ? { archivedAt: existingMetadata.archivedAt } : {}),
2177
+ ...(existingMetadata?.pinnedAt ? { pinnedAt: existingMetadata.pinnedAt } : {}),
2178
+ }
2179
+ const persistedState = {
2180
+ ...(existing?.state || {}),
2181
+ ...sessionData,
2182
+ ...(mergedMetadata.archivedAt ? { archivedAt: mergedMetadata.archivedAt } : {}),
2183
+ ...(mergedMetadata.pinnedAt ? { pinnedAt: mergedMetadata.pinnedAt } : {}),
2184
+ }
2185
+ try {
2186
+ const saved = saveSessionStatePair({
2187
+ state: persistedState,
2188
+ metadata: mergedMetadata,
2189
+ expectedRevision: session.persistedStorageRevision ?? existing?.revision ?? 0,
2190
+ })
2191
+ session.persistedStateJson = canonicalStateJson(stripStorageOwnedStateFields(saved.state))
2192
+ // F9 split bookkeeping: the savePair plan tells us exactly which
2193
+ // representation was written, so the conflict-detection counters stay in
2194
+ // sync with the stored rows (append/replace/body-only on split sessions,
2195
+ // inline on small sessions).
2196
+ if (saved.state?.messageStorage === 'split') {
2197
+ session.persistedMessageStorage = 'split'
2198
+ session.persistedMessageCount = saved.messageCount ?? sessionData.messages?.length ?? 0
2199
+ } else {
2200
+ session.persistedMessageStorage = null
2201
+ session.persistedMessageCount = Array.isArray(sessionData.messages) ? sessionData.messages.length : 0
2202
+ }
2203
+ session.persistedTailDigest = sessionMessagesTailDigest(sessionData.messages)
2204
+ return { ...saved, metadata: mergedMetadata }
2205
+ } catch (error) {
2206
+ if (error?.errorCode !== 'SESSION_STATE_CONFLICT') throw error
2207
+ lastConflict = error
2208
+ const current = readSessionStateRecord(sessionId)
2209
+ if (!current) return null
2210
+ const previous = session.persistedStateJson
2211
+ const bodyUnchanged = previous !== null && canonicalStateJson(stripStorageOwnedStateFields(current.state)) === previous
2212
+ // Split sessions keep messages out of the body, so the body comparison
2213
+ // alone would miss concurrent message writes. Compare the stored rows
2214
+ // (count + tail digest) against what this session last persisted.
2215
+ const messagesUnchanged = storedMessagesMatchLastPersist(session)
2216
+ if (bodyUnchanged && messagesUnchanged) {
2217
+ // Only storage-owned fields changed — adopt the storage row as the new
2218
+ // baseline and retry with the fresh revision.
2219
+ session.persistedStorageRevision = current.revision
2220
+ session.persistedStateVersion = current.stateVersion
2221
+ continue
2222
+ }
2223
+ session.persistConflictCount += 1
2224
+ logger.warn(`Session ${sessionId} persist conflict: storage changed agent-owned fields; skipping overwrite`, { sessionId })
2225
+ return null
2226
+ }
2227
+ }
2228
+ session.persistConflictCount += 1
2229
+ logger.warn(`Session ${sessionId} persist still conflicting after ${maxAttempts} merge retries`, { sessionId, reason: lastConflict?.message })
2230
+ return null
2231
+ }
2232
+
2047
2233
  /**
2048
2234
  * Persist session data to storage.
2049
2235
  */
@@ -2053,11 +2239,20 @@ async function persistSessionUnlocked(session) {
2053
2239
 
2054
2240
  if (messages.length === 0) {
2055
2241
  try {
2056
- await deleteSessionValue(sessionId)
2057
- await atomicSessionMetadataUpdate(scope, projectId, (data) => {
2058
- delete data[sessionId]
2059
- return data
2060
- })
2242
+ if (isSessionStateAuthoritative()) deleteSessionState(sessionId, { expectedRevision: session.persistedStorageRevision })
2243
+ else {
2244
+ await deleteSessionValue(sessionId)
2245
+ await atomicSessionMetadataUpdate(scope, projectId, (data) => {
2246
+ delete data[sessionId]
2247
+ return data
2248
+ })
2249
+ }
2250
+ session.persistedStorageRevision = null
2251
+ session.persistedStateVersion = null
2252
+ session.persistedStateJson = null
2253
+ session.persistedMessageStorage = null
2254
+ session.persistedMessageCount = null
2255
+ session.persistedTailDigest = null
2061
2256
  } catch (err) {
2062
2257
  logger.error(`Failed to remove empty session ${sessionId}:`, err, { sessionId })
2063
2258
  }
@@ -2132,6 +2327,7 @@ async function persistSessionUnlocked(session) {
2132
2327
  createdAt: createdAt || now,
2133
2328
  lastModified,
2134
2329
  messageCount: messages.length,
2330
+ stateVersion: session.stateVersion || 0,
2135
2331
  usage,
2136
2332
  thinkingLevel,
2137
2333
  harness,
@@ -2157,25 +2353,33 @@ async function persistSessionUnlocked(session) {
2157
2353
  idleRetention: session.idleRetention || undefined,
2158
2354
  }
2159
2355
 
2160
- // Write to storage atomically (read-modify-write within queue)
2356
+ // Write body + metadata as one authoritative SQLite transaction after cutover.
2161
2357
  let persistedMetadata
2162
2358
  try {
2163
- await writeSessionValue(sessionId, sessionData)
2164
- await atomicSessionMetadataUpdate(scope, projectId, (data) => {
2165
- const existingMetadata = data[sessionId]
2166
- const archivedAt = existingMetadata?.archivedAt
2167
- // Merge onto the existing record so storage-only fields the in-memory
2168
- // session does not own (pinnedAt, archivedAt, …) survive the rebuild.
2169
- persistedMetadata = {
2170
- ...existingMetadata,
2171
- ...metadata,
2172
- ...(archivedAt ? { archivedAt } : {}),
2359
+ if (isSessionStateAuthoritative()) {
2360
+ const saved = await persistAuthoritativeSessionState(session, sessionData, metadata)
2361
+ if (!saved) return null
2362
+ session.persistedStorageRevision = saved.revision
2363
+ session.persistedStateVersion = saved.stateVersion
2364
+ persistedMetadata = saved.metadata
2365
+ } else {
2366
+ await writeSessionValue(sessionId, sessionData)
2367
+ await atomicSessionMetadataUpdate(scope, projectId, (data) => {
2368
+ const existingMetadata = data[sessionId]
2369
+ const archivedAt = existingMetadata?.archivedAt
2370
+ // Merge onto the existing record so storage-only fields the in-memory
2371
+ // session does not own (pinnedAt, archivedAt, …) survive the rebuild.
2372
+ persistedMetadata = {
2373
+ ...existingMetadata,
2374
+ ...metadata,
2375
+ ...(archivedAt ? { archivedAt } : {}),
2376
+ }
2377
+ data[sessionId] = persistedMetadata
2378
+ return data
2379
+ })
2380
+ if (persistedMetadata?.archivedAt) {
2381
+ await writeSessionValue(sessionId, { ...sessionData, archivedAt: persistedMetadata.archivedAt })
2173
2382
  }
2174
- data[sessionId] = persistedMetadata
2175
- return data
2176
- })
2177
- if (persistedMetadata?.archivedAt) {
2178
- await writeSessionValue(sessionId, { ...sessionData, archivedAt: persistedMetadata.archivedAt })
2179
2383
  }
2180
2384
  } catch (err) {
2181
2385
  logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId })
@@ -2738,6 +2942,7 @@ export function getSessionState(sessionId) {
2738
2942
  createdAt: session.createdAt,
2739
2943
  lastModified: session.lastModified,
2740
2944
  stateVersion: session.stateVersion || 0,
2945
+ messageStorage: session.persistedMessageStorage === 'split' ? 'split' : undefined,
2741
2946
  status: session.status,
2742
2947
  startedAt: session.startedAt,
2743
2948
  finishedAt: session.finishedAt,
@@ -2881,6 +3086,14 @@ export async function restoreAgent(sessionId) {
2881
3086
 
2882
3087
  logger.info(`Restoring session ${sessionId} from storage (scope: ${sessionData.scope}, messages: ${sessionData.messages?.length ?? 0})`, { sessionId, scope: sessionData.scope, messageCount: sessionData.messages?.length ?? 0 })
2883
3088
 
3089
+ // Read the authoritative storage record once: the body (split marker),
3090
+ // revision for CAS, and the stored message rows (count + tail digest) used
3091
+ // for split-representation conflict detection after restore.
3092
+ const record = isSessionStateAuthoritative() ? readSessionStateRecord(sessionId) : null
3093
+ const storedMessages = isSessionStateAuthoritative() && record?.state?.messageStorage === 'split'
3094
+ ? storedMessagesState(sessionId)
3095
+ : null
3096
+
2884
3097
  return await createAgent(sessionId, {
2885
3098
  scope: sessionData.scope || 'global',
2886
3099
  projectId: sessionData.projectId || null,
@@ -2904,6 +3117,12 @@ export async function restoreAgent(sessionId) {
2904
3117
  openCodeUsage: sessionData.openCodeUsage || null,
2905
3118
  idleRetention: sessionData.idleRetention || null,
2906
3119
  stateVersion: sessionData.stateVersion,
3120
+ persistedStateVersion: isSessionStateAuthoritative() ? sessionData.stateVersion : null,
3121
+ persistedStorageRevision: isSessionStateAuthoritative() ? record?.revision ?? null : null,
3122
+ persistedStateJson: isSessionStateAuthoritative() ? canonicalStateJson(stripStorageOwnedStateFields(record?.state || {})) : null,
3123
+ persistedMessageStorage: record?.state?.messageStorage === 'split' ? 'split' : null,
3124
+ persistedMessageCount: storedMessages?.count ?? (Array.isArray(sessionData.messages) ? sessionData.messages.length : 0),
3125
+ persistedTailDigest: storedMessages?.tailDigest || sessionMessagesTailDigest(sessionData.messages),
2907
3126
  })
2908
3127
  } catch (err) {
2909
3128
  logger.error(`Failed to restore agent ${sessionId}:`, err, { sessionId })
@@ -3195,16 +3414,17 @@ export function updateSessionThinkingLevel(sessionId, thinkingLevel) {
3195
3414
  */
3196
3415
  export async function resetStaleTaskStatuses() {
3197
3416
  try {
3198
- const metadataStore = await readStore('sessions-metadata')
3199
3417
  let changed = false
3200
- for (const [id, meta] of Object.entries(metadataStore)) {
3201
- if (meta && meta.taskStatus === 'running') {
3202
- metadataStore[id] = { ...meta, taskStatus: 'idle', taskFinishedAt: meta.taskFinishedAt ?? new Date().toISOString() }
3203
- changed = true
3418
+ await atomicUpdate('sessions-metadata', (metadataStore) => {
3419
+ for (const [id, meta] of Object.entries(metadataStore)) {
3420
+ if (meta && meta.taskStatus === 'running') {
3421
+ metadataStore[id] = { ...meta, taskStatus: 'idle', taskFinishedAt: meta.taskFinishedAt ?? new Date().toISOString() }
3422
+ changed = true
3423
+ }
3204
3424
  }
3205
- }
3425
+ return metadataStore
3426
+ })
3206
3427
  if (changed) {
3207
- await atomicUpdate('sessions-metadata', () => metadataStore)
3208
3428
  logger.info('Reset stale task statuses in persisted metadata')
3209
3429
  }
3210
3430
  } catch (err) {
@@ -6,10 +6,12 @@ import { streamSimple } from '@earendil-works/pi-ai/compat'
6
6
  import { resolveManagedCloudProvider } from './cloud/runtime.mjs'
7
7
  import { ensureCloudChatIdempotencyKey } from './cloud/chat-idempotency-store.mjs'
8
8
  import {
9
- DEFAULT_AI_STREAM_DEADLINE_MS,
9
+ DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS,
10
+ DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
10
11
  withDefaultAiProviderOptions,
11
12
  } from './ai-provider-options.mjs'
12
13
  import { logsDir } from './storage.mjs'
14
+ import { logger } from './utils/logger.mjs'
13
15
 
14
16
  const PATCH_MARKER = Symbol.for('quickforge.aiHttpLogger.fetchPatched')
15
17
  const ORIGINAL_FETCH = Symbol.for('quickforge.aiHttpLogger.originalFetch')
@@ -243,27 +245,154 @@ function combineAbortSignals(parentSignal, deadlineSignal) {
243
245
  return controller.signal
244
246
  }
245
247
 
246
- function wrapStreamWithDeadline(stream, deadlineController, deadlineMs) {
247
- let timer
248
- const deadlinePromise = new Promise((_, reject) => {
249
- timer = setTimeout(() => {
250
- deadlineController.abort()
251
- reject(new Error(`AI stream timed out after ${deadlineMs}ms`))
252
- }, deadlineMs)
253
- timer.unref?.()
248
+ function wrapStreamWithTimeouts(stream, timeoutController, {
249
+ idleTimeoutMs,
250
+ totalTimeoutMs,
251
+ provider,
252
+ model,
253
+ purpose,
254
+ }) {
255
+ const startedAt = Date.now()
256
+ const iterator = stream[Symbol.asyncIterator]()
257
+ const queue = []
258
+ const waiting = []
259
+ let lastEventAt = null
260
+ let eventCount = 0
261
+ let idleTimer
262
+ let totalTimer
263
+ let settled = false
264
+ let iterationClosed = false
265
+ let iterationStopRequested = false
266
+ let iterationError
267
+ let rejectTimeout
268
+
269
+ const timeoutPromise = new Promise((_, reject) => {
270
+ rejectTimeout = reject
254
271
  })
255
- const resultPromise = Promise.race([stream.result(), deadlinePromise])
256
- .finally(() => clearTimeout(timer))
272
+ // The timeout may win async iteration before result() is observed.
273
+ timeoutPromise.catch(() => {})
274
+
275
+ const clearTimers = () => {
276
+ clearTimeout(idleTimer)
277
+ clearTimeout(totalTimer)
278
+ idleTimer = undefined
279
+ totalTimer = undefined
280
+ }
281
+
282
+ const closeIteration = (error) => {
283
+ if (iterationClosed) return
284
+ iterationClosed = true
285
+ iterationError = error
286
+ while (waiting.length > 0) {
287
+ const waiter = waiting.shift()
288
+ if (error) waiter.reject(error)
289
+ else waiter.resolve({ value: undefined, done: true })
290
+ }
291
+ }
292
+
293
+ const finish = (iterationFailure) => {
294
+ if (settled) return false
295
+ settled = true
296
+ clearTimers()
297
+ closeIteration(iterationFailure)
298
+ return true
299
+ }
300
+
301
+ const failTimeout = (timeoutType, timeoutMs) => {
302
+ if (settled) return
303
+ const now = Date.now()
304
+ const elapsedMs = now - startedAt
305
+ const lastEventAgoMs = lastEventAt === null ? null : now - lastEventAt
306
+ const phase = eventCount === 0 ? 'waiting_for_first_event' : 'waiting_for_next_event'
307
+ const error = new Error(`AI stream ${timeoutType} timeout after ${timeoutMs}ms`)
308
+ if (!finish(error)) return
309
+ logger.warn(error.message, {
310
+ provider,
311
+ model,
312
+ purpose,
313
+ timeoutType,
314
+ timeoutMs,
315
+ elapsedMs,
316
+ eventCount,
317
+ lastEventAgoMs,
318
+ lastEventAt: lastEventAt === null ? undefined : new Date(lastEventAt).toISOString(),
319
+ phase,
320
+ })
321
+ timeoutController.abort()
322
+ iterationStopRequested = true
323
+ if (typeof iterator.return === 'function') {
324
+ Promise.resolve(iterator.return()).catch(() => {})
325
+ }
326
+ rejectTimeout(error)
327
+ }
328
+
329
+ const resetIdleTimer = () => {
330
+ if (settled) return
331
+ clearTimeout(idleTimer)
332
+ idleTimer = setTimeout(() => failTimeout('idle', idleTimeoutMs), idleTimeoutMs)
333
+ idleTimer.unref?.()
334
+ }
335
+
336
+ const publish = (value) => {
337
+ eventCount += 1
338
+ lastEventAt = Date.now()
339
+ resetIdleTimer()
340
+ const waiter = waiting.shift()
341
+ if (waiter) waiter.resolve({ value, done: false })
342
+ else queue.push(value)
343
+ }
344
+
345
+ resetIdleTimer()
346
+ totalTimer = setTimeout(() => failTimeout('total', totalTimeoutMs), totalTimeoutMs)
347
+ totalTimer.unref?.()
348
+
349
+ const resultPromise = Promise.race([stream.result(), timeoutPromise])
350
+ .then(
351
+ (result) => {
352
+ settled = true
353
+ clearTimers()
354
+ return result
355
+ },
356
+ (error) => {
357
+ finish(error)
358
+ throw error
359
+ },
360
+ )
257
361
  resultPromise.catch(() => {})
258
362
 
363
+ const pumpPromise = (async () => {
364
+ try {
365
+ while (!iterationStopRequested) {
366
+ const iteration = await iterator.next()
367
+ if (iterationStopRequested) return
368
+ if (iteration.done) {
369
+ closeIteration()
370
+ return
371
+ }
372
+ publish(iteration.value)
373
+ }
374
+ } catch (error) {
375
+ closeIteration(error)
376
+ }
377
+ })()
378
+ pumpPromise.catch(() => {})
379
+
259
380
  return {
260
381
  result: () => resultPromise,
261
382
  [Symbol.asyncIterator]() {
262
- const iterator = stream[Symbol.asyncIterator]()
263
383
  return {
264
- next: () => Promise.race([iterator.next(), deadlinePromise]),
384
+ next: () => {
385
+ if (queue.length > 0) return Promise.resolve({ value: queue.shift(), done: false })
386
+ if (iterationClosed) {
387
+ return iterationError
388
+ ? Promise.reject(iterationError)
389
+ : Promise.resolve({ value: undefined, done: true })
390
+ }
391
+ return new Promise((resolve, reject) => waiting.push({ resolve, reject }))
392
+ },
265
393
  return: async () => {
266
- clearTimeout(timer)
394
+ iterationStopRequested = true
395
+ closeIteration()
267
396
  if (typeof iterator.return === 'function') return iterator.return()
268
397
  return { value: undefined, done: true }
269
398
  },
@@ -331,15 +460,23 @@ async function managedCloudIdempotencyKey(context, options) {
331
460
 
332
461
  export function streamSimpleWithAiHttpLogging(model, context, options = {}) {
333
462
  const providerOptions = withDefaultAiProviderOptions(options)
334
- const deadlineMs = Number.isFinite(providerOptions.deadlineMs)
463
+ const legacyDeadlineMs = Number.isFinite(providerOptions.deadlineMs)
335
464
  ? Math.max(1, providerOptions.deadlineMs)
336
- : DEFAULT_AI_STREAM_DEADLINE_MS
337
- const deadlineController = new AbortController()
465
+ : undefined
466
+ const idleTimeoutMs = Number.isFinite(providerOptions.idleTimeoutMs)
467
+ ? Math.max(1, providerOptions.idleTimeoutMs)
468
+ : legacyDeadlineMs ?? DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS
469
+ const totalTimeoutMs = Number.isFinite(providerOptions.totalTimeoutMs)
470
+ ? Math.max(1, providerOptions.totalTimeoutMs)
471
+ : DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS
472
+ const timeoutController = new AbortController()
338
473
  const effectiveOptions = {
339
474
  ...providerOptions,
340
- signal: combineAbortSignals(providerOptions.signal, deadlineController.signal),
475
+ signal: combineAbortSignals(providerOptions.signal, timeoutController.signal),
341
476
  }
342
477
  delete effectiveOptions.deadlineMs
478
+ delete effectiveOptions.idleTimeoutMs
479
+ delete effectiveOptions.totalTimeoutMs
343
480
 
344
481
  const managedCloud = model?.provider === 'quickforge-cloud' && model?.quickforgeModelSource === 'cloud'
345
482
  const stream = managedCloud
@@ -362,5 +499,11 @@ export function streamSimpleWithAiHttpLogging(model, context, options = {}) {
362
499
  )))
363
500
  : createProviderStream(model, context, effectiveOptions)
364
501
 
365
- return wrapStreamWithDeadline(stream, deadlineController, deadlineMs)
502
+ return wrapStreamWithTimeouts(stream, timeoutController, {
503
+ idleTimeoutMs,
504
+ totalTimeoutMs,
505
+ provider: model?.provider,
506
+ model: model?.id,
507
+ purpose: providerOptions.metadata?.quickforgePurpose || 'chat',
508
+ })
366
509
  }
@@ -1,6 +1,9 @@
1
1
  export const DEFAULT_AI_MAX_RETRIES = 3
2
2
  export const DEFAULT_AI_HTTP_TIMEOUT_MS = 2 * 60 * 1000
3
- export const DEFAULT_AI_STREAM_DEADLINE_MS = 5 * 60 * 1000
3
+ export const DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000
4
+ export const DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS = 20 * 60 * 1000
5
+ // Backward-compatible alias: deadlineMs now overrides the idle timeout.
6
+ export const DEFAULT_AI_STREAM_DEADLINE_MS = DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS
4
7
 
5
8
  export function withDefaultAiProviderOptions(options = {}) {
6
9
  return {