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
@@ -0,0 +1,896 @@
1
+ /**
2
+ * ThreadTaskRuntime (issue #1996, PR 3): the one object the live loop talks
3
+ * to. It owns this process's {@link ThreadTaskStore} and
4
+ * {@link ThreadTaskController} for the thread it serves — the default thread
5
+ * in the main process, a business thread in its worker.
6
+ *
7
+ * Hot-path rule: every method is fail-safe. A store, controller or model
8
+ * error is logged and swallowed; it must never break message handling, a
9
+ * wake or a reply. The cost of a failure is a missed task update that the
10
+ * next run can still recover from the persisted events, never a lost message.
11
+ */
12
+ import { randomUUID } from "node:crypto";
13
+ import path from "node:path";
14
+ import { getConfigDir } from "../config/index.js";
15
+ import { DEFAULT_THREAD_ID } from "../channels/interface.js";
16
+ import { getMessageDedupKey } from "../channels/message-dedup.js";
17
+ import { EXTERNAL_WAKE_KIND } from "../channels/queue.js";
18
+ import { logger } from "../logger.js";
19
+ import { OPEN_TASK_STATUSES } from "./task-contracts.js";
20
+ import { createSidecarModel } from "./task-sidecar.js";
21
+ import { ThreadTaskStore, messageEventKey } from "./task-store.js";
22
+ import { ThreadTaskController } from "./thread-task-controller.js";
23
+ import { renderThreadTaskContext } from "./thread-task-context.js";
24
+ import { taskContinuationRouting } from "../agent/reply-target.js";
25
+ /** Execution events are batched harder than owner messages; each run is a model call. */
26
+ const LIVE_CONTROLLER_DEFAULTS = {
27
+ batchDelayMs: 30_000,
28
+ maxBatchEvents: 40,
29
+ callTimeoutMs: 60_000,
30
+ budget: { maxCalls: 12, windowMs: 10 * 60_000 },
31
+ // Execution events alone never use the last 4 calls of the window, so an
32
+ // owner message still gets a run during a long busy wake.
33
+ ownerReserve: 4,
34
+ };
35
+ /**
36
+ * How long a fast reply waits for the sidecar before it settles for
37
+ * "received". Added in front of the fast responder's own 5 s budget.
38
+ */
39
+ export const REGISTRATION_WAIT_MS = 6_000;
40
+ /**
41
+ * How long a lifecycle boundary waits for the sidecar to catch up (#1996 PR 4).
42
+ * Wake end is short because the next message waits behind it; compaction
43
+ * and refresh are already slow.
44
+ */
45
+ export const RECONCILE_WAIT_MS = { wakeEnd: 5_000, boundary: 10_000 };
46
+ /** Newest unfolded events read for the context block. */
47
+ const UNFOLDED_CONTEXT_EVENTS = 200;
48
+ /** `meta.kind` of a queued resume (PR 5). A system row: no fast reply, watchdog or gate reply. */
49
+ export const TASK_RESUME_KIND = "thread-task-resume";
50
+ const RESUME_SENDER = "thread-task-resume";
51
+ /**
52
+ * At most this many resumes are queued per pass. One wake works one task at
53
+ * a time; the next pass after it queues the next one.
54
+ */
55
+ const RESUMES_PER_PASS = 1;
56
+ /** Unfolded events read at `/stop` to tell pre-stop tasks from later ones. */
57
+ const STOP_EVENTS = 1_000;
58
+ /** A resume pass waits for the sidecar to catch up; if it cannot, it retries after this. */
59
+ export const RESUME_RETRY_MS = 60_000;
60
+ function errorMessage(error) {
61
+ return error instanceof Error ? error.message : String(error);
62
+ }
63
+ export class ThreadTaskRuntime {
64
+ threadId;
65
+ store;
66
+ controller;
67
+ /**
68
+ * Latest event seq when this process started. A task whose last committed
69
+ * change came from events up to here was moved by a previous process.
70
+ */
71
+ startSeq;
72
+ /** Resumes this process accepted, by task id: a resume runs once per task per process. Released when its message goes back on the queue. */
73
+ resumesAccepted = new Set();
74
+ closed = false;
75
+ resumeHooks = null;
76
+ resumeRetry = null;
77
+ resumeRunning = false;
78
+ constructor(threadId, store, controller, startSeq, inProgressAtStart) {
79
+ this.threadId = threadId;
80
+ this.store = store;
81
+ this.controller = controller;
82
+ this.startSeq = startSeq;
83
+ if (inProgressAtStart > 0) {
84
+ logger.system(`[thread-tasks] thread=${threadId}: ${inProgressAtStart} task(s) were in progress when the previous process stopped`);
85
+ }
86
+ }
87
+ /**
88
+ * Open the thread's store next to its queue.db, take the writer slot and
89
+ * start the controller (which picks up events a previous process left).
90
+ * Returns null instead of throwing: without a runtime the agent runs
91
+ * exactly as before.
92
+ */
93
+ static start(options) {
94
+ let store;
95
+ try {
96
+ store = new ThreadTaskStore({
97
+ threadId: options.threadId,
98
+ writerId: options.writerId,
99
+ // Same file the MessageQueue owner opens, whichever process this is.
100
+ dbPath: options.dbPath ?? path.join(getConfigDir(), "queue.db"),
101
+ claimWriter: true,
102
+ });
103
+ // Read before the controller starts: it may move these tasks on, and
104
+ // nothing after start() may throw and leave its timer running.
105
+ const startSeq = store.latestSeq();
106
+ const inProgressAtStart = store.listTasks(["in_progress"]).length;
107
+ const controller = new ThreadTaskController({
108
+ store,
109
+ model: options.model ?? createSidecarModel(options.config),
110
+ ...LIVE_CONTROLLER_DEFAULTS,
111
+ ...options.controller,
112
+ });
113
+ const runtime = new ThreadTaskRuntime(options.threadId, store, controller, startSeq, inProgressAtStart);
114
+ controller.start();
115
+ return runtime;
116
+ }
117
+ catch (error) {
118
+ logger.err(`[thread-tasks] disabled for thread=${options.threadId}: ${errorMessage(error)}`);
119
+ try {
120
+ store?.close();
121
+ }
122
+ catch {
123
+ // Already closed or never opened.
124
+ }
125
+ return null;
126
+ }
127
+ }
128
+ /**
129
+ * Persist owner messages as events. Idempotent per message (queue dedup
130
+ * key), so a redelivered message, the watchdog and the wake path can all
131
+ * call it. Returns false if any message could not be recorded.
132
+ */
133
+ recordMessages(messages, options = {}) {
134
+ if (this.closed || messages.length === 0)
135
+ return !this.closed;
136
+ let ok = true;
137
+ let appended = false;
138
+ for (const msg of messages) {
139
+ try {
140
+ appended = this.store.appendMessageEvent(msg).appended || appended;
141
+ }
142
+ catch (error) {
143
+ ok = false;
144
+ logger.err(`[thread-tasks] could not record message ${msg.id} thread=${this.threadId}: ${errorMessage(error)}`);
145
+ }
146
+ }
147
+ if (appended && options.notify !== false)
148
+ this.safeNotify(true);
149
+ return ok;
150
+ }
151
+ /** Persist an execution event (response, tool result, todo update, status report). */
152
+ recordExecution(input) {
153
+ if (this.closed)
154
+ return;
155
+ try {
156
+ if (this.store.appendEvent(input).appended)
157
+ this.safeNotify(false);
158
+ }
159
+ catch (error) {
160
+ logger.warn(`[thread-tasks] could not record ${input.kind} thread=${this.threadId}: ${errorMessage(error)}`);
161
+ }
162
+ }
163
+ /**
164
+ * Record the messages, then wait up to `waitMs` for the sidecar to consume
165
+ * them. `registered` lists the open tasks the commit tied to these messages;
166
+ * empty means a reply may only acknowledge receipt.
167
+ *
168
+ * A run that started before these events cannot commit them (it snapshotted
169
+ * its batch), and a batch is a contiguous prefix, so the message may need
170
+ * more than one run: an execution-event backlog in front of it is folded
171
+ * first. The loop therefore waits for this message's events to be consumed,
172
+ * not for a single run, and stops as soon as the deadline passes or a run
173
+ * fails (the controller keeps retrying those events on its own schedule).
174
+ */
175
+ async registerMessages(messages, waitMs = REGISTRATION_WAIT_MS) {
176
+ const recorded = this.recordMessages(messages, { notify: false });
177
+ if (!recorded || this.closed)
178
+ return { recorded, registered: [] };
179
+ try {
180
+ const target = this.store.latestSeq();
181
+ const deadline = Date.now() + waitMs;
182
+ while (!this.isClosed() && this.store.getPosition() < target) {
183
+ const remaining = deadline - Date.now();
184
+ if (remaining <= 0)
185
+ break;
186
+ let timer;
187
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => { resolve("timeout"); }, remaining); });
188
+ const outcome = await Promise.race([this.controller.runOnce(), timeout]).finally(() => { clearTimeout(timer); });
189
+ // Only a commit or a conflict re-read is worth another pass; anything
190
+ // else (deadline, budget, fence, failure) is retried by the controller.
191
+ if (outcome === "timeout" || (outcome.status !== "committed" && outcome.status !== "conflict"))
192
+ break;
193
+ }
194
+ return { recorded, registered: this.tasksForMessages(messages) };
195
+ }
196
+ catch (error) {
197
+ logger.warn(`[thread-tasks] registration run failed thread=${this.threadId}: ${errorMessage(error)}`);
198
+ return { recorded, registered: [] };
199
+ }
200
+ }
201
+ /** `close()` may run while a wait awaits; a method read is not narrowed away. */
202
+ isClosed() {
203
+ return this.closed;
204
+ }
205
+ /** Open tasks already tied to any of these messages (no model call). */
206
+ tasksForMessages(messages) {
207
+ try {
208
+ const eventIds = new Set();
209
+ for (const msg of messages) {
210
+ const event = this.store.getEventByKey(messageEventKey(getMessageDedupKey(msg)));
211
+ if (event)
212
+ eventIds.add(event.id);
213
+ }
214
+ if (eventIds.size === 0)
215
+ return [];
216
+ return this.store.listOpenTasks().filter((task) => task.sourceEventIds.some((id) => eventIds.has(id)));
217
+ }
218
+ catch (error) {
219
+ logger.warn(`[thread-tasks] lookup failed thread=${this.threadId}: ${errorMessage(error)}`);
220
+ return [];
221
+ }
222
+ }
223
+ /** Tasks of this thread for the query tool; `[]` on error. */
224
+ listTasks(statuses = OPEN_TASK_STATUSES) {
225
+ try {
226
+ return this.store.listTasks(statuses);
227
+ }
228
+ catch (error) {
229
+ logger.warn(`[thread-tasks] list failed thread=${this.threadId}: ${errorMessage(error)}`);
230
+ return [];
231
+ }
232
+ }
233
+ /**
234
+ * Let the sidecar fold every event up to the latest one (the explicit
235
+ * position of this boundary), waiting at most `waitMs`. No events past the
236
+ * position means no model call. On timeout, budget exhaustion or failure
237
+ * the events simply stay unconsumed: the controller keeps retrying them,
238
+ * and {@link contextBlock} shows them raw to the next recovery.
239
+ */
240
+ async reconcile(phase, waitMs) {
241
+ let target = 0;
242
+ let position = 0;
243
+ try {
244
+ target = this.store.latestSeq();
245
+ position = this.store.getPosition();
246
+ const deadline = Date.now() + waitMs;
247
+ while (!this.closed && position < target) {
248
+ const remaining = deadline - Date.now();
249
+ if (remaining <= 0)
250
+ break;
251
+ let timer;
252
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => { resolve("timeout"); }, remaining); });
253
+ const outcome = await Promise.race([this.controller.runOnce(), timeout]).finally(() => { clearTimeout(timer); });
254
+ position = this.store.getPosition();
255
+ // Only a commit or a conflict re-read is worth another pass; anything
256
+ // else (timeout, budget, failure, fence) is retried by the controller.
257
+ if (outcome === "timeout" || (outcome.status !== "committed" && outcome.status !== "conflict"))
258
+ break;
259
+ }
260
+ }
261
+ catch (error) {
262
+ logger.warn(`[thread-tasks] ${phase} reconcile failed thread=${this.threadId}: ${errorMessage(error)}`);
263
+ }
264
+ if (position < target) {
265
+ logger.info(`[thread-tasks] ${phase} reconcile left ${target - position} event seq(s) unfolded thread=${this.threadId}`);
266
+ }
267
+ return { reached: position >= target, target, position };
268
+ }
269
+ /**
270
+ * Tasks a previous process left `in_progress`: their last change came from
271
+ * events recorded before this process started. That includes a task those
272
+ * events only move to `in_progress` when this process folds them at
273
+ * startup (the sidecar batches, so a crash usually leaves some). Once this
274
+ * process's own events move the task, it is no longer interrupted.
275
+ * TODO: a startup fold whose batch also holds events of this process
276
+ * stamps the task with the later seq and drops the flag early.
277
+ */
278
+ interruptedTaskIds() {
279
+ return new Set(this.store.listTasks(["in_progress"])
280
+ .filter((task) => task.lastEventSeq <= this.startSeq)
281
+ .map((task) => task.id));
282
+ }
283
+ /** The recovery context for this thread, or null when it owes nothing. Never throws. */
284
+ contextBlock() {
285
+ try {
286
+ const position = this.store.getPosition();
287
+ return renderThreadTaskContext({
288
+ openTasks: this.store.listOpenTasks(),
289
+ unfoldedEvents: this.store.readLatestEventsAfter(position, UNFOLDED_CONTEXT_EVENTS),
290
+ unfoldedTotal: this.store.latestSeq() - position,
291
+ interruptedTaskIds: this.interruptedTaskIds(),
292
+ });
293
+ }
294
+ catch (error) {
295
+ logger.warn(`[thread-tasks] context unavailable thread=${this.threadId}: ${errorMessage(error)}`);
296
+ return null;
297
+ }
298
+ }
299
+ /**
300
+ * Queue a resume for the next runnable task (PR 5), so the thread keeps
301
+ * working without a new owner message. Crash-safe in three steps:
302
+ * reserve the delivery record (`recovery:<task>:v<version>`), enqueue a
303
+ * system message whose id is that key, then mark it delivered. A crash
304
+ * before the enqueue leaves a reservation that the next pass re-sends; a
305
+ * crash after it is harmless because the queue dedups the id. A resume
306
+ * that was already delivered is never queued again for the same version.
307
+ *
308
+ * Never throws. Returns what it queued.
309
+ */
310
+ deliverResumes(queue, now = Date.now()) {
311
+ if (this.closed)
312
+ return { queued: [] };
313
+ const queued = [];
314
+ try {
315
+ // Re-send reservations a previous pass made but did not deliver.
316
+ for (const record of this.store.listUndeliveredRecoveries()) {
317
+ const task = this.store.getTask(record.taskId);
318
+ if (this.enqueueResume(queue, record.idempotencyKey, task, record.taskVersion))
319
+ queued.push(record.idempotencyKey);
320
+ }
321
+ if (queued.length < RESUMES_PER_PASS) {
322
+ // Work the previous process left in progress comes first. Its resume
323
+ // may have been replayed already (then it is accepted and skipped).
324
+ const interrupted = [...this.interruptedTaskIds()]
325
+ .filter((id) => !this.resumesAccepted.has(id))
326
+ .map((id) => this.store.getTask(id))
327
+ .filter((task) => task !== undefined);
328
+ for (const task of [...interrupted, ...this.store.listRunnableTasks(now)]) {
329
+ if (queued.length >= RESUMES_PER_PASS)
330
+ break;
331
+ if (hasNoRemainingWork(task))
332
+ continue;
333
+ const reservation = this.store.reserveRecoveryDelivery({ taskId: task.id, taskVersion: task.version });
334
+ if (!reservation.ok) {
335
+ logger.warn(`[thread-tasks] resume not reserved thread=${this.threadId}: ${reservation.reason}`);
336
+ break;
337
+ }
338
+ // Already delivered for this version: a wake took it and made no
339
+ // recorded progress. Do not loop on it; progress bumps the version.
340
+ if (!reservation.value.reserved)
341
+ continue;
342
+ if (this.enqueueResume(queue, reservation.value.record.idempotencyKey, task, task.version)) {
343
+ queued.push(reservation.value.record.idempotencyKey);
344
+ }
345
+ }
346
+ }
347
+ return { queued };
348
+ }
349
+ catch (error) {
350
+ logger.warn(`[thread-tasks] resume pass failed thread=${this.threadId}: ${errorMessage(error)}`);
351
+ return { queued };
352
+ }
353
+ }
354
+ /**
355
+ * Whether a dequeued resume may still run (the re-check before execution).
356
+ * Yes when its task is still pending at the version it was queued for and
357
+ * is due: a cancel, modification, wait or block since then makes it stale.
358
+ * Also yes, once per task per process, when a previous process left the
359
+ * task `in_progress` ({@link interruptedTaskIds}): that is a resume replayed
360
+ * after a crash, or one queued for the interrupted task. Its version is not
361
+ * compared, because folding the previous process's last events at startup
362
+ * bumps it.
363
+ *
364
+ * The "once" test is against {@link resumesAccepted}, which a requeue
365
+ * releases ({@link releaseResume}): an accepted resume whose message went
366
+ * back on the queue must still run when it is dequeued again, or the
367
+ * interrupted task keeps its only recovery row forever.
368
+ * Fail-closed: any error means no.
369
+ */
370
+ isResumeRunnable(msg, now = Date.now()) {
371
+ try {
372
+ const taskId = msg.meta?.taskId;
373
+ const taskVersion = msg.meta?.taskVersion;
374
+ if (typeof taskId !== "string" || typeof taskVersion !== "number")
375
+ return false;
376
+ const task = this.store.getTask(taskId);
377
+ if (!task || hasNoRemainingWork(task))
378
+ return false;
379
+ // Left in progress by a previous process: once, whatever the version.
380
+ if (task.status === "in_progress" && this.interruptedTaskIds().has(task.id)) {
381
+ return !this.resumesAccepted.has(task.id);
382
+ }
383
+ // Pending, or unfinished by an earlier wake of this process: unchanged
384
+ // since it was queued. Any progress, wait, block or cancel bumps the version.
385
+ return (task.status === "pending" || task.status === "in_progress")
386
+ && task.version === taskVersion
387
+ && (task.runAfter === undefined || task.runAfter <= now);
388
+ }
389
+ catch (error) {
390
+ logger.warn(`[thread-tasks] resume check failed thread=${this.threadId}: ${errorMessage(error)}`);
391
+ return false;
392
+ }
393
+ }
394
+ /**
395
+ * The re-check at dequeue: {@link isResumeRunnable}, and if yes, record
396
+ * that this process is now working on the task so a second copy of its
397
+ * resume is stale.
398
+ */
399
+ acceptResume(msg, now = Date.now()) {
400
+ if (!this.isResumeRunnable(msg, now))
401
+ return false;
402
+ this.resumesAccepted.add(msg.meta?.taskId);
403
+ return true;
404
+ }
405
+ /**
406
+ * The resume was accepted but its message went back on the queue (a failed
407
+ * inject, an orphaned injection, a failed wake). Takes the acceptance back
408
+ * so the next dequeue may run it. Safe for any message: not a resume, or
409
+ * never accepted, does nothing. #2664 review P1-B.
410
+ */
411
+ releaseResume(messages) {
412
+ for (const msg of messages) {
413
+ const taskId = msg.meta?.taskId;
414
+ if (!isTaskResumeMessage(msg) || typeof taskId !== "string")
415
+ continue;
416
+ if (this.resumesAccepted.delete(taskId)) {
417
+ logger.info(`[thread-tasks] resume released back to the queue thread=${this.threadId} task=${taskId}`);
418
+ }
419
+ }
420
+ }
421
+ /** Install the loop's hooks. Resumes are only queued once this is set. */
422
+ enableResumes(hooks) {
423
+ this.resumeHooks = hooks;
424
+ }
425
+ /**
426
+ * The owner sent `/stop` (option B, owner 10-04): cancel every unfinished
427
+ * task of this thread. Cancelled is a stored status, so nothing queued
428
+ * starts on its own, a restart cannot bring it back, and resumes already
429
+ * queued are retired at dequeue.
430
+ *
431
+ * The open tasks are cancelled at once. A message sent just before
432
+ * `/stop` may not be folded yet: the sidecar then folds up to the stop
433
+ * (bounded by `waitMs`) and the tasks those events created are cancelled
434
+ * too. Tasks from messages after the `/stop` are kept. Returns every
435
+ * cancelled task, for the reply. Never throws.
436
+ * TODO: a fold that misses `waitMs` (or more than {@link STOP_EVENTS}
437
+ * unfolded events) can still create a task from a pre-stop message later.
438
+ */
439
+ async cancelOpenTasks(waitMs = RECONCILE_WAIT_MS.wakeEnd, queue) {
440
+ if (this.closed)
441
+ return { cancelled: [], droppedQueued: 0 };
442
+ let beforeStop;
443
+ let cancelled;
444
+ let droppedQueued = 0;
445
+ try {
446
+ // Queue rows first, at this boundary: a message the interrupt debounce
447
+ // has not recorded yet is not an event and owns no task, so nothing
448
+ // below would ever see it. Left in place, the next wake reads it as a
449
+ // new request and runs it (#2664 review P1-A).
450
+ droppedQueued = this.dropQueuedMessages(queue);
451
+ beforeStop = new Set(this.store.readUnconsumedEvents(STOP_EVENTS).map((event) => event.id));
452
+ cancelled = this.cancelTasks(this.store.listOpenTasks());
453
+ }
454
+ catch (error) {
455
+ logger.warn(`[thread-tasks] /stop cancel failed thread=${this.threadId}: ${errorMessage(error)}`);
456
+ return { cancelled: [], droppedQueued };
457
+ }
458
+ if (beforeStop.size === 0)
459
+ return { cancelled, droppedQueued };
460
+ await this.reconcile("stop", waitMs);
461
+ if (this.isClosed())
462
+ return { cancelled, droppedQueued };
463
+ try {
464
+ // A task's first source event is the one that created it.
465
+ const late = this.store.listOpenTasks().filter((task) => beforeStop.has(task.sourceEventIds[0] ?? ""));
466
+ return { cancelled: [...cancelled, ...this.cancelTasks(late)], droppedQueued };
467
+ }
468
+ catch (error) {
469
+ logger.warn(`[thread-tasks] /stop cancel failed thread=${this.threadId}: ${errorMessage(error)}`);
470
+ return { cancelled, droppedQueued };
471
+ }
472
+ }
473
+ /**
474
+ * Drop queued owner messages: rows not claimed by a wake, that are not
475
+ * system rows (a resume, or the stop command itself) and that the task
476
+ * sidecar would read as owner input. Returns how many were dropped.
477
+ * Fail-safe: a row that cannot be read is left alone.
478
+ */
479
+ dropQueuedMessages(queue) {
480
+ if (!queue)
481
+ return 0;
482
+ let dropped = 0;
483
+ for (const msg of queue.snapshot()) {
484
+ if (msg.channel === "system" || !isTaskRelevantMessage(msg))
485
+ continue;
486
+ try {
487
+ // Handled, not just removed: a provider retry of the same message
488
+ // would otherwise be accepted again as a request after the stop.
489
+ if (queue.remove(msg.id)) {
490
+ queue.markHandled(msg);
491
+ dropped += 1;
492
+ }
493
+ }
494
+ catch (error) {
495
+ logger.warn(`[thread-tasks] could not drop queued message ${msg.id} thread=${this.threadId}: ${errorMessage(error)}`);
496
+ }
497
+ }
498
+ if (dropped > 0)
499
+ logger.system(`[thread-tasks] /stop dropped ${dropped} queued message(s) thread=${this.threadId}`);
500
+ return dropped;
501
+ }
502
+ /**
503
+ * Commit `cancelled` for these tasks without consuming any event, so the
504
+ * sidecar still folds everything. Read and commit run in one synchronous
505
+ * stretch, so no sidecar commit lands between them; a sidecar call in
506
+ * flight hits a version conflict and re-reads.
507
+ */
508
+ cancelTasks(tasks) {
509
+ if (tasks.length === 0)
510
+ return [];
511
+ const position = this.store.getPosition();
512
+ const result = this.store.commit({
513
+ expectedPosition: position,
514
+ throughSeq: position,
515
+ changes: tasks.map((task) => ({ op: "update", taskId: task.id, expectedVersion: task.version, patch: { status: "cancelled" } })),
516
+ });
517
+ if (!result.ok) {
518
+ logger.warn(`[thread-tasks] /stop cancel rejected thread=${this.threadId}: ${result.reason} ${result.detail}`);
519
+ return [];
520
+ }
521
+ logger.system(`[thread-tasks] /stop cancelled ${tasks.length} task(s) thread=${this.threadId}`);
522
+ return [...tasks];
523
+ }
524
+ /**
525
+ * Close one open task of this thread for the agent's `close_thread_task`
526
+ * tool (#2822). The sidecar completes a task only with evidence from the
527
+ * batch it folds, so finished work it missed stayed open and kept being
528
+ * resumed; this is the explicit way out. Commits like {@link cancelTasks}:
529
+ * no event is consumed, and a sidecar call in flight hits a version
530
+ * conflict and re-reads. Never throws.
531
+ */
532
+ closeTask(input) {
533
+ if (this.closed)
534
+ return { ok: false, reason: "The task list is closed." };
535
+ try {
536
+ // Scoped to this thread: another thread's id is not found.
537
+ const task = this.store.getTask(input.taskId);
538
+ if (!task)
539
+ return { ok: false, reason: `No task with id ${input.taskId} in this thread.` };
540
+ if (task.status === "completed" || task.status === "cancelled") {
541
+ return { ok: false, reason: `Task ${task.id} is already ${task.status}.` };
542
+ }
543
+ const position = this.store.getPosition();
544
+ const result = this.store.commit({
545
+ expectedPosition: position,
546
+ throughSeq: position,
547
+ changes: [{ op: "update", taskId: task.id, expectedVersion: task.version, patch: {
548
+ status: input.status,
549
+ result: { summary: input.summary, evidence: [input.evidenceRef] },
550
+ resumeCondition: null,
551
+ } }],
552
+ });
553
+ if (!result.ok)
554
+ return { ok: false, reason: `Not closed: ${result.reason} ${result.detail}` };
555
+ logger.system(`[thread-tasks] agent closed task=${task.id} as ${input.status} thread=${this.threadId}`);
556
+ const closed = this.store.getTask(task.id);
557
+ return closed ? { ok: true, task: closed } : { ok: false, reason: `Task ${task.id} vanished after closing.` };
558
+ }
559
+ catch (error) {
560
+ logger.warn(`[thread-tasks] close failed thread=${this.threadId}: ${errorMessage(error)}`);
561
+ return { ok: false, reason: `Not closed: ${errorMessage(error)}` };
562
+ }
563
+ }
564
+ /**
565
+ * Fold the events up to now, then queue the next runnable task. Called at
566
+ * wake end and at startup; never throws and never blocks the caller's
567
+ * loop. Without the fold a task the wake just finished could still look
568
+ * pending, so a pass that cannot catch up delivers nothing and retries
569
+ * after {@link RESUME_RETRY_MS} instead. A fold that needs a slow sidecar
570
+ * call therefore starts the next task about a minute later, not at once.
571
+ */
572
+ async requestResume(waitMs = RECONCILE_WAIT_MS.wakeEnd) {
573
+ const hooks = this.resumeHooks;
574
+ if (!hooks || this.closed || this.resumeRunning)
575
+ return { queued: [] };
576
+ this.resumeRunning = true;
577
+ this.clearResumeRetry();
578
+ try {
579
+ const reconciled = await this.reconcile("wake_end", waitMs);
580
+ if (this.isClosed())
581
+ return { queued: [] };
582
+ // Events that arrived during the fold (another wake ran and ended; its
583
+ // own pass was dropped because this one was running) may cancel or
584
+ // finish the task this pass would queue: fold them first.
585
+ if (!reconciled.reached || this.store.latestSeq() > reconciled.target) {
586
+ this.scheduleResumeRetry();
587
+ return { queued: [] };
588
+ }
589
+ // Busy: usually a wake, whose end starts the next pass; but nightly
590
+ // maintenance is busy too and has no wake end, so retry either way.
591
+ if (hooks.isBusy() || hooks.isGated()) {
592
+ this.scheduleResumeRetry();
593
+ return { queued: [] };
594
+ }
595
+ const outcome = this.deliverResumes(hooks.queue);
596
+ if (outcome.queued.length > 0)
597
+ hooks.wake();
598
+ // By design (owner 10-03): a not-yet-due task waits; its due or
599
+ // unblock wake comes from the existing calendar / event-trigger
600
+ // messages to this thread. Thread tasks never schedule wakes.
601
+ return outcome;
602
+ }
603
+ catch (error) {
604
+ logger.warn(`[thread-tasks] resume request failed thread=${this.threadId}: ${errorMessage(error)}`);
605
+ return { queued: [] };
606
+ }
607
+ finally {
608
+ this.resumeRunning = false;
609
+ }
610
+ }
611
+ scheduleResumeRetry() {
612
+ if (this.closed || this.resumeRetry)
613
+ return;
614
+ this.resumeRetry = setTimeout(() => {
615
+ this.resumeRetry = null;
616
+ void this.requestResume();
617
+ }, RESUME_RETRY_MS);
618
+ this.resumeRetry.unref();
619
+ }
620
+ clearResumeRetry() {
621
+ if (this.resumeRetry)
622
+ clearTimeout(this.resumeRetry);
623
+ this.resumeRetry = null;
624
+ }
625
+ /** Enqueue one reserved resume and mark it delivered. False when nothing was handed over. */
626
+ enqueueResume(queue, key, task, version) {
627
+ if (!task) {
628
+ // TODO: a reservation whose task row is gone stays reserved; harmless,
629
+ // the next pass skips it again.
630
+ return false;
631
+ }
632
+ // `enqueue` returns false for an id it already holds or handled: that
633
+ // resume reached the queue before a crash, so marking it is still right.
634
+ queue.enqueue(resumeMessage(this.threadId, key, task, version));
635
+ const marked = this.store.markRecoveryDelivered(key);
636
+ if (!marked.ok) {
637
+ logger.warn(`[thread-tasks] resume ${key} queued but not marked thread=${this.threadId}: ${marked.reason}`);
638
+ return false;
639
+ }
640
+ this.recordExecution({
641
+ idempotencyKey: key,
642
+ kind: "recovery",
643
+ payload: { text: `Resumed: ${task.title}`, data: { taskId: task.id, taskVersion: version } },
644
+ });
645
+ logger.system(`[thread-tasks] queued resume thread=${this.threadId} task=${task.id} v${version}`);
646
+ return true;
647
+ }
648
+ /** Events not yet folded into tasks (the sidecar is behind or failing). */
649
+ pendingEventCount() {
650
+ try {
651
+ return this.store.readUnconsumedEvents(1_000).length;
652
+ }
653
+ catch {
654
+ return 0;
655
+ }
656
+ }
657
+ async close(graceMs) {
658
+ if (this.closed)
659
+ return;
660
+ this.closed = true;
661
+ this.clearResumeRetry();
662
+ try {
663
+ await this.controller.close(graceMs);
664
+ }
665
+ catch (error) {
666
+ logger.warn(`[thread-tasks] controller close failed thread=${this.threadId}: ${errorMessage(error)}`);
667
+ }
668
+ try {
669
+ this.store.close();
670
+ }
671
+ catch {
672
+ // Closing twice or after a failed open is harmless.
673
+ }
674
+ }
675
+ safeNotify(immediate) {
676
+ try {
677
+ this.controller.notify({ immediate });
678
+ }
679
+ catch (error) {
680
+ logger.warn(`[thread-tasks] notify failed thread=${this.threadId}: ${errorMessage(error)}`);
681
+ }
682
+ }
683
+ }
684
+ /**
685
+ * A task whose checkpoint lists nothing remaining and no blockers: the work
686
+ * is done but the sidecar did not complete it (no evidence in its batch).
687
+ * Resuming it only wakes the agent to find nothing to do (#2822). A task
688
+ * with no checkpoint yet has not been worked on, so it still resumes.
689
+ * TODO: a checkpoint that empties `remaining` too early stops auto-resume;
690
+ * the task stays open and visible in the context block.
691
+ */
692
+ export function hasNoRemainingWork(task) {
693
+ return task.checkpoint?.remaining.length === 0 && task.checkpoint.blockers.length === 0;
694
+ }
695
+ /**
696
+ * The queue row for a resume. Its id is the delivery key, so the queue's
697
+ * own dedup refuses a second copy. `system` keeps it out of fast replies,
698
+ * the watchdog, activity cards and gate replies; the reply target travels
699
+ * with the task, not with this row.
700
+ */
701
+ function resumeMessage(threadId, key, task, version) {
702
+ // Replies go where the task came from, like any task continuation.
703
+ const routing = task.replyTarget
704
+ ? taskContinuationRouting({ sender: RESUME_SENDER, delivery: task.replyTarget })
705
+ : { meta: {} };
706
+ return {
707
+ id: key,
708
+ channel: "system",
709
+ sender: RESUME_SENDER,
710
+ text: `[Thread task resume] Continue the queued task "${task.title}" (id ${task.id}). ` +
711
+ "Its requirements and checkpoint are in the Thread Tasks block. Check the checkpoint and results first; never redo a step listed as done. " +
712
+ "If it is no longer wanted or cannot start yet, report that instead of doing it.",
713
+ attachments: [],
714
+ timestamp: new Date().toISOString(),
715
+ ...routing,
716
+ meta: { ...routing.meta, kind: TASK_RESUME_KIND, taskId: task.id, taskVersion: version },
717
+ ...(threadId !== DEFAULT_THREAD_ID ? { threadId } : {}),
718
+ };
719
+ }
720
+ /** True for a queued thread-task resume row. */
721
+ export function isTaskResumeMessage(msg) {
722
+ return msg.channel === "system" && msg.meta?.kind === TASK_RESUME_KIND;
723
+ }
724
+ /**
725
+ * The re-check before execution, at every place a wake takes rows off the
726
+ * queue: a resume whose task was cancelled, changed, put on hold or already
727
+ * started since it was queued is retired unexecuted. Without a runtime (the
728
+ * switch was turned off) every resume is stale. Returns true when retired.
729
+ */
730
+ export function retireStaleResume(queue, runtime, msg) {
731
+ if (!isTaskResumeMessage(msg) || runtime?.acceptResume(msg) === true)
732
+ return false;
733
+ logger.system(`[thread-tasks] dropping stale resume ${msg.id}`);
734
+ queue.markHandled(msg);
735
+ return true;
736
+ }
737
+ /**
738
+ * Messages the task store tracks: anything from a real channel. System
739
+ * messages (heartbeats, internal continuations) and external-wake markers
740
+ * carry no owner request. Email and group chats are included; the sidecar
741
+ * decides whether they hold a request.
742
+ */
743
+ export function isTaskRelevantMessage(msg) {
744
+ return msg.channel !== "system" && msg.meta?.kind !== EXTERNAL_WAKE_KIND;
745
+ }
746
+ /**
747
+ * The end-of-wake handoff (issue #1996 PR 1 rule): a claim is released as
748
+ * handled only after its message is a durable task event, so the follow-up
749
+ * belongs to the task store instead of vanishing with the queue row.
750
+ *
751
+ * Fail-safe: if recording fails the claims are still released. The agent
752
+ * already processed them in this wake; replaying them would redo the work.
753
+ * The error is logged. `handled` never means a task is completed.
754
+ */
755
+ export function releaseClaimsAfterRecording(queue, runtime) {
756
+ if (runtime) {
757
+ try {
758
+ runtime.recordMessages(queue.currentCycleClaims().filter(isTaskRelevantMessage));
759
+ }
760
+ catch (error) {
761
+ logger.err(`[thread-tasks] could not record claims before release: ${errorMessage(error)}`);
762
+ }
763
+ }
764
+ queue.markAllInFlightHandled();
765
+ }
766
+ /**
767
+ * Wake end. A wake cut short by an engine failover restart leaves its claims
768
+ * in flight for the replacement process (issue #2519) and folds nothing.
769
+ * Otherwise release the claims, fold this wake's events into the
770
+ * checkpoints and queue the next runnable task (PR 5). Not awaited: the
771
+ * session keeps its transcript, so the loop need not hold the next message
772
+ * for it. Bounded; leftovers stay unconsumed and the next wake's context
773
+ * shows them raw.
774
+ */
775
+ export function endWakeThreadTasks(queue, runtime, options) {
776
+ if (options.failoverRestartPending)
777
+ return;
778
+ releaseClaimsAfterRecording(queue, runtime);
779
+ void runtime?.requestResume();
780
+ }
781
+ /** Per-event caps; the sidecar clips again, this keeps the event log small. */
782
+ const EXECUTION_TEXT_CHARS = 1_500;
783
+ const TOOL_OUTPUT_CHARS = 400;
784
+ function clipText(text, max) {
785
+ return text.length <= max ? text : `${text.slice(0, max)}… [${text.length - max} more chars]`;
786
+ }
787
+ /**
788
+ * Provider tool-call ids are not unique for the life of a thread: some
789
+ * providers number them per response (`functions.notify_user:0`). A stream
790
+ * emits each result once, so the key only has to be unique; a process nonce
791
+ * plus a counter makes it so.
792
+ */
793
+ const EXECUTION_KEY_NONCE = randomUUID().slice(0, 8);
794
+ let executionKeySeq = 0;
795
+ function executionKey(prefix, toolUseId) {
796
+ executionKeySeq += 1;
797
+ return `${prefix}:${toolUseId}:${EXECUTION_KEY_NONCE}-${executionKeySeq}`;
798
+ }
799
+ /** Tools whose results say nothing about task progress. */
800
+ const NOISE_TOOLS = new Set(["wait", "finish", "TodoWrite", "TaskCreate", "TaskUpdate", "TaskList", "TaskGet"]);
801
+ function baseToolName(name) {
802
+ return name.replace(/^mcp__[^_]+(?:_[^_]+)*__/, "");
803
+ }
804
+ /**
805
+ * Engine-neutral execution events. Both engines feed the same stream
806
+ * handler, so these are called from there. Each id is unique per call, so
807
+ * keys never collide across wakes or retries.
808
+ */
809
+ export const executionEvents = {
810
+ toolResult(toolUseId, toolName, output, isError) {
811
+ const name = baseToolName(toolName);
812
+ // Some providers stream tool calls without an id; a shared empty key
813
+ // would dedup every later one away, so those results are not recorded.
814
+ if (NOISE_TOOLS.has(name) || !toolUseId)
815
+ return null;
816
+ return {
817
+ idempotencyKey: executionKey("tool", toolUseId),
818
+ kind: "tool_result",
819
+ payload: { data: { tool: name, ok: !isError, output: clipText(output, TOOL_OUTPUT_CHARS) } },
820
+ };
821
+ },
822
+ todos(toolUseId, todos) {
823
+ if (!toolUseId)
824
+ return null;
825
+ return {
826
+ idempotencyKey: executionKey("todo", toolUseId),
827
+ kind: "todo_update",
828
+ payload: { data: { todos: todos.map((todo) => ({ content: clipText(todo.content, 200), status: todo.status })) } },
829
+ };
830
+ },
831
+ response(turnId, text) {
832
+ const trimmed = text.trim();
833
+ if (!trimmed)
834
+ return null;
835
+ return { idempotencyKey: `response:${turnId}`, kind: "assistant_response", payload: { text: clipText(trimmed, EXECUTION_TEXT_CHARS) } };
836
+ },
837
+ /** What the agent told the owner through notify_user: an explicit status report. */
838
+ statusReport(toolUseId, message) {
839
+ const trimmed = message.trim();
840
+ if (!trimmed || !toolUseId)
841
+ return null;
842
+ return { idempotencyKey: executionKey("report", toolUseId), kind: "status_report", payload: { text: clipText(trimmed, EXECUTION_TEXT_CHARS) } };
843
+ },
844
+ };
845
+ /**
846
+ * The fast responder's reading of a batch, as one candidate event. It shares
847
+ * the message's dedup key, so the sidecar's origin key matches the message's
848
+ * and the same task is never created twice. Only for a single-message batch:
849
+ * a brief over several messages cannot be keyed to the right one, and the
850
+ * messages themselves are already events.
851
+ */
852
+ export function fastResponderCandidate(messages, brief) {
853
+ const relevant = messages.filter(isTaskRelevantMessage);
854
+ const first = relevant.length === 1 ? relevant[0] : undefined;
855
+ const text = typeof brief === "string" ? brief.trim() : "";
856
+ if (!first || !text)
857
+ return null;
858
+ const dedupKey = getMessageDedupKey(first);
859
+ return {
860
+ idempotencyKey: `candidate:${dedupKey}`,
861
+ kind: "fast_responder_candidate",
862
+ source: { channel: first.channel, sender: first.sender, messageId: first.id, dedupKey },
863
+ payload: { text: clipText(text, EXECUTION_TEXT_CHARS) },
864
+ };
865
+ }
866
+ /** Record the fast responder's brief as a candidate event. Never throws. */
867
+ export function recordFastResponderCandidate(messages, brief) {
868
+ try {
869
+ recordExecutionEvent(fastResponderCandidate(messages, brief));
870
+ }
871
+ catch (error) {
872
+ logger.warn(`[thread-tasks] could not record fast-responder candidate: ${errorMessage(error)}`);
873
+ }
874
+ }
875
+ /** Record an execution event on the process runtime, if there is one. Never throws. */
876
+ export function recordExecutionEvent(input) {
877
+ if (input)
878
+ current?.recordExecution(input);
879
+ }
880
+ /** The process runtime's recovery context, or null without a runtime (then nothing is injected). */
881
+ export function buildThreadTaskContext() {
882
+ return current?.contextBlock() ?? null;
883
+ }
884
+ /** Bounded reconcile on the process runtime; resolves immediately without one. */
885
+ export async function reconcileThreadTasks(phase, waitMs = RECONCILE_WAIT_MS.boundary) {
886
+ await current?.reconcile(phase, waitMs);
887
+ }
888
+ /** The runtime for this process, set once by the loop. Tools and hooks read it. */
889
+ let current = null;
890
+ export function setThreadTaskRuntime(runtime) {
891
+ current = runtime;
892
+ }
893
+ export function getThreadTaskRuntime() {
894
+ return current;
895
+ }
896
+ //# sourceMappingURL=thread-task-runtime.js.map