@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
@@ -7,6 +7,14 @@ import { agentProfileSnapshot, getAgentProfile } from '../agent-profiles.mjs'
7
7
  import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
8
8
  import { logger } from '../utils/logger.mjs'
9
9
  import { resolveModelBinding } from '../model-catalog.mjs'
10
+ import { createScheduledTaskRunsService } from '../scheduled-task-runs-service.mjs'
11
+ import {
12
+ assertScheduledRunsAvailable,
13
+ canStartScheduledRun,
14
+ configureScheduledRunsRuntimeHooks,
15
+ isScheduledRunsAuthoritative,
16
+ recordScheduledRunsDiagnostic,
17
+ } from '../scheduled-runs-cutover.mjs'
10
18
  import {
11
19
  dayMs,
12
20
  formatLocalDateTime,
@@ -29,7 +37,9 @@ const editableScheduleTypes = new Set(['once', 'daily', 'weekly', 'monthly'])
29
37
  const weekDayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
30
38
 
31
39
  let schedulerTimer = null
40
+ let runnerDesired = false
32
41
  let running = false
42
+ let tickPromise = null
33
43
  const runningTaskRunIds = new Map()
34
44
 
35
45
  function executionModeFor(task) {
@@ -385,61 +395,94 @@ function getTasks() {
385
395
  return readStore(STORE).then((data) => Object.values(data).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))))
386
396
  }
387
397
 
388
- function parsePositiveInteger(value, fallback, max) {
389
- const parsed = Number(value)
390
- if (!Number.isInteger(parsed) || parsed <= 0) return fallback
391
- return Math.min(parsed, max)
398
+ async function hydrateTaskRuns(task, limit = 5) {
399
+ if (!task || !isScheduledRunsAuthoritative()) return task
400
+ return { ...task, runs: await taskRunsService.recentRuns(task.id, limit) }
392
401
  }
393
402
 
394
- function runMatchesKeyword(run, keyword) {
395
- if (!keyword) return true
396
- const text = [run.taskTitle, run.inputContent, run.aiResult, run.result, run.errorMessage]
397
- .filter(Boolean)
398
- .join('\n')
399
- .toLowerCase()
400
- return text.includes(keyword.toLowerCase())
403
+ async function hydrateTasksRuns(tasks, limit = 5) {
404
+ if (!isScheduledRunsAuthoritative()) return tasks
405
+ return Promise.all(tasks.map((task) => hydrateTaskRuns(task, limit)))
401
406
  }
402
407
 
403
- async function getTaskRuns(url) {
404
- const params = url.searchParams
405
- const page = parsePositiveInteger(params.get('page'), 1, 100000)
406
- const pageSize = parsePositiveInteger(params.get('pageSize'), 10, 100)
407
- const taskId = String(params.get('taskId') || '').trim()
408
- const status = String(params.get('status') || '').trim()
409
- const trigger = String(params.get('trigger') || '').trim()
410
- const keyword = String(params.get('keyword') || '').trim()
411
- const startedFrom = params.get('startedFrom') ? new Date(String(params.get('startedFrom'))).getTime() : null
412
- const startedTo = params.get('startedTo') ? new Date(String(params.get('startedTo'))).getTime() : null
413
-
414
- let runs = (await getTasks()).flatMap((task) => (task.runs || []).map((run) => ({
415
- ...run,
416
- taskId: task.id,
417
- taskTitle: task.title,
418
- scheduleRule: task.scheduleRule,
419
- projectName: task.projectName,
420
- })))
408
+ const taskRunsService = createScheduledTaskRunsService({
409
+ readTasks: getTasks,
410
+ logger,
411
+ })
421
412
 
422
- if (taskId) runs = runs.filter((run) => run.taskId === taskId)
423
- if (status) runs = runs.filter((run) => run.status === status)
424
- if (trigger) runs = runs.filter((run) => run.trigger === trigger)
425
- if (startedFrom && !Number.isNaN(startedFrom)) runs = runs.filter((run) => new Date(run.startedAt).getTime() >= startedFrom)
426
- if (startedTo && !Number.isNaN(startedTo)) runs = runs.filter((run) => new Date(run.startedAt).getTime() <= startedTo)
427
- if (keyword) runs = runs.filter((run) => runMatchesKeyword(run, keyword))
413
+ function authoritativeRun(task, runId) {
414
+ return task?.runs?.find((run) => run.id === runId) || null
415
+ }
428
416
 
429
- runs.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)))
430
- const total = runs.length
431
- const start = (page - 1) * pageSize
432
- return { runs: runs.slice(start, start + pageSize), total, page, pageSize }
417
+ async function persistRun(taskId, run, phase) {
418
+ if (!run?.id) return null
419
+ try {
420
+ return await taskRunsService.syncRun(taskId, run, { phase })
421
+ } catch (error) {
422
+ logger.warn('Scheduled task run persistence rejected', {
423
+ taskId,
424
+ runId: run.id,
425
+ phase,
426
+ errorName: error?.name || 'Error',
427
+ errorCode: error?.code,
428
+ })
429
+ throw error
430
+ }
431
+ }
432
+
433
+ async function syncAuthoritativeRun(task, runId, phase) {
434
+ const run = authoritativeRun(task, runId)
435
+ if (!run) return null
436
+ try {
437
+ return await persistRun(task.id, run, phase)
438
+ } catch (error) {
439
+ if (isScheduledRunsAuthoritative()) throw error
440
+ return null
441
+ }
442
+ }
443
+
444
+ async function deleteAuthoritativeTaskRuns(taskId) {
445
+ try {
446
+ return await taskRunsService.deleteTaskRuns(taskId)
447
+ } catch (error) {
448
+ if (isScheduledRunsAuthoritative()) throw error
449
+ return false
450
+ }
451
+ }
452
+
453
+ async function getTaskRuns(url) {
454
+ const params = url.searchParams
455
+ return taskRunsService.listRuns({
456
+ page: params.get('page'),
457
+ pageSize: params.get('pageSize'),
458
+ taskId: params.get('taskId'),
459
+ status: params.get('status'),
460
+ trigger: params.get('trigger'),
461
+ keyword: params.get('keyword'),
462
+ startedFrom: params.get('startedFrom'),
463
+ startedTo: params.get('startedTo'),
464
+ })
433
465
  }
