@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.
- package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
- package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
- package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
- package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
- package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
- package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
- package/dist/assets/index-BTPzyq7d.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
package/server/agent-manager.mjs
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
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
|
|
2356
|
+
// Write body + metadata as one authoritative SQLite transaction after cutover.
|
|
2161
2357
|
let persistedMetadata
|
|
2162
2358
|
try {
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
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
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
|
463
|
+
const legacyDeadlineMs = Number.isFinite(providerOptions.deadlineMs)
|
|
335
464
|
? Math.max(1, providerOptions.deadlineMs)
|
|
336
|
-
:
|
|
337
|
-
const
|
|
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,
|
|
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
|
|
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
|
|
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 {
|