@swarmclawai/swarmclaw 1.2.0 → 1.2.2

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 (241) hide show
  1. package/README.md +19 -0
  2. package/package.json +5 -2
  3. package/skills/coding-agent/SKILL.md +111 -0
  4. package/skills/github/SKILL.md +140 -0
  5. package/skills/nano-banana-pro/SKILL.md +62 -0
  6. package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
  7. package/skills/nano-pdf/SKILL.md +53 -0
  8. package/skills/openai-image-gen/SKILL.md +78 -0
  9. package/skills/openai-image-gen/scripts/gen.py +328 -0
  10. package/skills/resourceful-problem-solving/SKILL.md +49 -0
  11. package/skills/skill-creator/SKILL.md +147 -0
  12. package/skills/skill-creator/scripts/init_skill.py +378 -0
  13. package/skills/skill-creator/scripts/quick_validate.py +159 -0
  14. package/skills/summarize/SKILL.md +77 -0
  15. package/src/app/api/auth/route.ts +20 -5
  16. package/src/app/api/chats/[id]/deploy/route.ts +11 -6
  17. package/src/app/api/chats/[id]/devserver/route.ts +17 -20
  18. package/src/app/api/chats/[id]/messages/route.ts +15 -11
  19. package/src/app/api/chats/[id]/route.ts +9 -10
  20. package/src/app/api/chats/[id]/stop/route.ts +5 -7
  21. package/src/app/api/chats/messages-route.test.ts +8 -6
  22. package/src/app/api/chats/route.ts +9 -10
  23. package/src/app/api/credentials/[id]/route.ts +4 -1
  24. package/src/app/api/extensions/marketplace/route.ts +5 -2
  25. package/src/app/api/ip/route.ts +2 -2
  26. package/src/app/api/memory/maintenance/route.ts +5 -2
  27. package/src/app/api/preview-server/route.ts +15 -12
  28. package/src/app/api/projects/[id]/route.ts +7 -46
  29. package/src/app/api/system/status/route.ts +11 -0
  30. package/src/app/api/upload/route.ts +4 -1
  31. package/src/cli/index.js +7 -0
  32. package/src/cli/spec.js +1 -0
  33. package/src/components/agents/agent-files-editor.tsx +44 -32
  34. package/src/components/agents/personality-builder.tsx +13 -7
  35. package/src/components/agents/trash-list.tsx +1 -1
  36. package/src/components/chat/chat-area.tsx +45 -23
  37. package/src/components/chat/message-bubble.test.ts +35 -0
  38. package/src/components/chat/message-bubble.tsx +20 -9
  39. package/src/components/chat/message-list.tsx +62 -42
  40. package/src/components/chat/swarm-status-card.tsx +10 -3
  41. package/src/components/input/chat-input.tsx +34 -14
  42. package/src/components/layout/daemon-indicator.tsx +7 -8
  43. package/src/components/layout/update-banner.tsx +8 -13
  44. package/src/components/logs/log-list.tsx +1 -1
  45. package/src/components/memory/memory-card.tsx +3 -1
  46. package/src/components/org-chart/org-chart-view.tsx +4 -0
  47. package/src/components/projects/project-list.tsx +4 -2
  48. package/src/components/projects/tabs/overview-tab.tsx +3 -2
  49. package/src/components/secrets/secret-sheet.tsx +1 -1
  50. package/src/components/secrets/secrets-list.tsx +1 -1
  51. package/src/components/shared/agent-switch-dialog.tsx +12 -6
  52. package/src/components/shared/dir-browser.tsx +22 -18
  53. package/src/components/skills/skill-sheet.tsx +2 -3
  54. package/src/components/tasks/task-list.tsx +1 -1
  55. package/src/components/tasks/task-sheet.tsx +1 -1
  56. package/src/hooks/use-openclaw-gateway.ts +46 -27
  57. package/src/instrumentation.ts +10 -7
  58. package/src/lib/chat/assistant-render-id.ts +3 -0
  59. package/src/lib/chat/chat-streaming-state.test.ts +42 -3
  60. package/src/lib/chat/chat-streaming-state.ts +20 -8
  61. package/src/lib/chat/chat.ts +18 -2
  62. package/src/lib/chat/queued-message-queue.test.ts +23 -1
  63. package/src/lib/chat/queued-message-queue.ts +11 -2
  64. package/src/lib/providers/anthropic.ts +6 -3
  65. package/src/lib/providers/claude-cli.ts +9 -3
  66. package/src/lib/providers/cli-utils.test.ts +124 -0
  67. package/src/lib/providers/cli-utils.ts +15 -0
  68. package/src/lib/providers/codex-cli.ts +9 -3
  69. package/src/lib/providers/gemini-cli.ts +6 -2
  70. package/src/lib/providers/index.ts +4 -1
  71. package/src/lib/providers/ollama.ts +5 -2
  72. package/src/lib/providers/openai.ts +8 -5
  73. package/src/lib/providers/opencode-cli.ts +6 -2
  74. package/src/lib/server/activity/activity-log.ts +21 -0
  75. package/src/lib/server/agents/agent-availability.test.ts +10 -5
  76. package/src/lib/server/agents/agent-cascade.ts +79 -59
  77. package/src/lib/server/agents/agent-registry.ts +23 -4
  78. package/src/lib/server/agents/agent-repository.ts +90 -0
  79. package/src/lib/server/agents/delegation-job-repository.ts +53 -0
  80. package/src/lib/server/agents/delegation-jobs.ts +11 -4
  81. package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
  82. package/src/lib/server/agents/guardian.ts +2 -2
  83. package/src/lib/server/agents/main-agent-loop.ts +14 -6
  84. package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
  85. package/src/lib/server/agents/subagent-runtime.ts +9 -6
  86. package/src/lib/server/agents/subagent-swarm.ts +3 -2
  87. package/src/lib/server/agents/task-session.ts +3 -4
  88. package/src/lib/server/approvals/approval-repository.ts +30 -0
  89. package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
  90. package/src/lib/server/autonomy/supervisor-reflection.ts +14 -1
  91. package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
  92. package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
  93. package/src/lib/server/chat-execution/chat-execution.ts +84 -1914
  94. package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
  95. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
  96. package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
  98. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
  99. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
  100. package/src/lib/server/chat-execution/continuation-evaluator.ts +4 -3
  101. package/src/lib/server/chat-execution/continuation-limits.ts +6 -3
  102. package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
  103. package/src/lib/server/chat-execution/message-classifier.ts +5 -2
  104. package/src/lib/server/chat-execution/post-stream-finalization.ts +5 -2
  105. package/src/lib/server/chat-execution/prompt-builder.ts +22 -1
  106. package/src/lib/server/chat-execution/prompt-sections.ts +55 -13
  107. package/src/lib/server/chat-execution/response-completeness.ts +5 -2
  108. package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
  109. package/src/lib/server/chat-execution/stream-agent-chat.ts +58 -25
  110. package/src/lib/server/chatrooms/chatroom-memory-bridge.ts +6 -3
  111. package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
  112. package/src/lib/server/connectors/bluebubbles.ts +7 -4
  113. package/src/lib/server/connectors/connector-inbound.ts +16 -13
  114. package/src/lib/server/connectors/connector-lifecycle.ts +11 -8
  115. package/src/lib/server/connectors/connector-outbound.ts +6 -3
  116. package/src/lib/server/connectors/connector-repository.ts +58 -0
  117. package/src/lib/server/connectors/discord.ts +10 -7
  118. package/src/lib/server/connectors/email.ts +17 -14
  119. package/src/lib/server/connectors/googlechat.ts +7 -4
  120. package/src/lib/server/connectors/inbound-audio-transcription.ts +5 -2
  121. package/src/lib/server/connectors/matrix.ts +6 -3
  122. package/src/lib/server/connectors/openclaw.ts +20 -17
  123. package/src/lib/server/connectors/outbox.ts +4 -1
  124. package/src/lib/server/connectors/runtime-state.test.ts +117 -0
  125. package/src/lib/server/connectors/runtime-state.ts +19 -0
  126. package/src/lib/server/connectors/session-consolidation.ts +5 -2
  127. package/src/lib/server/connectors/signal.ts +9 -6
  128. package/src/lib/server/connectors/slack.ts +13 -10
  129. package/src/lib/server/connectors/teams.ts +8 -5
  130. package/src/lib/server/connectors/telegram.ts +15 -12
  131. package/src/lib/server/connectors/whatsapp.ts +32 -29
  132. package/src/lib/server/credentials/credential-repository.ts +7 -0
  133. package/src/lib/server/embeddings.ts +4 -1
  134. package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
  135. package/src/lib/server/link-understanding.ts +4 -1
  136. package/src/lib/server/memory/memory-abstract.test.ts +59 -0
  137. package/src/lib/server/memory/memory-abstract.ts +59 -0
  138. package/src/lib/server/memory/memory-db.ts +40 -14
  139. package/src/lib/server/missions/mission-repository.ts +74 -0
  140. package/src/lib/server/missions/mission-service/actions.ts +6 -0
  141. package/src/lib/server/missions/mission-service/bindings.ts +9 -0
  142. package/src/lib/server/missions/mission-service/context.ts +4 -0
  143. package/src/lib/server/missions/mission-service/core.ts +2269 -0
  144. package/src/lib/server/missions/mission-service/queries.ts +12 -0
  145. package/src/lib/server/missions/mission-service/recovery.ts +5 -0
  146. package/src/lib/server/missions/mission-service/ticks.ts +9 -0
  147. package/src/lib/server/missions/mission-service.test.ts +9 -2
  148. package/src/lib/server/missions/mission-service.ts +6 -2263
  149. package/src/lib/server/openclaw/gateway.ts +8 -5
  150. package/src/lib/server/persistence/repository-utils.ts +154 -0
  151. package/src/lib/server/persistence/storage-context.ts +51 -0
  152. package/src/lib/server/persistence/transaction.ts +1 -0
  153. package/src/lib/server/project-utils.ts +13 -0
  154. package/src/lib/server/projects/project-repository.ts +36 -0
  155. package/src/lib/server/projects/project-service.ts +79 -0
  156. package/src/lib/server/protocols/protocol-agent-turn.ts +5 -2
  157. package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
  158. package/src/lib/server/protocols/protocol-run-lifecycle.ts +5 -2
  159. package/src/lib/server/protocols/protocol-step-helpers.ts +4 -1
  160. package/src/lib/server/provider-health.ts +18 -0
  161. package/src/lib/server/query-expansion.ts +4 -1
  162. package/src/lib/server/runtime/alert-dispatch.ts +8 -7
  163. package/src/lib/server/runtime/daemon-policy.ts +1 -1
  164. package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
  165. package/src/lib/server/runtime/daemon-state/health.ts +6 -0
  166. package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
  167. package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
  168. package/src/lib/server/runtime/daemon-state.test.ts +48 -0
  169. package/src/lib/server/runtime/daemon-state.ts +3 -1331
  170. package/src/lib/server/runtime/estop-repository.ts +4 -0
  171. package/src/lib/server/runtime/estop.ts +3 -1
  172. package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
  173. package/src/lib/server/runtime/heartbeat-service.ts +78 -34
  174. package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
  175. package/src/lib/server/runtime/idle-window.ts +6 -3
  176. package/src/lib/server/runtime/network.ts +11 -0
  177. package/src/lib/server/runtime/orchestrator-events.ts +2 -2
  178. package/src/lib/server/runtime/perf.ts +4 -1
  179. package/src/lib/server/runtime/process-manager.ts +7 -4
  180. package/src/lib/server/runtime/queue/claims.ts +4 -0
  181. package/src/lib/server/runtime/queue/core.ts +2079 -0
  182. package/src/lib/server/runtime/queue/execution.ts +7 -0
  183. package/src/lib/server/runtime/queue/followups.ts +4 -0
  184. package/src/lib/server/runtime/queue/queries.ts +12 -0
  185. package/src/lib/server/runtime/queue/recovery.ts +7 -0
  186. package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
  187. package/src/lib/server/runtime/queue-repository.ts +17 -0
  188. package/src/lib/server/runtime/queue.ts +5 -2058
  189. package/src/lib/server/runtime/run-ledger.ts +6 -5
  190. package/src/lib/server/runtime/run-repository.ts +73 -0
  191. package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
  192. package/src/lib/server/runtime/runtime-settings.ts +1 -1
  193. package/src/lib/server/runtime/runtime-state.ts +99 -0
  194. package/src/lib/server/runtime/scheduler.ts +13 -8
  195. package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
  196. package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
  197. package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
  198. package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
  199. package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
  200. package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
  201. package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
  202. package/src/lib/server/runtime/session-run-manager.ts +72 -1374
  203. package/src/lib/server/runtime/watch-job-repository.ts +35 -0
  204. package/src/lib/server/runtime/watch-jobs.ts +3 -1
  205. package/src/lib/server/sandbox/bridge-auth-registry.ts +6 -0
  206. package/src/lib/server/sandbox/novnc-auth.ts +10 -0
  207. package/src/lib/server/schedules/schedule-repository.ts +42 -0
  208. package/src/lib/server/session-tools/context.ts +14 -0
  209. package/src/lib/server/session-tools/discovery.ts +9 -6
  210. package/src/lib/server/session-tools/index.ts +3 -1
  211. package/src/lib/server/session-tools/platform.ts +1 -1
  212. package/src/lib/server/session-tools/subagent.ts +23 -2
  213. package/src/lib/server/session-tools/wallet.ts +4 -1
  214. package/src/lib/server/sessions/session-repository.ts +85 -0
  215. package/src/lib/server/settings/settings-repository.ts +25 -0
  216. package/src/lib/server/skills/clawhub-client.ts +4 -1
  217. package/src/lib/server/skills/runtime-skill-resolver.ts +8 -2
  218. package/src/lib/server/skills/skill-discovery.test.ts +2 -2
  219. package/src/lib/server/skills/skill-discovery.ts +2 -2
  220. package/src/lib/server/skills/skill-eligibility.ts +6 -0
  221. package/src/lib/server/skills/skill-repository.ts +14 -0
  222. package/src/lib/server/solana.ts +6 -0
  223. package/src/lib/server/storage-auth.ts +5 -5
  224. package/src/lib/server/storage-normalization.ts +4 -0
  225. package/src/lib/server/storage.ts +32 -32
  226. package/src/lib/server/tasks/task-followups.ts +4 -1
  227. package/src/lib/server/tasks/task-repository.ts +54 -0
  228. package/src/lib/server/tool-loop-detection.ts +8 -3
  229. package/src/lib/server/tool-planning.ts +226 -0
  230. package/src/lib/server/tool-retry.ts +4 -3
  231. package/src/lib/server/usage/usage-repository.ts +30 -0
  232. package/src/lib/server/wallet/wallet-portfolio.ts +29 -0
  233. package/src/lib/server/webhooks/webhook-repository.ts +10 -0
  234. package/src/lib/server/ws-hub.ts +5 -2
  235. package/src/lib/strip-internal-metadata.test.ts +78 -37
  236. package/src/lib/strip-internal-metadata.ts +20 -6
  237. package/src/stores/use-approval-store.ts +7 -1
  238. package/src/stores/use-chat-store.test.ts +54 -0
  239. package/src/stores/use-chat-store.ts +26 -6
  240. package/src/types/index.ts +6 -0
  241. /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