434
466
 
435
467
  async function updateTask(taskId, updater) {
436
468
  let updated = null
437
- await atomicUpdate(STORE, (data) => {
438
- if (!data[taskId]) return data
439
- updated = updater(data[taskId])
440
- data[taskId] = updated
441
- return data
442
- })
469
+ try {
470
+ await atomicUpdate(STORE, (data) => {
471
+ if (!data[taskId]) return data
472
+ updated = updater(data[taskId])
473
+ if (isScheduledRunsAuthoritative() && updated) {
474
+ const { runs: _runs, ...metadata } = updated
475
+ updated = metadata
476
+ }
477
+ data[taskId] = updated
478
+ return data
479
+ })
480
+ } catch (error) {
481
+ if (isScheduledRunsAuthoritative()) {
482
+ try { recordScheduledRunsDiagnostic('task_metadata_update', error, { taskId }) } catch { /* Preserve metadata failure. */ }
483
+ }
484
+ throw error
485
+ }
443
486
  return updated
444
487
  }
445
488
 
@@ -495,40 +538,76 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
495
538
  let agentWarning = null
496
539
  let agentSnapshot = null
497
540
 
541
+ const createdRun = {
542
+ id: runId,
543
+ status: 'running',
544
+ trigger,
545
+ inputContent: task.instruction,
546
+ sessionId,
547
+ agentId: executionAgent?.id || task.agentId || null,
548
+ agentLabel: executionAgent?.label || null,
549
+ agentSnapshot,
550
+ warning: agentWarning || undefined,
551
+ scheduledAt,
552
+ startedAt,
553
+ }
554
+ let currentRun = createdRun
498
555
  let started = false
