visionclaw 0.1.219-dev.codex-issue-2805-worker-runtime.2 → 0.1.219-dev.codex-fix-2941-activity-foreground.1

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 (313) hide show
  1. package/dist/agent/api-proxy.d.ts +16 -0
  2. package/dist/agent/api-proxy.d.ts.map +1 -1
  3. package/dist/agent/api-proxy.js +8 -9
  4. package/dist/agent/api-proxy.js.map +1 -1
  5. package/dist/agent/browser-launcher.d.ts +0 -3
  6. package/dist/agent/browser-launcher.d.ts.map +1 -1
  7. package/dist/agent/browser-launcher.js +43 -101
  8. package/dist/agent/browser-launcher.js.map +1 -1
  9. package/dist/agent/coding-conventions.d.ts +3 -2
  10. package/dist/agent/coding-conventions.d.ts.map +1 -1
  11. package/dist/agent/coding-conventions.js +8 -7
  12. package/dist/agent/coding-conventions.js.map +1 -1
  13. package/dist/agent/command-handlers.d.ts.map +1 -1
  14. package/dist/agent/command-handlers.js +16 -4
  15. package/dist/agent/command-handlers.js.map +1 -1
  16. package/dist/agent/context-action-restart-fallback.d.ts.map +1 -1
  17. package/dist/agent/context-action-restart-fallback.js +3 -9
  18. package/dist/agent/context-action-restart-fallback.js.map +1 -1
  19. package/dist/agent/context-refresh.d.ts.map +1 -1
  20. package/dist/agent/context-refresh.js +7 -0
  21. package/dist/agent/context-refresh.js.map +1 -1
  22. package/dist/agent/context.d.ts.map +1 -1
  23. package/dist/agent/context.js +8 -6
  24. package/dist/agent/context.js.map +1 -1
  25. package/dist/agent/engine-fallback.d.ts +35 -0
  26. package/dist/agent/engine-fallback.d.ts.map +1 -1
  27. package/dist/agent/engine-fallback.js +3 -4
  28. package/dist/agent/engine-fallback.js.map +1 -1
  29. package/dist/agent/engines/browser-tool-policy.d.ts +1 -12
  30. package/dist/agent/engines/browser-tool-policy.d.ts.map +1 -1
  31. package/dist/agent/engines/browser-tool-policy.js +2 -17
  32. package/dist/agent/engines/browser-tool-policy.js.map +1 -1
  33. package/dist/agent/engines/claude/session.d.ts +0 -1
  34. package/dist/agent/engines/claude/session.d.ts.map +1 -1
  35. package/dist/agent/engines/claude/session.js +36 -21
  36. package/dist/agent/engines/claude/session.js.map +1 -1
  37. package/dist/agent/engines/pi/tools.d.ts.map +1 -1
  38. package/dist/agent/engines/pi/tools.js +10 -2
  39. package/dist/agent/engines/pi/tools.js.map +1 -1
  40. package/dist/agent/engines/pi-coding/session.d.ts +0 -3
  41. package/dist/agent/engines/pi-coding/session.d.ts.map +1 -1
  42. package/dist/agent/engines/pi-coding/session.js +22 -19
  43. package/dist/agent/engines/pi-coding/session.js.map +1 -1
  44. package/dist/agent/engines/session-types.d.ts +0 -6
  45. package/dist/agent/engines/session-types.d.ts.map +1 -1
  46. package/dist/agent/external-context/runtime.d.ts.map +1 -1
  47. package/dist/agent/external-context/runtime.js +14 -15
  48. package/dist/agent/external-context/runtime.js.map +1 -1
  49. package/dist/agent/fast-responder.d.ts +20 -4
  50. package/dist/agent/fast-responder.d.ts.map +1 -1
  51. package/dist/agent/fast-responder.js +39 -9
  52. package/dist/agent/fast-responder.js.map +1 -1
  53. package/dist/agent/interrupt-handler.d.ts +18 -4
  54. package/dist/agent/interrupt-handler.d.ts.map +1 -1
  55. package/dist/agent/interrupt-handler.js +284 -49
  56. package/dist/agent/interrupt-handler.js.map +1 -1
  57. package/dist/agent/lab-run-system-prompt.d.ts.map +1 -1
  58. package/dist/agent/lab-run-system-prompt.js +0 -3
  59. package/dist/agent/lab-run-system-prompt.js.map +1 -1
  60. package/dist/agent/lifecycle-trace.d.ts.map +1 -1
  61. package/dist/agent/lifecycle-trace.js +5 -6
  62. package/dist/agent/lifecycle-trace.js.map +1 -1
  63. package/dist/agent/loop.d.ts +1 -4
  64. package/dist/agent/loop.d.ts.map +1 -1
  65. package/dist/agent/loop.js +129 -105
  66. package/dist/agent/loop.js.map +1 -1
  67. package/dist/agent/memory-protocol.d.ts +2 -2
  68. package/dist/agent/memory-protocol.d.ts.map +1 -1
  69. package/dist/agent/memory-protocol.js +15 -3
  70. package/dist/agent/memory-protocol.js.map +1 -1
  71. package/dist/agent/memory-store.d.ts.map +1 -1
  72. package/dist/agent/memory-store.js +1 -11
  73. package/dist/agent/memory-store.js.map +1 -1
  74. package/dist/agent/model-provider.d.ts +0 -2
  75. package/dist/agent/model-provider.d.ts.map +1 -1
  76. package/dist/agent/model-provider.js +25 -31
  77. package/dist/agent/model-provider.js.map +1 -1
  78. package/dist/agent/product-identity.d.ts +4 -5
  79. package/dist/agent/product-identity.d.ts.map +1 -1
  80. package/dist/agent/product-identity.js +6 -7
  81. package/dist/agent/product-identity.js.map +1 -1
  82. package/dist/agent/public-activity.d.ts +1 -1
  83. package/dist/agent/public-activity.d.ts.map +1 -1
  84. package/dist/agent/public-activity.js.map +1 -1
  85. package/dist/agent/reply-watchdog.d.ts +3 -5
  86. package/dist/agent/reply-watchdog.d.ts.map +1 -1
  87. package/dist/agent/reply-watchdog.js +4 -6
  88. package/dist/agent/reply-watchdog.js.map +1 -1
  89. package/dist/agent/session-manager.d.ts +0 -5
  90. package/dist/agent/session-manager.d.ts.map +1 -1
  91. package/dist/agent/session-manager.js +0 -7
  92. package/dist/agent/session-manager.js.map +1 -1
  93. package/dist/agent/state.d.ts +0 -7
  94. package/dist/agent/state.d.ts.map +1 -1
  95. package/dist/agent/state.js +11 -21
  96. package/dist/agent/state.js.map +1 -1
  97. package/dist/agent/stream-handler.d.ts.map +1 -1
  98. package/dist/agent/stream-handler.js +17 -0
  99. package/dist/agent/stream-handler.js.map +1 -1
  100. package/dist/agent/system-prompt.d.ts.map +1 -1
  101. package/dist/agent/system-prompt.js +6 -10
  102. package/dist/agent/system-prompt.js.map +1 -1
  103. package/dist/agent/transcript/transcript-indexer.d.ts +28 -0
  104. package/dist/agent/transcript/transcript-indexer.d.ts.map +1 -1
  105. package/dist/agent/transcript/transcript-indexer.js +57 -58
  106. package/dist/agent/transcript/transcript-indexer.js.map +1 -1
  107. package/dist/agent/usage/usage-report-queue.d.ts.map +1 -1
  108. package/dist/agent/usage/usage-report-queue.js +52 -53
  109. package/dist/agent/usage/usage-report-queue.js.map +1 -1
  110. package/dist/agent/wake-cycle-runner.d.ts +8 -7
  111. package/dist/agent/wake-cycle-runner.d.ts.map +1 -1
  112. package/dist/agent/wake-cycle-runner.js +66 -105
  113. package/dist/agent/wake-cycle-runner.js.map +1 -1
  114. package/dist/builtin-skills/owner-manual/SKILL.md +2 -2
  115. package/dist/calendar/occurrence-ownership.d.ts.map +1 -1
  116. package/dist/calendar/occurrence-ownership.js +5 -6
  117. package/dist/calendar/occurrence-ownership.js.map +1 -1
  118. package/dist/calendar/scheduler.d.ts.map +1 -1
  119. package/dist/calendar/scheduler.js +3 -4
  120. package/dist/calendar/scheduler.js.map +1 -1
  121. package/dist/channels/interface.d.ts +2 -8
  122. package/dist/channels/interface.d.ts.map +1 -1
  123. package/dist/channels/interface.js +1 -8
  124. package/dist/channels/interface.js.map +1 -1
  125. package/dist/channels/manager.d.ts.map +1 -1
  126. package/dist/channels/manager.js +0 -2
  127. package/dist/channels/manager.js.map +1 -1
  128. package/dist/channels/qq-status.d.ts.map +1 -1
  129. package/dist/channels/qq-status.js +1 -0
  130. package/dist/channels/qq-status.js.map +1 -1
  131. package/dist/channels/queue.d.ts.map +1 -1
  132. package/dist/channels/queue.js +0 -8
  133. package/dist/channels/queue.js.map +1 -1
  134. package/dist/channels/telegram-status.d.ts.map +1 -1
  135. package/dist/channels/telegram-status.js +2 -0
  136. package/dist/channels/telegram-status.js.map +1 -1
  137. package/dist/channels/tui.d.ts +2 -2
  138. package/dist/channels/tui.d.ts.map +1 -1
  139. package/dist/channels/tui.js +2 -7
  140. package/dist/channels/tui.js.map +1 -1
  141. package/dist/config/index.d.ts.map +1 -1
  142. package/dist/config/index.js +15 -16
  143. package/dist/config/index.js.map +1 -1
  144. package/dist/config/types.d.ts +8 -16
  145. package/dist/config/types.d.ts.map +1 -1
  146. package/dist/config/types.js +24 -13
  147. package/dist/config/types.js.map +1 -1
  148. package/dist/email/email-route-store.d.ts.map +1 -1
  149. package/dist/email/email-route-store.js +5 -6
  150. package/dist/email/email-route-store.js.map +1 -1
  151. package/dist/i18n/messages.d.ts +0 -12
  152. package/dist/i18n/messages.d.ts.map +1 -1
  153. package/dist/i18n/messages.js +9 -21
  154. package/dist/i18n/messages.js.map +1 -1
  155. package/dist/logger.d.ts +0 -2
  156. package/dist/logger.d.ts.map +1 -1
  157. package/dist/logger.js +1 -19
  158. package/dist/logger.js.map +1 -1
  159. package/dist/threads/active-threads-store.d.ts.map +1 -1
  160. package/dist/threads/active-threads-store.js +27 -8
  161. package/dist/threads/active-threads-store.js.map +1 -1
  162. package/dist/threads/execution-host.d.ts.map +1 -1
  163. package/dist/threads/execution-host.js +0 -1
  164. package/dist/threads/execution-host.js.map +1 -1
  165. package/dist/threads/execution-scheduler.d.ts +0 -4
  166. package/dist/threads/execution-scheduler.d.ts.map +1 -1
  167. package/dist/threads/execution-scheduler.js +2 -38
  168. package/dist/threads/execution-scheduler.js.map +1 -1
  169. package/dist/threads/execution-store.d.ts +5 -20
  170. package/dist/threads/execution-store.d.ts.map +1 -1
  171. package/dist/threads/execution-store.js +8 -45
  172. package/dist/threads/execution-store.js.map +1 -1
  173. package/dist/threads/foreground-task.d.ts +6 -10
  174. package/dist/threads/foreground-task.d.ts.map +1 -1
  175. package/dist/threads/foreground-task.js +26 -34
  176. package/dist/threads/foreground-task.js.map +1 -1
  177. package/dist/threads/foreman-client.d.ts.map +1 -1
  178. package/dist/threads/foreman-client.js +1 -3
  179. package/dist/threads/foreman-client.js.map +1 -1
  180. package/dist/threads/foreman-execution.d.ts +12 -12
  181. package/dist/threads/foreman-execution.d.ts.map +1 -1
  182. package/dist/threads/foreman-execution.js +19 -30
  183. package/dist/threads/foreman-execution.js.map +1 -1
  184. package/dist/threads/foreman-host.d.ts +2 -17
  185. package/dist/threads/foreman-host.d.ts.map +1 -1
  186. package/dist/threads/foreman-host.js +59 -423
  187. package/dist/threads/foreman-host.js.map +1 -1
  188. package/dist/threads/foreman-protocol.d.ts +12 -109
  189. package/dist/threads/foreman-protocol.d.ts.map +1 -1
  190. package/dist/threads/foreman-protocol.js +15 -37
  191. package/dist/threads/foreman-protocol.js.map +1 -1
  192. package/dist/threads/foreman-store.d.ts +9 -36
  193. package/dist/threads/foreman-store.d.ts.map +1 -1
  194. package/dist/threads/foreman-store.js +19 -63
  195. package/dist/threads/foreman-store.js.map +1 -1
  196. package/dist/threads/generic-execution.d.ts +2 -2
  197. package/dist/threads/generic-execution.d.ts.map +1 -1
  198. package/dist/threads/generic-execution.js +6 -9
  199. package/dist/threads/generic-execution.js.map +1 -1
  200. package/dist/threads/ipc.d.ts +0 -3
  201. package/dist/threads/ipc.d.ts.map +1 -1
  202. package/dist/threads/ipc.js.map +1 -1
  203. package/dist/threads/relay-adapter.d.ts +2 -0
  204. package/dist/threads/relay-adapter.d.ts.map +1 -1
  205. package/dist/threads/relay-adapter.js +7 -1
  206. package/dist/threads/relay-adapter.js.map +1 -1
  207. package/dist/threads/supervisor.d.ts +51 -21
  208. package/dist/threads/supervisor.d.ts.map +1 -1
  209. package/dist/threads/supervisor.js +449 -160
  210. package/dist/threads/supervisor.js.map +1 -1
  211. package/dist/threads/task-contracts.d.ts +194 -0
  212. package/dist/threads/task-contracts.d.ts.map +1 -0
  213. package/dist/threads/task-contracts.js +82 -0
  214. package/dist/threads/task-contracts.js.map +1 -0
  215. package/dist/threads/task-sidecar.d.ts +145 -0
  216. package/dist/threads/task-sidecar.d.ts.map +1 -0
  217. package/dist/threads/task-sidecar.js +311 -0
  218. package/dist/threads/task-sidecar.js.map +1 -0
  219. package/dist/threads/task-store.d.ts +172 -0
  220. package/dist/threads/task-store.d.ts.map +1 -0
  221. package/dist/threads/task-store.js +599 -0
  222. package/dist/threads/task-store.js.map +1 -0
  223. package/dist/threads/thread-activity-controller.d.ts +7 -0
  224. package/dist/threads/thread-activity-controller.d.ts.map +1 -1
  225. package/dist/threads/thread-activity-controller.js +52 -2
  226. package/dist/threads/thread-activity-controller.js.map +1 -1
  227. package/dist/threads/thread-task-context.d.ts +40 -0
  228. package/dist/threads/thread-task-context.d.ts.map +1 -0
  229. package/dist/threads/thread-task-context.js +160 -0
  230. package/dist/threads/thread-task-context.js.map +1 -0
  231. package/dist/threads/thread-task-controller.d.ts +122 -0
  232. package/dist/threads/thread-task-controller.d.ts.map +1 -0
  233. package/dist/threads/thread-task-controller.js +276 -0
  234. package/dist/threads/thread-task-controller.js.map +1 -0
  235. package/dist/threads/thread-task-runtime.d.ts +350 -0
  236. package/dist/threads/thread-task-runtime.d.ts.map +1 -0
  237. package/dist/threads/thread-task-runtime.js +896 -0
  238. package/dist/threads/thread-task-runtime.js.map +1 -0
  239. package/dist/threads/worker-entry.d.ts.map +1 -1
  240. package/dist/threads/worker-entry.js +28 -29
  241. package/dist/threads/worker-entry.js.map +1 -1
  242. package/dist/threads/worker-execution-runtime.d.ts +2 -3
  243. package/dist/threads/worker-execution-runtime.d.ts.map +1 -1
  244. package/dist/threads/worker-execution-runtime.js +2 -5
  245. package/dist/threads/worker-execution-runtime.js.map +1 -1
  246. package/dist/tools/delegation.d.ts +2 -71
  247. package/dist/tools/delegation.d.ts.map +1 -1
  248. package/dist/tools/delegation.js +28 -62
  249. package/dist/tools/delegation.js.map +1 -1
  250. package/dist/tools/email.d.ts +1 -1
  251. package/dist/tools/execution-control.d.ts +0 -5
  252. package/dist/tools/execution-control.d.ts.map +1 -1
  253. package/dist/tools/execution-control.js +2 -9
  254. package/dist/tools/execution-control.js.map +1 -1
  255. package/dist/tools/index.d.ts.map +1 -1
  256. package/dist/tools/index.js +6 -2
  257. package/dist/tools/index.js.map +1 -1
  258. package/dist/tools/mcp-manager.d.ts +1 -1
  259. package/dist/tools/memory.d.ts +1 -1
  260. package/dist/tools/notify.d.ts +0 -2
  261. package/dist/tools/notify.d.ts.map +1 -1
  262. package/dist/tools/notify.js +5 -21
  263. package/dist/tools/notify.js.map +1 -1
  264. package/dist/tools/skill.d.ts +1 -1
  265. package/dist/tools/thread-tasks.d.ts +23 -0
  266. package/dist/tools/thread-tasks.d.ts.map +1 -0
  267. package/dist/tools/thread-tasks.js +122 -0
  268. package/dist/tools/thread-tasks.js.map +1 -0
  269. package/dist/tools/totp.d.ts +1 -1
  270. package/dist/tui/request-store.d.ts +1 -1
  271. package/dist/tui/request-store.d.ts.map +1 -1
  272. package/dist/tui/request-store.js +2 -2
  273. package/dist/tui/request-store.js.map +1 -1
  274. package/dist/tui/server.d.ts.map +1 -1
  275. package/dist/tui/server.js +2 -17
  276. package/dist/tui/server.js.map +1 -1
  277. package/dist/utils/restart.d.ts.map +1 -1
  278. package/dist/utils/restart.js +0 -6
  279. package/dist/utils/restart.js.map +1 -1
  280. package/dist-agent/bundle.cjs +2 -2
  281. package/package.json +1 -1
  282. package/dist/agent/runtime-context.d.ts +0 -27
  283. package/dist/agent/runtime-context.d.ts.map +0 -1
  284. package/dist/agent/runtime-context.js +0 -60
  285. package/dist/agent/runtime-context.js.map +0 -1
  286. package/dist/agent/runtime-disclosure-policy.d.ts +0 -6
  287. package/dist/agent/runtime-disclosure-policy.d.ts.map +0 -1
  288. package/dist/agent/runtime-disclosure-policy.js +0 -16
  289. package/dist/agent/runtime-disclosure-policy.js.map +0 -1
  290. package/dist/conversations/lazy-runtime.d.ts +0 -5
  291. package/dist/conversations/lazy-runtime.d.ts.map +0 -1
  292. package/dist/conversations/lazy-runtime.js +0 -12
  293. package/dist/conversations/lazy-runtime.js.map +0 -1
  294. package/dist/conversations/runtime.d.ts +0 -17
  295. package/dist/conversations/runtime.d.ts.map +0 -1
  296. package/dist/conversations/runtime.js +0 -92
  297. package/dist/conversations/runtime.js.map +0 -1
  298. package/dist/conversations/scheduler.d.ts +0 -57
  299. package/dist/conversations/scheduler.d.ts.map +0 -1
  300. package/dist/conversations/scheduler.js +0 -175
  301. package/dist/conversations/scheduler.js.map +0 -1
  302. package/dist/threads/foreman-business.d.ts +0 -9
  303. package/dist/threads/foreman-business.d.ts.map +0 -1
  304. package/dist/threads/foreman-business.js +0 -117
  305. package/dist/threads/foreman-business.js.map +0 -1
  306. package/dist/threads/foreman-delivery.d.ts +0 -5
  307. package/dist/threads/foreman-delivery.d.ts.map +0 -1
  308. package/dist/threads/foreman-delivery.js +0 -21
  309. package/dist/threads/foreman-delivery.js.map +0 -1
  310. package/dist/threads/task-workspace.d.ts +0 -8
  311. package/dist/threads/task-workspace.d.ts.map +0 -1
  312. package/dist/threads/task-workspace.js +0 -52
  313. package/dist/threads/task-workspace.js.map +0 -1