@@ -1,533 +1,52 @@
1
- import { genId } from '@/lib/id'
2
- import type {
3
- RunEventRecord,
4
- SessionRunHeartbeatConfig,
5
- SessionQueueSnapshot,
6
- SessionQueuedTurn,
7
- SessionRunRecord,
8
- SessionRunStatus,
9
- SSEEvent,
10
- } from '@/types'
11
1
  import {
12
- active,
13
- isRuntimeLockActive,
14
- loadSession,
15
- releaseRuntimeLock,
16
- tryAcquireRuntimeLock,
17
- } from '@/lib/server/storage'
18
- import { executeSessionChatTurn, type ExecuteChatTurnResult } from '@/lib/server/chat-execution/chat-execution'
19
- import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
20
- import { log } from '@/lib/server/logger'
21
- import { isInternalHeartbeatRun } from '@/lib/server/runtime/heartbeat-source'
22
- import { cleanupSessionBrowser } from '@/lib/server/session-tools/web'
23
- import { cancelDelegationJobsForParentSession } from '@/lib/server/agents/delegation-jobs'
24
- import { getMainLoopStateForSession, handleMainLoopRunResult } from '@/lib/server/agents/main-agent-loop'
25
- import { observeAutonomyRunOutcome } from '@/lib/server/autonomy/supervisor-reflection'
26
- import { observeLearnedSkillRunOutcome } from '@/lib/server/skills/learned-skills'
27
- import { errorMessage, hmrSingleton } from '@/lib/shared-utils'
28
- import { getEnabledToolIds } from '@/lib/capability-selection'
2
+ acquireExternalSessionExecutionHold as acquireExternalSessionExecutionHoldInternal,
3
+ hasActiveNonHeartbeatSessionLease,
4
+ resetSessionRunManagerStateForTests,
5
+ } from '@/lib/server/runtime/session-run-manager/state'
29
6
  import {
30
- appendPersistedRunEvent,
31
- isRestartRecoverableSource,
32
- listPersistedRunEvents,
33
- listPersistedRuns,
34
- loadPersistedRun,
35
- loadRecoverableStaleRuns,
36
- patchPersistedRun,
37
- persistRun,
38
- } from '@/lib/server/runtime/run-ledger'
39
- import { isAllEstopEngaged, isAutonomyEstopEngaged } from '@/lib/server/runtime/estop'
40
- import { notify } from '@/lib/server/ws-hub'
41
-
42
- export type SessionQueueMode = 'followup' | 'steer' | 'collect'
43
-
44
- interface QueueEntry {
45
- executionKey: string
46
- run: SessionRunRecord
47
- message: string
48
- imagePath?: string
49
- imageUrl?: string
50
- attachedFiles?: string[]
51
- onEvents: Array<(event: SSEEvent) => void>
52
- signalController: AbortController
53
- maxRuntimeMs?: number
54
- modelOverride?: string
55
- heartbeatConfig?: SessionRunHeartbeatConfig
56
- replyToId?: string
57
- resolve: (value: ExecuteChatTurnResult) => void
58
- reject: (error: Error) => void
59
- promise: Promise<ExecuteChatTurnResult>
60
- /** Whether this entry has been counted in nonHeartbeatWorkCount (prevents double-decrement). */
61
- nonHeartbeatCounted?: boolean
62
- }
63
-
64
- interface RuntimeState {
65
- runningByExecution: Map<string, QueueEntry>
66
- queueByExecution: Map<string, QueueEntry[]>
67
- runs: Map<string, SessionRunRecord>
68
- recentRunIds: string[]
69
- promises: Map<string, Promise<ExecuteChatTurnResult>>
70
- deferredDrainTimers: Map<string, ReturnType<typeof setTimeout>>
71
- activityLeaseRenewTimers: Map<string, ReturnType<typeof setInterval>>
72
- externalSessionHolds: Map<string, number>
73
- externalHoldTimers: Map<string, ReturnType<typeof setTimeout>>
74
- drainDepth: Map<string, number>
75
- lastQueuedAt: number
76
- nonHeartbeatWorkCount: Map<string, number>
77
- }
78
-
79
- const MAX_RECENT_RUNS = 500
80
- const COLLECT_COALESCE_WINDOW_MS = 1500
81
- const SHARED_ACTIVITY_LEASE_TTL_MS = 15_000
82
- const SHARED_ACTIVITY_LEASE_RENEW_MS = 5_000
83
- const EXTERNAL_HOLD_TTL_MS = 60_000
84
- const MAX_DRAIN_DEPTH = 25
85
- const HEARTBEAT_BUSY_RETRY_MS = 1_000
86
- const STALE_QUEUED_RUN_MS = 15_000
87
- const SHARED_ACTIVITY_LEASE_OWNER = `session-run:${process.pid}:${genId(6)}`
88
- const state: RuntimeState = hmrSingleton<RuntimeState>('__swarmclaw_session_run_manager__', () => ({
89
- runningByExecution: new Map<string, QueueEntry>(),
90
- queueByExecution: new Map<string, QueueEntry[]>(),
91
- runs: new Map<string, SessionRunRecord>(),
92
- recentRunIds: [],
93
- promises: new Map<string, Promise<ExecuteChatTurnResult>>(),
94
- deferredDrainTimers: new Map<string, ReturnType<typeof setTimeout>>(),
95
- activityLeaseRenewTimers: new Map<string, ReturnType<typeof setInterval>>(),
96
- externalSessionHolds: new Map<string, number>(),
97
- externalHoldTimers: new Map<string, ReturnType<typeof setTimeout>>(),
98
- drainDepth: new Map<string, number>(),
99
- lastQueuedAt: 0,
100
- nonHeartbeatWorkCount: new Map<string, number>(),
101
- }))
102
- const recoveryState = hmrSingleton('__swarmclaw_session_run_recovery__', () => ({ completed: false }))
103
-
104
- // Backfill fields for hot-reloaded state objects created by older code versions.
105
- if (!state.runningByExecution) state.runningByExecution = new Map<string, QueueEntry>()
106
- if (!state.queueByExecution) state.queueByExecution = new Map<string, QueueEntry[]>()
107
- if (!state.runs) state.runs = new Map<string, SessionRunRecord>()
108
- if (!state.recentRunIds) state.recentRunIds = []
109
- if (!state.promises) state.promises = new Map<string, Promise<ExecuteChatTurnResult>>()
110
- if (!state.deferredDrainTimers) state.deferredDrainTimers = new Map<string, ReturnType<typeof setTimeout>>()
111
- if (!state.activityLeaseRenewTimers) state.activityLeaseRenewTimers = new Map<string, ReturnType<typeof setInterval>>()
112
- if (!state.externalSessionHolds) state.externalSessionHolds = new Map<string, number>()
113
- if (!state.externalHoldTimers) state.externalHoldTimers = new Map<string, ReturnType<typeof setTimeout>>()
114
- if (!state.drainDepth) state.drainDepth = new Map<string, number>()
115
- if (typeof state.lastQueuedAt !== 'number') state.lastQueuedAt = 0
116
- if (!state.nonHeartbeatWorkCount) state.nonHeartbeatWorkCount = new Map<string, number>()
117
-
118
- function now() {
119
- return Date.now()
120
- }
121
-
122
- function nextQueuedAt() {
123
- const current = now()
124
- const next = current <= state.lastQueuedAt ? state.lastQueuedAt + 1 : current
125
- state.lastQueuedAt = next
126
- return next
127
- }
128
-
129
- function messagePreview(text: string): string {
130
- return (text || '').replace(/\s+/g, ' ').trim().slice(0, 140)
131
- }
132
-
133
- function trimRecentRuns() {
134
- while (state.recentRunIds.length > MAX_RECENT_RUNS) {
135
- const id = state.recentRunIds.shift()
136
- if (!id) continue
137
- state.runs.delete(id)
138
- state.promises.delete(id)
139
- }
140
- }
141
-
142
- function syncRunRecord(run: SessionRunRecord): SessionRunRecord {
143
- state.runs.set(run.id, run)
144
- persistRun(run)
145
- return run
146
- }
147
-
148
- function registerRun(run: SessionRunRecord) {
149
- syncRunRecord(run)
150
- state.recentRunIds.push(run.id)
151
- trimRecentRuns()
152
- }
153
-
154
- function shouldPersistRunEvent(event: SSEEvent): boolean {
155
- return event.t !== 'd' && event.t !== 'thinking' && event.t !== 'reset'
156
- }
157
-
158
- function persistEventForRun(entry: QueueEntry, event: SSEEvent, opts?: {
159
- phase?: RunEventRecord['phase']
160
- status?: SessionRunStatus
161
- summary?: string
162
- }): void {
163
- if (!shouldPersistRunEvent(event)) return
164
- appendPersistedRunEvent({
165
- runId: entry.run.id,
166
- sessionId: entry.run.sessionId,
167
- phase: opts?.phase || 'event',
168
- status: opts?.status,
169
- summary: opts?.summary,
170
- event,
171
- })
172
- }
173
-
174
- /** Chain an external AbortSignal to an internal AbortController so that
175
- * when the caller (e.g. HTTP request) disconnects, the run is cancelled. */
176
- function chainCallerSignal(callerSignal: AbortSignal, controller: AbortController): void {
177
- if (callerSignal.aborted) {
178
- controller.abort()
179
- return
180
- }
181
- const onAbort = () => controller.abort()
182
- callerSignal.addEventListener('abort', onAbort, { once: true })
183
- }
184
-
185
- function emitToSubscribers(entry: QueueEntry, event: SSEEvent) {
186
- persistEventForRun(entry, event)
187
- for (const send of entry.onEvents) {
188
- try {
189
- send(event)
190
- } catch {
191
- // Subscriber stream can be closed by the client.
192
- }
193
- }
194
- }
195
-
196
- function emitRunMeta(entry: QueueEntry, status: SessionRunStatus, extra?: Record<string, unknown>) {
197
- const event: SSEEvent = {
198
- t: 'md',
199
- text: JSON.stringify({
200
- run: {
201
- id: entry.run.id,
202
- sessionId: entry.run.sessionId,
203
- status,
204
- source: entry.run.source,
205
- internal: entry.run.internal,
206
- ...extra,
207
- },
208
- }),
209
- }
210
- persistEventForRun(entry, event, { phase: 'status', status })
211
- for (const send of entry.onEvents) {
212
- try {
213
- send(event)
214
- } catch {
215
- // Subscriber stream can be closed by the client.
216
- }
217
- }
218
- notifySessionRunState(entry.run.sessionId)
219
- }
220
-
221
- function notifySessionRunState(sessionId: string): void {
222
- notify('runs')
223
- notify('sessions')
224
- notify(`session:${sessionId}`)
225
- }
226
-
227
- function queueAutonomyObservation(input: {
228
- runId: string
229
- sessionId: string
230
- source: string
231
- status: SessionRunStatus
232
- resultText?: string | null
233
- error?: string | null
234
- toolEvents?: ExecuteChatTurnResult['toolEvents']
235
- sourceMessage?: string | null
236
- }) {
237
- const session = loadSession(input.sessionId)
238
- void observeAutonomyRunOutcome({
239
- runId: input.runId,
240
- sessionId: input.sessionId,
241
- agentId: session?.agentId || null,
242
- source: input.source,
243
- status: input.status,
244
- resultText: input.resultText,
245
- error: input.error || undefined,
246
- toolEvents: input.toolEvents,
247
- mainLoopState: getMainLoopStateForSession(input.sessionId),
248
- sourceMessage: input.sourceMessage,
249
- }).then(({ reflection }) => observeLearnedSkillRunOutcome({
250
- runId: input.runId,
251
- sessionId: input.sessionId,
252
- agentId: session?.agentId || null,
253
- source: input.source,
254
- status: input.status,
255
- resultText: input.resultText,
256
- error: input.error || undefined,
257
- toolEvents: input.toolEvents,
258
- reflection,
259
- })).catch((err: unknown) => {
260
- log.warn('session-run', `Autonomy observation failed for ${input.runId}`, {
261
- sessionId: input.sessionId,
262
- error: errorMessage(err),
263
- })
264
- })
265
- }
266
-
267
- function markRunningEntryCancelled(entry: QueueEntry, reason: string) {
268
- if (entry.run.status === 'cancelled') return
269
- entry.run.status = 'cancelled'
270
- entry.run.endedAt = now()
271
- entry.run.error = reason
272
- syncRunRecord(entry.run)
273
- emitRunMeta(entry, 'cancelled', { reason })
274
- }
275
-
276
- function abortSessionRuntime(entry: QueueEntry, reason: string) {
277
- markRunningEntryCancelled(entry, reason)
278
- entry.signalController.abort()
279
- try { active.get(entry.run.sessionId)?.kill?.() } catch { /* noop */ }
280
- active.delete(entry.run.sessionId)
281
- try { cleanupSessionBrowser(entry.run.sessionId) } catch { /* noop */ }
282
- try { cancelDelegationJobsForParentSession(entry.run.sessionId, reason) } catch { /* noop */ }
283
- }
284
-
285
- function executionKeyForSession(sessionId: string): string {
286
- return `session:${sessionId}`
287
- }
288
-
289
- function nonHeartbeatActivityLeaseName(sessionId: string): string {
290
- return `session-non-heartbeat:${sessionId}`
291
- }
292
-
293
- export function hasActiveNonHeartbeatSessionLease(sessionId: string): boolean {
294
- return isRuntimeLockActive(nonHeartbeatActivityLeaseName(sessionId))
295
- }
296
-
297
- function hasExternalSessionExecutionHold(sessionId: string): boolean {
298
- return (state.externalSessionHolds.get(sessionId) || 0) > 0
299
- }
300
-
301
- export function acquireExternalSessionExecutionHold(sessionId: string): () => void {
302
- const current = state.externalSessionHolds.get(sessionId) || 0
303
- state.externalSessionHolds.set(sessionId, current + 1)
304
- let released = false
305
- const holdKey = `${sessionId}:${current + 1}`
306
- const ttlTimer = setTimeout(() => {
307
- if (released) return
308
- log.warn('session-run', 'External hold auto-released after TTL', { sessionId, holdKey, ttlMs: EXTERNAL_HOLD_TTL_MS })
309
- release()
310
- }, EXTERNAL_HOLD_TTL_MS)
311
- state.externalHoldTimers.set(holdKey, ttlTimer)
312
- const release = () => {
313
- if (released) return
314
- released = true
315
- const timer = state.externalHoldTimers.get(holdKey)
316
- if (timer) {
317
- clearTimeout(timer)
318
- state.externalHoldTimers.delete(holdKey)
319
- }
320
- const next = (state.externalSessionHolds.get(sessionId) || 1) - 1
321
- if (next > 0) state.externalSessionHolds.set(sessionId, next)
322
- else state.externalSessionHolds.delete(sessionId)
323
- void drainExecution(executionKeyForSession(sessionId))
324
- }
325
- return release
326
- }
327
-
328
- function queueForExecution(executionKey: string): QueueEntry[] {
329
- const existing = state.queueByExecution.get(executionKey)
330
- if (existing) return existing
331
- const created: QueueEntry[] = []
332
- state.queueByExecution.set(executionKey, created)
333
- return created
334
- }
335
-
336
- function normalizeMode(mode: string | undefined, internal: boolean): SessionQueueMode {
337
- if (mode === 'steer' || mode === 'collect' || mode === 'followup') return mode
338
- return internal ? 'collect' : 'followup'
339
- }
7
+ cancelAllHeartbeatRuns as cancelAllHeartbeatRunsInternal,
8
+ cancelAllRuns as cancelAllRunsInternal,
9
+ cancelQueuedRunById as cancelQueuedRunByIdInternal,
10
+ cancelQueuedRunsForSession as cancelQueuedRunsForSessionInternal,
11
+ cancelSessionRuns as cancelSessionRunsInternal,
12
+ } from '@/lib/server/runtime/session-run-manager/cancellation'
13
+ import { drainExecution as drainExecutionInternal } from '@/lib/server/runtime/session-run-manager/drain'
14
+ import { enqueueSessionRun as enqueueSessionRunInternal } from '@/lib/server/runtime/session-run-manager/enqueue'
15
+ import {
16
+ getRunById as getRunByIdInternal,
17
+ getSessionExecutionState as getSessionExecutionStateInternal,
18
+ getSessionQueueSnapshot as getSessionQueueSnapshotInternal,
19
+ getSessionRunState as getSessionRunStateInternal,
20
+ listRunEvents as listRunEventsInternal,
21
+ listRuns as listRunsInternal,
22
+ } from '@/lib/server/runtime/session-run-manager/queries'
23
+ import {
24
+ ensureRecoveredPersistedRuns as ensureRecoveredPersistedRunsInternal,
25
+ repairSessionRunQueue as repairSessionRunQueueInternal,
26
+ sweepStuckRuns as sweepStuckRunsInternal,
27
+ } from '@/lib/server/runtime/session-run-manager/recovery'
28
+ import type {
29
+ EnqueueSessionRunInput,
30
+ EnqueueSessionRunResult,
31
+ } from '@/lib/server/runtime/session-run-manager/types'
340
32
 