499
- await updateTask(task.id, (current) => {
500
- const otherRunIds = activeRunIdsFor(current).filter((id) => id !== runId)
501
- if (mode === 'serial' && otherRunIds.length > 0) return current
502
- started = true
503
- const nextRunAt = advanceNextRunAtAtStart
504
- ? calculateNextRun(current, new Date(startedAt))
505
- : current.nextRunAt
506
- const activeRunIds = appendCurrentRunId(current, runId)
507
- return {
508
- ...current,
509
- currentRunId: activeRunIds[activeRunIds.length - 1] || null,
510
- currentRunIds: activeRunIds,
511
- lastSessionId: sessionId,
512
- nextRunAt,
513
- runs: [{
514
- id: runId,
515
- status: 'running',
516
- trigger,
517
- inputContent: current.instruction,
518
- sessionId,
519
- agentId: executionAgent?.id || task.agentId || null,
520
- agentLabel: executionAgent?.label || null,
521
- agentSnapshot,
522
- warning: agentWarning || undefined,
523
- scheduledAt,
524
- startedAt,
525
- }, ...(current.runs || [])].slice(0, MAX_RUN_HISTORY_PER_TASK),
556
+ let createdTask
557
+ if (isScheduledRunsAuthoritative()) {
558
+ await persistRun(task.id, createdRun, 'created')
559
+ try {
560
+ createdTask = await updateTask(task.id, (current) => {
561
+ const otherRunIds = activeRunIdsFor(current).filter((id) => id !== runId)
562
+ if (mode === 'serial' && otherRunIds.length > 0) return current
563
+ started = true
564
+ const nextRunAt = advanceNextRunAtAtStart
565
+ ? calculateNextRun(current, new Date(startedAt))
566
+ : current.nextRunAt
567
+ const activeRunIds = appendCurrentRunId(current, runId)
568
+ return {
569
+ ...current,
570
+ currentRunId: activeRunIds[activeRunIds.length - 1] || null,
571
+ currentRunIds: activeRunIds,
572
+ lastSessionId: sessionId,
573
+ nextRunAt,
574
+ }
575
+ })
576
+ } catch (error) {
577
+ try {
578
+ await taskRunsService.deleteRun(task.id, runId)
579
+ } catch (compensationError) {
580
+ throw new Error(`Scheduled run create metadata failed and SQLite compensation failed: ${compensationError?.message || compensationError}`, { cause: compensationError })
581
+ } finally {
582
+ removeActiveRun(task.id, runId)
583
+ }
584
+ throw error
526
585
  }
527
- })
586
+ if (!started) await taskRunsService.deleteRun(task.id, runId)
587
+ } else {
588
+ createdTask = await updateTask(task.id, (current) => {
589
+ const otherRunIds = activeRunIdsFor(current).filter((id) => id !== runId)
590
+ if (mode === 'serial' && otherRunIds.length > 0) return current
591
+ started = true
592
+ const nextRunAt = advanceNextRunAtAtStart
593
+ ? calculateNextRun(current, new Date(startedAt))
594
+ : current.nextRunAt
595
+ const activeRunIds = appendCurrentRunId(current, runId)
596
+ return {
597
+ ...current,
598
+ currentRunId: activeRunIds[activeRunIds.length - 1] || null,
599
+ currentRunIds: activeRunIds,
600
+ lastSessionId: sessionId,
601
+ nextRunAt,
602
+ runs: [createdRun, ...(current.runs || [])].slice(0, MAX_RUN_HISTORY_PER_TASK),
603
+ }
604
+ })
605
+ }
528
606
  if (!started) {
529
607
  removeActiveRun(task.id, runId)
530
608
  return
531
609
  }
610
+ if (!isScheduledRunsAuthoritative()) await syncAuthoritativeRun(createdTask, runId, 'created')
532
611
 
533
612
  let settled = false
534
613
 
@@ -588,11 +667,23 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
588
667
  message: truncateText(task.instruction, 500),
589
668
  })
590
669
 
