@shawnstack/quickforge 1.7.10 → 1.7.12

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 (124) hide show
  1. package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
  2. package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
  4. package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
  7. package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
  8. package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
  9. package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
  10. package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
  11. package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
  12. package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
  13. package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
  14. package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
  15. package/dist/assets/channel-CeXY-wZo.js +1 -0
  16. package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
  17. package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
  18. package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
  19. package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
  20. package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
  21. package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
  22. package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
  23. package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
  24. package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
  25. package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
  26. package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
  27. package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
  28. package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
  29. package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
  30. package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
  31. package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
  32. package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
  33. package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
  34. package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
  35. package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
  36. package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
  37. package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
  38. package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
  39. package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
  40. package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
  41. package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
  42. package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
  43. package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
  44. package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
  46. package/dist/assets/index-BWf6y3Kh.css +3 -0
  47. package/dist/assets/index-D3GWkXJR.js +66 -0
  48. package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
  50. package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
  51. package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
  52. package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
  53. package/dist/assets/lit-vendor-CinchGmI.js +2 -0
  54. package/dist/assets/local-tools-DY0yEU3T.js +350 -0
  55. package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
  56. package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
  57. package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
  58. package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
  59. package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
  60. package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
  62. package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
  63. package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
  64. package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
  65. package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
  66. package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
  67. package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
  68. package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
  69. package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
  70. package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
  71. package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
  72. package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
  73. package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
  74. package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
  75. package/dist/index.html +6 -6
  76. package/package.json +1 -1
  77. package/server/acp/server.mjs +3 -6
  78. package/server/agent-manager.mjs +192 -48
  79. package/server/approval-store.mjs +1 -0
  80. package/server/ask-store.mjs +78 -0
  81. package/server/auto-archive.mjs +55 -8
  82. package/server/index.mjs +172 -51
  83. package/server/lan-access-cutover.mjs +21 -4
  84. package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
  85. package/server/maintenance/export-session-state-v1.mjs +1 -11
  86. package/server/routes/agent.mjs +9 -0
  87. package/server/routes/backup.mjs +1 -1
  88. package/server/routes/storage.mjs +63 -49
  89. package/server/scheduled-runs-cutover.mjs +15 -1
  90. package/server/session-index-service.mjs +57 -391
  91. package/server/session-state-backup.mjs +56 -5
  92. package/server/session-state-import.mjs +174 -0
  93. package/server/session-state-maintenance.mjs +126 -0
  94. package/server/session-state-service.mjs +128 -210
  95. package/server/share-cutover.mjs +21 -4
  96. package/server/sqlite/database.mjs +142 -7
  97. package/server/sqlite/lan-access-repository.mjs +6 -4
  98. package/server/sqlite/migrations.mjs +109 -0
  99. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  100. package/server/sqlite/session-index-repository.mjs +59 -208
  101. package/server/sqlite/session-state-repository.mjs +337 -387
  102. package/server/sqlite/share-repository.mjs +56 -45
  103. package/server/startup-state.mjs +107 -0
  104. package/server/storage.mjs +218 -454
  105. package/server/tools/definitions.mjs +18 -0
  106. package/server/tools/index.mjs +14 -2
  107. package/server/utils/logger.mjs +15 -4
  108. package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
  109. package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
  110. package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
  111. package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
  112. package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
  113. package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
  114. package/dist/assets/channel-Duu1CuH9.js +0 -1
  115. package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
  116. package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
  117. package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
  118. package/dist/assets/index-DQBOm9gt.js +0 -66
  119. package/dist/assets/index-ps85guTs.css +0 -3
  120. package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
  121. package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
  122. package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
  123. package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
  124. package/server/session-state-cutover.mjs +0 -563
@@ -1,36 +1,28 @@
1
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
2
  import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValueWithMetadata, deleteSessionWithMetadata, applySessionBatch, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
3
3
  import { AUTO_ARCHIVE_SETTINGS_KEY, archiveInactiveSessions, normalizeAutoArchiveSettings } from '../auto-archive.mjs'
4
- import { refreshAllSessionModels } from '../agent-manager.mjs'
4
+ import { refreshAllSessionModels, destroyAgent } from '../agent-manager.mjs'
5
5
  import { logger } from '../utils/logger.mjs'
6
6
  import { directorySize } from '../utils/workspace.mjs'
7
7
  import { isAuthenticatedAppClient } from '../access-policy.mjs'
8
- import { getSessionIndexDiagnostics, markSessionIndexQueryFailure, querySessionIndexPage } from '../session-index-service.mjs'
9
- import { isSessionStateMaintenanceActive } from '../session-state-cutover.mjs'
8
+ import { querySessionIndexPage } from '../session-index-service.mjs'
9
+ import { isSessionStateMaintenanceActive } from '../session-state-maintenance.mjs'
10
+ import { isSessionStateAuthoritative } from '../session-state-service.mjs'
11
+ import { verifySessionStateIntegrityForMaintenance } from '../session-state-backup.mjs'
10
12
 
11
13
  const metadataIndexCache = new Map()
12
14
  const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
13
15
  const METADATA_INDEX_CACHE_TTL_MS = 1000