341
- function markPersistedRunInterrupted(run: SessionRunRecord, reason: string): SessionRunRecord {
342
- const interruptedAt = now()
343
- const next = patchPersistedRun(run.id, (current) => {
344
- const target = current || run
345
- return {
346
- ...target,
347
- status: 'cancelled',
348
- endedAt: target.endedAt || interruptedAt,
349
- interruptedAt,
350
- interruptedReason: reason,
351
- error: target.error || reason,
352
- }
353
- }) || {
354
- ...run,
355
- status: 'cancelled',
356
- endedAt: run.endedAt || interruptedAt,
357
- interruptedAt,
358
- interruptedReason: reason,
359
- error: run.error || reason,
360
- }
361
- state.runs.set(next.id, next)
362
- if (!state.recentRunIds.includes(next.id)) {
363
- state.recentRunIds.push(next.id)
364
- trimRecentRuns()
365
- }
366
- appendPersistedRunEvent({
367
- runId: next.id,
368
- sessionId: next.sessionId,
369
- phase: 'status',
370
- status: 'cancelled',
371
- summary: reason,
372
- event: {
373
- t: 'md',
374
- text: JSON.stringify({
375
- run: {
376
- id: next.id,
377
- sessionId: next.sessionId,
378
- status: 'cancelled',
379
- interrupted: true,
380
- reason,
381
- },
382
- }),
383
- },
384
- })
385
- return next
386
- }
33
+ export type {
34
+ EnqueueSessionRunInput,
35
+ EnqueueSessionRunResult,
36
+ SessionQueueMode,
37
+ } from '@/lib/server/runtime/session-run-manager/types'
387
38
 