591
- await updateTask(task.id, (current) => ({
670
+ currentRun = {
671
+ ...currentRun,
672
+ sessionId,
673
+ agentId: executionAgent?.id || task.agentId || null,
674
+ agentLabel: executionAgent?.label || null,
675
+ agentSnapshot,
676
+ warning: agentWarning || currentRun.warning,
677
+ }
678
+ const resolvedTask = await updateTask(task.id, (current) => ({
592
679
  ...current,
593
680
  lastSessionId: sessionId,
594
- runs: (current.runs || []).map((run) => run.id === runId ? { ...run, sessionId, agentId: executionAgent?.id || task.agentId || null, agentLabel: executionAgent?.label || null, agentSnapshot, warning: agentWarning || run.warning } : run),
681
+ ...(!isScheduledRunsAuthoritative() ? {
682
+ runs: (current.runs || []).map((run) => run.id === runId ? { ...run, ...currentRun } : run),
683
+ } : {}),
595
684
  }))
685
+ if (isScheduledRunsAuthoritative()) await persistRun(task.id, currentRun, 'resolved')
686
+ else await syncAuthoritativeRun(resolvedTask, runId, 'resolved')
596
687
  onStarted?.({ taskId: task.id, runId, sessionId })
597
688
 
598
689
  const eventBus = getSessionEventBus(sessionId)
@@ -681,30 +772,49 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
681
772
  ? (nextRunAt ? 'enabled' : 'completed')
682
773
  : (recurring && nextRunAt ? 'enabled' : 'failed')
683
774
 
684
- await updateTask(task.id, (current) => ({
685
- ...current,
686
- status: nextStatus,
687
- currentRunId: stillRunning ? remainingRunIds[remainingRunIds.length - 1] : null,
688
- currentRunIds: remainingRunIds,
689
- lastRunAt: finishedAt,
690
- nextRunAt: nextRunAt ?? current.nextRunAt,
691
- lastSessionId: sessionId,
692
- runs: (current.runs || []).map((run) => run.id === runId ? {
693
- ...run,
694
- status: result.aborted ? 'failed' : (result.ok ? 'success' : 'failed'),
695
- inputContent: run.inputContent ?? latestTask.instruction,
696
- aiResult: result.ok ? aiResult : undefined,
697
- result: result.ok ? (aiResult || `已完成,结果保存在会话 ${sessionId}`) : undefined,
698
- errorMessage: result.error,
699
- sessionId,
700
- agentId: executionAgent?.id || latestTask.agentId || null,
701
- agentLabel: executionAgent?.label || null,
702
- agentSnapshot,
703
- warning: agentWarning || run.warning,
704
- finishedAt,
705
- durationMs,
706
- } : run),
707
- }))
775
+ const terminalRun = {
776
+ ...currentRun,
777
+ status: result.aborted ? 'failed' : (result.ok ? 'success' : 'failed'),
778
+ inputContent: currentRun.inputContent ?? latestTask.instruction,
779
+ aiResult: result.ok ? aiResult : undefined,
780
+ result: result.ok ? (aiResult || `已完成,结果保存在会话 ${sessionId}`) : undefined,
781
+ errorMessage: result.error,
782
+ sessionId,
783
+ agentId: executionAgent?.id || latestTask.agentId || null,
784
+ agentLabel: executionAgent?.label || null,
785
+ agentSnapshot,
786
+ warning: agentWarning || currentRun.warning,
787
+ finishedAt,
788
+ durationMs,
789
+ }
790
+ let terminalPersisted = false
791
+ if (isScheduledRunsAuthoritative()) {
792
+ await persistRun(task.id, terminalRun, 'terminal')
793
+ terminalPersisted = true
794
+ }
795
+ let terminalMetadataError = null
796
+ let terminalTask
797
+ try {
798
+ terminalTask = await updateTask(task.id, (current) => ({
799
+ ...current,
800
+ status: nextStatus,
801
+ currentRunId: stillRunning ? remainingRunIds[remainingRunIds.length - 1] : null,
802
+ currentRunIds: remainingRunIds,
803
+ lastRunAt: finishedAt,
804
+ nextRunAt: nextRunAt ?? current.nextRunAt,
805
+ lastSessionId: sessionId,
806
+ ...(!isScheduledRunsAuthoritative() ? {
807
+ runs: (current.runs || []).map((run) => run.id === runId ? terminalRun : run),
808
+ } : {}),
809
+ }))
810
+ } catch (error) {
811
+ if (!terminalPersisted) throw error
812
+ terminalMetadataError = error
813
+ try { recordScheduledRunsDiagnostic('terminal_metadata', error, { taskId: task.id, runId }) } catch { /* Preserve terminal success. */ }
814
+ logger.warn('Scheduled task terminal metadata persistence failed', { taskId: task.id, runId, errorName: error?.name || 'Error' })
815
+ }
816
+ if (!isScheduledRunsAuthoritative()) await syncAuthoritativeRun(terminalTask, runId, 'terminal')
817
+ if (terminalMetadataError) settled = true
708
818
 
709
819
  emitScheduledTaskNotification({
710
820
  task: latestTask,
@@ -718,7 +828,33 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
718
828
  const finishedAt = new Date().toISOString()
719
829
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime()
720
830
  removeActiveRun(task.id, runId)
721
- await updateTask(task.id, (current) => {
831
+ const failureMessage = error?.message || String(error)
832
+ const failedRun = {
833
+ ...currentRun,
834
+ status: 'failed',
835
+ errorMessage: failureMessage,
836
+ sessionId,
837
+ agentId: executionAgent?.id || task.agentId || null,
838
+ agentLabel: executionAgent?.label || null,
839
+ agentSnapshot,
840
+ warning: agentWarning || currentRun.warning,
841
+ finishedAt,
842
+ durationMs,
843
+ }
844
+ let persistenceError = null
845
+ if (isScheduledRunsAuthoritative()) {
846
+ try {
847
+ await persistRun(task.id, failedRun, 'exception-terminal')
848
+ } catch (terminalError) {
849
+ persistenceError = terminalError
850
+ }
851
+ }
852
+ if (persistenceError) {
853
+ try { recordScheduledRunsDiagnostic('exception_terminal', persistenceError, { taskId: task.id, runId }) } catch { /* Preserve the original failure. */ }
854
+ logger.error('Scheduled task terminal SQLite persistence failed', { taskId: task.id, runId, errorName: persistenceError?.name || 'Error' })
855
+ return
856
+ }
857
+ const failedTask = await updateTask(task.id, (current) => {
722
858
  const remainingRunIds = removeCurrentRunId(current, runId)
723
859
  const stillRunning = remainingRunIds.length > 0
724
860
  return {
@@ -729,20 +865,12 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
729
865
  lastRunAt: finishedAt,
730
866
  lastSessionId: sessionId,
731
867
  nextRunAt: stillRunning || advanceNextRunAtAtStart ? current.nextRunAt : (isRecurringTask(current) ? (calculateNextRun(current, new Date(finishedAt)) ?? current.nextRunAt) : current.nextRunAt),
732
- runs: (current.runs || []).map((run) => run.id === runId ? {
733
- ...run,
734
- status: 'failed',
735
- errorMessage: error?.message || String(error),
736
- sessionId,
737
- agentId: executionAgent?.id || current.agentId || null,
738
- agentLabel: executionAgent?.label || null,
739
- agentSnapshot,
740
- warning: agentWarning || run.warning,
741
- finishedAt,
742
- durationMs,
743
- } : run),
868
+ ...(!isScheduledRunsAuthoritative() ? {
869
+ runs: (current.runs || []).map((run) => run.id === runId ? { ...run, ...failedRun } : run),
870
+ } : {}),
744
871
  }
745
872
  })
873
+ if (!isScheduledRunsAuthoritative()) await syncAuthoritativeRun(failedTask, runId, 'exception-terminal')
746
874
  emitScheduledTaskNotification({
747
875
  task,
748
876
  runId,
@@ -756,14 +884,52 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
756
884
  }
757
885
  }
758
886
 
887
+ export async function recoverStaleScheduledTaskRuns({ now = () => new Date() } = {}) {
888
+ const tasks = await getTasks()
889
+ const finishedAt = now().toISOString()
890
+ for (const task of tasks) {
891
+ const activeIds = currentRunIdsFor(task)
892
+ const sqliteRunning = isScheduledRunsAuthoritative()
893
+ ? (await taskRunsService.listRuns({ taskId: task.id, status: 'running', page: 1, pageSize: MAX_RUN_HISTORY_PER_TASK })).runs
894
+ : []
895
+ const jsonRunning = Array.isArray(task.runs) ? task.runs.filter((run) => run?.status === 'running') : []
896
+ const runningById = new Map([...sqliteRunning, ...jsonRunning].map((run) => [run.id, run]))
897
+ for (const runId of activeIds) {
898
+ if (!runningById.has(runId)) runningById.set(runId, { id: runId, status: 'running', trigger: 'schedule', startedAt: task.lastRunAt || task.updatedAt || finishedAt })
899
+ }
900
+ if (runningById.size === 0 && activeIds.length === 0) continue
901
+ const failedRuns = []
902
+ for (const run of runningById.values()) {
903
+ const startedMs = new Date(run.startedAt).getTime()
904
+ const durationMs = Number.isFinite(startedMs) ? Math.max(0, new Date(finishedAt).getTime() - startedMs) : 0
905
+ const failed = { ...run, status: 'failed', errorMessage: 'Interrupted by previous process shutdown', finishedAt, durationMs }
906
+ failedRuns.push(failed)
907
+ if (isScheduledRunsAuthoritative()) await persistRun(task.id, failed, 'startup-recovery')
908
+ }
909
+ await updateTask(task.id, (current) => ({
910
+ ...current,
911
+ currentRunId: null,
912
+ currentRunIds: [],
913
+ status: current.status === 'paused' ? 'paused' : (isRecurringTask(current) ? 'enabled' : 'failed'),
914
+ lastRunAt: finishedAt,
915
+ nextRunAt: isRecurringTask(current) ? (calculateNextRun(current, new Date(finishedAt)) ?? current.nextRunAt) : current.nextRunAt,
916
+ ...(!isScheduledRunsAuthoritative() ? {
917
+ runs: (current.runs || []).map((run) => failedRuns.find((failed) => failed.id === run.id) || run),
918
+ } : {}),
919
+ }))
920
+ }
921
+ }
922
+
759
923
  async function schedulerTick() {
760
- if (running) return
924
+ if (running || !canStartScheduledRun()) return
761
925
  running = true
762
926
  try {
927
+ if (!canStartScheduledRun()) return
763
928
  await repairRecurringTaskStatuses()
764
929
  const now = Date.now()
765
930
  const tasks = await getTasks()
766
931
  for (const task of tasks) {
932
+ if (!canStartScheduledRun()) break
767
933
  if (task.status !== 'enabled') continue
768
934
  if (!task.nextRunAt || new Date(task.nextRunAt).getTime() > now) continue
769
935
  if (executionModeFor(task) === 'serial' && hasActiveTaskRuns(task)) continue
@@ -774,23 +940,50 @@ async function schedulerTick() {
774
940
  }
775
941
  }
776
942
 
943
+ function requestSchedulerTick() {
944
+ if (tickPromise) return tickPromise
945
+ tickPromise = schedulerTick()
946
+ .catch((error) => logger.error('Scheduled task tick failed:', error))
947
+ .finally(() => { tickPromise = null })
948
+ return tickPromise
949
+ }
950
+
951
+ async function pauseScheduledTaskRunner() {
952
+ if (schedulerTimer) {
953
+ clearInterval(schedulerTimer)
954
+ schedulerTimer = null
955
+ }
956
+ if (tickPromise) await tickPromise
957
+ const tasks = await getTasks()
958
+ if (runningTaskRunIds.size > 0 || tasks.some(hasActiveTaskRuns)) {
959
+ const error = requestError('Cannot restore scheduled tasks while a run is active', 409)
960
+ error.errorCode = 'scheduled_runs_active'
961
+ throw error
962
+ }
963
+ }
964
+
965
+ function resumeScheduledTaskRunner() {
966
+ if (runnerDesired) startScheduledTaskRunner()
967
+ }
968
+
969
+ configureScheduledRunsRuntimeHooks({ pause: pauseScheduledTaskRunner, resume: resumeScheduledTaskRunner })
970
+
777
971
  export function startScheduledTaskRunner() {
778
- if (schedulerTimer) return
779
- schedulerTimer = setInterval(() => {
780
- schedulerTick().catch((error) => logger.error('Scheduled task tick failed:', error))
781
- }, RUN_CHECK_INTERVAL_MS)
782
- schedulerTick().catch((error) => logger.error('Scheduled task initial tick failed:', error))
972
+ runnerDesired = true
973
+ if (schedulerTimer || !canStartScheduledRun()) return
974
+ schedulerTimer = setInterval(requestSchedulerTick, RUN_CHECK_INTERVAL_MS)
975
+ requestSchedulerTick()
783
976
  }
784
977
 
785
978
  export function stopScheduledTaskRunner() {
786
- if (!schedulerTimer) return
787
- clearInterval(schedulerTimer)
979
+ runnerDesired = false
980
+ if (schedulerTimer) clearInterval(schedulerTimer)
788
981
  schedulerTimer = null
789
- runningTaskRunIds.clear()
790
982
  }
791
983
 
792
984
  export async function handleScheduledTasksApi(req, res, url, context = {}) {
793
985
  const parts = url.pathname.split('/').filter(Boolean)
986
+ assertScheduledRunsAvailable()
794
987
 
795
988
  if (req.method === 'POST' && url.pathname === '/api/scheduled-tasks/parse') {
796
989
  const body = await readJsonBody(req)
@@ -799,7 +992,7 @@ export async function handleScheduledTasksApi(req, res, url, context = {}) {
799
992
  }
800
993
 
801
994
  if (req.method === 'GET' && url.pathname === '/api/scheduled-tasks') {
802
- sendJson(res, 200, { tasks: await getTasks() })
995
+ sendJson(res, 200, { tasks: await hydrateTasksRuns(await getTasks()) })
803
996
  return
804
997
  }
805
998
 
@@ -829,13 +1022,13 @@ export async function handleScheduledTasksApi(req, res, url, context = {}) {
829
1022
  status: enabled ? 'enabled' : 'paused',
830
1023
  createdAt: now,
831
1024
  updatedAt: now,
832
- runs: [],
1025
+ ...(isScheduledRunsAuthoritative() ? {} : { runs: [] }),
833
1026
  }
834
1027
  await atomicUpdate(STORE, (data) => {
835
1028
  data[task.id] = task
836
1029
  return data
837
1030
  })
838
- sendJson(res, 200, { task })
1031
+ sendJson(res, 200, { task: await hydrateTaskRuns(task) })
839
1032
  return
840
1033
  }
841
1034
 
@@ -874,15 +1067,34 @@ export async function handleScheduledTasksApi(req, res, url, context = {}) {
874
1067
  status: parsed.enabled === false || parsed.status === 'paused' ? 'paused' : 'enabled',
875
1068
  updatedAt: now,
876
1069
  }))
877
- sendJson(res, 200, { task })
1070
+ sendJson(res, 200, { task: await hydrateTaskRuns(task) })
878
1071
  return
879
1072
  }
880
1073
 
881
1074
  if (req.method === 'DELETE' && !action) {
882
- await atomicUpdate(STORE, (data) => {
883
- delete data[taskId]
884
- return data
885
- })
1075
+ const existingForDelete = (await readStore(STORE))[taskId]
1076
+ const before = existingForDelete ? await hydrateTaskRuns(existingForDelete, MAX_RUN_HISTORY_PER_TASK) : null
1077
+ if (isScheduledRunsAuthoritative()) {
1078
+ await deleteAuthoritativeTaskRuns(taskId)
1079
+ try {
1080
+ await atomicUpdate(STORE, (data) => {
1081
+ delete data[taskId]
1082
+ return data
1083
+ })
1084
+ } catch (error) {
1085
+ if (before) {
1086
+ const restoredRuns = Array.isArray(before.runs) ? before.runs : []
1087
+ for (const run of restoredRuns) await persistRun(taskId, run, 'delete-compensation')
1088
+ }
1089
+ throw error
1090
+ }
1091
+ } else {
1092
+ await atomicUpdate(STORE, (data) => {
1093
+ delete data[taskId]
1094
+ return data
1095
+ })
1096
+ await deleteAuthoritativeTaskRuns(taskId)
1097
+ }
886
1098
  sendJson(res, 200, { ok: true })
887
1099
  return
888
1100
  }
@@ -891,7 +1103,7 @@ export async function handleScheduledTasksApi(req, res, url, context = {}) {
891
1103
  const current = (await readStore(STORE))[taskId]
892
1104
  if (!current) throw requestError('Task not found', 404)
893
1105
  const task = await updateTask(taskId, (latest) => ({ ...latest, status: 'paused', updatedAt: new Date().toISOString() }))
894
- sendJson(res, 200, { task })
1106
+ sendJson(res, 200, { task: await hydrateTaskRuns(task) })
895
1107
  return
896
1108
  }
897
1109
 
@@ -905,7 +1117,7 @@ export async function handleScheduledTasksApi(req, res, url, context = {}) {
905
1117
  updatedAt: new Date().toISOString(),
906
1118
  }))
907
1119
  if (!task) throw requestError('Task not found', 404)
908
- sendJson(res, 200, { task })
1120
+ sendJson(res, 200, { task: await hydrateTaskRuns(task) })
909
1121
  return
910
1122
  }
911
1123
 
@@ -914,14 +1126,15 @@ export async function handleScheduledTasksApi(req, res, url, context = {}) {
914
1126
  const task = data[taskId]
915
1127
  if (!task) throw requestError('Task not found', 404)
916
1128
  if (executionModeFor(task) === 'serial' && hasActiveTaskRuns(task)) throw requestError('Task is already running', 409)
917
- await new Promise((resolve) => {
1129
+ await new Promise((resolve, reject) => {
918
1130
  executeTask(task, 'manual', resolve).catch((error) => {
919
1131
  logger.error(`Manual scheduled task ${task.id} failed:`, error)
920
- resolve()
1132
+ if (isScheduledRunsAuthoritative()) reject(error)
1133
+ else resolve()
921
1134
  })
922
1135
  })
923
1136
  const updatedTask = (await readStore(STORE))[taskId] ?? task
924
- sendJson(res, 200, { ok: true, task: updatedTask })
1137
+ sendJson(res, 200, { ok: true, task: await hydrateTaskRuns(updatedTask) })
925
1138
  return
926
1139
  }
927
1140
  }
@@ -6,6 +6,7 @@ import {
6
6
  createConversationShare,
7
7
  deleteConversationShare,
8
8
  listConversationShares,
9
+ readConversationShare,
9
10
  restoreConversationShare,
10
11
  revokeConversationShare,
11
12
  updateConversationShare,
@@ -102,8 +103,9 @@ export async function handleSharesApi(req, res, url, context = { isLocalRequest:
102
103
  if (req.method === 'POST' && action === 'restore') {
103
104
  const body = await readJsonBody(req)
104
105
  const expiresAt = typeof body?.expiresAt === 'string' && body.expiresAt ? body.expiresAt : undefined
105
- const currentShares = await listConversationShares()
106
- const current = currentShares.find((share) => share.id === shareId)
106
+ // Restore operates on the share record itself (including revoked state);
107
+ // the default list excludes revoked shares, so resolve directly.
108
+ const current = await readConversationShare(shareId)
107
109
  if (!current) {
108
110
  const error = new Error('Share not found')
109
111
  error.statusCode = 404