14
16
  const SESSION_QUERY_INDEXES = new Set(['createdAt', 'lastModified', 'pinnedAt'])
15
- const shadowGenerationByShape = new Map()
16
- let shadowSampler = () => Math.random() < 0.01
17
17
 
18
- export function configureSessionIndexQueryShadow({ sample } = {}) {
19
- if (sample !== undefined && typeof sample !== 'function') throw new TypeError('sample must be a function')
20
- shadowSampler = sample ?? (() => Math.random() < 0.01)
21
- shadowGenerationByShape.clear()
22
- }
23
-
24
- function canonicalJson(value) {
25
- if (Array.isArray(value)) return value.map(canonicalJson)
26
- if (!value || typeof value !== 'object') return value
27
- return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalJson(value[key])]))
28
- }
18
+ // In-memory agent disposal used when sessions are deleted from storage.
19
+ // destroyAgent performs a final persist, so deletions must destroy the agent
20
+ // BEFORE removing persisted state otherwise the final persist resurrects
21
+ // the deleted row. Tests inject a spy through configureStorageSessionAgentDisposal.
22
+ let destroyAgentForSession = destroyAgent
29
23
 
30
- function sameShadowPage(sqlPage, jsonValues, limit, offset) {
31
- const expected = jsonValues.slice(offset, offset + limit)
32
- if (sqlPage.total !== jsonValues.length || sqlPage.values.length !== expected.length) return false
33
- return JSON.stringify(sqlPage.values.map(canonicalJson)) === JSON.stringify(expected.map(canonicalJson))
24
+ export function configureStorageSessionAgentDisposal({ destroy } = {}) {
25
+ destroyAgentForSession = destroy ?? destroyAgent
34
26
  }
35
27
 
36
28
  function parseSqlPagination(limitParam, offsetParam) {
@@ -67,19 +59,6 @@ function sqlSessionQueryOptions({ store, indexName, directionParam, scope, proje
67
59
  }
68
60
  }
69
61
 
70
- function shadowShape(options) {
71
- return JSON.stringify({
72
- scopeMode: options.scopeMode,
73
- hasProjectId: options.scopeMode === 'project',
74
- archive: options.archive,
75
- pinnedOnly: options.pinnedOnly,
76
- sort: options.sort,
77
- direction: options.direction,
78
- limit: options.limit,
79
- offset: options.offset,
80
- })
81
- }
82
-
83
62
  function metadataIndexCacheKey({ scope, projectId, indexName, direction, archived, pinned }) {
84
63
  return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction, archived: archived || '', pinned: pinned || '' })
85
64
  }
@@ -215,6 +194,17 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
215
194
  throw error
216
195
  }
217
196
  try {
197
+ // Destroy in-memory agents for deleted sessions BEFORE removing the
198
+ // persisted state: destroyAgent's final persist would otherwise
199
+ // resurrect the deleted row right after the batch commit.
200
+ for (const operation of operations) {
201
+ if (operation?.type !== 'delete' || operation?.store !== 'sessions') continue
202
+ try {
203
+ await destroyAgentForSession(operation.key)
204
+ } catch (error) {
205
+ logger.warn(`Failed to destroy in-memory agent for deleted session ${operation.key}:`, error?.message || error)
206
+ }
207
+ }
218
208
  const result = await applySessionBatch(operations)
219
209
  sendJson(res, 200, { ok: true, saved: result?.saved ?? 0, deleted: result?.deleted ?? 0 })
220
210
  } catch (error) {
@@ -232,6 +222,34 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
232
222
  return
233
223
  }
234
224
 