@@ -1,7 +1,17 @@
1
1
  /**
2
- * Host for foreground conversations and the shared background execution pool.
3
- * Chat queues run through ConversationScheduler. Only admitted executions and
4
- * LabRuns fork processes; their ledger owns recovery and confirmed stops.
2
+ * ThreadSupervisor: orchestrates worker processes for non-default business threads.
3
+ *
4
+ * Responsibilities:
5
+ * - Spawn worker processes on demand (child_process.fork)
6
+ * - Route messages to workers by threadId
7
+ * - Relay worker replies back to real channels
8
+ * - Enforce concurrency limits (maxConcurrentThreads)
9
+ * - Idle timeout & graceful worker shutdown
10
+ * - Crash detection & auto-restart
11
+ * - Force global commands (/restart, /upgrade, /subscribe, …) to the default thread
12
+ * - Engine switch (/engine): update thread.json + restart worker
13
+ * - Worker admission pause for the main process's nightly maintenance
14
+ * - Graceful supervisor shutdown: notify all workers
5
15
  */
6
16
  import { fork } from "node:child_process";
7
17
  import { randomUUID } from "node:crypto";
@@ -12,6 +22,8 @@ import { PUBLIC_ACTIVITY_HISTORY_LIMIT } from "../agent/public-activity.js";
12
22
  import { isDeliveryTarget } from "../channels/delivery-target.js";