388
39
  function ensureRecoveredPersistedRuns(): void {
389
- if (recoveryState.completed) return
390
- recoveryState.completed = true
391
- const staleRuns = loadRecoverableStaleRuns()
392
- if (!staleRuns.length) return
393
- const recoveryBlocked = isAutonomyEstopEngaged() || isAllEstopEngaged()
394
-
395
- for (const run of staleRuns) {
396
- const interrupted = markPersistedRunInterrupted(run, 'Interrupted by server restart before the run completed.')
397
- const payload = interrupted.recoveryPayload
398
- if (
399
- recoveryBlocked
400
- || interrupted.recoveredFromRestart
401
- || !payload
402
- || !isRestartRecoverableSource(interrupted.source)
403
- ) {
404
- continue
405
- }
406
-
407
- try {
408
- enqueueSessionRun({
409
- sessionId: interrupted.sessionId,
410
- message: payload.message,
411
- imagePath: payload.imagePath,
412
- imageUrl: payload.imageUrl,
413
- attachedFiles: payload.attachedFiles,
414
- internal: payload.internal,
415
- source: payload.source,
416
- mode: normalizeMode(payload.mode, payload.internal),
417
- dedupeKey: interrupted.dedupeKey,
418
- maxRuntimeMs: payload.maxRuntimeMs,
419
- modelOverride: payload.modelOverride,
420
- heartbeatConfig: payload.heartbeatConfig,
421
- replyToId: payload.replyToId,
422
- executionGroupKey: payload.executionGroupKey,
423
- recoveredFromRestart: true,
424
- recoveredFromRunId: interrupted.id,
425
- })
426
- } catch (err: unknown) {
427
- log.warn('session-run', `Failed to requeue interrupted run ${interrupted.id}`, {
428
- sessionId: interrupted.sessionId,
429
- error: errorMessage(err),
430
- })
431
- }
432
- }
433
- }
434
-
435
- function isNonHeartbeatEntry(entry: QueueEntry): boolean {
436
- return !isInternalHeartbeatRun(entry.run.internal, entry.run.source)
437
- }
438
-
439
- function incrementNonHeartbeatWork(entry: QueueEntry): void {
440
- if (!isNonHeartbeatEntry(entry)) return
441
- entry.nonHeartbeatCounted = true
442
- state.nonHeartbeatWorkCount.set(entry.run.sessionId, (state.nonHeartbeatWorkCount.get(entry.run.sessionId) || 0) + 1)
443
- }
444
-
445
- function decrementNonHeartbeatWork(entry: QueueEntry): void {
446
- if (!entry.nonHeartbeatCounted) return
447
- entry.nonHeartbeatCounted = false
448
- const sessionId = entry.run.sessionId
449
- const count = (state.nonHeartbeatWorkCount.get(sessionId) || 0) - 1
450
- if (count <= 0) state.nonHeartbeatWorkCount.delete(sessionId)
451
- else state.nonHeartbeatWorkCount.set(sessionId, count)
452
- }
453
-
454
- function hasLocalNonHeartbeatWork(sessionId: string): boolean {
455
- return (state.nonHeartbeatWorkCount.get(sessionId) || 0) > 0
40
+ ensureRecoveredPersistedRunsInternal(enqueueSessionRun)
456
41
  }
457
42
 