225
+ // Manual session state integrity verification (design review suggestion
226
+ // 9): startup only runs the lightweight SQL-level check, so per-row digest
227
+ // rot would stay invisible until an offline full verification. POST with
228
+ // { full: true } runs the complete per-row digest recomputation under the
229
+ // session state maintenance lock (same pattern as the authoritative backup
230
+ // export); the response carries only summary counters and the elapsed
231
+ // time, never row payloads.
232
+ if (req.method === 'POST' && parts.length === 4 && store === 'maintenance' && parts[3] === 'verify-session-integrity') {
233
+ if (!isSessionStateAuthoritative()) {
234
+ const error = new Error('Session state integrity verification requires authoritative SQLite storage')
235
+ error.statusCode = 409
236
+ error.errorCode = 'SESSION_STATE_NOT_AUTHORITATIVE'
237
+ throw error
238
+ }
239
+ if (isSessionStateMaintenanceActive()) {
240
+ const error = new Error('Session storage maintenance is in progress')
241
+ error.statusCode = 423
242
+ error.errorCode = 'SESSION_STORAGE_MAINTENANCE'
243
+ throw error
244
+ }
245
+ const body = await readJsonBody(req)
246
+ // forceQuickCheck: the manual endpoint always runs a real quick_check scan
247
+ // (never the startup process-cache/marker shortcut).
248
+ const result = await verifySessionStateIntegrityForMaintenance({ full: body?.full === true, forceQuickCheck: true, maintenance: true })
249
+ sendJson(res, 200, result)
250
+ return
251
+ }
252
+
235
253
  if (req.method === 'GET' && parts[3] === 'keys') {
236
254
  const prefix = url.searchParams.get('prefix') || ''
237
255
  const data = await readStore(store)
@@ -256,25 +274,14 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
256
274
  const sqlOptions = sqlSessionQueryOptions({
257
275
  store, indexName, directionParam, scope, projectId, archived, pinned, limitParam, offsetParam,
258
276
  })
277
+ // Storage v2: sessions-metadata queries eligible for SQL LIMIT/OFFSET are
278
+ // served straight from the authoritative sessions table (no JSON shadow —
279
+ // the SQLite projection IS the store). Everything else keeps the legacy
280
+ // read+sort path (which also reads from SQLite through the facade).
259
281
  if (sqlOptions) {
260
282
  const sqlResult = await querySessionIndexPage(sqlOptions)
261
283
  if (sqlResult.ok) {
262
- const diagnostics = getSessionIndexDiagnostics()
263
- const shape = shadowShape(sqlOptions)
264
- const lastGeneration = shadowGenerationByShape.get(shape)
265
- const forced = lastGeneration !== diagnostics.rebuildGeneration
266
- if (!forced && !shadowSampler({ shape, generation: diagnostics.rebuildGeneration })) {
267
- sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
268
- return
269
- }
270
- const jsonValues = await readIndexedValues(store, indexName, direction, scope, projectId, archived, pinned)
271
- shadowGenerationByShape.set(shape, diagnostics.rebuildGeneration)
272
- if (sameShadowPage(sqlResult.page, jsonValues, sqlOptions.limit, sqlOptions.offset)) {
273
- sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
274
- return
275
- }
276
- markSessionIndexQueryFailure('shadow_mismatch')
277
- sendJson(res, 200, { values: jsonValues.slice(sqlOptions.offset, sqlOptions.offset + sqlOptions.limit), total: jsonValues.length })
284
+ sendJson(res, 200, { values: sqlResult.page.values, total: sqlResult.page.total })
278
285
  return
279
286
  }
280
287
  }
@@ -362,6 +369,13 @@ export async function handleStorageApi(req, res, url, context = { isLocalRequest
362
369
 
363
370
  if (req.method === 'DELETE') {
364
371
  if (store === 'sessions') {
372
+ // Destroy the in-memory agent first: its final persist would
373
+ // otherwise resurrect the row right after the delete.
374
+ try {
375
+ await destroyAgentForSession(key)
376
+ } catch (error) {
377
+ logger.warn(`Failed to destroy in-memory agent for deleted session ${key}:`, error?.message || error)
378
+ }
365
379
  await deleteSessionWithMetadata(key)
366
380
  sendJson(res, 200, { ok: true })
367
381
  return
@@ -241,8 +241,9 @@ export function acquireScheduledRunsMaintenanceLock(storage, {
241
241
  return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
242
242
  }
243
243
  if (current) {
244
+ const expired = Date.parse(current.expires_at) <= nowMs
244
245
  const stalePid = ownerPidFromRow(current)
245
- if (stalePid === null || pidAlive(stalePid)) return null
246
+ if (!expired || stalePid === null || pidAlive(stalePid)) return null
246
247
  }
247
248
  const fencing = Number(current?.fencing || 0) + 1
248
249
  database.prepare(`
@@ -341,6 +342,10 @@ export async function runScheduledRunsMaintenance(operation, options = {}) {
341
342
  throw error
342
343
  } finally {
343
344
  if (retainedMaintenance?.lease !== lease) {
345
+ // Reset any stale retained flag from an earlier retain: this branch
346
+ // releases the lock normally, so retainedMaintenance must only stay set
347
+ // while a retained lease is genuinely still held.
348
+ retainedMaintenance = null
344
349
  clearInterval(timer)
345
350
  releaseScheduledRunsMaintenanceLock(storage, lease)
346
351
  localMaintenanceCount = Math.max(0, localMaintenanceCount - 1)
@@ -424,9 +429,18 @@ export async function initializeScheduledRunsCutover(options = {}) {
424
429
  }
425
430
 
426
431
  if (state.phase === SCHEDULED_RUNS_PHASES.AUTHORITATIVE) {
432
+ // The JSON file is only a non-authoritative mirror in this phase: a
433
+ // corrupt or unslimmable mirror degrades (diagnostic + warn) without
434
+ // blocking startup, while the authoritative SQLite health check stays
435
+ // fail-closed.
427
436
  try {
428
437
  const snapshot = splitScheduledTasksRuns(await readTasks())
429
438
  if (snapshot.count > 0) await slimJson(readTasks, writeTasks, snapshot.metadata)
439
+ } catch (error) {
440
+ recordScheduledRunsDiagnostic('authoritative_validation', error, {}, storage)
441
+ log.warn('Scheduled runs authoritative JSON mirror is invalid; continuing startup', { errorName: error?.name || 'Error' })
442
+ }
443
+ try {
430
444
  storage.health({ quickCheck: true })
431
445
  } catch (error) {
432
446
  recordScheduledRunsDiagnostic('authoritative_validation', error, {}, storage)