13
23
  import { DEFAULT_THREAD_ID, isEmailChannel, normalizeChannelName } from "../channels/interface.js";
14
24
  import { MessageQueue } from "../channels/queue.js";
25
+ import { isThreadTasksEnabled } from "../config/types.js";
26
+ import { taskStorePathForThread, threadHasRunnableTasks } from "./task-store.js";
15
27
  import { logger } from "../logger.js";
16
28
  import { isOwnerInitiatedMessage } from "../agent/owner-initiated-eligibility.js";
17
29
  import { getProfile, getSharedProfileDir, getThreadDir, loadOwnerConfig } from "../config/index.js";
@@ -23,9 +35,6 @@ import { ensureThreadApplication, formatThreadEngineLabel, readThreadMeta, setTh
23
35
  import { getLlmRouting } from "../agent/model-provider.js";
24
36
  import { ExecutionStore, isTerminalExecutionState } from "./execution-store.js";
25
37
  import { ExecutionScheduler } from "./execution-scheduler.js";
26
- import { ConversationScheduler } from "../conversations/scheduler.js";
27
- import { createLazyConversationRuntime } from "../conversations/lazy-runtime.js";
28
- import { bindRuntimeContext } from "../agent/runtime-context.js";
29
38
  import { EXECUTION_VERIFY_KEYS_ENV, createExecutionAuthority, verifyKeysOf, } from "./execution-authority.js";
30
39
  import { EXECUTOR_GROUP_REGISTRY_FILE, executorGroupStatus, killExecutorGroupAndWait, reapExitedExecutorGroup, signalExecutorGroup, stopRegisteredGroups, } from "./process-group.js";
31
40
  import { ExecutorRefusedError } from "./executor-exit.js";
@@ -45,7 +54,7 @@ import { AgentFeedbackOutbox } from "../agent/feedback-outbox.js";
45
54
  import { serializeBrowserRuntimeResolution, serializeDesktopRuntimeResolution, WORKER_BROWSER_RUNTIME_ENV, WORKER_DESKTOP_RUNTIME_ENV, } from "./worker-runtime-resolution.js";
46
55
  import { isGlobalCommand } from "./global-commands.js";
47
56
  import { getClaudeTranscriptDirForProfileDir } from "../utils/claude-transcripts.js";
48
- import { isForemanRuntime, supportsTaskRuntime } from "./foreman-protocol.js";
57
+ import { MAX_PARALLEL_FOREMAN_TASKS } from "./foreman-protocol.js";
49
58
  export { isGlobalCommand } from "./global-commands.js";
50
59
  const __filename = fileURLToPath(import.meta.url);
51
60
  const __dirname = path.dirname(__filename);
@@ -108,8 +117,6 @@ export function parseEngineCommand(text) {
108
117
  return null;
109
118
  }
110
119
  export class ThreadSupervisor {
111
- conversations;
112
- foregroundDisabled;
113
120
  channelManager;
114
121
  messageQueue;
115
122
  maxConcurrentThreads;
@@ -120,6 +127,7 @@ export class ThreadSupervisor {
120
127
  workerEnv;
121
128
  desktopRuntimeResolution;
122
129
  browserRuntimeResolution;
130
+ crashRestartDelayMs;
123
131
  threadQueueFactory;
124
132
  onFeedback;
125
133
  onBusyChange;
@@ -137,18 +145,23 @@ export class ThreadSupervisor {
137
145
  workers = new Map();
138
146
  /** Main-process handles for the durable queue.db owned by each thread. */
139
147
  threadQueues = new Map();
148
+ /** Crash counts must outlive WorkerState objects, which are replaced on respawn. */
149
+ crashCounts = new Map();
150
+ /** Threads waiting for their bounded crash-backoff timer. */
151
+ restartTimers = new Map();
152
+ /** Startup failures / exhausted crash budgets stay dormant until new work arrives. */
153
+ recoveryExhaustedThreads = new Set();
140
154
  /** True once shutdownAll() ran — no new workers may be spawned afterwards. */
141
155
  stopped = false;
142
156
  maintenancePaused = false;
157
+ maintenanceMessages = [];
143
158
  /** The one execution the maintenance owner submitted under its pause. */
144
159
  maintenanceExecutionId = null;
145
160
  onMaintenanceInput;
146
161
  handleForemanCommand;
147
- handleForemanBusinessRead;
148
162
  executionStore;
149
163
  executionScheduler;
150
164
  constructor(options) {
151
- this.foregroundDisabled = options.conversationFactory === null;
152
165
  this.channelManager = options.channelManager;
153
166
  this.messageQueue = options.messageQueue;
154
167
  this.maxConcurrentThreads = options.maxConcurrentThreads;
@@ -159,6 +172,7 @@ export class ThreadSupervisor {
159
172
  this.workerEnv = options.workerEnv;
160
173
  this.desktopRuntimeResolution = options.desktopRuntimeResolution;
161
174
  this.browserRuntimeResolution = options.browserRuntimeResolution;
175
+ this.crashRestartDelayMs = options.crashRestartDelayMs ?? 2000;
162
176
  this.executionAuthority = options.executionAuthority ?? createExecutionAuthority();
163
177
  this.workerStopGraceMs = options.workerStopGraceMs ?? 10_000;
164
178
  this.executorGroups = options.executorGroups ?? {
@@ -172,33 +186,11 @@ export class ThreadSupervisor {
172
186
  this.onBusyChange = options.onBusyChange;
173
187
  this.onMaintenanceInput = options.onMaintenanceInput;
174
188
  this.handleForemanCommand = options.handleForemanCommand;
175
- this.handleForemanBusinessRead = options.handleForemanBusinessRead;
176
189
  this.threadQueueFactory = options.threadQueueFactory ?? ((threadId, recoverInFlight) => {
177
190
  const threadDir = getThreadDir(threadId);
178
191
  fs.mkdirSync(threadDir, { recursive: true });
179
192
  return new MessageQueue(path.join(threadDir, "queue.db"), { recoverInFlight });
180
193
  });
181
- // Foreground conversation runtimes never use the background worker transport.
182
- const conversationCommand = this.handleForemanCommand;
183
- this.conversations = !this.foregroundDisabled
184
- ? new ConversationScheduler({
185
- create: options.conversationFactory ?? ((start) => createLazyConversationRuntime({
186
- ...start,
187
- config: this.config,
188
- foremanClient: conversationCommand && supportsTaskRuntime()
189
- ? { delegationEnabled: isForemanRuntime(this.config), request: bindRuntimeContext((command) => conversationCommand(start.conversationId, command)) }
190
- : null,
191
- desktopRuntimeResolution: this.desktopRuntimeResolution,
192
- browserRuntimeResolution: this.browserRuntimeResolution,
193
- })),
194
- queue: (id) => this.getThreadQueue(id, false),
195
- emit: (id, event, reply) => this.handleConversationEvent(id, event, reply),
196
- awareness: (id) => this.getConversationTaskSnapshot(id),
197
- onBusyChange: this.onBusyChange,
198
- maxConcurrentTurns: this.config.workers?.foregroundConcurrency ?? 2,
199
- idleTtlMs: this.idleTimeoutMs,
200
- })
201
- : null;
202
194
  this.executionStore = options.executionStore === undefined
203
195
  ? new ExecutionStore()
204
196
  : options.executionStore;
@@ -270,9 +262,7 @@ export class ThreadSupervisor {
270
262
  * execution runs, is stopping, or waits in the queue.
271
263
  */
272
264
  tryPauseForMaintenance() {
273
- if (this.stopped || this.maintenancePaused)
274
- return null;
275
- if (this.conversations?.hasBusyConversations())
265
+ if (this.stopped || this.maintenancePaused || this.restartTimers.size > 0)
276
266
  return null;
277
267
  if ([...this.workers.values()].some((worker) => !worker.ready || worker.shuttingDown || this.isWorkerVisiblyBusy(worker)))
278
268
  return null;
@@ -292,9 +282,20 @@ export class ThreadSupervisor {
292
282
  }
293
283
  this.maintenanceExecutionId = null;
294
284
  this.maintenancePaused = false;
285
+ const pending = this.maintenanceMessages;
286
+ this.maintenanceMessages = [];
295
287
  if (this.stopped)
296
- return;
288
+ return; // Accepted ordinary messages are already durable.
289
+ // Executions queued behind the hold are admitted now, not on the next submit.
297
290
  this.executionScheduler?.requestAdmission();
291
+ for (const { msg, threadId } of pending) {
292
+ try {
293
+ this.routeMessage(msg, threadId);
294
+ }
295
+ catch (err) {
296
+ logger.warn(`[supervisor] Could not resume queued thread=${threadId}: ${String(err)}`);
297
+ }
298
+ }
298
299
  this.schedulePendingThreads();
299
300
  };
300
301
  }
@@ -305,16 +306,58 @@ export class ThreadSupervisor {
305
306
  if (queue.hasOutstandingWork()) {
306
307
  logger.system(`[supervisor] Recovered pending work for thread=${threadId}`);
307
308
  }
309
+ else if (this.hasRunnableTasks(threadId)) {
310
+ logger.system(`[supervisor] Recovered runnable tasks for thread=${threadId}`);
311
+ }
308
312
  }
309
313
  this.schedulePendingThreads();
310
314
  }
311
- /** Resume durable foreground messages without allocating a worker process. */
315
+ /**
316
+ * Whether a thread without queued messages still has a task that may
317
+ * start (#1996 PR 5). Its worker queues the resume itself once it runs:
318
+ * the supervisor only starts it, it never writes the thread's task store.
319
+ * By design (owner 10-03): a task that is not due yet is not counted;
320
+ * its due or unblock wake comes from the existing calendar / event-trigger
321
+ * messages to the thread. Thread tasks never schedule wakes.
322
+ * TODO: a worker started this way whose first pass queues nothing (gated)
323
+ * has no idle timer until its first turn, so it holds a slot until evicted.
324
+ */
325
+ hasRunnableTasks(threadId) {
326
+ if (!isThreadTasksEnabled(this.config))
327
+ return false;
328
+ return threadHasRunnableTasks(taskStorePathForThread(threadId), threadId);
329
+ }
330
+ /**
331
+ * Start persisted threads whenever a worker slot is available. Work remains
332
+ * in its queue.db when all slots are busy and is retried after a worker turn
333
+ * or exit, rather than being rejected or routed to the default thread.
334
+ */
312
335
  schedulePendingThreads() {
313
- if (this.stopped || this.foregroundDisabled)
336
+ if (this.stopped || this.maintenancePaused)
314
337
  return;
315
- for (const [id, queue] of this.threadQueues) {
316
- if (!isLabRunThreadId(id) && !isGenericExecutionThreadId(id) && queue.hasOutstandingWork())
317
- this.conversations?.accept(id);
338
+ for (const threadId of this.threadQueues.keys()) {
339
+ if (this.workers.has(threadId))
340
+ continue;
341
+ if (this.restartTimers.has(threadId))
342
+ continue;
343
+ if (this.recoveryExhaustedThreads.has(threadId))
344
+ continue;
345
+ const queue = this.threadQueues.get(threadId);
346
+ if (!queue?.hasOutstandingWork() && !this.hasRunnableTasks(threadId))
347
+ continue;
348
+ if (this.getActiveWorkerCount() >= this.maxConcurrentThreads) {
349
+ const evicted = this.evictLeastRecentlyActiveIdleWorker(threadId);
350
+ if (!evicted && this.getActiveWorkerCount() >= this.maxConcurrentThreads)
351
+ break;
352
+ }
353
+ try {
354
+ this.spawnWorker(threadId);
355
+ }
356
+ catch (error) {
357
+ logger.warn(`[supervisor] Failed to recover thread=${threadId}: ${error instanceof Error ? error.message : String(error)}`);
358
+ this.recoveryExhaustedThreads.add(threadId);
359
+ this.notifyPendingSendersOfFailure(threadId, this.outstandingMessages(threadId, []));
360
+ }
318
361
  }
319
362
  }
320
363
  /**
@@ -383,7 +426,7 @@ export class ThreadSupervisor {
383
426
  }
384
427
  // Thread-scoped engine switch: handled by the supervisor (write thread.json
385
428
  // + restart worker), NOT forwarded to the worker.
386
- const engine = isLabRunThreadId(threadId) ? null : parseEngineCommand(msg.text);
429
+ const engine = parseEngineCommand(msg.text);
387
430
  if (engine !== null) {
388
431
  this.switchThreadEngine(threadId, engine, msg);
389
432
  return true;
@@ -409,32 +452,71 @@ export class ThreadSupervisor {
409
452
  throw new Error("A previous run of this Lab is still stopping; retry this delivery");
410
453
  return false;
411
454
  }
455
+ let durableInserted = false;
412
456
  if (!labRun) {
413
- this.getThreadQueue(threadId, true).enqueue(msg);
414
- if (this.maintenancePaused && isOwnerInitiatedMessage(msg))
415
- this.onMaintenanceInput?.();
416
- if (!this.foregroundDisabled) {
417
- this.conversations?.accept(threadId, msg);
457
+ durableInserted = this.getThreadQueue(threadId, true).enqueue(msg);
458
+ // A new provider/request id is an explicit retry after an exhausted
459
+ // startup/crash budget. Re-enable this thread without letting unrelated
460
+ // scheduler ticks revive it forever.
461
+ if (durableInserted && this.recoveryExhaustedThreads.delete(threadId)) {
462
+ this.crashCounts.delete(threadId);
418
463
  }
464
+ }
465
+ if (this.maintenancePaused) {
466
+ this.maintenanceMessages.push({ msg, threadId });
467
+ if (isOwnerInitiatedMessage(msg))
468
+ this.onMaintenanceInput?.();
419
469
  return true;
420
470
  }
421
471
  let state = this.workers.get(threadId);
422
472
  if (state?.shuttingDown) {
423
- if (claimRejected)
424
- throw new Error("The Lab executor is stopping; retry this delivery");
425
- return false;
473
+ logger.debug(`[supervisor] Worker shutting down, buffering message for thread=${threadId}`);
474
+ const wasVisibleBusy = this.isWorkerVisiblyBusy(state);
475
+ state.pendingMessages.push(msg);
476
+ this.emitBusyChangeIfNeeded(wasVisibleBusy, state);
477
+ return true;
478
+ }
479
+ if (!labRun && this.restartTimers.has(threadId)) {
480
+ logger.debug(`[supervisor] Worker restart already scheduled for thread=${threadId}`);
481
+ return true;
482
+ }
483
+ if (!labRun && this.recoveryExhaustedThreads.has(threadId) && !durableInserted) {
484
+ logger.debug(`[supervisor] Duplicate work remains dormant after recovery exhaustion: thread=${threadId}`);
485
+ return true;
426
486
  }
427
487
  if (!state) {
488
+ // Check concurrency limit before spawning. Idle workers are only a
489
+ // keep-warm optimization: they must not block a new logical thread from
490
+ // making progress. If the process pool is full, evict the least-recently
491
+ // active idle worker first, and reject only when every slot is busy (or
492
+ // still spawning/shutting down).
428
493
  if (this.getActiveWorkerCount() >= this.maxConcurrentThreads) {
429
- deliverReplyToMessage(this.channelManager, msg, `⚠️ Worker capacity reached (${this.maxConcurrentThreads} active). Please try again later.`)
430
- .catch(() => { });
431
- return claimRejected;
494
+ const evicted = this.evictLeastRecentlyActiveIdleWorker(threadId);
495
+ if (!evicted && this.getActiveWorkerCount() >= this.maxConcurrentThreads) {
496
+ if (labRun) {
497
+ // LabRun has its own Fin-app lease and is intentionally not stored
498
+ // in the ordinary thread queue. Preserve the pre-existing explicit
499
+ // failure contract instead of claiming work that was never saved.
500
+ logger.warn(`[supervisor] Concurrency limit reached (${this.maxConcurrentThreads}), rejecting LabRun thread=${threadId}`);
501
+ deliverReplyToMessage(this.channelManager, msg, `⚠️ Thread concurrency limit reached (${this.maxConcurrentThreads} active). Please try again later.`).catch(() => { });
502
+ return claimRejected;
503
+ }
504
+ logger.info(`[supervisor] Concurrency limit reached (${this.maxConcurrentThreads}); keeping thread=${threadId} queued`);
505
+ this.publishSyntheticActivity(threadId, "queued", "Waiting for a worker slot", [msg]);
506
+ // The message is already durably accepted in queue.db. Do not leak
507
+ // it into the default thread or ask the provider to retry.
508
+ return true;
509
+ }
432
510
  }
433
511
  state = this.spawnWorker(threadId, { bootstrapMessage: msg });
434
512
  }
513
+ // Worker still spawning: buffer the message; flushed on `ready`.
435
514
  if (!state.ready) {
515
+ logger.debug(`[supervisor] Worker spawning, buffering message for thread=${threadId}`);
436
516
  const wasVisibleBusy = this.isWorkerVisiblyBusy(state);
437
517
  state.pendingMessages.push(msg);
518
+ // Check all pending messages: only publish status if at least one is owner-initiated
519
+ this.publishSyntheticActivity(threadId, "queued", "Starting thread worker", state.pendingMessages);
438
520
  this.emitBusyChangeIfNeeded(wasVisibleBusy, state);
439
521
  return true;
440
522
  }
@@ -442,11 +524,28 @@ export class ThreadSupervisor {
442
524
  return true;
443
525
  }
444
526
  getActiveWorkerCount() {
445
- // Persisted process records include groups whose leader exited but whose
446
- // stop has not been proved. Neither they nor stopping workers free a slot.
447
- const untracked = this.executionStore?.liveExecutions()
448
- .filter((record) => !this.workers.has(record.executionId)).length ?? this.pendingGroupReaps.size;
449
- return this.workers.size + untracked;
527
+ let count = 0;
528
+ for (const state of this.workers.values()) {
529
+ if (!state.shuttingDown)
530
+ count += 1;
531
+ }
532
+ return count;
533
+ }
534
+ evictLeastRecentlyActiveIdleWorker(requestedThreadId) {
535
+ let candidate = null;
536
+ for (const state of this.workers.values()) {
537
+ // An internal executor between turns is not idle keep-warm capacity.
538
+ if (!state.ready || state.busy || state.shuttingDown || state.internalExecution)
539
+ continue;
540
+ if (!candidate || Date.parse(state.lastActivityAt) < Date.parse(candidate.lastActivityAt)) {
541
+ candidate = state;
542
+ }
543
+ }
544
+ if (!candidate)
545
+ return false;
546
+ logger.system(`[supervisor] Evicting idle worker thread=${candidate.threadId} to make room for thread=${requestedThreadId}`);
547
+ this.shutdownWorker(candidate.threadId, "idle-evicted");
548
+ return true;
450
549
  }
451
550
  /**
452
551
  * Switch a thread's engine: persist the model/provider into thread.json, then
@@ -465,7 +564,6 @@ export class ThreadSupervisor {
465
564
  return;
466
565
  }
467
566
  // Restart the running worker so it re-reads thread.json on next spawn.
468
- void this.conversations?.stop(threadId);
469
567
  const state = this.workers.get(threadId);
470
568
  if (state) {
471
569
  this.shutdownWorker(threadId, "engine-switch");
@@ -478,47 +576,6 @@ export class ThreadSupervisor {
478
576
  startExecutions() {
479
577
  this.executionScheduler?.start();
480
578
  }
481
- /** Foreground turns have their own admission pool; execution slots are unrelated. */
482
- acquireConversationTurn(signal) {
483
- return this.conversations?.acquireTurn(signal) ?? Promise.resolve(() => undefined);
484
- }
485
- handleConversationEvent(conversationId, event, reply) {
486
- if (event.type === "reply") {
487
- if (!isDeliveryTarget(event.target)
488
- || (event.ownerEscalation === true && !this.isOwnerEscalationTarget(event.target))) {
489
- if (event.replyId)
490
- reply({ type: "reply-result", replyId: event.replyId, ok: false, errorMessage: "Invalid conversation reply target" });
491
- return;
492
- }
493
- void this.channelManager.deliver(event.target, event.message, event.attachments, {
494
- businessThreadId: event.ownerEscalation ? DEFAULT_THREAD_ID : conversationId,
495
- ...(event.kind ? { kind: event.kind } : {}),
496
- }).then(() => {
497
- this.replyEscalation.success(conversationId);
498
- if (event.replyId)
499
- reply({ type: "reply-result", replyId: event.replyId, ok: true });
500
- }).catch((error) => {
501
- const message = error instanceof Error ? error.message : String(error);
502
- if (event.replyId)
503
- reply({ type: "reply-result", replyId: event.replyId, ok: false, errorMessage: message });
504
- if (this.replyEscalation.failure(conversationId))
505
- void this.alertOwnerOfThreadDeliveryFailure(conversationId, event.target, message);
506
- });
507
- }
508
- else if (event.type === "typing") {
509
- if (isDeliveryTarget(event.target))
510
- void this.channelManager.sendTyping(event.target).catch(() => { });
511
- }
512
- else if (event.type === "status" && event.activity) {
513
- this.latestActivities.set(conversationId, event.activity);
514
- this.channelManager.publishThreadActivity(conversationId, event.activity, this.agentInstanceId);
515
- }
516
- else if (event.type === "feedback") {
517
- const persisted = this.feedbackOutbox.enqueue(event.feedback) ?? this.feedbackOutbox.getPersisted(event.feedback.id);
518
- if (persisted)
519
- this.onFeedback?.(persisted);
520
- }
521
- }
522
579
  /**
523
580
  * The execution API for trusted in-process callers. Submissions are
524
581
  * accepted only after the command, record and snapshot are durable.
@@ -528,14 +585,12 @@ export class ThreadSupervisor {
528
585
  if (!store)
529
586
  return null;
530
587
  return {
531
- atomic: (operation) => store.atomic(operation),
532
- attemptsForTask: (taskId, scope) => store.attemptsForTask(taskId, scope),
533
588
  submit: (envelope, input) => {
534
589
  const executionId = `exec-${randomUUID()}`;
535
590
  const outcome = store.submit(envelope, {
536
591
  ...input,
537
592
  executionId,
538
- snapshot: this.signExecutionSnapshotFor(executionId, input.budget, envelope.caller),
593
+ snapshot: this.signExecutionSnapshotFor(executionId),
539
594
  });
540
595
  if (outcome.kind === "ok" && !outcome.replay) {
541
596
  if (this.maintenancePaused)
@@ -622,11 +677,21 @@ export class ThreadSupervisor {
622
677
  : !this.maintenancePaused;
623
678
  if (!admitted)
624
679
  return 0;
625
- const parent = record.payload.parentTaskId ?? record.taskId;
626
- const perTask = this.config.workers?.maxConcurrentPerTask ?? this.maxConcurrentThreads;
627
- const live = this.executionStore?.liveExecutions() ?? [];
628
- if (live.filter((item) => (item.payload.parentTaskId ?? item.taskId) === parent).length >= perTask)
629
- return 0;
680
+ // Foreman tasks share the overall pool but never take more than their cap.
681
+ // A stopping executor still counts: its slot frees only once it exits.
682
+ if (record.caller.kind === "general") {
683
+ let foremanWorkers = 0;
684
+ for (const state of this.workers.values())
685
+ if (state.foreman)
686
+ foremanWorkers += 1;
687
+ if (foremanWorkers >= MAX_PARALLEL_FOREMAN_TASKS)
688
+ return 0;
689
+ }
690
+ const free = this.maxConcurrentThreads - this.getActiveWorkerCount();
691
+ // An idle keep-warm worker yields its slot rather than holding admission
692
+ // until its idle timeout.
693
+ if (free > 0 || !this.evictLeastRecentlyActiveIdleWorker(record.executionId))
694
+ return free;
630
695
  return this.maxConcurrentThreads - this.getActiveWorkerCount();
631
696
  }
632
697
  /**
@@ -835,9 +900,6 @@ export class ThreadSupervisor {
835
900
  * Spawn a worker process for the given threadId.
836
901
  */
837
902
  spawnWorker(threadId, options = {}) {
838
- if (!options.execution && !isLabRunThreadId(threadId)) {
839
- throw new Error("Only admitted Tasks and LabRuns may start worker processes");
840
- }
841
903
  logger.system(`[supervisor] Spawning worker for thread=${threadId}`);
842
904
  const threadMeta = ensureThreadApplication(threadId, options.bootstrapMessage?.app);
843
905
  threadApplication(threadId, this.config);
@@ -898,6 +960,7 @@ export class ThreadSupervisor {
898
960
  busy: false,
899
961
  taskDescription: "spawning",
900
962
  lastActivityAt: new Date().toISOString(),
963
+ idleTimer: null,
901
964
  shuttingDown: false,
902
965
  pendingMessages: [],
903
966
  internalExecution,
@@ -946,13 +1009,16 @@ export class ThreadSupervisor {
946
1009
  this.handleReply(state, data);
947
1010
  break;
948
1011
  case "typing":
1012
+ this.handleTyping(state, data);
949
1013
  break;
950
1014
  case "status":
951
1015
  this.handleStatus(state, data);
952
1016
  break;
953
1017
  case "feedback":
1018
+ this.handleFeedback(state, data);
954
1019
  break;
955
1020
  case "turn-complete":
1021
+ this.handleTurnComplete(state, data);
956
1022
  break;
957
1023
  case "execution-bootstrap-ready":
958
1024
  this.handleExecutionBootstrap(state);
@@ -992,25 +1058,15 @@ export class ThreadSupervisor {
992
1058
  };
993
1059
  if (typeof msg.requestId !== "string")
994
1060
  return;
995
- if (state.internalExecution && msg.command.type === "business_read") {
996
- const expected = state.execution;
997
- const record = expected ? this.executionStore?.get(expected.executionId) : null;
998
- if (!record || state.shuttingDown || record.state !== "running" || record.caller.kind !== "general"
999
- || record.workerInstanceId !== expected?.generation || !record.payload.businessRead || !this.handleForemanBusinessRead) {
1000
- answer({ ok: false, text: "Business reads require a live authorized Foreman execution" });
1001
- return;
1002
- }
1003
- const workspace = record.payload.workspaceRef ?? path.join(getThreadDir(state.threadId), "workspace");
1004
- this.handleForemanBusinessRead(record.executionId, msg.command, workspace).then((result) => {
1005
- const current = this.executionStore?.get(record.executionId);
1006
- if (current?.state === "running" && current.workerInstanceId === expected.generation)
1007
- answer(result);
1008
- else
1009
- answer({ ok: false, text: "The execution stopped before the business read completed" });
1010
- }).catch((error) => answer({ ok: false, text: `Business read failed: ${error instanceof Error ? error.message : String(error)}` }));
1061
+ if (state.internalExecution || !this.handleForemanCommand) {
1062
+ answer({ ok: false, text: "Error: background tasks are not available in this conversation." });
1011
1063
  return;
1012
1064
  }
1013
- answer({ ok: false, text: "Internal executions cannot delegate or control other Tasks" });
1065
+ this.handleForemanCommand(state.threadId, msg.command)
1066
+ .then(answer)
1067
+ .catch((error) => {
1068
+ answer({ ok: false, text: `Error: ${error instanceof Error ? error.message : String(error)}` });
1069
+ });
1014
1070
  }
1015
1071
  /**
1016
1072
  * The executor is listening: hand it the admitted record from the ledger.
@@ -1033,15 +1089,22 @@ export class ThreadSupervisor {
1033
1089
  type: "execution-start",
1034
1090
  record,
1035
1091
  unfinishedTools: store.unfinishedToolCalls(record.executionId),
1036
- // input_applied is a session handoff cursor, not a durable provider
1037
- // receipt. A crash between that cursor and the SDK's transcript write
1038
- // must not erase an accepted answer. Replay the authoritative history.
1039
- pendingInputs: store.pendingInputs(record.executionId, 0),
1092
+ pendingInputs: store.pendingInputs(record.executionId, record.processedInputSeq),
1040
1093
  }, (error) => {
1041
1094
  if (error)
1042
1095
  logger.debug(`[supervisor] Execution start IPC unavailable for ${state.threadId}: ${error.message}`);
1043
1096
  });
1044
1097
  }
1098
+ handleFeedback(state, msg) {
1099
+ logger.debug(`[supervisor] Feedback received from thread=${state.threadId}: ${msg.feedback.id}`);
1100
+ const persisted = this.feedbackOutbox.enqueue(msg.feedback)
1101
+ ?? this.feedbackOutbox.getPersisted(msg.feedback.id);
1102
+ if (!persisted)
1103
+ return;
1104
+ this.onFeedback?.(persisted);
1105
+ // Main HeartbeatManager will discover this durable item on its next tick.
1106
+ logger.debug(`[supervisor] Persisted feedback from thread=${state.threadId}: ${persisted.id}`);
1107
+ }
1045
1108
  handleLabRuntimeBootstrapReady(state) {
1046
1109
  if (!state.labRuntimePolicy || !isLabRunThreadId(state.threadId)) {
1047
1110
  logger.warn(`[supervisor] Refusing Lab credential bootstrap for non-Lab thread=${state.threadId}`);
@@ -1119,7 +1182,47 @@ export class ThreadSupervisor {
1119
1182
  }
1120
1183
  }
1121
1184
  handleReply(state, msg) {
1122
- this.sendReplyResult(state, msg.replyId, false, "Internal worker executions cannot deliver external messages");
1185
+ if (state.internalExecution) {
1186
+ this.sendReplyResult(state, msg.replyId, false, "Internal worker executions cannot deliver external messages");
1187
+ return;
1188
+ }
1189
+ if (!isDeliveryTarget(msg.target)) {
1190
+ const errorMessage = "worker reply contained an invalid target";
1191
+ logger.warn(`[supervisor] ${errorMessage}`);
1192
+ this.sendReplyResult(state, msg.replyId, false, errorMessage);
1193
+ return;
1194
+ }
1195
+ // A worker's sends belong to its thread, so mail adapters record provider
1196
+ // replies against it. An owner escalation must not: the owner's answer is
1197
+ // private to the owner's own (default) conversation. The marker is only
1198
+ // honored for a real owner target resolved here, in the main process.
1199
+ let businessThreadId = state.threadId;
1200
+ if (msg.ownerEscalation === true) {
1201
+ if (!this.isOwnerEscalationTarget(msg.target)) {
1202
+ const errorMessage = "owner escalation target is not a bound owner target";
1203
+ logger.warn(`[supervisor] Refused reply from thread=${state.threadId}: ${errorMessage}`);
1204
+ this.sendReplyResult(state, msg.replyId, false, errorMessage);
1205
+ return;
1206
+ }
1207
+ businessThreadId = DEFAULT_THREAD_ID;
1208
+ }
1209
+ logger.debug(`[supervisor] Relaying reply from channel=${msg.target.channel}`);
1210
+ this.channelManager.deliver(msg.target, msg.message, msg.attachments, {
1211
+ businessThreadId,
1212
+ ...(msg.kind ? { kind: msg.kind } : {}),
1213
+ })
1214
+ .then(() => {
1215
+ this.replyEscalation.success(state.threadId);
1216
+ this.sendReplyResult(state, msg.replyId, true);
1217
+ })
1218
+ .catch((err) => {
1219
+ const errorMessage = err instanceof Error ? err.message : String(err);
1220
+ logger.warn(`[supervisor] Failed to relay reply: ${errorMessage}`);
1221
+ this.sendReplyResult(state, msg.replyId, false, errorMessage);
1222
+ if (this.replyEscalation.failure(state.threadId)) {
1223
+ void this.alertOwnerOfThreadDeliveryFailure(state.threadId, msg.target, errorMessage);
1224
+ }
1225
+ });
1123
1226
  }
1124
1227
  /** True when `target` is one of the owner's usable bound targets (no reply context). */
1125
1228
  isOwnerEscalationTarget(target) {
@@ -1175,6 +1278,17 @@ export class ThreadSupervisor {
1175
1278
  logger.debug(`[supervisor] Could not ack reply ${replyId}: ${err instanceof Error ? err.message : String(err)}`);
1176
1279
  }
1177
1280
  }
1281
+ handleTyping(state, msg) {
1282
+ if (state.internalExecution)
1283
+ return;
1284
+ if (!isDeliveryTarget(msg.target)) {
1285
+ logger.warn("[supervisor] Worker typing contained an invalid target");
1286
+ return;
1287
+ }
1288
+ this.channelManager.sendTyping(msg.target).catch(() => {
1289
+ // best-effort
1290
+ });
1291
+ }
1178
1292
  handleStatus(state, msg) {
1179
1293
  if (msg.activity && !state.privateActivity) {
1180
1294
  const current = state.activity;
@@ -1205,6 +1319,34 @@ export class ThreadSupervisor {
1205
1319
  this.executionScheduler?.requestAdmission();
1206
1320
  }
1207
1321
  }
1322
+ handleTurnComplete(state, _msg) {
1323
+ logger.system(`[supervisor] Turn complete from thread=${state.threadId}`);
1324
+ // A completed turn proves the worker is healthy — reset its crash budget so
1325
+ // sporadic crashes spread over a long lifetime don't exhaust the cap.
1326
+ this.crashCounts.delete(state.threadId);
1327
+ this.recoveryExhaustedThreads.delete(state.threadId);
1328
+ // Billing is NOT handled here: the worker's own WakeCycleRunner already
1329
+ // wrote the wake-cycle row into the shared transcript-memory.db (WAL-safe).
1330
+ // Memory dreaming is not triggered here either: it rewrites and deletes
1331
+ // files in the shared memories/ tree, so it runs on the deep-night sweep
1332
+ // only (owned by NightlyMaintenance), not once per wake cycle.
1333
+ this.armIdleTimer(state);
1334
+ this.schedulePendingThreads();
1335
+ this.executionScheduler?.requestAdmission();
1336
+ }
1337
+ armIdleTimer(state) {
1338
+ if (state.idleTimer !== null) {
1339
+ clearTimeout(state.idleTimer);
1340
+ }
1341
+ state.idleTimer = setTimeout(() => {
1342
+ if (state.busy) {
1343
+ logger.debug(`[supervisor] Worker still busy at idle timeout, extending: thread=${state.threadId}`);
1344
+ this.armIdleTimer(state);
1345
+ return;
1346
+ }
1347
+ this.shutdownWorker(state.threadId, "idle-timeout");
1348
+ }, this.idleTimeoutMs);
1349
+ }
1208
1350
  handleWorkerExit(threadId, code, signal) {
1209
1351
  const state = this.workers.get(threadId);
1210
1352
  if (!state)
@@ -1212,6 +1354,11 @@ export class ThreadSupervisor {
1212
1354
  const wasReady = state.ready;
1213
1355
  logger.system(`[supervisor] Worker exited: thread=${threadId} code=${code} signal=${signal} wasReady=${wasReady}`);
1214
1356
  // Clear idle timer.
1357
+ if (state.idleTimer !== null) {
1358
+ clearTimeout(state.idleTimer);
1359
+ state.idleTimer = null;
1360
+ }
1361
+ const pendingMessages = state.pendingMessages;
1215
1362
  if (state.busy && state.activity && !state.shuttingDown) {
1216
1363
  const now = new Date().toISOString();
1217
1364
  const failedActivity = {
@@ -1242,8 +1389,68 @@ export class ThreadSupervisor {
1242
1389
  this.schedulePendingThreads();
1243
1390
  return;
1244
1391
  }
1245
- // A refused Lab bootstrap may exit before registering an execution.
1246
- // Its service owns retries; there are no conversational process respawns.
1392
+ // LabRun workers are single-shot executors. A retry is a new durable run
1393
+ // with a new `fin-lab-run-<runId>` thread; never revive the old session.
1394
+ if (isLabRunThreadId(threadId)) {
1395
+ this.crashCounts.delete(threadId);
1396
+ this.schedulePendingThreads();
1397
+ return;
1398
+ }
1399
+ // Clean exit, or any exit during a requested shutdown (a graceful stop that
1400
+ // ends non-zero is not a crash): don't count towards the crash cap. Respawn
1401
+ // only when messages arrived during the shutdown window — but never after
1402
+ // the supervisor itself has been stopped.
1403
+ if (code === 0 || state.shuttingDown) {
1404
+ if (code === 0)
1405
+ this.crashCounts.delete(threadId);
1406
+ if (pendingMessages.length > 0 && !this.stopped) {
1407
+ this.spawnWorkerForPendingMessages(threadId, pendingMessages, "shutdown-respawn", state.labRuntimePolicy, state.executionSnapshot);
1408
+ }
1409
+ this.schedulePendingThreads();
1410
+ return;
1411
+ }
1412
+ // Crash recovery: restart if crashed unexpectedly and under threshold.
1413
+ const MAX_CRASH_COUNT = 3;
1414
+ const nextCrashCount = wasReady ? (this.crashCounts.get(threadId) ?? 0) + 1 : this.crashCounts.get(threadId) ?? 0;
1415
+ if (wasReady) {
1416
+ this.crashCounts.set(threadId, nextCrashCount);
1417
+ }
1418
+ if (wasReady && nextCrashCount <= MAX_CRASH_COUNT && !this.stopped) {
1419
+ logger.warn(`[supervisor] Worker crashed, restarting (attempt ${nextCrashCount}/${MAX_CRASH_COUNT}): thread=${threadId}`);
1420
+ // Re-spawn after a brief delay.
1421
+ const trustedLabRuntimePolicy = state.labRuntimePolicy;
1422
+ const trustedExecutionSnapshot = state.executionSnapshot;
1423
+ const timer = setTimeout(() => {
1424
+ this.restartTimers.delete(threadId);
1425
+ if (this.stopped || this.maintenancePaused || this.workers.has(threadId))
1426
+ return;
1427
+ try {
1428
+ const next = this.spawnWorker(threadId, {
1429
+ bootstrapMessage: pendingMessages[0],
1430
+ trustedLabRuntimePolicy,
1431
+ trustedExecutionSnapshot,
1432
+ });
1433
+ this.enqueuePendingWork(next, pendingMessages);
1434
+ }
1435
+ catch (error) {
1436
+ this.handleWorkerRespawnFailure(threadId, pendingMessages, "crash-restart", error);
1437
+ }
1438
+ }, this.crashRestartDelayMs);
1439
+ this.restartTimers.set(threadId, timer);
1440
+ this.schedulePendingThreads();
1441
+ return;
1442
+ }
1443
+ // Not restarting: either the worker died before ever becoming ready
1444
+ // (startup failure — restarting would loop) or the crash cap is exhausted.
1445
+ // Tell the senders of any buffered messages instead of dropping silently.
1446
+ if (nextCrashCount > MAX_CRASH_COUNT) {
1447
+ logger.err(`[supervisor] Worker crash limit exceeded, not restarting: thread=${threadId}`);
1448
+ }
1449
+ else {
1450
+ logger.err(`[supervisor] Worker failed during startup, not restarting: thread=${threadId}`);
1451
+ }
1452
+ this.recoveryExhaustedThreads.add(threadId);
1453
+ this.notifyPendingSendersOfFailure(threadId, this.outstandingMessages(threadId, pendingMessages));
1247
1454
  this.schedulePendingThreads();
1248
1455
  }
1249
1456
  /**
@@ -1266,6 +1473,7 @@ export class ThreadSupervisor {
1266
1473
  this.executionScheduler?.handleWorkerExit(state.execution.executionId, code, signal, { hostStopping, groupStopped });
1267
1474
  }
1268
1475
  else if (state.labExecution) {
1476
+ this.crashCounts.delete(state.threadId);
1269
1477
  if (groupStopped) {
1270
1478
  this.confirmLabExecutionStopped(state.labExecution, code);
1271
1479
  }
@@ -1279,12 +1487,96 @@ export class ThreadSupervisor {
1279
1487
  .finally(() => { this.pendingGroupReaps.delete(reap); });
1280
1488
  this.pendingGroupReaps.add(reap);
1281
1489
  }
1490
+ spawnWorkerForPendingMessages(threadId, pending, reason, trustedLabRuntimePolicy, trustedExecutionSnapshot) {
1491
+ if (pending.length === 0)
1492
+ return;
1493
+ if (this.getActiveWorkerCount() >= this.maxConcurrentThreads) {
1494
+ const evicted = this.evictLeastRecentlyActiveIdleWorker(threadId);
1495
+ if (!evicted && this.getActiveWorkerCount() >= this.maxConcurrentThreads) {
1496
+ logger.warn(`[supervisor] Cannot respawn worker for thread=${threadId} after ${reason}: concurrency limit reached`);
1497
+ if (isLabRunThreadId(threadId)) {
1498
+ this.notifyPendingSendersOfFailure(threadId, pending);
1499
+ }
1500
+ this.schedulePendingThreads();
1501
+ return;
1502
+ }
1503
+ }
1504
+ try {
1505
+ const next = this.spawnWorker(threadId, {
1506
+ bootstrapMessage: pending[0],
1507
+ trustedLabRuntimePolicy,
1508
+ trustedExecutionSnapshot,
1509
+ });
1510
+ this.enqueuePendingWork(next, pending);
1511
+ }
1512
+ catch (error) {
1513
+ this.handleWorkerRespawnFailure(threadId, pending, reason, error);
1514
+ }
1515
+ }
1516
+ handleWorkerRespawnFailure(threadId, pending, reason, error) {
1517
+ const message = error instanceof Error ? error.message : String(error);
1518
+ logger.err(`[supervisor] Failed to respawn worker thread=${threadId} after ${reason}: ${message}`);
1519
+ this.publishSyntheticActivity(threadId, "failed", "Worker restart failed");
1520
+ this.recoveryExhaustedThreads.add(threadId);
1521
+ this.notifyPendingSendersOfFailure(threadId, this.outstandingMessages(threadId, pending));
1522
+ }
1523
+ outstandingMessages(threadId, pending) {
1524
+ const combined = [
1525
+ ...pending,
1526
+ ...(this.threadQueues.get(threadId)?.outstandingSnapshot() ?? []),
1527
+ ];
1528
+ const seen = new Set();
1529
+ return combined.filter((message) => {
1530
+ const key = `${message.channel}:${message.sender}:${message.id}`;
1531
+ if (seen.has(key))
1532
+ return false;
1533
+ seen.add(key);
1534
+ return true;
1535
+ });
1536
+ }
1282
1537
  freezeLabRuntimePolicy(envelope) {
1283
1538
  return Object.freeze({
1284
1539
  payload: envelope.payload,
1285
1540
  signature: envelope.signature,
1286
1541
  });
1287
1542
  }
1543
+ publishSyntheticActivity(threadId, state, label, messages) {
1544
+ if (!threadId.startsWith("fin-") || threadId.startsWith("fin-lab-"))
1545
+ return;
1546
+ // Suppress owner-facing status for passive-only wakes (email/external-wake).
1547
+ // Only publish when at least one message is owner-initiated (Telegram/Discord/etc.).
1548
+ if (messages && messages.length > 0 && !messages.some(isOwnerInitiatedMessage)) {
1549
+ logger.debug(`[supervisor] Suppressing synthetic activity for thread=${threadId}: all messages are passive ingress`);
1550
+ return;
1551
+ }
1552
+ const now = new Date().toISOString();
1553
+ const previous = this.latestActivities.get(threadId);
1554
+ const activity = {
1555
+ runId: randomUUID(),
1556
+ sequence: 1,
1557
+ state,
1558
+ currentStep: label,
1559
+ todos: [],
1560
+ updatedAt: now,
1561
+ ...(state === "queued" ? { heartbeatAt: now } : { completedAt: now }),
1562
+ activities: [{ state, label, at: now }],
1563
+ workerInstanceId: previous?.workerInstanceId ?? randomUUID(),
1564
+ };
1565
+ this.latestActivities.set(threadId, activity);
1566
+ this.channelManager.publishThreadActivity(threadId, activity, this.agentInstanceId);
1567
+ }
1568
+ /** Best-effort failure notice to the sender(s) of messages we could not process. */
1569
+ notifyPendingSendersOfFailure(threadId, pending) {
1570
+ const notified = new Set();
1571
+ for (const m of pending) {
1572
+ const key = `${m.channel}:${m.sender}`;
1573
+ if (notified.has(key))
1574
+ continue;
1575
+ notified.add(key);
1576
+ deliverReplyToMessage(this.channelManager, m, `⚠️ Thread "${threadId}" failed to start and your message could not be processed. Please try again later or contact the owner.`)
1577
+ .catch(() => { });
1578
+ }
1579
+ }
1288
1580
  /**
1289
1581
  * Deliver a message to the worker via IPC.
1290
1582
  */
@@ -1330,6 +1622,7 @@ export class ThreadSupervisor {
1330
1622
  ...(snapshot.length > 0 ? { activeThreadsSnapshot: snapshot } : {}),
1331
1623
  };
1332
1624
  state.process.send(ipcMsg);
1625
+ this.armIdleTimer(state);
1333
1626
  }
1334
1627
  authority() {
1335
1628
  return this.executionAuthority;
@@ -1338,15 +1631,11 @@ export class ThreadSupervisor {
1338
1631
  return this.authority().labPolicy.publicKey;
1339
1632
  }
1340
1633
  /** Freeze and sign a new generic execution's snapshot with this host's authority. */
1341
- signExecutionSnapshotFor(executionId, budget = DEFAULT_LAB_RUN_BUDGET, caller) {
1342
- const meta = caller?.kind === "general" ? readThreadMeta(caller.ref) : null;
1343
- const selection = meta?.engineOverride
1344
- ? { engineOverride: meta.engineOverride, pinnedModel: meta.pinnedModel }
1345
- : { engineOverride: this.config.engineOverride, pinnedModel: this.config.pinnedModel };
1634
+ signExecutionSnapshotFor(executionId, budget = DEFAULT_LAB_RUN_BUDGET) {
1346
1635
  return JSON.stringify(signExecutionSnapshot({
1347
1636
  version: EXECUTION_SNAPSHOT_VERSION,
1348
1637
  executionId,
1349
- model: freezeExecutionModel(selection, getLlmRouting(), this.config.provider),
1638
+ model: freezeExecutionModel({ engineOverride: this.config.engineOverride, pinnedModel: this.config.pinnedModel }, getLlmRouting(), this.config.provider),
1350
1639
  budget,
1351
1640
  }, this.authority().snapshot));
1352
1641
  }
@@ -1400,6 +1689,10 @@ export class ThreadSupervisor {
1400
1689
  return;
1401
1690
  state.shuttingDown = true;
1402
1691
  logger.system(`[supervisor] Shutting down worker: thread=${threadId} reason=${reason}`);
1692
+ if (state.idleTimer !== null) {
1693
+ clearTimeout(state.idleTimer);
1694
+ state.idleTimer = null;
1695
+ }
1403
1696
  const shutdownMsg = {
1404
1697
  type: "shutdown",
1405
1698
  reason,
@@ -1434,7 +1727,9 @@ export class ThreadSupervisor {
1434
1727
  logger.system(`[supervisor] Shutting down all workers (${this.workers.size} active)`);
1435
1728
  this.stopped = true;
1436
1729
  this.executionScheduler?.stop();
1437
- const foregroundShutdown = this.conversations?.shutdown();
1730
+ for (const timer of this.restartTimers.values())
1731
+ clearTimeout(timer);
1732
+ this.restartTimers.clear();
1438
1733
  // Initiate graceful shutdown for all workers
1439
1734
  for (const [threadId] of this.workers) {
1440
1735
  this.shutdownWorker(threadId, "supervisor-shutdown");
@@ -1456,7 +1751,7 @@ export class ThreadSupervisor {
1456
1751
  });
1457
1752
  exitPromises.push(exitPromise);
1458
1753
  }
1459
- await Promise.all([foregroundShutdown, ...exitPromises]);
1754
+ await Promise.all(exitPromises);
1460
1755
  // Executor groups are confirmed empty (or left for the next host) before the ledger closes.
1461
1756
  await Promise.all([...this.pendingGroupReaps]);
1462
1757
  for (const queue of this.threadQueues.values()) {
@@ -1475,18 +1770,23 @@ export class ThreadSupervisor {
1475
1770
  this.onBusyChange?.();
1476
1771
  }
1477
1772
  }
1773
+ enqueuePendingWork(state, pending) {
1774
+ if (pending.length === 0)
1775
+ return;
1776
+ const wasVisibleBusy = this.isWorkerVisiblyBusy(state);
1777
+ state.pendingMessages.push(...pending);
1778
+ this.emitBusyChangeIfNeeded(wasVisibleBusy, state);
1779
+ }
1478
1780
  /**
1479
1781
  * Get a snapshot of all active (ready) worker threads for cross-thread
1480
1782
  * awareness. Does not include the main "default" thread.
1481
1783
  */
1482
1784
  getActiveThreadsSnapshot() {
1483
- const result = this.conversations?.overview() ?? [];
1785
+ const result = [];
1484
1786
  for (const [threadId, state] of this.workers) {
1485
1787
  if (state.ready && !state.shuttingDown) {
1486
1788
  result.push({
1487
1789
  threadId,
1488
- kind: state.internalExecution ? "execution" : "conversation",
1489
- ...(state.execution ? { taskId: this.executionStore?.get(state.execution.executionId)?.taskId } : {}),
1490
1790
  busy: state.busy,
1491
1791
  taskDescription: state.taskDescription,
1492
1792
  lastActivityAt: state.lastActivityAt,
@@ -1504,14 +1804,7 @@ export class ThreadSupervisor {
1504
1804
  * contains worker threads.
1505
1805
  */
1506
1806
  refreshMainProcessAwareness() {
1507
- setActiveThreadsSnapshot(this.getConversationTaskSnapshot(DEFAULT_THREAD_ID));
1508
- }
1509
- /** A chat must never receive another chat's task descriptions or inputs. */
1510
- getConversationTaskSnapshot(conversationId) {
1511
- return this.getActiveThreadsSnapshot().filter((item) => {
1512
- const record = this.executionStore?.get(item.threadId);
1513
- return record?.caller.kind === "general" && record.caller.ref === conversationId;
1514
- });
1807
+ setActiveThreadsSnapshot(this.getActiveThreadsSnapshot());
1515
1808
  }
1516
1809
  /**
1517
1810
  * Obs-facing overview of all worker threads (including still-spawning ones),
@@ -1519,14 +1812,10 @@ export class ThreadSupervisor {
1519
1812
  * which is the lightweight cross-thread awareness feed (ready workers only).
1520
1813
  */
1521
1814
  getThreadsOverview() {
1522
- const result = (this.conversations?.overview() ?? []).map((item) => ({
1523
- ...item, engine: formatThreadEngineLabel(item.threadId, this.config),
1524
- }));
1815
+ const result = [];
1525
1816
  for (const [threadId, state] of this.workers) {
1526
1817
  result.push({
1527
1818
  threadId,
1528
- kind: state.internalExecution ? "execution" : "conversation",
1529
- ...(state.execution ? { taskId: this.executionStore?.get(state.execution.executionId)?.taskId } : {}),
1530
1819
  // A spawning worker with buffered work is already active even before
1531
1820
  // its first busy status IPC arrives.
1532
1821
  busy: this.isWorkerVisiblyBusy(state),