458
- function clearDeferredDrain(executionKey: string): void {
459
- const timer = state.deferredDrainTimers.get(executionKey)
460
- if (!timer) return
461
- clearTimeout(timer)
462
- state.deferredDrainTimers.delete(executionKey)
43
+ function drainExecution(executionKey: string): Promise<void> {
44
+ return drainExecutionInternal(executionKey, { enqueueSessionRun })
463
45
  }
464
46
 
465
- function deleteQueueEntry(queue: QueueEntry[], target: QueueEntry): boolean {
466
- const idx = queue.indexOf(target)
467
- if (idx === -1) return false
468
- queue.splice(idx, 1)
469
- return true
470
- }
471
-
472
- function scheduleDeferredDrain(executionKey: string, delayMs = HEARTBEAT_BUSY_RETRY_MS): void {
473
- if (state.deferredDrainTimers.has(executionKey)) return
474
- const timer = setTimeout(() => {
475
- state.deferredDrainTimers.delete(executionKey)
47
+ export function acquireExternalSessionExecutionHold(sessionId: string): () => void {
48
+ return acquireExternalSessionExecutionHoldInternal(sessionId, (executionKey) => {
476
49
  void drainExecution(executionKey)
477
- }, delayMs)
478
- state.deferredDrainTimers.set(executionKey, timer)
479
- }
480
-
481
- function stopSessionActivityLease(sessionId: string): void {
482
- const timer = state.activityLeaseRenewTimers.get(sessionId)
483
- if (timer) {
484
- clearInterval(timer)
485
- state.activityLeaseRenewTimers.delete(sessionId)
486
- }
487
- releaseRuntimeLock(nonHeartbeatActivityLeaseName(sessionId), SHARED_ACTIVITY_LEASE_OWNER)
488
- }
489
-
490
- function startSessionActivityLease(sessionId: string): void {
491
- if (state.activityLeaseRenewTimers.has(sessionId)) return
492
- const leaseName = nonHeartbeatActivityLeaseName(sessionId)
493
- tryAcquireRuntimeLock(leaseName, SHARED_ACTIVITY_LEASE_OWNER, SHARED_ACTIVITY_LEASE_TTL_MS)
494
- const timer = setInterval(() => {
495
- if (!hasLocalNonHeartbeatWork(sessionId)) {
496
- stopSessionActivityLease(sessionId)
497
- return
498
- }
499
- tryAcquireRuntimeLock(leaseName, SHARED_ACTIVITY_LEASE_OWNER, SHARED_ACTIVITY_LEASE_TTL_MS)
500
- }, SHARED_ACTIVITY_LEASE_RENEW_MS)
501
- state.activityLeaseRenewTimers.set(sessionId, timer)
502
- }
503
-
504
- function reconcileSessionActivityLease(sessionId: string): void {
505
- if (hasLocalNonHeartbeatWork(sessionId)) startSessionActivityLease(sessionId)
506
- else stopSessionActivityLease(sessionId)
507
- }
508
-
509
- function resolveRecoveredQueuedEntry(entry: QueueEntry, reason: string): void {
510
- decrementNonHeartbeatWork(entry)
511
- if (entry.run.status === 'completed' || entry.run.status === 'failed' || entry.run.status === 'cancelled') {
512
- entry.run.endedAt = entry.run.endedAt || now()
513
- } else {
514
- entry.run.status = 'failed'
515
- entry.run.endedAt = now()
516
- }
517
- entry.run.error = reason
518
- syncRunRecord(entry.run)
519
- emitToSubscribers(entry, { t: 'err', text: reason })
520
- emitRunMeta(entry, 'failed', {
521
- error: reason,
522
- recovered: true,
523
- })
524
- entry.resolve({
525
- runId: entry.run.id,
526
- sessionId: entry.run.sessionId,
527
- text: '',
528
- persisted: false,
529
- toolEvents: [],
530
- error: reason,
531
50
  })
532
51
  }
533
52
 
@@ -542,900 +61,79 @@ export function repairSessionRunQueue(
542
61
  kickedExecutionKeys: number
543
62
  recoveredQueuedRuns: number
544
63
  } {
545
- const maxQueuedAgeMs = Math.max(1_000, opts?.maxQueuedAgeMs ?? STALE_QUEUED_RUN_MS)
546
- const reason = opts?.reason || 'Recovered stale queued run'
547
- const targetExecutionKey = typeof opts?.executionKey === 'string' && opts.executionKey.trim()
548
- ? opts.executionKey.trim()
549
- : null
550
- const queuedNow = now()
551
- let kickedExecutionKeys = 0
552
- let recoveredQueuedRuns = 0
553
-
554
- for (const [executionKey, queue] of state.queueByExecution.entries()) {
555
- if (targetExecutionKey && executionKey !== targetExecutionKey) continue
556
- if (!queue.length) {
557
- clearDeferredDrain(executionKey)
558
- state.queueByExecution.delete(executionKey)
559
- continue
560
- }
561
- if (state.runningByExecution.has(executionKey)) continue
562
-
563
- const matching = queue.filter((entry) => entry.run.sessionId === sessionId)
564
- if (!matching.length) continue
565
-
566
- for (const entry of [...matching]) {
567
- const missingPromise = !state.promises.has(entry.run.id)
568
- const previousStatus = entry.run.status
569
- const nonQueued = previousStatus !== 'queued'
570
- const ageMs = Math.max(0, queuedNow - (entry.run.queuedAt || 0))
571
- const stale = nonQueued || missingPromise || ageMs >= maxQueuedAgeMs
572
- if (!stale) continue
573
- if (!deleteQueueEntry(queue, entry)) continue
574
- clearDeferredDrain(executionKey)
575
- resolveRecoveredQueuedEntry(entry, reason)
576
- recoveredQueuedRuns += 1
577
- log.warn('session-run', `Recovered stale queued run ${entry.run.id}`, {
578
- sessionId: entry.run.sessionId,
579
- executionKey,
580
- source: entry.run.source,
581
- ageMs,
582
- missingPromise,
583
- previousStatus,
584
- })
585
- }
586
-
587
- if (!queue.length) {
588
- clearDeferredDrain(executionKey)
589
- state.queueByExecution.delete(executionKey)
590
- continue
591
- }
592
-
593
- if (queue.some((entry) => entry.run.sessionId === sessionId)) {
594
- clearDeferredDrain(executionKey)
595
- kickedExecutionKeys += 1
596
- void drainExecution(executionKey)
597
- }
598
- }
599
-
600
- if (recoveredQueuedRuns > 0) reconcileSessionActivityLease(sessionId)
601
- return { kickedExecutionKeys, recoveredQueuedRuns }
602
- }
603
-
604
- function cancelPendingForSession(sessionId: string, reason: string): number {
605
- let cancelled = 0
606
- for (const [key, queue] of state.queueByExecution.entries()) {
607
- if (!queue.length) continue
608
- const keep: QueueEntry[] = []
609
- for (const entry of queue) {
610
- if (entry.run.sessionId !== sessionId) {
611
- keep.push(entry)
612
- continue
613
- }
614
- entry.run.status = 'cancelled'
615
- entry.run.endedAt = now()
616
- entry.run.error = reason
617
- syncRunRecord(entry.run)
618
- emitRunMeta(entry, 'cancelled', { reason })
619
- entry.reject(new Error(reason))
620
- decrementNonHeartbeatWork(entry)
621
- cancelled++
622
- }
623
- if (keep.length > 0) state.queueByExecution.set(key, keep)
624
- else state.queueByExecution.delete(key)
625
- }
626
- reconcileSessionActivityLease(sessionId)
627
- return cancelled
628
- }
629
-
630
- function cancelQueuedEntries(
631
- matcher: (entry: QueueEntry) => boolean,
632
- reason: string,
633
- ): { cancelled: number; sessionIds: Set<string> } {
634
- let cancelled = 0
635
- const sessionIds = new Set<string>()
636
- for (const [key, queue] of state.queueByExecution.entries()) {
637
- if (!queue.length) continue
638
- const keep: QueueEntry[] = []
639
- for (const entry of queue) {
640
- if (!matcher(entry)) {
641
- keep.push(entry)
642
- continue
643
- }
644
- entry.run.status = 'cancelled'
645
- entry.run.endedAt = now()
646
- entry.run.error = reason
647
- syncRunRecord(entry.run)
648
- emitRunMeta(entry, 'cancelled', { reason })
649
- entry.reject(new Error(reason))
650
- decrementNonHeartbeatWork(entry)
651
- sessionIds.add(entry.run.sessionId)
652
- cancelled += 1
653
- }
654
- if (keep.length > 0) state.queueByExecution.set(key, keep)
655
- else state.queueByExecution.delete(key)
656
- }
657
- for (const sessionId of sessionIds) reconcileSessionActivityLease(sessionId)
658
- return { cancelled, sessionIds }
659
- }
660
-
661
- export function cancelAllHeartbeatRuns(reason = 'Heartbeat disabled globally'): { cancelledQueued: number; abortedRunning: number } {
662
- ensureRecoveredPersistedRuns()
663
- let cancelledQueued = 0
664
- let abortedRunning = 0
665
-
666
- for (const [key, queue] of state.queueByExecution.entries()) {
667
- if (!queue.length) continue
668
- const keep: QueueEntry[] = []
669
- for (const entry of queue) {
670
- const isHeartbeat = isInternalHeartbeatRun(entry.run.internal, entry.run.source)
671
- if (!isHeartbeat) {
672
- keep.push(entry)
673
- continue
674
- }
675
- entry.run.status = 'cancelled'
676
- entry.run.endedAt = now()
677
- entry.run.error = reason
678
- syncRunRecord(entry.run)
679
- emitRunMeta(entry, 'cancelled', { reason })
680
- entry.reject(new Error(reason))
681
- cancelledQueued += 1
682
- }
683
- if (keep.length > 0) state.queueByExecution.set(key, keep)
684
- else state.queueByExecution.delete(key)
685
- }
686
-
687
- for (const entry of state.runningByExecution.values()) {
688
- const isHeartbeat = isInternalHeartbeatRun(entry.run.internal, entry.run.source)
689
- if (!isHeartbeat) continue
690
- abortedRunning += 1
691
- abortSessionRuntime(entry, reason)
692
- }
693
-
694
- return { cancelledQueued, abortedRunning }
695
- }
696
-
697
- export function cancelAllRuns(reason = 'Cancelled'): { cancelledQueued: number; abortedRunning: number } {
698
- ensureRecoveredPersistedRuns()
699
- let cancelledQueued = 0
700
- let abortedRunning = 0
701
-
702
- for (const [key, queue] of state.queueByExecution.entries()) {
703
- if (!queue.length) continue
704
- for (const entry of queue) {
705
- entry.run.status = 'cancelled'
706
- entry.run.endedAt = now()
707
- entry.run.error = reason
708
- syncRunRecord(entry.run)
709
- emitRunMeta(entry, 'cancelled', { reason })
710
- entry.reject(new Error(reason))
711
- cancelledQueued += 1
712
- }
713
- state.queueByExecution.delete(key)
714
- }
715
-
716
- for (const entry of state.runningByExecution.values()) {
717
- abortedRunning += 1
718
- abortSessionRuntime(entry, reason)
719
- }
720
- state.runningByExecution.clear()
721
- state.nonHeartbeatWorkCount.clear()
722
-
723
- return { cancelledQueued, abortedRunning }
724
- }
725
-
726
- async function drainExecution(executionKey: string): Promise<void> {
727
- const depth = (state.drainDepth.get(executionKey) || 0) + 1
728
- state.drainDepth.set(executionKey, depth)
729
- if (depth > MAX_DRAIN_DEPTH) {
730
- log.error('session-run', 'Drain recursion depth exceeded, deferring', { executionKey, depth, max: MAX_DRAIN_DEPTH })
731
- state.drainDepth.delete(executionKey)
732
- scheduleDeferredDrain(executionKey, 500)
733
- return
734
- }
735
- try {
736
- if (state.runningByExecution.has(executionKey)) return
737
- const q = queueForExecution(executionKey)
738
- // 3-tier drain priority: (1) user-facing, (2) internal non-heartbeat, (3) heartbeat
739
- const userIdx = q.findIndex(e => !e.run.internal)
740
- let next: QueueEntry | undefined
741
- if (userIdx >= 0) {
742
- next = q.splice(userIdx, 1)[0]
743
- } else {
744
- const internalIdx = q.findIndex(e => !isInternalHeartbeatRun(e.run.internal, e.run.source))
745
- next = internalIdx >= 0 ? q.splice(internalIdx, 1)[0] : q.shift()
746
- }
747
- if (!next) {
748
- clearDeferredDrain(executionKey)
749
- return
750
- }
751
-
752
- if (isInternalHeartbeatRun(next.run.internal, next.run.source) && hasActiveNonHeartbeatSessionLease(next.run.sessionId)) {
753
- q.unshift(next)
754
- scheduleDeferredDrain(executionKey, HEARTBEAT_BUSY_RETRY_MS)
755
- log.info('session-run', `Deferred heartbeat run ${next.run.id} for shared busy session`, {
756
- sessionId: next.run.sessionId,
757
- source: next.run.source,
758
- leaseName: nonHeartbeatActivityLeaseName(next.run.sessionId),
759
- })
760
- return
761
- }
762
-
763
- if (hasExternalSessionExecutionHold(next.run.sessionId)) {
764
- q.unshift(next)
765
- scheduleDeferredDrain(executionKey, HEARTBEAT_BUSY_RETRY_MS)
766
- log.info('session-run', `Deferred run ${next.run.id} for external session hold`, {
767
- sessionId: next.run.sessionId,
768
- source: next.run.source,
769
- mode: next.run.mode,
770
- })
771
- return
772
- }
773
-
774
- clearDeferredDrain(executionKey)
775
- state.runningByExecution.set(executionKey, next)
776
- next.run.status = 'running'
777
- next.run.startedAt = now()
778
- syncRunRecord(next.run)
779
- emitRunMeta(next, 'running')
780
- log.info('session-run', `Run started ${next.run.id}`, {
781
- sessionId: next.run.sessionId,
782
- source: next.run.source,
783
- internal: next.run.internal,
784
- mode: next.run.mode,
785
- timeoutMs: next.maxRuntimeMs || null,
786
- })
787
-
788
- let runtimeTimer: ReturnType<typeof setTimeout> | null = null
789
- let finishedMissionId: string | null = null
790
- if (next.maxRuntimeMs && next.maxRuntimeMs > 0) {
791
- runtimeTimer = setTimeout(() => {
792
- next.signalController.abort()
793
- }, next.maxRuntimeMs)
794
- }
795
-
796
- try {
797
- const result = await executeSessionChatTurn({
798
- sessionId: next.run.sessionId,
799
- message: next.message,
800
- imagePath: next.imagePath,
801
- imageUrl: next.imageUrl,
802
- attachedFiles: next.attachedFiles,
803
- internal: next.run.internal,
804
- source: next.run.source,
805
- runId: next.run.id,
806
- signal: next.signalController.signal,
807
- onEvent: (event) => emitToSubscribers(next, event),
808
- modelOverride: next.modelOverride,
809
- heartbeatConfig: next.heartbeatConfig,
810
- replyToId: next.replyToId,
811
- })
812
-
813
- const failed = !!result.error
814
- const aborted = next.signalController.signal.aborted
815
- next.run.status = aborted ? 'cancelled' : (failed ? 'failed' : 'completed')
816
- next.run.endedAt = next.run.endedAt || now()
817
- next.run.error = aborted ? (next.run.error || 'Cancelled') : result.error
818
- next.run.missionId = result.missionId || next.run.missionId || null
819
- finishedMissionId = next.run.missionId || null
820
- next.run.resultPreview = result.text?.slice(0, 280)
821
- syncRunRecord(next.run)
822
- emitRunMeta(next, next.run.status, {
823
- persisted: result.persisted,
824
- hasText: !!result.text,
825
- error: next.run.error || null,
826
- })
827
- log.info('session-run', `Run finished ${next.run.id}`, {
828
- sessionId: next.run.sessionId,
829
- status: next.run.status,
830
- persisted: result.persisted,
831
- hasText: !!result.text,
832
- error: next.run.error || null,
833
- durationMs: (next.run.endedAt || now()) - (next.run.startedAt || now()),
834
- })
835
- const followup = handleMainLoopRunResult({
836
- runId: next.run.id,
837
- sessionId: next.run.sessionId,
838
- message: next.message,
839
- internal: next.run.internal,
840
- source: next.run.source,
841
- resultText: result.text,
842
- error: next.run.error,
843
- toolEvents: result.toolEvents,
844
- inputTokens: result.inputTokens,
845
- outputTokens: result.outputTokens,
846
- estimatedCost: result.estimatedCost,
847
- })
848
- queueAutonomyObservation({
849
- runId: next.run.id,
850
- sessionId: next.run.sessionId,
851
- source: next.run.source,
852
- status: next.run.status,
853
- resultText: result.text,
854
- error: next.run.error || null,
855
- toolEvents: result.toolEvents,
856
- sourceMessage: next.message,
857
- })
858
- if (followup) {
859
- setTimeout(() => {
860
- try {
861
- enqueueSessionRun({
862
- sessionId: next.run.sessionId,
863
- message: followup.message,
864
- internal: true,
865
- source: 'main-loop-followup',
866
- mode: 'followup',
867
- dedupeKey: followup.dedupeKey,
868
- })
869
- } catch (err: unknown) {
870
- log.warn('session-run', `Main loop follow-up enqueue failed for ${next.run.sessionId}`, {
871
- error: errorMessage(err),
872
- })
873
- }
874
- }, Math.max(0, followup.delayMs || 0))
875
- }
876
- next.resolve(result)
877
- } catch (err: unknown) {
878
- const aborted = next.signalController.signal.aborted
879
- next.run.status = aborted ? 'cancelled' : 'failed'
880
- next.run.endedAt = now()
881
- next.run.error = errorMessage(err)
882
- finishedMissionId = next.run.missionId || null
883
- syncRunRecord(next.run)
884
- emitRunMeta(next, next.run.status, { error: next.run.error })
885
- log.error('session-run', `Run failed ${next.run.id}`, {
886
- sessionId: next.run.sessionId,
887
- status: next.run.status,
888
- error: next.run.error,
889
- durationMs: (next.run.endedAt || now()) - (next.run.startedAt || now()),
890
- })
891
- if (err instanceof Error && err.stack) {
892
- log.error('session-run', `Run failed stack trace ${next.run.id}`, {
893
- sessionId: next.run.sessionId,
894
- stack: err.stack,
895
- })
896
- }
897
- queueAutonomyObservation({
898
- runId: next.run.id,
899
- sessionId: next.run.sessionId,
900
- source: next.run.source,
901
- status: next.run.status,
902
- error: next.run.error || null,
903
- sourceMessage: next.message,
904
- })
905
- next.reject(err instanceof Error ? err : new Error(next.run.error))
906
- } finally {
907
- if (runtimeTimer) clearTimeout(runtimeTimer)
908
- state.runningByExecution.delete(executionKey)
909
- decrementNonHeartbeatWork(next)
910
- reconcileSessionActivityLease(next.run.sessionId)
911
- notify(`stream-end:${next.run.sessionId}`)
912
- if (finishedMissionId && next.run.source !== 'chat') {
913
- queueMicrotask(() => {
914
- import('@/lib/server/missions/mission-service')
915
- .then(({ loadMissionById, requestMissionTick }) => {
916
- const mission = loadMissionById(finishedMissionId)
917
- if (!mission) return
918
- if (mission.status !== 'active') return
919
- if (mission.phase === 'dispatching' || mission.phase === 'executing') return
920
- requestMissionTick(finishedMissionId as string, 'run_drained', {
921
- runId: next.run.id,
922
- source: next.run.source,
923
- status: next.run.status,
924
- })
925
- })
926
- .catch((err: unknown) => {
927
- log.warn('session-run', 'Mission tick failed', { missionId: finishedMissionId, runId: next.run.id, error: errorMessage(err) })
928
- })
929
- })
930
- }
931
- void drainExecution(executionKey)
932
- }
933
- } finally {
934
- state.drainDepth.delete(executionKey)
935
- }
936
- }
937
-
938
- function findDedupeMatch(sessionId: string, dedupeKey?: string): QueueEntry | null {
939
- if (!dedupeKey) return null
940
- const executionKey = executionKeyForSession(sessionId)
941
- const running = state.runningByExecution.get(executionKey)
942
- if (running?.run.sessionId === sessionId && running?.run.dedupeKey === dedupeKey) return running
943
- const q = queueForExecution(executionKey)
944
- return q.find((e) => e.run.sessionId === sessionId && e.run.dedupeKey === dedupeKey) || null
945
- }
946
-
947
- export interface EnqueueSessionRunInput {
948
- sessionId: string
949
- message: string
950
- missionId?: string | null
951
- imagePath?: string
952
- imageUrl?: string
953
- attachedFiles?: string[]
954
- internal?: boolean
955
- source?: string
956
- mode?: SessionQueueMode
957
- onEvent?: (event: SSEEvent) => void
958
- dedupeKey?: string
959
- maxRuntimeMs?: number
960
- modelOverride?: string
961
- heartbeatConfig?: SessionRunHeartbeatConfig
962
- replyToId?: string
963
- /** Optional shared execution lane key. When set, multiple sessions can be serialized together. */
964
- executionGroupKey?: string
965
- /** External abort signal (e.g. from the HTTP request) — chained to the run's internal AbortController */
966
- callerSignal?: AbortSignal
967
- recoveredFromRestart?: boolean
968
- recoveredFromRunId?: string
969
- }
970
-
971
- export interface EnqueueSessionRunResult {
972
- runId: string
973
- position: number
974
- deduped?: boolean
975
- coalesced?: boolean
976
- promise: Promise<ExecuteChatTurnResult>
977
- /** Abort the run's internal AbortController (cancels the LLM stream). */
978
- abort: () => void
979
- /** Remove this caller's onEvent listener from the run (call on client disconnect). */
980
- unsubscribe: () => void
981
- }
982
-
983
- const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli', 'opencode_cli'])
984
- type SessionToolConfig = {
985
- tools?: string[] | null
986
- extensions?: string[] | null
987
- }
988
-
989
- function computeEffectiveRunTimeoutMs(
990
- baseTimeoutMs: number,
991
- sessionTools: string[],
992
- runtime: { claudeCodeTimeoutMs: number },
993
- ): number {
994
- const hasLongTool = sessionTools.some(t => LONG_TOOL_NAMES.has(t))
995
- if (!hasLongTool) return baseTimeoutMs
996
- const toolTimeout = runtime.claudeCodeTimeoutMs + 120_000
997
- return Math.max(baseTimeoutMs, toolTimeout)
998
- }
999
-
1000
- function isAutonomyManagedEnqueue(source: string, internal: boolean): boolean {
1001
- return !(source === 'chat' && !internal)
1002
- }
1003
-
1004
- function buildRecoveryPayload(
1005
- input: EnqueueSessionRunInput,
1006
- source: string,
1007
- mode: SessionQueueMode,
1008
- maxRuntimeMs: number | undefined,
1009
- executionKey: string,
1010
- ) {
1011
- return {
1012
- message: input.message,
1013
- imagePath: input.imagePath,
1014
- imageUrl: input.imageUrl,
1015
- attachedFiles: input.attachedFiles,
1016
- internal: input.internal === true,
1017
- source,
1018
- mode,
1019
- maxRuntimeMs,
1020
- modelOverride: input.modelOverride,
1021
- heartbeatConfig: input.heartbeatConfig,
1022
- replyToId: input.replyToId,
1023
- executionGroupKey: executionKey.startsWith('session:') ? undefined : executionKey,
1024
- }
64
+ return repairSessionRunQueueInternal(sessionId, drainExecution, opts)
1025
65
  }
1026
66
 
1027
67
  export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSessionRunResult {
1028
68
  ensureRecoveredPersistedRuns()
1029
- const internal = input.internal === true
1030
- const mode = normalizeMode(input.mode, internal)
1031
- const source = input.source || 'chat'
1032
- if (isAllEstopEngaged()) {
1033
- throw new Error('Execution is blocked because all estop is engaged.')
1034
- }
1035
- if (isAutonomyEstopEngaged() && isAutonomyManagedEnqueue(source, internal)) {
1036
- throw new Error(`Autonomy estop is engaged. New ${source} runs are paused.`)
1037
- }
1038
- const executionKey = typeof input.executionGroupKey === 'string' && input.executionGroupKey.trim()
1039
- ? input.executionGroupKey.trim()
1040
- : executionKeyForSession(input.sessionId)
1041
- repairSessionRunQueue(input.sessionId, {
1042
- executionKey,
1043
- reason: 'Recovered stale queued run before enqueue',
1044
- })
1045
- const runtime = loadRuntimeSettings()
1046
- const defaultMaxRuntimeMs = runtime.ongoingLoopMaxRuntimeMs ?? (10 * 60_000)
1047
- const sessionData = loadSession(input.sessionId) as SessionToolConfig | null
1048
- const sessionTools = getEnabledToolIds(sessionData)
1049
- const adjustedDefaultMs = computeEffectiveRunTimeoutMs(defaultMaxRuntimeMs, sessionTools, runtime)
1050
- const effectiveMaxRuntimeMs = typeof input.maxRuntimeMs === 'number'
1051
- ? input.maxRuntimeMs
1052
- : adjustedDefaultMs
1053
-
1054
- const dedupe = findDedupeMatch(input.sessionId, input.dedupeKey)
1055
- if (dedupe) {
1056
- const cb = input.onEvent
1057
- if (cb) dedupe.onEvents.push(cb)
1058
- if (input.callerSignal) chainCallerSignal(input.callerSignal, dedupe.signalController)
1059
- return {
1060
- runId: dedupe.run.id,
1061
- position: 0,
1062
- deduped: true,
1063
- promise: dedupe.promise,
1064
- abort: () => dedupe.signalController.abort(),
1065
- unsubscribe: () => {
1066
- if (!cb) return
1067
- const idx = dedupe.onEvents.indexOf(cb)
1068
- if (idx >= 0) dedupe.onEvents.splice(idx, 1)
1069
- },
1070
- }
1071
- }
1072
-
1073
- if (mode === 'steer') {
1074
- const running = state.runningByExecution.get(executionKey)
1075
- if (running && running.run.sessionId === input.sessionId) {
1076
- running.signalController.abort()
1077
- try { active.get(input.sessionId)?.kill?.() } catch { /* noop */ }
1078
- }
1079
- cancelPendingForSession(input.sessionId, 'Cancelled by steer mode')
1080
- }
1081
-
1082
- // Heartbeat preemption: if a user chat arrives while a heartbeat is running,
1083
- // abort the heartbeat so the user doesn't wait. The heartbeat will retry
1084
- // on the next tick.
1085
- if (!internal && source === 'chat') {
1086
- const running = state.runningByExecution.get(executionKey)
1087
- if (running && isInternalHeartbeatRun(running.run.internal, running.run.source)) {
1088
- log.info('session-run', `Preempting heartbeat ${running.run.id} for user chat on ${input.sessionId}`)
1089
- abortSessionRuntime(running, 'Preempted by user chat')
1090
- state.runningByExecution.delete(executionKey)
1091
- }
1092
- }
1093
-
1094
- const running = state.runningByExecution.get(executionKey)
1095
- const q = queueForExecution(executionKey)
1096
- if (mode === 'collect' && !input.imagePath && !input.imageUrl && !input.attachedFiles?.length) {
1097
- const nowMs = nextQueuedAt()
1098
- const candidate = q.at(-1)
1099
- const canCoalesce = !!candidate
1100
- && candidate.run.mode === 'collect'
1101
- && candidate.run.internal === internal
1102
- && candidate.run.source === source
1103
- && !candidate.imagePath
1104
- && !candidate.imageUrl
1105
- && !candidate.attachedFiles?.length
1106
- && (nowMs - candidate.run.queuedAt) <= COLLECT_COALESCE_WINDOW_MS
1107
-
1108
- if (candidate && canCoalesce) {
1109
- const nextChunk = input.message.trim()
1110
- if (nextChunk) {
1111
- const current = candidate.message.trim()
1112
- candidate.message = current
1113
- ? `${current}\n\n[Collected follow-up]\n${nextChunk}`
1114
- : nextChunk
1115
- candidate.run.messagePreview = messagePreview(candidate.message)
1116
- candidate.run.queuedAt = nowMs
1117
- syncRunRecord(candidate.run)
1118
- }
1119
- const coalesceCb = input.onEvent
1120
- if (coalesceCb) candidate.onEvents.push(coalesceCb)
1121
- if (input.callerSignal) chainCallerSignal(input.callerSignal, candidate.signalController)
1122
- emitRunMeta(candidate, 'queued', { position: 0, coalesced: true, mergedIntoRunId: candidate.run.id })
1123
- return {
1124
- runId: candidate.run.id,
1125
- position: 0,
1126
- coalesced: true,
1127
- promise: candidate.promise,
1128
- abort: () => candidate.signalController.abort(),
1129
- unsubscribe: () => {
1130
- if (!coalesceCb) return
1131
- const idx = candidate.onEvents.indexOf(coalesceCb)
1132
- if (idx >= 0) candidate.onEvents.splice(idx, 1)
1133
- },
1134
- }
1135
- }
1136
- }
1137
-
1138
- const runId = genId(8)
1139
- const run: SessionRunRecord = {
1140
- id: runId,
1141
- sessionId: input.sessionId,
1142
- missionId: input.missionId ?? loadSession(input.sessionId)?.missionId ?? null,
1143
- source,
1144
- internal,
1145
- mode,
1146
- status: 'queued',
1147
- messagePreview: messagePreview(input.message),
1148
- dedupeKey: input.dedupeKey,
1149
- queuedAt: nextQueuedAt(),
1150
- recoveredFromRestart: input.recoveredFromRestart === true,
1151
- recoveredFromRunId: input.recoveredFromRunId,
1152
- recoveryPayload: buildRecoveryPayload(
1153
- input,
1154
- source,
1155
- mode,
1156
- effectiveMaxRuntimeMs > 0 ? effectiveMaxRuntimeMs : undefined,
1157
- executionKey,
1158
- ),
1159
- }
1160
- registerRun(run)
1161
-
1162
- let resolve!: (value: ExecuteChatTurnResult) => void
1163
- let reject!: (error: Error) => void
1164
- const promise = new Promise<ExecuteChatTurnResult>((res, rej) => {
1165
- resolve = res
1166
- reject = rej
69
+ return enqueueSessionRunInternal(input, {
70
+ repairSessionRunQueue: (sessionId, opts) => repairSessionRunQueue(sessionId, opts),
71
+ drainExecution,
1167
72
  })
1168
- promise.catch(() => {}) // prevent unhandledRejection when entry is cancelled
1169
- state.promises.set(runId, promise)
1170
-
1171
- const entry: QueueEntry = {
1172
- executionKey,
1173
- run,
1174
- message: input.message,
1175
- imagePath: input.imagePath,
1176
- imageUrl: input.imageUrl,
1177
- attachedFiles: input.attachedFiles,
1178
- onEvents: input.onEvent ? [input.onEvent] : [],
1179
- signalController: new AbortController(),
1180
- maxRuntimeMs: effectiveMaxRuntimeMs > 0 ? effectiveMaxRuntimeMs : undefined,
1181
- modelOverride: input.modelOverride,
1182
- heartbeatConfig: input.heartbeatConfig,
1183
- replyToId: input.replyToId,
1184
- resolve,
1185
- reject,
1186
- promise,
1187
- }
1188
-
1189
- if (input.callerSignal) chainCallerSignal(input.callerSignal, entry.signalController)
1190
-
1191
- q.push(entry)
1192
- incrementNonHeartbeatWork(entry)
1193
- if (entry.nonHeartbeatCounted) {
1194
- reconcileSessionActivityLease(input.sessionId)
1195
- }
1196
- const position = (running ? 1 : 0) + q.length - 1
1197
- emitRunMeta(entry, 'queued', { position })
1198
- void drainExecution(executionKey)
1199
-
1200
- const entryCb = input.onEvent
1201
- return {
1202
- runId,
1203
- position,
1204
- promise,
1205
- abort: () => entry.signalController.abort(),
1206
- unsubscribe: () => {
1207
- if (!entryCb) return
1208
- const idx = entry.onEvents.indexOf(entryCb)
1209
- if (idx >= 0) entry.onEvents.splice(idx, 1)
1210
- },
1211
- }
1212
73
  }
1213
74
 
1214
- export function getSessionRunState(sessionId: string): {
1215
- runningRunId?: string
1216
- queueLength: number
1217
- } {
75
+ export function getSessionRunState(sessionId: string) {
1218
76
  ensureRecoveredPersistedRuns()
1219
- const summary = getSessionExecutionState(sessionId)
1220
- return {
1221
- runningRunId: summary.runningRunId,
1222
- queueLength: summary.queueLength,
1223
- }
1224
- }
1225
-
1226
- function visibleQueuedEntriesForSession(sessionId: string): QueueEntry[] {
1227
- return Array.from(state.queueByExecution.values())
1228
- .flatMap((queue) => queue)
1229
- .filter((entry) => entry.run.sessionId === sessionId && entry.run.internal !== true)
1230
- .sort((left, right) => left.run.queuedAt - right.run.queuedAt)
1231
- }
1232
-
1233
- function toQueuedTurn(entry: QueueEntry, index: number): SessionQueuedTurn {
1234
- return {
1235
- runId: entry.run.id,
1236
- sessionId: entry.run.sessionId,
1237
- missionId: entry.run.missionId || null,
1238
- text: entry.message,
1239
- queuedAt: entry.run.queuedAt,
1240
- position: index + 1,
1241
- imagePath: entry.imagePath,
1242
- imageUrl: entry.imageUrl,
1243
- attachedFiles: entry.attachedFiles,
1244
- replyToId: entry.replyToId,
1245
- source: entry.run.source,
1246
- }
77
+ return getSessionRunStateInternal(sessionId)
1247
78
  }
1248
79
 
1249
- export function getSessionQueueSnapshot(sessionId: string): SessionQueueSnapshot {
80
+ export function getSessionQueueSnapshot(sessionId: string) {
1250
81
  ensureRecoveredPersistedRuns()
1251
- const execution = getSessionExecutionState(sessionId)
1252
- const visibleQueued = visibleQueuedEntriesForSession(sessionId)
1253
- return {
1254
- sessionId,
1255
- activeRunId: execution.runningRunId || null,
1256
- queueLength: visibleQueued.length,
1257
- items: visibleQueued.map((entry, index) => toQueuedTurn(entry, index)),
1258
- }
82
+ return getSessionQueueSnapshotInternal(sessionId)
1259
83
  }
1260
84
 
1261
- export function getSessionExecutionState(sessionId: string): {
1262
- runningRunId?: string
1263
- queueLength: number
1264
- hasRunning: boolean
1265
- hasQueued: boolean
1266
- hasRunningHeartbeat: boolean
1267
- hasQueuedHeartbeat: boolean
1268
- hasRunningNonHeartbeat: boolean
1269
- hasQueuedNonHeartbeat: boolean
1270
- } {
85
+ export function getSessionExecutionState(sessionId: string) {
1271
86
  ensureRecoveredPersistedRuns()
1272
- const running = Array.from(state.runningByExecution.values())
1273
- .find((entry) => entry.run.sessionId === sessionId)
1274
- const runningMatchesSession = Boolean(running)
1275
- const runningHeartbeat = Boolean(
1276
- runningMatchesSession
1277
- && running
1278
- && isInternalHeartbeatRun(running.run.internal, running.run.source),
1279
- )
1280
- const runningNonHeartbeat = Boolean(runningMatchesSession && !runningHeartbeat)
1281
- const queuedEntries = Array.from(state.queueByExecution.values())
1282
- .flatMap((queue) => queue)
1283
- .filter((entry) => entry.run.sessionId === sessionId)
1284
- const queuedHeartbeat = queuedEntries.filter((entry) =>
1285
- isInternalHeartbeatRun(entry.run.internal, entry.run.source),
1286
- ).length
1287
- const queuedNonHeartbeat = queuedEntries.length - queuedHeartbeat
1288
- return {
1289
- runningRunId: (runningMatchesSession && running?.run.status === 'running')
1290
- ? running.run.id
1291
- : undefined,
1292
- queueLength: queuedEntries.length,
1293
- hasRunning: Boolean(runningMatchesSession),
1294
- hasQueued: queuedEntries.length > 0,
1295
- hasRunningHeartbeat: runningHeartbeat,
1296
- hasQueuedHeartbeat: queuedHeartbeat > 0,
1297
- hasRunningNonHeartbeat: runningNonHeartbeat,
1298
- hasQueuedNonHeartbeat: queuedNonHeartbeat > 0,
1299
- }
87
+ return getSessionExecutionStateInternal(sessionId)
1300
88
  }
1301
89
 
1302
- export function getRunById(runId: string): SessionRunRecord | null {
90
+ export function getRunById(runId: string) {
1303
91
  ensureRecoveredPersistedRuns()
1304
- return state.runs.get(runId) || loadPersistedRun(runId)
92
+ return getRunByIdInternal(runId)
1305
93
  }
1306
94
 
1307
- export function listRuns(params?: {
1308
- sessionId?: string
1309
- status?: SessionRunStatus
1310
- limit?: number
1311
- }): SessionRunRecord[] {
95
+ export function listRuns(params?: Parameters<typeof listRunsInternal>[0]) {
1312
96
  ensureRecoveredPersistedRuns()
1313
- return listPersistedRuns(params)
97
+ return listRunsInternal(params)
1314
98
  }
1315
99
 
1316
- export function listRunEvents(runId: string, limit?: number): RunEventRecord[] {
100
+ export function listRunEvents(runId: string, limit?: number) {
1317
101
  ensureRecoveredPersistedRuns()
1318
- return listPersistedRunEvents(runId, limit)
102
+ return listRunEventsInternal(runId, limit)
1319
103
  }
1320
104
 
1321
105
  export function cancelQueuedRunById(runId: string, reason = 'Removed from queue'): boolean {
1322
106
  ensureRecoveredPersistedRuns()
1323
- const result = cancelQueuedEntries((entry) => entry.run.id === runId, reason)
1324
- return result.cancelled > 0
107
+ return cancelQueuedRunByIdInternal(runId, reason)
1325
108
  }
1326
109
 
1327
110
  export function cancelQueuedRunsForSession(sessionId: string, reason = 'Cleared queued messages'): number {
1328
111
  ensureRecoveredPersistedRuns()
1329
- const result = cancelQueuedEntries((entry) => entry.run.sessionId === sessionId, reason)
1330
- return result.cancelled
112
+ return cancelQueuedRunsForSessionInternal(sessionId, reason)
1331
113
  }
1332
114
 
1333
- export function cancelSessionRuns(sessionId: string, reason = 'Cancelled'): { cancelledQueued: number; cancelledRunning: boolean } {
115
+ export function cancelSessionRuns(sessionId: string, reason = 'Cancelled') {
1334
116
  ensureRecoveredPersistedRuns()
1335
- const running = Array.from(state.runningByExecution.values())
1336
- .find((entry) => entry.run.sessionId === sessionId)
1337
- let cancelledRunning = false
1338
- if (running) {
1339
- cancelledRunning = true
1340
- abortSessionRuntime(running, reason)
1341
- state.runningByExecution.delete(running.executionKey)
1342
- decrementNonHeartbeatWork(running)
1343
- }
1344
- const cancelledQueued = cancelPendingForSession(sessionId, reason)
1345
- reconcileSessionActivityLease(sessionId)
1346
- return { cancelledQueued, cancelledRunning }
117
+ return cancelSessionRunsInternal(sessionId, reason)
1347
118
  }
1348
119
 
1349
- // ---------------------------------------------------------------------------
1350
- // Stuck-run watchdog — safety net for runs that outlive their timeout.
1351
- // Called periodically from daemon health checks.
1352
- // ---------------------------------------------------------------------------
120
+ export function cancelAllHeartbeatRuns(reason = 'Heartbeat disabled globally') {
121
+ ensureRecoveredPersistedRuns()
122
+ return cancelAllHeartbeatRunsInternal(reason)
123
+ }
1353
124
 
1354
- const STUCK_RUN_THRESHOLD_MS = 20 * 60_000 // 20 min ( default maxRuntime)
125
+ export function cancelAllRuns(reason = 'Cancelled') {
126
+ ensureRecoveredPersistedRuns()
127
+ return cancelAllRunsInternal(reason)
128
+ }
1355
129
 
1356
130
  export function sweepStuckRuns(): { aborted: number } {
1357
- const deadline = now()
1358
- let aborted = 0
1359
-
1360
- // 1. In-memory running entries that have exceeded their timeout
1361
- for (const [execKey, entry] of state.runningByExecution.entries()) {
1362
- const age = deadline - (entry.run.startedAt || entry.run.queuedAt)
1363
- // If the run has an explicit maxRuntimeMs, the existing setTimeout handles it;
1364
- // the watchdog only kicks in at 1.5× as a safety net.
1365
- if (entry.maxRuntimeMs && age < entry.maxRuntimeMs * 1.5) continue
1366
- if (age < STUCK_RUN_THRESHOLD_MS) continue
1367
-
1368
- abortSessionRuntime(entry, 'Watchdog: run exceeded maximum allowed duration')
1369
- state.runningByExecution.delete(execKey)
1370
- decrementNonHeartbeatWork(entry)
1371
- reconcileSessionActivityLease(entry.run.sessionId)
1372
- aborted++
1373
- }
1374
-
1375
- // 2. Persisted runs marked running but with no in-memory entry (orphaned by HMR/crash)
1376
- const persistedRunning = listPersistedRuns({ status: 'running' })
1377
- for (const run of persistedRunning) {
1378
- const execKey = run.recoveryPayload?.executionGroupKey || executionKeyForSession(run.sessionId)
1379
- const inMemory = state.runningByExecution.get(execKey)
1380
- if (inMemory && inMemory.run.id === run.id) continue // still tracked
1381
-
1382
- const age = deadline - (run.startedAt || run.queuedAt)
1383
- if (age < STUCK_RUN_THRESHOLD_MS) continue
1384
-
1385
- markPersistedRunInterrupted(run, 'Watchdog: orphaned run detected after server restart or HMR')
1386
- aborted++
1387
-
1388
- // Re-enqueue if the source is recoverable and no other run is already in-flight for this session
1389
- const alreadyRunning = state.runningByExecution.has(execKey)
1390
- const alreadyQueued = (state.queueByExecution.get(execKey) || []).some(e => e.run.sessionId === run.sessionId)
1391
- if (run.recoveryPayload && isRestartRecoverableSource(run.source) && !alreadyRunning && !alreadyQueued) {
1392
- try {
1393
- const payload = run.recoveryPayload
1394
- enqueueSessionRun({
1395
- sessionId: run.sessionId,
1396
- message: payload.message,
1397
- imagePath: payload.imagePath,
1398
- imageUrl: payload.imageUrl,
1399
- attachedFiles: payload.attachedFiles,
1400
- internal: payload.internal,
1401
- source: payload.source,
1402
- mode: normalizeMode(payload.mode, payload.internal),
1403
- dedupeKey: run.dedupeKey,
1404
- maxRuntimeMs: payload.maxRuntimeMs,
1405
- modelOverride: payload.modelOverride,
1406
- heartbeatConfig: payload.heartbeatConfig,
1407
- replyToId: payload.replyToId,
1408
- executionGroupKey: payload.executionGroupKey,
1409
- recoveredFromRestart: true,
1410
- recoveredFromRunId: run.id,
1411
- })
1412
- } catch (err: unknown) {
1413
- log.warn('session-run', `Watchdog: failed to re-enqueue orphaned run ${run.id}`, {
1414
- sessionId: run.sessionId,
1415
- error: errorMessage(err),
1416
- })
1417
- }
1418
- }
1419
- }
1420
-
1421
- return { aborted }
131
+ ensureRecoveredPersistedRuns()
132
+ return sweepStuckRunsInternal(enqueueSessionRun)
1422
133
  }
1423
134
 
1424
135
  export function resetSessionRunManagerForTests(): void {
1425
- recoveryState.completed = false
1426
- for (const timer of state.deferredDrainTimers.values()) clearTimeout(timer)
1427
- state.deferredDrainTimers.clear()
1428
- for (const [sessionId, timer] of state.activityLeaseRenewTimers.entries()) {
1429
- clearInterval(timer)
1430
- releaseRuntimeLock(nonHeartbeatActivityLeaseName(sessionId), SHARED_ACTIVITY_LEASE_OWNER)
1431
- }
1432
- state.activityLeaseRenewTimers.clear()
1433
- state.runningByExecution.clear()
1434
- state.queueByExecution.clear()
1435
- state.runs.clear()
1436
- state.recentRunIds.length = 0
1437
- state.promises.clear()
1438
- state.externalSessionHolds.clear()
1439
- state.nonHeartbeatWorkCount.clear()
1440
- state.lastQueuedAt = 0
136
+ resetSessionRunManagerStateForTests()
1441
137
  }
138
+
139
+ export { hasActiveNonHeartbeatSessionLease }