@kynetic-ai/spec 0.11.0 → 0.12.0

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 (501) hide show
  1. package/README.md +55 -455
  2. package/dist/agent-runtime/bootstrap.d.ts +31 -0
  3. package/dist/agent-runtime/bootstrap.d.ts.map +1 -0
  4. package/dist/agent-runtime/bootstrap.js +302 -0
  5. package/dist/agent-runtime/bootstrap.js.map +1 -0
  6. package/dist/agent-runtime/dispatch.d.ts +119 -10
  7. package/dist/agent-runtime/dispatch.d.ts.map +1 -1
  8. package/dist/agent-runtime/dispatch.js +1154 -219
  9. package/dist/agent-runtime/dispatch.js.map +1 -1
  10. package/dist/agent-runtime/invocation.d.ts +28 -1
  11. package/dist/agent-runtime/invocation.d.ts.map +1 -1
  12. package/dist/agent-runtime/invocation.js +171 -59
  13. package/dist/agent-runtime/invocation.js.map +1 -1
  14. package/dist/agent-runtime/prompts.d.ts +9 -0
  15. package/dist/agent-runtime/prompts.d.ts.map +1 -1
  16. package/dist/agent-runtime/prompts.js +42 -7
  17. package/dist/agent-runtime/prompts.js.map +1 -1
  18. package/dist/agent-runtime/session-event-accumulator.d.ts +83 -0
  19. package/dist/agent-runtime/session-event-accumulator.d.ts.map +1 -0
  20. package/dist/agent-runtime/session-event-accumulator.js +203 -0
  21. package/dist/agent-runtime/session-event-accumulator.js.map +1 -0
  22. package/dist/agent-runtime/session-event-types.d.ts +67 -0
  23. package/dist/agent-runtime/session-event-types.d.ts.map +1 -0
  24. package/dist/agent-runtime/session-event-types.js +13 -0
  25. package/dist/agent-runtime/session-event-types.js.map +1 -0
  26. package/dist/agent-runtime/workspace.d.ts +244 -0
  27. package/dist/agent-runtime/workspace.d.ts.map +1 -0
  28. package/dist/agent-runtime/workspace.js +2025 -0
  29. package/dist/agent-runtime/workspace.js.map +1 -0
  30. package/dist/agents/adapters.d.ts.map +1 -1
  31. package/dist/agents/adapters.js +58 -13
  32. package/dist/agents/adapters.js.map +1 -1
  33. package/dist/agents/spawner.d.ts +8 -0
  34. package/dist/agents/spawner.d.ts.map +1 -1
  35. package/dist/agents/spawner.js +25 -3
  36. package/dist/agents/spawner.js.map +1 -1
  37. package/dist/cli/batch-exec.js +1 -1
  38. package/dist/cli/batch-exec.js.map +1 -1
  39. package/dist/cli/command-annotations.d.ts +15 -3
  40. package/dist/cli/command-annotations.d.ts.map +1 -1
  41. package/dist/cli/command-annotations.js +23 -3
  42. package/dist/cli/command-annotations.js.map +1 -1
  43. package/dist/cli/commands/agent.d.ts +2 -0
  44. package/dist/cli/commands/agent.d.ts.map +1 -1
  45. package/dist/cli/commands/agent.js +144 -27
  46. package/dist/cli/commands/agent.js.map +1 -1
  47. package/dist/cli/commands/agents.d.ts.map +1 -1
  48. package/dist/cli/commands/agents.js +5 -5
  49. package/dist/cli/commands/agents.js.map +1 -1
  50. package/dist/cli/commands/derive.d.ts.map +1 -1
  51. package/dist/cli/commands/derive.js +118 -3
  52. package/dist/cli/commands/derive.js.map +1 -1
  53. package/dist/cli/commands/guard.d.ts.map +1 -1
  54. package/dist/cli/commands/guard.js +8 -6
  55. package/dist/cli/commands/guard.js.map +1 -1
  56. package/dist/cli/commands/index.d.ts +1 -0
  57. package/dist/cli/commands/index.d.ts.map +1 -1
  58. package/dist/cli/commands/index.js +1 -0
  59. package/dist/cli/commands/index.js.map +1 -1
  60. package/dist/cli/commands/init.d.ts.map +1 -1
  61. package/dist/cli/commands/init.js +20 -0
  62. package/dist/cli/commands/init.js.map +1 -1
  63. package/dist/cli/commands/item.d.ts.map +1 -1
  64. package/dist/cli/commands/item.js +205 -47
  65. package/dist/cli/commands/item.js.map +1 -1
  66. package/dist/cli/commands/log.d.ts.map +1 -1
  67. package/dist/cli/commands/log.js +24 -10
  68. package/dist/cli/commands/log.js.map +1 -1
  69. package/dist/cli/commands/plan-import.d.ts +3 -3
  70. package/dist/cli/commands/plan-import.d.ts.map +1 -1
  71. package/dist/cli/commands/plan-import.js +213 -528
  72. package/dist/cli/commands/plan-import.js.map +1 -1
  73. package/dist/cli/commands/plan.d.ts.map +1 -1
  74. package/dist/cli/commands/plan.js +533 -83
  75. package/dist/cli/commands/plan.js.map +1 -1
  76. package/dist/cli/commands/review.d.ts +14 -0
  77. package/dist/cli/commands/review.d.ts.map +1 -0
  78. package/dist/cli/commands/review.js +1142 -0
  79. package/dist/cli/commands/review.js.map +1 -0
  80. package/dist/cli/commands/serve.d.ts +1 -0
  81. package/dist/cli/commands/serve.d.ts.map +1 -1
  82. package/dist/cli/commands/serve.js +33 -10
  83. package/dist/cli/commands/serve.js.map +1 -1
  84. package/dist/cli/commands/session/checkpoint.d.ts +2 -4
  85. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  86. package/dist/cli/commands/session/checkpoint.js +6 -107
  87. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  88. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  89. package/dist/cli/commands/session/commands.js +33 -23
  90. package/dist/cli/commands/session/commands.js.map +1 -1
  91. package/dist/cli/commands/session/compact.js +4 -4
  92. package/dist/cli/commands/session/compact.js.map +1 -1
  93. package/dist/cli/commands/session/create.js +2 -2
  94. package/dist/cli/commands/session/create.js.map +1 -1
  95. package/dist/cli/commands/session/format.d.ts.map +1 -1
  96. package/dist/cli/commands/session/format.js +1 -6
  97. package/dist/cli/commands/session/format.js.map +1 -1
  98. package/dist/cli/commands/session/log.d.ts +32 -7
  99. package/dist/cli/commands/session/log.d.ts.map +1 -1
  100. package/dist/cli/commands/session/log.js +166 -60
  101. package/dist/cli/commands/session/log.js.map +1 -1
  102. package/dist/cli/commands/session/migrate.d.ts +9 -0
  103. package/dist/cli/commands/session/migrate.d.ts.map +1 -0
  104. package/dist/cli/commands/session/migrate.js +46 -0
  105. package/dist/cli/commands/session/migrate.js.map +1 -0
  106. package/dist/cli/commands/session/stale-close.d.ts.map +1 -1
  107. package/dist/cli/commands/session/stale-close.js +5 -8
  108. package/dist/cli/commands/session/stale-close.js.map +1 -1
  109. package/dist/cli/commands/session/types.d.ts +1 -1
  110. package/dist/cli/commands/session/types.d.ts.map +1 -1
  111. package/dist/cli/commands/setup.d.ts +2 -2
  112. package/dist/cli/commands/setup.d.ts.map +1 -1
  113. package/dist/cli/commands/setup.js +287 -257
  114. package/dist/cli/commands/setup.js.map +1 -1
  115. package/dist/cli/commands/shadow.d.ts.map +1 -1
  116. package/dist/cli/commands/shadow.js +147 -31
  117. package/dist/cli/commands/shadow.js.map +1 -1
  118. package/dist/cli/commands/skill-crud.d.ts +7 -0
  119. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  120. package/dist/cli/commands/skill-crud.js +41 -18
  121. package/dist/cli/commands/skill-crud.js.map +1 -1
  122. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  123. package/dist/cli/commands/skill-diff.js +29 -3
  124. package/dist/cli/commands/skill-diff.js.map +1 -1
  125. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  126. package/dist/cli/commands/skill-install.js +5 -4
  127. package/dist/cli/commands/skill-install.js.map +1 -1
  128. package/dist/cli/commands/task.d.ts.map +1 -1
  129. package/dist/cli/commands/task.js +359 -49
  130. package/dist/cli/commands/task.js.map +1 -1
  131. package/dist/cli/commands/trait.d.ts.map +1 -1
  132. package/dist/cli/commands/trait.js +5 -27
  133. package/dist/cli/commands/trait.js.map +1 -1
  134. package/dist/cli/commands/validate.d.ts.map +1 -1
  135. package/dist/cli/commands/validate.js +113 -52
  136. package/dist/cli/commands/validate.js.map +1 -1
  137. package/dist/cli/index.d.ts.map +1 -1
  138. package/dist/cli/index.js +69 -2
  139. package/dist/cli/index.js.map +1 -1
  140. package/dist/cli/output.d.ts +26 -0
  141. package/dist/cli/output.d.ts.map +1 -1
  142. package/dist/cli/output.js +108 -1
  143. package/dist/cli/output.js.map +1 -1
  144. package/dist/cli/sync-mode.d.ts +44 -0
  145. package/dist/cli/sync-mode.d.ts.map +1 -0
  146. package/dist/cli/sync-mode.js +64 -0
  147. package/dist/cli/sync-mode.js.map +1 -0
  148. package/dist/daemon/middleware/project-context.ts +25 -7
  149. package/dist/daemon/project-context.ts +18 -0
  150. package/dist/daemon/routes/agent-dispatch.ts +99 -22
  151. package/dist/daemon/routes/aggregation.ts +184 -0
  152. package/dist/daemon/routes/inbox.ts +5 -0
  153. package/dist/daemon/routes/items.ts +145 -0
  154. package/dist/daemon/routes/meta.ts +1 -1
  155. package/dist/daemon/routes/projects.ts +28 -6
  156. package/dist/daemon/routes/ref-resolution.ts +119 -0
  157. package/dist/daemon/routes/refs.ts +42 -0
  158. package/dist/daemon/routes/session-related.ts +140 -0
  159. package/dist/daemon/routes/sessions.ts +420 -19
  160. package/dist/daemon/routes/tasks.ts +62 -5
  161. package/dist/daemon/routes/triage.ts +40 -1
  162. package/dist/daemon/server.ts +143 -49
  163. package/dist/daemon/session-sync.ts +11 -0
  164. package/dist/daemon/shadow-sync.ts +11 -0
  165. package/dist/daemon/watcher.ts +56 -5
  166. package/dist/daemon/websocket/project-resolution.ts +77 -0
  167. package/dist/export/json.d.ts.map +1 -1
  168. package/dist/export/json.js +104 -1
  169. package/dist/export/json.js.map +1 -1
  170. package/dist/export/types.d.ts +52 -1
  171. package/dist/export/types.d.ts.map +1 -1
  172. package/dist/index.d.ts +1 -0
  173. package/dist/index.d.ts.map +1 -1
  174. package/dist/index.js +1 -0
  175. package/dist/index.js.map +1 -1
  176. package/dist/parser/agent-detection.d.ts +1 -1
  177. package/dist/parser/agent-detection.d.ts.map +1 -1
  178. package/dist/parser/agent-detection.js +10 -0
  179. package/dist/parser/agent-detection.js.map +1 -1
  180. package/dist/parser/config.d.ts +397 -2
  181. package/dist/parser/config.d.ts.map +1 -1
  182. package/dist/parser/config.js +125 -3
  183. package/dist/parser/config.js.map +1 -1
  184. package/dist/parser/dispatch-workspaces.d.ts +18 -0
  185. package/dist/parser/dispatch-workspaces.d.ts.map +1 -0
  186. package/dist/parser/dispatch-workspaces.js +209 -0
  187. package/dist/parser/dispatch-workspaces.js.map +1 -0
  188. package/dist/parser/doctor.d.ts.map +1 -1
  189. package/dist/parser/doctor.js +27 -8
  190. package/dist/parser/doctor.js.map +1 -1
  191. package/dist/parser/file-lock.d.ts.map +1 -1
  192. package/dist/parser/file-lock.js +9 -2
  193. package/dist/parser/file-lock.js.map +1 -1
  194. package/dist/parser/index.d.ts +6 -0
  195. package/dist/parser/index.d.ts.map +1 -1
  196. package/dist/parser/index.js +6 -0
  197. package/dist/parser/index.js.map +1 -1
  198. package/dist/parser/plans.d.ts.map +1 -1
  199. package/dist/parser/plans.js +1 -0
  200. package/dist/parser/plans.js.map +1 -1
  201. package/dist/parser/refs.d.ts +8 -1
  202. package/dist/parser/refs.d.ts.map +1 -1
  203. package/dist/parser/refs.js +27 -1
  204. package/dist/parser/refs.js.map +1 -1
  205. package/dist/parser/review-operations.d.ts +72 -0
  206. package/dist/parser/review-operations.d.ts.map +1 -0
  207. package/dist/parser/review-operations.js +185 -0
  208. package/dist/parser/review-operations.js.map +1 -0
  209. package/dist/parser/review-task-integration.d.ts +78 -0
  210. package/dist/parser/review-task-integration.d.ts.map +1 -0
  211. package/dist/parser/review-task-integration.js +173 -0
  212. package/dist/parser/review-task-integration.js.map +1 -0
  213. package/dist/parser/review-threads.d.ts +101 -0
  214. package/dist/parser/review-threads.d.ts.map +1 -0
  215. package/dist/parser/review-threads.js +222 -0
  216. package/dist/parser/review-threads.js.map +1 -0
  217. package/dist/parser/review-validation.d.ts +69 -0
  218. package/dist/parser/review-validation.d.ts.map +1 -0
  219. package/dist/parser/review-validation.js +207 -0
  220. package/dist/parser/review-validation.js.map +1 -0
  221. package/dist/parser/reviews.d.ts +58 -0
  222. package/dist/parser/reviews.d.ts.map +1 -0
  223. package/dist/parser/reviews.js +230 -0
  224. package/dist/parser/reviews.js.map +1 -0
  225. package/dist/parser/session-branch.d.ts +91 -0
  226. package/dist/parser/session-branch.d.ts.map +1 -0
  227. package/dist/parser/session-branch.js +565 -0
  228. package/dist/parser/session-branch.js.map +1 -0
  229. package/dist/parser/session-sync-scheduler.d.ts +53 -0
  230. package/dist/parser/session-sync-scheduler.d.ts.map +1 -0
  231. package/dist/parser/session-sync-scheduler.js +100 -0
  232. package/dist/parser/session-sync-scheduler.js.map +1 -0
  233. package/dist/parser/setup-status.d.ts +7 -1
  234. package/dist/parser/setup-status.d.ts.map +1 -1
  235. package/dist/parser/setup-status.js +104 -39
  236. package/dist/parser/setup-status.js.map +1 -1
  237. package/dist/parser/shadow-sync-scheduler.d.ts +71 -0
  238. package/dist/parser/shadow-sync-scheduler.d.ts.map +1 -0
  239. package/dist/parser/shadow-sync-scheduler.js +139 -0
  240. package/dist/parser/shadow-sync-scheduler.js.map +1 -0
  241. package/dist/parser/shadow.d.ts +121 -14
  242. package/dist/parser/shadow.d.ts.map +1 -1
  243. package/dist/parser/shadow.js +752 -27
  244. package/dist/parser/shadow.js.map +1 -1
  245. package/dist/parser/skill-render.d.ts +24 -0
  246. package/dist/parser/skill-render.d.ts.map +1 -1
  247. package/dist/parser/skill-render.js +98 -26
  248. package/dist/parser/skill-render.js.map +1 -1
  249. package/dist/parser/validate.d.ts +43 -3
  250. package/dist/parser/validate.d.ts.map +1 -1
  251. package/dist/parser/validate.js +204 -30
  252. package/dist/parser/validate.js.map +1 -1
  253. package/dist/parser/yaml.d.ts +47 -11
  254. package/dist/parser/yaml.d.ts.map +1 -1
  255. package/dist/parser/yaml.js +329 -149
  256. package/dist/parser/yaml.js.map +1 -1
  257. package/dist/review/checks.d.ts +97 -0
  258. package/dist/review/checks.d.ts.map +1 -0
  259. package/dist/review/checks.js +175 -0
  260. package/dist/review/checks.js.map +1 -0
  261. package/dist/review/index.d.ts +3 -0
  262. package/dist/review/index.d.ts.map +1 -0
  263. package/dist/review/index.js +3 -0
  264. package/dist/review/index.js.map +1 -0
  265. package/dist/review/subject-bindings.d.ts +83 -0
  266. package/dist/review/subject-bindings.d.ts.map +1 -0
  267. package/dist/review/subject-bindings.js +175 -0
  268. package/dist/review/subject-bindings.js.map +1 -0
  269. package/dist/schema/common.d.ts +26 -0
  270. package/dist/schema/common.d.ts.map +1 -1
  271. package/dist/schema/common.js +13 -0
  272. package/dist/schema/common.js.map +1 -1
  273. package/dist/schema/dispatch-workspace.d.ts +2643 -0
  274. package/dist/schema/dispatch-workspace.d.ts.map +1 -0
  275. package/dist/schema/dispatch-workspace.js +187 -0
  276. package/dist/schema/dispatch-workspace.js.map +1 -0
  277. package/dist/schema/inbox.d.ts +8 -8
  278. package/dist/schema/index.d.ts +2 -0
  279. package/dist/schema/index.d.ts.map +1 -1
  280. package/dist/schema/index.js +2 -0
  281. package/dist/schema/index.js.map +1 -1
  282. package/dist/schema/meta.d.ts +648 -116
  283. package/dist/schema/meta.d.ts.map +1 -1
  284. package/dist/schema/meta.js +27 -0
  285. package/dist/schema/meta.js.map +1 -1
  286. package/dist/schema/plan.d.ts +30 -19
  287. package/dist/schema/plan.d.ts.map +1 -1
  288. package/dist/schema/plan.js +3 -1
  289. package/dist/schema/plan.js.map +1 -1
  290. package/dist/schema/review-records.d.ts +2676 -0
  291. package/dist/schema/review-records.d.ts.map +1 -0
  292. package/dist/schema/review-records.js +232 -0
  293. package/dist/schema/review-records.js.map +1 -0
  294. package/dist/schema/spec.d.ts +32 -14
  295. package/dist/schema/spec.d.ts.map +1 -1
  296. package/dist/schema/spec.js +5 -0
  297. package/dist/schema/spec.js.map +1 -1
  298. package/dist/schema/task.d.ts +187 -29
  299. package/dist/schema/task.d.ts.map +1 -1
  300. package/dist/schema/task.js +12 -2
  301. package/dist/schema/task.js.map +1 -1
  302. package/dist/schema/triage.d.ts +22 -22
  303. package/dist/sessions/cache.d.ts +119 -0
  304. package/dist/sessions/cache.d.ts.map +1 -0
  305. package/dist/sessions/cache.js +284 -0
  306. package/dist/sessions/cache.js.map +1 -0
  307. package/dist/sessions/index.d.ts +1 -0
  308. package/dist/sessions/index.d.ts.map +1 -1
  309. package/dist/sessions/index.js +2 -0
  310. package/dist/sessions/index.js.map +1 -1
  311. package/dist/sessions/legacy.d.ts +77 -0
  312. package/dist/sessions/legacy.d.ts.map +1 -0
  313. package/dist/sessions/legacy.js +146 -0
  314. package/dist/sessions/legacy.js.map +1 -0
  315. package/dist/sessions/store.d.ts +103 -73
  316. package/dist/sessions/store.d.ts.map +1 -1
  317. package/dist/sessions/store.js +335 -186
  318. package/dist/sessions/store.js.map +1 -1
  319. package/dist/sessions/types.d.ts +44 -16
  320. package/dist/sessions/types.d.ts.map +1 -1
  321. package/dist/sessions/types.js +11 -2
  322. package/dist/sessions/types.js.map +1 -1
  323. package/dist/strings/errors.d.ts +32 -0
  324. package/dist/strings/errors.d.ts.map +1 -1
  325. package/dist/strings/errors.js +17 -0
  326. package/dist/strings/errors.js.map +1 -1
  327. package/dist/strings/labels.d.ts +1 -0
  328. package/dist/strings/labels.d.ts.map +1 -1
  329. package/dist/strings/labels.js +1 -0
  330. package/dist/strings/labels.js.map +1 -1
  331. package/dist/utils/activity.d.ts +101 -0
  332. package/dist/utils/activity.d.ts.map +1 -0
  333. package/dist/utils/activity.js +408 -0
  334. package/dist/utils/activity.js.map +1 -0
  335. package/dist/utils/git.d.ts +31 -0
  336. package/dist/utils/git.d.ts.map +1 -1
  337. package/dist/utils/git.js +87 -0
  338. package/dist/utils/git.js.map +1 -1
  339. package/dist/utils/index.d.ts +2 -0
  340. package/dist/utils/index.d.ts.map +1 -1
  341. package/dist/utils/index.js +1 -0
  342. package/dist/utils/index.js.map +1 -1
  343. package/dist/web-ui/_app/immutable/assets/0.tmlwn-Ih.css +1 -0
  344. package/dist/web-ui/_app/immutable/assets/9.BwwJybWx.css +1 -0
  345. package/dist/web-ui/_app/immutable/chunks/2KqE8gtn.js +1 -0
  346. package/dist/web-ui/_app/immutable/chunks/70-t_QvE.js +1 -0
  347. package/dist/web-ui/_app/immutable/chunks/AiWQj974.js +1 -0
  348. package/dist/web-ui/_app/immutable/chunks/{CPPfDSei.js → B25nWFyA.js} +4 -4
  349. package/dist/web-ui/_app/immutable/chunks/{DBYE9jOd.js → B2bcA_Q_.js} +1 -1
  350. package/dist/web-ui/_app/immutable/chunks/B5e5HYyB.js +1 -0
  351. package/dist/web-ui/_app/immutable/chunks/B7-5z6eA.js +1 -0
  352. package/dist/web-ui/_app/immutable/chunks/B7bGmhK0.js +1 -0
  353. package/dist/web-ui/_app/immutable/chunks/{DzO4hlg9.js → B8tYZKAE.js} +1 -1
  354. package/dist/web-ui/_app/immutable/chunks/{B5LJFxqa.js → BFGAyJjD.js} +1 -1
  355. package/dist/web-ui/_app/immutable/chunks/BG0850zf.js +1 -0
  356. package/dist/web-ui/_app/immutable/chunks/{DAMmvwn4.js → BG8eSzAd.js} +1 -1
  357. package/dist/web-ui/_app/immutable/chunks/BIMxXS8I.js +1 -0
  358. package/dist/web-ui/_app/immutable/chunks/BSzL1fpU.js +1 -0
  359. package/dist/web-ui/_app/immutable/chunks/BYtjHfeq.js +1 -0
  360. package/dist/web-ui/_app/immutable/chunks/{DxCk-KHc.js → Bp5pFYXL.js} +1 -1
  361. package/dist/web-ui/_app/immutable/chunks/{B8a0xDxR.js → BsJFsuAT.js} +1 -1
  362. package/dist/web-ui/_app/immutable/chunks/BvpNHcD6.js +1 -0
  363. package/dist/web-ui/_app/immutable/chunks/BypqA25-.js +1 -0
  364. package/dist/web-ui/_app/immutable/chunks/{BVA9Exy-.js → C0w6WDm5.js} +1 -1
  365. package/dist/web-ui/_app/immutable/chunks/C5_PAZ0y.js +1 -0
  366. package/dist/web-ui/_app/immutable/chunks/CDRO15Iv.js +1 -0
  367. package/dist/web-ui/_app/immutable/chunks/CF1CoqD5.js +1 -0
  368. package/dist/web-ui/_app/immutable/chunks/CS2sa4_m.js +1 -0
  369. package/dist/web-ui/_app/immutable/chunks/{BJ0JX3ea.js → CWUQwB9H.js} +1 -1
  370. package/dist/web-ui/_app/immutable/chunks/CY5FDdSU.js +1 -0
  371. package/dist/web-ui/_app/immutable/chunks/C_7MTDoj.js +1 -0
  372. package/dist/web-ui/_app/immutable/chunks/{D3vxvonu.js → CaAJD3dl.js} +1 -1
  373. package/dist/web-ui/_app/immutable/chunks/{BP352uRn.js → ChB5iyEL.js} +1 -1
  374. package/dist/web-ui/_app/immutable/chunks/{pE6cYWlS.js → ChQD-6N8.js} +1 -1
  375. package/dist/web-ui/_app/immutable/chunks/{Eo4gF7ih.js → CqbsoCwA.js} +1 -1
  376. package/dist/web-ui/_app/immutable/chunks/DCeJW50p.js +1 -0
  377. package/dist/web-ui/_app/immutable/chunks/{Cncwi6fQ.js → DJtZNgcs.js} +1 -1
  378. package/dist/web-ui/_app/immutable/chunks/DKIeaprD.js +1 -0
  379. package/dist/web-ui/_app/immutable/chunks/DLd2uVIA.js +1 -0
  380. package/dist/web-ui/_app/immutable/chunks/{DjcCz-PU.js → DW_subyT.js} +2 -2
  381. package/dist/web-ui/_app/immutable/chunks/DbU6lVn0.js +1 -0
  382. package/dist/web-ui/_app/immutable/chunks/Dc7ZCC5m.js +1 -0
  383. package/dist/web-ui/_app/immutable/chunks/Dd5umPsk.js +2 -0
  384. package/dist/web-ui/_app/immutable/chunks/{BysXJlZb.js → Dg_zDpDS.js} +1 -1
  385. package/dist/web-ui/_app/immutable/chunks/Dgqu8Yuc.js +1 -0
  386. package/dist/web-ui/_app/immutable/chunks/DmxsPZTB.js +1 -0
  387. package/dist/web-ui/_app/immutable/chunks/DphTaFUB.js +1 -0
  388. package/dist/web-ui/_app/immutable/chunks/DqK4iHp0.js +1 -0
  389. package/dist/web-ui/_app/immutable/chunks/{D9QNBZM2.js → DqT6OH_u.js} +2 -2
  390. package/dist/web-ui/_app/immutable/chunks/Ds9I9wQb.js +1 -0
  391. package/dist/web-ui/_app/immutable/chunks/Du5ng3u4.js +1 -0
  392. package/dist/web-ui/_app/immutable/chunks/DxJw79Wi.js +1 -0
  393. package/dist/web-ui/_app/immutable/chunks/GFTX8GgV.js +1 -0
  394. package/dist/web-ui/_app/immutable/chunks/{C076q4JN.js → HNjs76Zz.js} +1 -1
  395. package/dist/web-ui/_app/immutable/chunks/HVMjDi4_.js +1 -0
  396. package/dist/web-ui/_app/immutable/chunks/{BkOJ8DkV.js → P0A_fJvS.js} +1 -1
  397. package/dist/web-ui/_app/immutable/chunks/T3vGWjIL.js +1 -0
  398. package/dist/web-ui/_app/immutable/chunks/VTmrX9Qu.js +1 -0
  399. package/dist/web-ui/_app/immutable/chunks/{k_Qegko0.js → Xvwhx_F1.js} +1 -1
  400. package/dist/web-ui/_app/immutable/chunks/Yyz1XMQA.js +1 -0
  401. package/dist/web-ui/_app/immutable/chunks/{62JVKtnb.js → dh5HeqUr.js} +1 -1
  402. package/dist/web-ui/_app/immutable/chunks/fZMteyca.js +62 -0
  403. package/dist/web-ui/_app/immutable/chunks/{D82RulSH.js → gPrj-hqC.js} +1 -1
  404. package/dist/web-ui/_app/immutable/chunks/htcWMiYN.js +1 -0
  405. package/dist/web-ui/_app/immutable/chunks/{CwELQvbx.js → oTsvd9y4.js} +1 -1
  406. package/dist/web-ui/_app/immutable/chunks/qJfLUwU4.js +1 -0
  407. package/dist/web-ui/_app/immutable/chunks/xCtiO_JE.js +1 -0
  408. package/dist/web-ui/_app/immutable/chunks/{DvA-KON-.js → y4GeEH6k.js} +1 -1
  409. package/dist/web-ui/_app/immutable/entry/app.C4h_eOn6.js +2 -0
  410. package/dist/web-ui/_app/immutable/entry/start.CQFTf9ep.js +1 -0
  411. package/dist/web-ui/_app/immutable/nodes/0.Dh1xO970.js +1 -0
  412. package/dist/web-ui/_app/immutable/nodes/1.l75D3Opx.js +1 -0
  413. package/dist/web-ui/_app/immutable/nodes/10.DBidBPc-.js +1 -0
  414. package/dist/web-ui/_app/immutable/nodes/11.Ab0gUKWe.js +1 -0
  415. package/dist/web-ui/_app/immutable/nodes/12.CMsnoxfs.js +1 -0
  416. package/dist/web-ui/_app/immutable/nodes/13.D8YKuknB.js +1 -0
  417. package/dist/web-ui/_app/immutable/nodes/14.DZ0aan7y.js +1 -0
  418. package/dist/web-ui/_app/immutable/nodes/15.CUIKreDL.js +2 -0
  419. package/dist/web-ui/_app/immutable/nodes/16.BWc8--BO.js +1 -0
  420. package/dist/web-ui/_app/immutable/nodes/2.CDUonbuh.js +1 -0
  421. package/dist/web-ui/_app/immutable/nodes/3.Ctg3M00i.js +1 -0
  422. package/dist/web-ui/_app/immutable/nodes/4.Ci-JDwbA.js +2 -0
  423. package/dist/web-ui/_app/immutable/nodes/5.CTyEDAq0.js +1 -0
  424. package/dist/web-ui/_app/immutable/nodes/6.BTZZqsAb.js +1 -0
  425. package/dist/web-ui/_app/immutable/nodes/7.BI52g_Jo.js +137 -0
  426. package/dist/web-ui/_app/immutable/nodes/8.3hZPaB9x.js +1 -0
  427. package/dist/web-ui/_app/immutable/nodes/9.DS49kvwl.js +29 -0
  428. package/dist/web-ui/_app/version.json +1 -1
  429. package/dist/web-ui/favicon-192.png +0 -0
  430. package/dist/web-ui/favicon-32.png +0 -0
  431. package/dist/web-ui/favicon.ico +0 -0
  432. package/dist/web-ui/index.html +14 -14
  433. package/package.json +12 -6
  434. package/plugin/.claude-plugin/marketplace.json +1 -1
  435. package/plugin/.claude-plugin/plugin.json +1 -1
  436. package/plugin/plugins/kspec/skills/merge/SKILL.md +127 -0
  437. package/plugin/plugins/kspec/skills/plan/SKILL.md +55 -26
  438. package/plugin/plugins/kspec/skills/review/SKILL.md +350 -133
  439. package/plugin/plugins/kspec/skills/task-work/SKILL.md +96 -106
  440. package/templates/agents-sections/04-pr-workflow.md +15 -12
  441. package/templates/agents-sections/06-ralph-loop.md +15 -10
  442. package/templates/skills/manifest.yaml +25 -7
  443. package/templates/skills/merge/SKILL.md +120 -0
  444. package/templates/skills/plan/SKILL.md +55 -26
  445. package/templates/skills/review/SKILL.md +346 -130
  446. package/templates/skills/task-work/SKILL.md +93 -103
  447. package/dist/web-ui/_app/immutable/assets/0.BJaYkGW2.css +0 -1
  448. package/dist/web-ui/_app/immutable/assets/9.SzGLxi4x.css +0 -1
  449. package/dist/web-ui/_app/immutable/chunks/-lc0BifF.js +0 -1
  450. package/dist/web-ui/_app/immutable/chunks/8RBjHMN1.js +0 -1
  451. package/dist/web-ui/_app/immutable/chunks/B5wTVqxm.js +0 -1
  452. package/dist/web-ui/_app/immutable/chunks/B6VSmczZ.js +0 -1
  453. package/dist/web-ui/_app/immutable/chunks/BEOQc37C.js +0 -1
  454. package/dist/web-ui/_app/immutable/chunks/BHtYorjv.js +0 -1
  455. package/dist/web-ui/_app/immutable/chunks/BMuCqDX8.js +0 -1
  456. package/dist/web-ui/_app/immutable/chunks/BUZujXJ2.js +0 -1
  457. package/dist/web-ui/_app/immutable/chunks/BWET-efb.js +0 -1
  458. package/dist/web-ui/_app/immutable/chunks/BXkNecpt.js +0 -1
  459. package/dist/web-ui/_app/immutable/chunks/BYzrIfX8.js +0 -1
  460. package/dist/web-ui/_app/immutable/chunks/BpuwufMc.js +0 -1
  461. package/dist/web-ui/_app/immutable/chunks/BwMO4RrG.js +0 -1
  462. package/dist/web-ui/_app/immutable/chunks/C33JaVbg.js +0 -1
  463. package/dist/web-ui/_app/immutable/chunks/CGtqifKp.js +0 -1
  464. package/dist/web-ui/_app/immutable/chunks/CHDZZ7OG.js +0 -1
  465. package/dist/web-ui/_app/immutable/chunks/CUir3f4J.js +0 -60
  466. package/dist/web-ui/_app/immutable/chunks/CrCIbn0C.js +0 -1
  467. package/dist/web-ui/_app/immutable/chunks/D6TVmR9T.js +0 -1
  468. package/dist/web-ui/_app/immutable/chunks/D7LTux4W.js +0 -1
  469. package/dist/web-ui/_app/immutable/chunks/DAh4Wfku.js +0 -1
  470. package/dist/web-ui/_app/immutable/chunks/DAx07bEQ.js +0 -1
  471. package/dist/web-ui/_app/immutable/chunks/DOno4cA2.js +0 -1
  472. package/dist/web-ui/_app/immutable/chunks/DQA8NZIH.js +0 -2
  473. package/dist/web-ui/_app/immutable/chunks/DRfPm2bo.js +0 -1
  474. package/dist/web-ui/_app/immutable/chunks/DhQhksaB.js +0 -1
  475. package/dist/web-ui/_app/immutable/chunks/DjG7s6hm.js +0 -1
  476. package/dist/web-ui/_app/immutable/chunks/DkltRNvh.js +0 -1
  477. package/dist/web-ui/_app/immutable/chunks/DlaTnPKL.js +0 -1
  478. package/dist/web-ui/_app/immutable/chunks/ExCq5swK.js +0 -1
  479. package/dist/web-ui/_app/immutable/chunks/T3zZGv51.js +0 -1
  480. package/dist/web-ui/_app/immutable/chunks/XZumBYeP.js +0 -1
  481. package/dist/web-ui/_app/immutable/chunks/_ySfNjkF.js +0 -1
  482. package/dist/web-ui/_app/immutable/chunks/iEtR5cV6.js +0 -1
  483. package/dist/web-ui/_app/immutable/entry/app.Cgu6uKeS.js +0 -2
  484. package/dist/web-ui/_app/immutable/entry/start.9XifnLoB.js +0 -1
  485. package/dist/web-ui/_app/immutable/nodes/0.DISwcKSK.js +0 -1
  486. package/dist/web-ui/_app/immutable/nodes/1.Cx2Ufqp1.js +0 -1
  487. package/dist/web-ui/_app/immutable/nodes/10.C3z8ijXL.js +0 -1
  488. package/dist/web-ui/_app/immutable/nodes/11.DZdIjZmM.js +0 -1
  489. package/dist/web-ui/_app/immutable/nodes/12.FsIGfAOa.js +0 -1
  490. package/dist/web-ui/_app/immutable/nodes/13.DZoFwagf.js +0 -1
  491. package/dist/web-ui/_app/immutable/nodes/14.DaIzDKbQ.js +0 -1
  492. package/dist/web-ui/_app/immutable/nodes/15.BYyt4XWF.js +0 -2
  493. package/dist/web-ui/_app/immutable/nodes/16.CQkSqpOe.js +0 -1
  494. package/dist/web-ui/_app/immutable/nodes/2.Bkf_j2UJ.js +0 -1
  495. package/dist/web-ui/_app/immutable/nodes/3.kaMCurJG.js +0 -1
  496. package/dist/web-ui/_app/immutable/nodes/4.BSsFPTHG.js +0 -2
  497. package/dist/web-ui/_app/immutable/nodes/5.CpPlcCEZ.js +0 -1
  498. package/dist/web-ui/_app/immutable/nodes/6.BN4FqQmY.js +0 -1
  499. package/dist/web-ui/_app/immutable/nodes/7.9kBYIZik.js +0 -1
  500. package/dist/web-ui/_app/immutable/nodes/8.BuijtZ6B.js +0 -1
  501. package/dist/web-ui/_app/immutable/nodes/9.C-Weba8R.js +0 -1
@@ -12,9 +12,15 @@
12
12
  import * as path from "node:path";
13
13
  import { spawnSync } from "node:child_process";
14
14
  import { ulid } from "ulid";
15
- import { initContext, loadAllTasks, loadMetaContext, } from "../parser/index.js";
16
- import { runInvocation } from "./invocation.js";
15
+ import { initContext, loadAllTasks, loadMetaContext, areDependenciesMet, loadReviewRecords, } from "../parser/index.js";
16
+ import { DEFAULT_KSPEC_CLI_PATH, runInvocation } from "./invocation.js";
17
+ import { loadProjectConfig } from "../parser/config.js";
18
+ import { SessionEventAccumulator } from "./session-event-accumulator.js";
19
+ import { interpolateTemplate, rewriteSkillReferencesForAdapter, } from "./prompts.js";
17
20
  import { getAdapter } from "../agents/adapters.js";
21
+ import { provisionDispatchWorkspace, DispatchWorkspaceError, getDispatchShadowMutationLockPath, markDispatchWorkspaceActive, markDispatchWorkspaceIdle, reconcileDispatchWorkspaceRegistry, getDispatchWorkspaceHealth, reconcileDispatchWorkspaceLifecycle, cleanupReviewerDispatchWorkspace, reconcileDispatchWorkspaceArtifacts, discoverWorkspaceForReviewOrFixCycle, } from "./workspace.js";
22
+ import { ensureWorkspaceBootstrap, DispatchBootstrapError, } from "./bootstrap.js";
23
+ import { getSessionCache } from "../sessions/cache.js";
18
24
  // ─── Simple Mutex ─────────────────────────────────────────────────────────────
19
25
  /**
20
26
  * A minimal promise-based mutex for serializing async operations.
@@ -79,22 +85,16 @@ const STATUS_TO_EVENT = {
79
85
  const STATUS_PRECEDENCE = {
80
86
  in_progress: 0,
81
87
  needs_work: 1,
82
- pending: 2,
83
- pending_review: 3,
88
+ pending_review: 2,
89
+ pending: 3,
84
90
  blocked: 4,
85
91
  completed: 5,
86
92
  cancelled: 6,
87
93
  };
88
- // ─── Prompt Helpers (exported for testing) ───────────────────────────────────
89
- /**
90
- * Interpolate {{variable}} placeholders in a prompt template.
91
- * Unresolved variables pass through unchanged.
92
- *
93
- * AC: @agent-dispatch-engine ac-16
94
- */
95
- export function interpolateTemplate(template, vars) {
96
- return template.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
97
- }
94
+ const CONTINUITY_STARVATION_THRESHOLD = 2;
95
+ // ─── Prompt Helpers ──────────────────────────────────────────────────────────
96
+ // AC: @agent-dispatch-engine ac-16 re-exported from prompts.ts for backwards compat
97
+ export { interpolateTemplate };
98
98
  /**
99
99
  * Human-readable trigger description for orientation context.
100
100
  */
@@ -112,6 +112,21 @@ function triggerDescription(trigger) {
112
112
  return `Trigger: ${trigger}`;
113
113
  }
114
114
  }
115
+ function focusDescription(trigger, role) {
116
+ if (role === "reviewer") {
117
+ return "Review the submitted changes in this snapshot and decide whether the task should advance or return for fixes.";
118
+ }
119
+ if (trigger === "task.needs_work") {
120
+ return "Resume the canonical worker branch, address review findings, and move the task back toward review.";
121
+ }
122
+ if (trigger === "task.in_progress") {
123
+ return "Resume the existing canonical worker branch and continue the in-flight implementation.";
124
+ }
125
+ return "Work the assigned task in this mutable workspace and move it to the next appropriate state.";
126
+ }
127
+ function shortSha(commit) {
128
+ return commit ? commit.slice(0, 12) : "(unavailable)";
129
+ }
115
130
  /**
116
131
  * Format recent notes for inclusion in dispatch prompts.
117
132
  * Takes last N notes, truncates each to maxLen characters, strips newlines.
@@ -128,19 +143,224 @@ function formatRecentNotes(notes, count = 3, maxLen = 200) {
128
143
  });
129
144
  return lines.join("\n");
130
145
  }
146
+ class DispatchPromptError extends Error {
147
+ suggestion;
148
+ constructor(message, suggestion) {
149
+ super(message);
150
+ this.name = "DispatchPromptError";
151
+ this.suggestion = suggestion;
152
+ }
153
+ }
154
+ function resolveDispatchRole(trigger) {
155
+ return trigger === "task.pending_review" ? "reviewer" : "worker";
156
+ }
157
+ async function renderEntrypointForAdapter(entrypoint, adapterId, projectDir) {
158
+ const trimmed = entrypoint.trim();
159
+ if (!trimmed) {
160
+ return trimmed;
161
+ }
162
+ const portableResolved = await rewriteSkillReferencesForAdapter(trimmed, projectDir, adapterId);
163
+ if (portableResolved !== trimmed) {
164
+ return portableResolved.trim();
165
+ }
166
+ switch (adapterId) {
167
+ case "codex-acp":
168
+ return trimmed
169
+ .replace(/^\/kspec:([a-z0-9][a-z0-9-]*)$/i, "$kspec-$1")
170
+ .replace(/^\/([a-z0-9][a-z0-9-]*)$/i, "$$$1");
171
+ case "claude-agent-acp":
172
+ case "claude-code-acp":
173
+ return trimmed
174
+ .replace(/^\$kspec-([a-z0-9][a-z0-9-]*)$/i, "/kspec:$1")
175
+ .replace(/^\$([a-z0-9][a-z0-9-]*)$/i, "/$1");
176
+ default:
177
+ return trimmed;
178
+ }
179
+ }
180
+ async function resolveRoleEntrypoint(role, adapterId, projectDir, config) {
181
+ const rawEntrypoint = role === "reviewer"
182
+ ? config.ralph.skills.pr_review
183
+ : config.ralph.skills.task_work;
184
+ const rendered = await renderEntrypointForAdapter(rawEntrypoint, adapterId, projectDir);
185
+ if (!rendered) {
186
+ throw new DispatchPromptError(`No valid ${role} entrypoint is configured for adapter "${adapterId}".`, `Set ralph.skills.${role === "reviewer" ? "pr_review" : "task_work"} in kspec.config.yaml to a non-empty workflow or skill entrypoint.`);
187
+ }
188
+ return rendered;
189
+ }
190
+ function buildPublicationInstructions(role, metadata) {
191
+ const lines = [
192
+ `Publication mode: \`${metadata.publicationMode}\``,
193
+ `Publish target: \`${metadata.mergeTargetBranch}\``,
194
+ `Canonical branch: \`${metadata.canonicalBranch}\``,
195
+ ];
196
+ if (metadata.publicationMode === "pull_request") {
197
+ if (role === "reviewer") {
198
+ lines.push(`Review and merge the PR that targets \`${metadata.mergeTargetBranch}\`; do not retarget it to a different base branch.`, "If you push fixes during review, re-run the required verification on the new HEAD before merging.");
199
+ }
200
+ else {
201
+ lines.push(`After submitting the task, create or update a PR from \`${metadata.canonicalBranch}\` into \`${metadata.mergeTargetBranch}\` using the recorded base branch as the PR target.`);
202
+ }
203
+ return lines;
204
+ }
205
+ if (metadata.publicationMode === "manual_merge") {
206
+ if (role === "reviewer") {
207
+ lines.push(`If review is clean, merge \`${metadata.canonicalBranch}\` back into \`${metadata.mergeTargetBranch}\` manually against the recorded base branch.`, `If conflicts appear, stop, run \`git merge --abort\`, and move the task to \`needs_work\` or \`blocked\` with a note describing the conflict. Do not guess at conflict resolution.`);
208
+ }
209
+ else {
210
+ lines.push(`Manual merge-back is recorded for this workspace. Submit the task for review; do not open a PR against \`${metadata.mergeTargetBranch}\`.`, `If you must prepare the merge path, keep the work on \`${metadata.canonicalBranch}\` and hand review a clean branch lineage back to \`${metadata.mergeTargetBranch}\`.`);
211
+ }
212
+ return lines;
213
+ }
214
+ throw new DispatchPromptError(`Workspace publication mode "${metadata.publicationMode}" is invalid.`, "Re-provision the dispatch workspace or repair its metadata so publicationMode is pull_request or manual_merge.");
215
+ }
216
+ async function buildRoleEntryContext(projectDir, adapterId, trigger, metadata) {
217
+ const role = resolveDispatchRole(trigger);
218
+ const { config } = await loadProjectConfig(projectDir, projectDir);
219
+ const entrypoint = await resolveRoleEntrypoint(role, adapterId, projectDir, config);
220
+ const publication = buildPublicationInstructions(role, metadata);
221
+ return [
222
+ "## Role Entry",
223
+ `Role: ${role}`,
224
+ `Workflow entrypoint: \`${entrypoint}\``,
225
+ `Start by executing the ${role === "reviewer" ? "review" : "work"} flow defined by \`${entrypoint}\`.`,
226
+ ...publication,
227
+ ].join("\n");
228
+ }
229
+ /**
230
+ * Find the examined commit from the most recent closed review for a task.
231
+ * Returns null when no prior examined commit exists.
232
+ *
233
+ * AC: @review-fix-cycle-diff ac-2 — find prior review's examined commit
234
+ */
235
+ export function findPriorExaminedCommit(reviews, taskRef) {
236
+ const cleanRef = taskRef.startsWith("@") ? taskRef.slice(1) : taskRef;
237
+ const taskReviews = reviews.filter((r) => r.related_refs.includes(cleanRef)
238
+ || (r.subject.type === "task" && "ref" in r.subject && r.subject.ref === cleanRef));
239
+ const closedWithCommit = taskReviews
240
+ .filter((r) => r.lifecycle_state === "closed" && r.examined_commit)
241
+ .sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
242
+ if (closedWithCommit.length === 0)
243
+ return null;
244
+ return closedWithCommit[0].examined_commit;
245
+ }
246
+ /**
247
+ * Compute a git diff --stat between two commits. Returns null on any error.
248
+ *
249
+ * AC: @review-fix-cycle-diff ac-3 — graceful omission on unreachable commits
250
+ */
251
+ export function computeDiffStat(fromCommit, toCommit, cwd) {
252
+ try {
253
+ const result = spawnSync("git", ["diff", "--stat", fromCommit, toCommit], { cwd, encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
254
+ if (result.status !== 0)
255
+ return null;
256
+ const stat = result.stdout?.trim();
257
+ if (!stat)
258
+ return null;
259
+ return [
260
+ `Changes since prior review (${shortSha(fromCommit)}..${shortSha(toCommit)}):`,
261
+ stat,
262
+ ].join("\n");
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }
268
+ /**
269
+ * Compute a diff summary between the prior review's examined commit and the
270
+ * current canonical branch head. Returns null when no prior examined commit
271
+ * exists or when the diff cannot be computed (unreachable commits, etc.).
272
+ *
273
+ * AC: @review-fix-cycle-diff ac-2 — diff summary for reviewer orientation
274
+ * AC: @review-fix-cycle-diff ac-3 — graceful omission on unreachable commits
275
+ */
276
+ export async function getFixCycleDiffSummary(projectDir, taskRef, canonicalBranchHead, workspaceCwd) {
277
+ if (!canonicalBranchHead)
278
+ return null;
279
+ try {
280
+ const ctx = await initContext(projectDir);
281
+ const reviews = await loadReviewRecords(ctx);
282
+ const priorCommit = findPriorExaminedCommit(reviews, taskRef);
283
+ if (!priorCommit)
284
+ return null;
285
+ return computeDiffStat(priorCommit, canonicalBranchHead, workspaceCwd ?? projectDir);
286
+ }
287
+ catch {
288
+ // AC: @review-fix-cycle-diff ac-3 — graceful omission on any error
289
+ return null;
290
+ }
291
+ }
131
292
  /**
132
293
  * Build orientation context block for a dispatch prompt.
133
294
  * Provides the agent with task title, trigger meaning, and relevant context.
134
295
  *
135
296
  * AC: @agent-dispatch-engine ac-13, ac-14, ac-15
136
297
  */
137
- export function buildOrientationContext(taskRef, trigger, task) {
298
+ export function buildOrientationContext(taskRef, trigger, workspaceOrTask, taskOrMetadata, metadataOrRole, explicitRole, options) {
299
+ const usingProvisionedWorkspace = typeof workspaceOrTask === "object"
300
+ && workspaceOrTask !== null
301
+ && "cwd" in workspaceOrTask
302
+ && "metadata" in workspaceOrTask;
303
+ const role = explicitRole
304
+ ?? (usingProvisionedWorkspace
305
+ ? (trigger === "task.pending_review" ? "reviewer" : "worker")
306
+ : (metadataOrRole
307
+ ?? (trigger === "task.pending_review" ? "reviewer" : "worker")));
308
+ const workspace = usingProvisionedWorkspace
309
+ ? workspaceOrTask
310
+ : null;
311
+ const task = usingProvisionedWorkspace
312
+ ? taskOrMetadata
313
+ : workspaceOrTask;
314
+ const metadata = (usingProvisionedWorkspace
315
+ ? workspaceOrTask.metadata
316
+ : taskOrMetadata) ?? null;
138
317
  const title = task?.title ?? "(unavailable)";
318
+ const bootstrapRoleState = metadata?.bootstrap?.roleStates?.[role];
319
+ const workspacePath = workspace?.cwd
320
+ ?? (role === "reviewer" ? metadata?.reviewerWorktreeDir : metadata?.workerWorktreeDir)
321
+ ?? "(unavailable)";
322
+ const workspaceMode = role === "reviewer" ? "detached review snapshot" : "mutable worker branch";
323
+ const bootstrapSummary = !bootstrapRoleState
324
+ ? "not available"
325
+ : bootstrapRoleState.status === "succeeded"
326
+ ? role === "reviewer" && bootstrapRoleState.steps.length === 0
327
+ ? "reused worker bootstrap"
328
+ : "prepared"
329
+ : bootstrapRoleState.status === "failed"
330
+ ? `failed${bootstrapRoleState.failureMessage ? ` (${bootstrapRoleState.failureMessage})` : ""}`
331
+ : "not run";
332
+ const dependencyStatus = bootstrapRoleState && bootstrapRoleState.invalidationReasons.length > 0
333
+ ? bootstrapRoleState.invalidationReasons.join("; ")
334
+ : "satisfied";
335
+ const healthSummary = metadata?.healthStatus === "healthy"
336
+ ? "ready"
337
+ : metadata?.healthReason
338
+ ? `${metadata.healthStatus} (${metadata.healthReason})`
339
+ : (metadata?.healthStatus ?? "unknown");
340
+ const canonicalHeadContext = role === "reviewer"
341
+ ? `${shortSha(metadata?.canonicalBranchHead)} (snapshot under review)`
342
+ : `${shortSha(metadata?.canonicalBranchHead)} (canonical branch head to resume)`;
139
343
  const lines = [
140
344
  "## Task Context",
141
345
  `Task: ${taskRef} \u2014 "${title}"`,
142
- `Trigger: ${triggerDescription(trigger)}`,
346
+ `Selection reason: ${triggerDescription(trigger)}`,
347
+ `Role: ${role}`,
348
+ `Focus: ${focusDescription(trigger, role)}`,
349
+ `Workspace (your working directory): ${workspacePath}`,
350
+ `Workspace mode: ${workspaceMode}`,
351
+ `Canonical branch: ${metadata?.canonicalBranch ?? "(unavailable)"}`,
352
+ `Integration target: ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}`,
353
+ `Canonical head: ${canonicalHeadContext}`,
354
+ `Bootstrap state: ${bootstrapSummary}`,
355
+ `Workspace health: ${healthSummary}`,
356
+ `Dependency status: ${dependencyStatus}`,
143
357
  ];
358
+ if (role === "reviewer") {
359
+ lines.push(`Prepared state: Detached reviewer snapshot at ${shortSha(metadata?.canonicalBranchHead)}. The mutable worker branch remains ${metadata?.canonicalBranch ?? "(unavailable)"}.`);
360
+ }
361
+ else {
362
+ lines.push(`Prepared state: Mutable worker worktree attached to ${metadata?.canonicalBranch ?? "(unavailable)"} under ${metadata?.worktreeRoot ?? "(unavailable)"}.`);
363
+ }
144
364
  // AC: @agent-dispatch-engine ac-14 - Include recent notes for fix cycles
145
365
  if (trigger === "task.needs_work" && task?.notes && task.notes.length > 0) {
146
366
  const noteText = formatRecentNotes(task.notes);
@@ -152,9 +372,35 @@ export function buildOrientationContext(taskRef, trigger, task) {
152
372
  if (trigger === "task.pending_review") {
153
373
  const url = task?.review_url ?? "Not provided \u2014 find PR via task notes or git log.";
154
374
  lines.push(`Review URL: ${url}`);
375
+ lines.push(`Cycle context: Review cycle on a detached snapshot. If changes are kicked back, the follow-up worker resumes ${metadata?.canonicalBranch ?? "(unavailable)"} and still publishes against ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}.`);
376
+ // AC: @review-fix-cycle-diff ac-2 — Include fix-cycle diff summary for reviewer
377
+ if (options?.fixCycleDiffSummary) {
378
+ lines.push("", "## Fix-Cycle Diff", options.fixCycleDiffSummary);
379
+ }
380
+ }
381
+ if (trigger === "task.needs_work") {
382
+ lines.push(`Cycle context: Fix cycle after review. You are resuming ${metadata?.canonicalBranch ?? "(unavailable)"}; publication still targets ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}.`);
155
383
  }
384
+ const publicationGuidance = metadata?.publicationMode === "pull_request"
385
+ ? `- Publish via PR: create or update a pull request from ${metadata.canonicalBranch} into ${metadata.integrationTargetBranch}.`
386
+ : `- Publish via manual merge: merge ${metadata?.canonicalBranch ?? "(unavailable)"} back into ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}; if conflicts occur, stop and escalate with the conflict details instead of improvising.`;
387
+ lines.push("", "Dispatch Branch Context:", `- Canonical branch: ${metadata?.canonicalBranch ?? "(unavailable)"}`, `- Integration target: ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"} @ ${metadata?.integrationTargetCommit ?? metadata?.baseBranchPoint ?? "(unavailable)"}`, `- Publication mode: ${metadata?.publicationMode ?? "manual_merge"}`, role === "reviewer"
388
+ ? `- Snapshot under review: ${metadata?.canonicalBranchHead ?? "(unavailable)"}`
389
+ : `- Canonical head: ${metadata?.canonicalBranchHead ?? "(unavailable)"}`, publicationGuidance);
156
390
  return lines.join("\n");
157
391
  }
392
+ function resolveCleanupStateForTaskChange(change) {
393
+ if (change.toStatus === "completed") {
394
+ return { integrationState: "merged", taskStatus: "completed" };
395
+ }
396
+ if (change.toStatus === "cancelled") {
397
+ return { integrationState: "abandoned", taskStatus: "cancelled" };
398
+ }
399
+ if (change.fromStatus === "completed" || change.fromStatus === "cancelled") {
400
+ return { integrationState: "reset", taskStatus: change.toStatus };
401
+ }
402
+ return null;
403
+ }
158
404
  // ─── DispatchEngine ───────────────────────────────────────────────────────────
159
405
  /**
160
406
  * The core dispatch runtime.
@@ -173,9 +419,13 @@ export class DispatchEngine {
173
419
  cwd;
174
420
  dedupWindowMs;
175
421
  reconcileIntervalMs;
422
+ /** AC: @per-task-dispatch-drain-coalescing ac-4 */
423
+ coalesceWindowMs;
176
424
  kspecCliPath;
177
425
  onInvocationEvent;
178
- onTextChunk;
426
+ onSessionEvent;
427
+ /** Per-session text accumulator for newline-boundary streaming. */
428
+ accumulator = new SessionEventAccumulator();
179
429
  /** Queue of pending dispatch entries, per agent id */
180
430
  queues = new Map();
181
431
  /** Count of active (running) invocations per agent id */
@@ -194,10 +444,22 @@ export class DispatchEngine {
194
444
  invocationAbortControllers = new Set();
195
445
  /** Per-invocation tracking records for status display */
196
446
  activeInvocationDetails = new Map();
447
+ /** Task refs currently between queue removal and active tracking registration */
448
+ inFlightTaskKeys = new Set();
197
449
  /** Monotonic enqueue sequence for deterministic queue ordering */
198
450
  nextQueueSequence = 0;
451
+ /** Last task selected/completed, used as continuity affinity signal. */
452
+ recentTaskAffinityRef = null;
199
453
  /** Timer handle for periodic reconciliation. AC: @agent-dispatch-engine ac-20 */
200
454
  reconcileTimer = null;
455
+ /** All in-flight reconciliation promises so stop() can await every one. */
456
+ inFlightReconciles = new Set();
457
+ /** Per-task coalescing timers. AC: @per-task-dispatch-drain-coalescing ac-1 */
458
+ coalesceTimers = new Map();
459
+ /** Whether a drain is currently in progress. AC: @per-task-dispatch-drain-coalescing ac-8 */
460
+ drainInProgress = false;
461
+ /** Whether another drain was requested while one is already running. AC: @per-task-dispatch-drain-coalescing ac-8 */
462
+ drainPending = false;
201
463
  constructor(options) {
202
464
  this.projectDir = options.projectDir;
203
465
  this.specDir = options.specDir ?? path.join(options.projectDir, ".kspec");
@@ -206,9 +468,11 @@ export class DispatchEngine {
206
468
  this.reconcileIntervalMs = (options.reconcileIntervalMs === null || options.reconcileIntervalMs === 0)
207
469
  ? 0
208
470
  : (options.reconcileIntervalMs ?? 60_000);
471
+ // AC: @per-task-dispatch-drain-coalescing ac-4
472
+ this.coalesceWindowMs = options.coalesceWindowMs ?? 5000;
209
473
  this.kspecCliPath = options.kspecCliPath;
210
474
  this.onInvocationEvent = options.onInvocationEvent;
211
- this.onTextChunk = options.onTextChunk;
475
+ this.onSessionEvent = options.onSessionEvent;
212
476
  }
213
477
  // ─── Public API ─────────────────────────────────────────────────────────────
214
478
  /**
@@ -219,15 +483,32 @@ export class DispatchEngine {
219
483
  */
220
484
  async start() {
221
485
  this.running = true;
486
+ try {
487
+ const ctx = await initContext(this.projectDir);
488
+ const tasks = await loadAllTasks(ctx);
489
+ const taskStatusByRef = new Map(tasks.map((task) => [`@${task._ulid}`, task.status]));
490
+ await this.shadowMutex.runExclusive(async () => {
491
+ await reconcileDispatchWorkspaceRegistry(this.projectDir, taskStatusByRef);
492
+ });
493
+ }
494
+ catch (err) {
495
+ console.error("[dispatch] Workspace registry reconciliation error:", err);
496
+ }
497
+ await reconcileDispatchWorkspaceArtifacts(this.projectDir, {
498
+ activeTaskRefs: this._activeTaskRefs(),
499
+ });
222
500
  // AC: @agent-dispatch-engine ac-8 - Bootstrap: evaluate existing task states
223
501
  await this._bootstrap();
224
502
  // AC: @agent-dispatch-engine ac-19, ac-20 - Start periodic reconciliation
225
503
  if (this.reconcileIntervalMs > 0) {
226
504
  this.reconcileTimer = setInterval(() => {
227
505
  if (this.running) {
228
- this._reconcile().catch((err) => {
506
+ const p = this._reconcile().catch((err) => {
229
507
  console.error("[dispatch] Reconciliation error:", err);
508
+ }).finally(() => {
509
+ this.inFlightReconciles.delete(p);
230
510
  });
511
+ this.inFlightReconciles.add(p);
231
512
  }
232
513
  }, this.reconcileIntervalMs);
233
514
  this.reconcileTimer.unref();
@@ -246,21 +527,54 @@ export class DispatchEngine {
246
527
  return;
247
528
  }
248
529
  this._recordEvent(change);
530
+ const cleanupState = resolveCleanupStateForTaskChange(change);
531
+ if (cleanupState) {
532
+ try {
533
+ await this.shadowMutex.runExclusive(async () => {
534
+ await reconcileDispatchWorkspaceLifecycle({
535
+ projectDir: this.projectDir,
536
+ taskRef: change.taskRef,
537
+ task: change.task
538
+ ? {
539
+ title: change.task.title,
540
+ slugs: change.task.slugs,
541
+ }
542
+ : undefined,
543
+ cleanupState,
544
+ });
545
+ });
546
+ }
547
+ catch (err) {
548
+ const message = err instanceof Error ? err.message : String(err);
549
+ console.error(`[dispatch] Failed to reconcile workspace lifecycle for ${change.taskRef}: ${message}`);
550
+ }
551
+ }
249
552
  // AC: @agent-dispatch-engine ac-1 - Match against dispatch rules
250
553
  const agents = await this._loadAgents();
251
554
  const eventType = STATUS_TO_EVENT[change.toStatus];
252
555
  if (!eventType)
253
556
  return;
254
- // Load task data for filter evaluation if not provided
557
+ // Load all tasks for filter evaluation (needed for dependency checks)
558
+ let allTasks;
255
559
  let taskData = change.task;
256
560
  if (!taskData && change.taskId) {
257
561
  try {
258
562
  const ctx = await initContext(this.projectDir);
259
- const tasks = await loadAllTasks(ctx);
260
- taskData = tasks.find((t) => t._ulid === change.taskId);
563
+ allTasks = await loadAllTasks(ctx);
564
+ taskData = allTasks.find((t) => t._ulid === change.taskId);
261
565
  }
262
566
  catch {
263
- // Can't load task, filter evaluation will be lenient
567
+ // Can't load tasks, filter evaluation will be lenient
568
+ }
569
+ }
570
+ // Load allTasks for dependency checking even when task data was provided
571
+ if (!allTasks && taskData) {
572
+ try {
573
+ const ctx = await initContext(this.projectDir);
574
+ allTasks = await loadAllTasks(ctx);
575
+ }
576
+ catch {
577
+ // Can't load tasks, dependency check will be skipped
264
578
  }
265
579
  }
266
580
  // Make loaded task available for prompt building (AC: @agent-dispatch-engine ac-13)
@@ -272,14 +586,22 @@ export class DispatchEngine {
272
586
  if (rule.on !== eventType)
273
587
  continue;
274
588
  // AC: @agent-dispatch-engine ac-6 - Apply filters
275
- if (!this._matchesFilter(change, rule, taskData))
589
+ if (!this._matchesFilter(change, rule, taskData, allTasks))
276
590
  continue;
277
591
  // AC: @agent-dispatch-engine ac-2 - Each matching agent queued independently
278
592
  this._enqueue(agent, change);
279
593
  }
280
594
  }
281
- // Drain queues after enqueuing
282
- await this._drainQueues(agents);
595
+ // AC: @per-task-dispatch-drain-coalescing ac-1, ac-4, ac-6
596
+ // Schedule a per-task coalescing timer instead of draining immediately.
597
+ // If coalesceWindowMs is 0, drain immediately for backward compatibility.
598
+ // AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
599
+ if (this.coalesceWindowMs <= 0) {
600
+ await this._serializedDrain();
601
+ }
602
+ else {
603
+ this._scheduleCoalescedDrain(change.taskId);
604
+ }
283
605
  }
284
606
  /**
285
607
  * Handle file watcher notification: diff previous vs current task states.
@@ -327,20 +649,39 @@ export class DispatchEngine {
327
649
  */
328
650
  async stop() {
329
651
  this.running = false;
652
+ // AC: @per-task-dispatch-drain-coalescing ac-5 - Cancel all pending coalescing timers
653
+ for (const timer of this.coalesceTimers.values()) {
654
+ clearTimeout(timer);
655
+ }
656
+ this.coalesceTimers.clear();
330
657
  // AC: @agent-dispatch-engine ac-20 - Stop periodic reconciliation
331
658
  if (this.reconcileTimer !== null) {
332
659
  clearInterval(this.reconcileTimer);
333
660
  this.reconcileTimer = null;
334
661
  }
662
+ // Wait for ALL in-flight reconciliations to finish so none
663
+ // write files after stop() returns (prevents ENOTEMPTY in test teardown).
664
+ if (this.inFlightReconciles.size > 0) {
665
+ await Promise.allSettled(Array.from(this.inFlightReconciles));
666
+ this.inFlightReconciles.clear();
667
+ }
668
+ // Clear queues BEFORE awaiting invocations so completion handlers
669
+ // that call _drainQueues find nothing to spawn. This prevents
670
+ // second-generation invocations from being added to runningInvocations
671
+ // after our snapshot, eliminating the need for a while loop (which
672
+ // risks hanging indefinitely if an invocation never resolves).
673
+ this.queues.clear();
335
674
  // AC: @agent-dispatch-engine ac-11 - Send graceful cancel to all active invocations
336
675
  for (const controller of this.invocationAbortControllers) {
337
676
  controller.abort();
338
677
  }
339
- // Wait for all running invocations to complete (or abort)
678
+ // Wait for all running invocations to complete (or abort).
679
+ // Safe as a single pass: queues are already cleared above, and
680
+ // _spawnInvocation guards with !this.running, so no new promises
681
+ // can be added to runningInvocations during this await.
340
682
  if (this.runningInvocations.size > 0) {
341
683
  await Promise.allSettled(Array.from(this.runningInvocations));
342
684
  }
343
- this.queues.clear();
344
685
  this.activeCount.clear();
345
686
  this.recentEvents.clear();
346
687
  this.invocationAbortControllers.clear();
@@ -353,6 +694,9 @@ export class DispatchEngine {
353
694
  getShadowMutex() {
354
695
  return this.shadowMutex;
355
696
  }
697
+ getCwd() {
698
+ return this.cwd;
699
+ }
356
700
  /**
357
701
  * Returns current engine status info including per-invocation details.
358
702
  * AC: @cli-agent-commands ac-6
@@ -390,8 +734,8 @@ export class DispatchEngine {
390
734
  try {
391
735
  const enqueued = await this._evaluateAllTasks({ skipIfActive: false });
392
736
  if (enqueued > 0) {
393
- const agents = await this._loadAgents();
394
- await this._drainQueues(agents);
737
+ // AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
738
+ await this._serializedDrain();
395
739
  }
396
740
  }
397
741
  catch (err) {
@@ -404,11 +748,25 @@ export class DispatchEngine {
404
748
  * AC: @agent-dispatch-engine ac-19
405
749
  */
406
750
  async _reconcile() {
751
+ try {
752
+ const ctx = await initContext(this.projectDir);
753
+ const tasks = await loadAllTasks(ctx);
754
+ const taskStatusByRef = new Map(tasks.map((task) => [`@${task._ulid}`, task.status]));
755
+ await this.shadowMutex.runExclusive(async () => {
756
+ await reconcileDispatchWorkspaceRegistry(this.projectDir, taskStatusByRef, this._activeRoleByTaskRef());
757
+ });
758
+ }
759
+ catch (err) {
760
+ console.error("[dispatch] Workspace registry reconciliation error:", err);
761
+ }
762
+ await reconcileDispatchWorkspaceArtifacts(this.projectDir, {
763
+ activeTaskRefs: this._activeTaskRefs(),
764
+ });
407
765
  const enqueued = await this._evaluateAllTasks({ skipIfActive: true });
408
766
  if (enqueued > 0) {
409
767
  console.log(`[dispatch] Reconciliation enqueued ${enqueued} task(s)`);
410
- const agents = await this._loadAgents();
411
- await this._drainQueues(agents);
768
+ // AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
769
+ await this._serializedDrain();
412
770
  }
413
771
  }
414
772
  /**
@@ -447,7 +805,7 @@ export class DispatchEngine {
447
805
  timestamp: now,
448
806
  task,
449
807
  };
450
- if (!this._matchesFilter(change, rule, task))
808
+ if (!this._matchesFilter(change, rule, task, tasks))
451
809
  continue;
452
810
  if (opts.skipIfActive && this._hasActiveOrQueuedInvocation(agent.id, task._ulid))
453
811
  continue;
@@ -463,6 +821,9 @@ export class DispatchEngine {
463
821
  * AC: @agent-dispatch-engine ac-19
464
822
  */
465
823
  _hasActiveOrQueuedInvocation(agentId, taskId) {
824
+ if (this.inFlightTaskKeys.has(`${agentId}:@${taskId}`)) {
825
+ return true;
826
+ }
466
827
  // Check active invocations
467
828
  for (const record of this.activeInvocationDetails.values()) {
468
829
  if (record.agentId === agentId && record.taskRef === `@${taskId}`) {
@@ -473,6 +834,46 @@ export class DispatchEngine {
473
834
  const queue = this.queues.get(agentId) ?? [];
474
835
  return queue.some((entry) => entry.change.taskId === taskId);
475
836
  }
837
+ _activeRoleByTaskRef() {
838
+ const roles = new Map();
839
+ for (const record of this.activeInvocationDetails.values()) {
840
+ if (record.taskRef) {
841
+ roles.set(record.taskRef, record.role);
842
+ }
843
+ }
844
+ return roles;
845
+ }
846
+ _activeTaskRefs() {
847
+ const refs = new Set();
848
+ for (const record of this.activeInvocationDetails.values()) {
849
+ if (record.taskRef) {
850
+ refs.add(record.taskRef);
851
+ }
852
+ }
853
+ return refs;
854
+ }
855
+ /**
856
+ * Check whether any agent has an active or in-flight invocation for a task.
857
+ * Considers both registered active invocations (activeInvocationDetails) and
858
+ * tasks that are between queue removal and active registration (inFlightTaskKeys).
859
+ *
860
+ * AC: @agent-dispatch-engine ac-26
861
+ */
862
+ _hasActiveInvocationForTask(taskRef) {
863
+ // Check active invocations across all agents
864
+ for (const record of this.activeInvocationDetails.values()) {
865
+ if (record.taskRef === taskRef) {
866
+ return true;
867
+ }
868
+ }
869
+ // Check in-flight keys (format: "agentId:taskRef") across all agents
870
+ for (const key of this.inFlightTaskKeys) {
871
+ if (key.endsWith(`:${taskRef}`)) {
872
+ return true;
873
+ }
874
+ }
875
+ return false;
876
+ }
476
877
  /**
477
878
  * Load agent definitions from meta context.
478
879
  */
@@ -488,19 +889,42 @@ export class DispatchEngine {
488
889
  }
489
890
  /**
490
891
  * Check if a state change matches a dispatch rule's filters.
892
+ * Base readiness (deps, blocked_by) is checked before consumer filters
893
+ * per @trait-task-readiness ac-composable.
894
+ *
491
895
  * AC: @agent-dispatch-engine ac-6
896
+ * AC: @agent-dispatch-engine ac-21
897
+ * AC: @trait-task-readiness ac-deps
898
+ * AC: @trait-task-readiness ac-not-blocked
899
+ * AC: @trait-task-readiness ac-composable
492
900
  */
493
- _matchesFilter(change, rule, task) {
494
- if (!rule.filter)
495
- return true;
901
+ _matchesFilter(change, rule, task, allTasks) {
902
+ // AC: @agent-dispatch-engine ac-21 — default to automation: eligible for
903
+ // task.ready and task.needs_work when no filter is specified
904
+ const defaultsToEligible = rule.on === "task.ready" || rule.on === "task.needs_work";
496
905
  // We need the task to evaluate filters — if not provided, reject to avoid
497
906
  // enqueuing non-matching tasks (AC-6: all filters must match)
498
907
  if (!task)
908
+ return !rule.filter && !defaultsToEligible;
909
+ // Any unresolved blocker excludes the candidate from scheduling.
910
+ if ((task.blocked_by ?? []).length > 0) {
499
911
  return false;
500
- const { filter } = rule;
912
+ }
913
+ // Any unresolved dependency excludes the candidate from scheduling.
914
+ if (allTasks && (task.depends_on ?? []).length > 0) {
915
+ if (!areDependenciesMet(task, allTasks)) {
916
+ return false;
917
+ }
918
+ }
919
+ if (!rule.filter && !defaultsToEligible)
920
+ return true;
921
+ // AC: @trait-task-readiness ac-composable — consumer filters applied after base readiness
922
+ const filter = rule.filter ?? {};
923
+ // Apply default automation filter for task.ready/task.needs_work
924
+ const effectiveAutomation = filter.automation ?? (defaultsToEligible ? "eligible" : undefined);
501
925
  // Automation filter
502
- if (filter.automation !== undefined) {
503
- if (task.automation !== filter.automation) {
926
+ if (effectiveAutomation !== undefined) {
927
+ if (task.automation !== effectiveAutomation) {
504
928
  return false;
505
929
  }
506
930
  }
@@ -511,9 +935,11 @@ export class DispatchEngine {
511
935
  return false;
512
936
  }
513
937
  }
514
- // Priority filter
938
+ // Priority filter — threshold semantics: task priority at or above (numerically <=)
939
+ // AC: @ui-agent-dispatch ac-8
515
940
  if (filter.priority !== undefined) {
516
- if (task.priority !== filter.priority) {
941
+ const taskPriority = task.priority;
942
+ if (taskPriority === undefined || taskPriority > filter.priority) {
517
943
  return false;
518
944
  }
519
945
  }
@@ -566,6 +992,7 @@ export class DispatchEngine {
566
992
  nextRetryAt: 0,
567
993
  enqueuedAtMs: Date.now(),
568
994
  sequence: this.nextQueueSequence++,
995
+ starvationDeferrals: 0,
569
996
  };
570
997
  this._insertQueueEntry(queue, entry);
571
998
  this.queues.set(agent.id, queue);
@@ -583,90 +1010,320 @@ export class DispatchEngine {
583
1010
  queue.splice(insertAt, 0, entry);
584
1011
  }
585
1012
  /**
586
- * Compare queue entries by dispatch precedence, then by enqueue sequence.
1013
+ * Compare queue entries by dispatch precedence, numeric task priority, then FIFO.
587
1014
  * AC: @dispatch-in-progress-priority ac-1
588
1015
  */
589
1016
  _compareQueueEntries(a, b) {
590
1017
  const statusDelta = STATUS_PRECEDENCE[a.change.toStatus] - STATUS_PRECEDENCE[b.change.toStatus];
591
1018
  if (statusDelta !== 0)
592
1019
  return statusDelta;
1020
+ const priorityDelta = this._taskPriorityForEntry(a) - this._taskPriorityForEntry(b);
1021
+ if (priorityDelta !== 0)
1022
+ return priorityDelta;
593
1023
  return a.sequence - b.sequence;
594
1024
  }
595
- /**
596
- * Drain queues, spawning invocations up to each agent's max_concurrent limit.
597
- * AC: @agent-dispatch-engine ac-3, ac-17
598
- */
599
- async _drainQueues(agents) {
600
- // Prevent new invocation starts during/after shutdown.
601
- if (!this.running)
1025
+ _taskPriorityForEntry(entry) {
1026
+ return entry.change.task?.priority ?? 3;
1027
+ }
1028
+ _hasContinuityAffinity(entry) {
1029
+ if (!this.recentTaskAffinityRef)
1030
+ return false;
1031
+ return entry.change.taskRef === this.recentTaskAffinityRef;
1032
+ }
1033
+ _compareSchedulerCandidates(a, b) {
1034
+ const statusDelta = STATUS_PRECEDENCE[a.entry.change.toStatus] - STATUS_PRECEDENCE[b.entry.change.toStatus];
1035
+ if (statusDelta !== 0) {
1036
+ return statusDelta;
1037
+ }
1038
+ const priorityDelta = this._taskPriorityForEntry(a.entry) - this._taskPriorityForEntry(b.entry);
1039
+ if (priorityDelta !== 0) {
1040
+ return priorityDelta;
1041
+ }
1042
+ const sameBand = STATUS_PRECEDENCE[a.entry.change.toStatus] === STATUS_PRECEDENCE[b.entry.change.toStatus];
1043
+ const samePriority = this._taskPriorityForEntry(a.entry) === this._taskPriorityForEntry(b.entry);
1044
+ if (sameBand && samePriority) {
1045
+ const aAffinity = this._hasContinuityAffinity(a.entry);
1046
+ const bAffinity = this._hasContinuityAffinity(b.entry);
1047
+ if (aAffinity !== bAffinity) {
1048
+ if (aAffinity && b.entry.starvationDeferrals < CONTINUITY_STARVATION_THRESHOLD) {
1049
+ return -1;
1050
+ }
1051
+ if (bAffinity && a.entry.starvationDeferrals < CONTINUITY_STARVATION_THRESHOLD) {
1052
+ return 1;
1053
+ }
1054
+ }
1055
+ }
1056
+ return a.entry.sequence - b.entry.sequence;
1057
+ }
1058
+ _recordContinuityDeferrals(selected, candidates) {
1059
+ const selectedAffinity = this._hasContinuityAffinity(selected.entry);
1060
+ if (!selectedAffinity) {
1061
+ selected.entry.starvationDeferrals = 0;
602
1062
  return;
603
- // AC: @agent-dispatch-engine ac-17 - Load current task states once for staleness checks
604
- let currentTaskStates;
605
- try {
606
- const ctx = await initContext(this.projectDir);
607
- const tasks = await loadAllTasks(ctx);
608
- currentTaskStates = new Map(tasks.map((t) => [t._ulid, t.status]));
609
1063
  }
610
- catch {
611
- // If we can't load tasks, skip staleness checks (best effort)
1064
+ const selectedBand = STATUS_PRECEDENCE[selected.entry.change.toStatus];
1065
+ const selectedPriority = this._taskPriorityForEntry(selected.entry);
1066
+ for (const candidate of candidates) {
1067
+ if (candidate.entry === selected.entry)
1068
+ continue;
1069
+ const sameBand = STATUS_PRECEDENCE[candidate.entry.change.toStatus] === selectedBand;
1070
+ const samePriority = this._taskPriorityForEntry(candidate.entry) === selectedPriority;
1071
+ if (!sameBand || !samePriority)
1072
+ continue;
1073
+ if (this._hasContinuityAffinity(candidate.entry))
1074
+ continue;
1075
+ candidate.entry.starvationDeferrals += 1;
612
1076
  }
1077
+ selected.entry.starvationDeferrals = 0;
1078
+ }
1079
+ // AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-1, ac-2, ac-3, ac-4
1080
+ async _workspaceCandidateHealth(entry) {
1081
+ const role = entry.change.toStatus === "pending_review" ? "reviewer" : "worker";
1082
+ const taskInfo = entry.change.task
1083
+ ? {
1084
+ title: entry.change.task.title,
1085
+ slugs: entry.change.task.slugs,
1086
+ }
1087
+ : undefined;
1088
+ const health = await getDispatchWorkspaceHealth({
1089
+ projectDir: this.projectDir,
1090
+ taskRef: entry.change.taskRef,
1091
+ task: taskInfo,
1092
+ role,
1093
+ });
1094
+ // AC: @adopt-existing-task-branch-lineage ac-1 — when workspace doesn't exist
1095
+ // but the task has submission linkage, allow provisioning to adopt the branch.
1096
+ const hasSubmissionLinkage = Boolean(entry.change.task?.submission_linkage?.branch);
1097
+ const eligible = !health.exists
1098
+ ? (entry.change.toStatus !== "in_progress" && entry.change.toStatus !== "pending_review") || hasSubmissionLinkage
1099
+ : health.healthy;
1100
+ // For pending_review and needs_work tasks, attempt workspace discovery
1101
+ // before discarding the queue entry as missing or ineligible.
1102
+ if (!eligible &&
1103
+ (entry.change.toStatus === "pending_review" || entry.change.toStatus === "needs_work")) {
1104
+ const discoveryResult = await discoverWorkspaceForReviewOrFixCycle({
1105
+ projectDir: this.projectDir,
1106
+ taskRef: entry.change.taskRef,
1107
+ role,
1108
+ task: entry.change.task
1109
+ ? {
1110
+ title: entry.change.task.title,
1111
+ slugs: entry.change.task.slugs,
1112
+ submission_linkage: entry.change.task.submission_linkage ?? undefined,
1113
+ review_url: entry.change.task.review_url,
1114
+ }
1115
+ : undefined,
1116
+ });
1117
+ // Emit diagnostics for failed discovery (AC-3) or conflicting signals (AC-4).
1118
+ for (const diagnostic of discoveryResult.diagnostics) {
1119
+ console.log(`[dispatch] Workspace discovery diagnostic for ${diagnostic.taskRef}: [${diagnostic.code}] ${diagnostic.message}`);
1120
+ console.log(`[dispatch] Suggestion: ${diagnostic.suggestion}`);
1121
+ }
1122
+ if (discoveryResult.recovered) {
1123
+ // AC-2: Recovery succeeded — re-evaluate eligibility with recovered workspace.
1124
+ return {
1125
+ eligible: true,
1126
+ exists: discoveryResult.health.exists,
1127
+ reason: discoveryResult.health.reason,
1128
+ };
1129
+ }
1130
+ // Discovery failed — return ineligible with diagnostic-enriched reason.
1131
+ const diagnosticReason = discoveryResult.diagnostics[0]?.code
1132
+ ?? (health.exists ? health.reason : "workspace-missing-no-recovery");
1133
+ return {
1134
+ eligible: false,
1135
+ exists: health.exists || discoveryResult.health.exists,
1136
+ reason: diagnosticReason,
1137
+ };
1138
+ }
1139
+ return {
1140
+ eligible,
1141
+ exists: health.exists,
1142
+ reason: health.reason,
1143
+ };
1144
+ }
1145
+ async _pruneIneligibleQueueEntries(agents, currentTasks, currentTaskStates) {
1146
+ const tasksById = new Map((currentTasks ?? []).map((task) => [task._ulid, task]));
613
1147
  for (const agent of agents) {
614
- const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
615
- const active = this.activeCount.get(agent.id) ?? 0;
616
1148
  const queue = this.queues.get(agent.id) ?? [];
617
- // AC: @agent-dispatch-engine ac-17 - Discard stale entries before spawning.
618
- // Only discard when we have positive evidence the task moved: either the task
619
- // exists on disk with a different status, or the task was previously tracked
620
- // (in prevTaskStates) but is no longer found (deleted).
621
- if (currentTaskStates) {
622
- const before = queue.length;
623
- for (let i = queue.length - 1; i >= 0; i--) {
624
- const entry = queue[i];
625
- const currentStatus = currentTaskStates.get(entry.change.taskId);
626
- const expectedEvent = STATUS_TO_EVENT[entry.change.toStatus];
627
- if (!expectedEvent)
628
- continue; // No event mapping — skip check
1149
+ const before = queue.length;
1150
+ const discardedDetails = [];
1151
+ for (let i = queue.length - 1; i >= 0; i--) {
1152
+ const entry = queue[i];
1153
+ const currentStatus = currentTaskStates?.get(entry.change.taskId);
1154
+ const expectedEvent = STATUS_TO_EVENT[entry.change.toStatus];
1155
+ if (expectedEvent) {
629
1156
  if (currentStatus === undefined) {
630
- // Task not on disk — only discard if we previously knew about it
631
- // (it was deleted). Tasks from pure handleStateChange events without
632
- // on-disk presence should still be processed.
633
1157
  if (this.prevTaskStates.has(entry.change.taskId)) {
1158
+ discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task no longer exists on disk`);
634
1159
  queue.splice(i, 1);
1160
+ continue;
635
1161
  }
636
1162
  }
637
1163
  else {
638
1164
  const currentEvent = STATUS_TO_EVENT[currentStatus];
639
1165
  if (currentEvent !== expectedEvent) {
1166
+ discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task state changed to ${currentStatus}`);
640
1167
  queue.splice(i, 1);
1168
+ continue;
641
1169
  }
642
1170
  }
643
1171
  }
644
- if (before > queue.length) {
645
- console.log(`[dispatch] Discarded ${before - queue.length} stale queue entries for agent "${agent.id}"`);
1172
+ const currentTask = tasksById.get(entry.change.taskId);
1173
+ if (currentTask) {
1174
+ entry.change.task = currentTask;
1175
+ if (currentTask.blocked_by.length > 0) {
1176
+ discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task is blocked by ${currentTask.blocked_by.join(", ")}`);
1177
+ queue.splice(i, 1);
1178
+ continue;
1179
+ }
1180
+ if (currentTask.depends_on.length > 0 &&
1181
+ currentTasks &&
1182
+ !areDependenciesMet(currentTask, currentTasks)) {
1183
+ discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": dependencies are no longer satisfied`);
1184
+ queue.splice(i, 1);
1185
+ continue;
1186
+ }
646
1187
  }
647
- }
648
- let slots = maxConcurrent - active;
649
- while (slots > 0 && queue.length > 0) {
650
- const now = Date.now();
651
- const nextReadyIndex = queue.findIndex((entry) => entry.nextRetryAt <= now);
652
- if (nextReadyIndex === -1) {
653
- break;
1188
+ const workspaceHealth = await this._workspaceCandidateHealth(entry);
1189
+ if (!workspaceHealth.eligible) {
1190
+ discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": workspace ${workspaceHealth.exists ? "is unhealthy" : "is missing"}${workspaceHealth.reason ? ` (${workspaceHealth.reason})` : ""}`);
1191
+ queue.splice(i, 1);
654
1192
  }
655
- const [entry] = queue.splice(nextReadyIndex, 1);
656
- const spawned = this._spawnInvocation(agent, entry);
657
- if (spawned)
658
- slots--;
1193
+ }
1194
+ for (const detail of discardedDetails) {
1195
+ console.log(`[dispatch] Discarded queue entry ${detail}`);
1196
+ }
1197
+ if (before > queue.length) {
1198
+ console.log(`[dispatch] Discarded ${before - queue.length} ineligible queue entr${before - queue.length === 1 ? "y" : "ies"} for agent "${agent.id}"`);
659
1199
  }
660
1200
  this.queues.set(agent.id, queue);
661
1201
  }
662
1202
  }
1203
+ _selectNextCandidate(agents) {
1204
+ const now = Date.now();
1205
+ const candidates = [];
1206
+ for (const agent of agents) {
1207
+ const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
1208
+ const active = this.activeCount.get(agent.id) ?? 0;
1209
+ if (active >= maxConcurrent)
1210
+ continue;
1211
+ const queue = this.queues.get(agent.id) ?? [];
1212
+ for (let index = 0; index < queue.length; index++) {
1213
+ const entry = queue[index];
1214
+ if (entry.nextRetryAt > now)
1215
+ continue;
1216
+ // AC: @agent-dispatch-engine ac-26 — cross-agent task exclusivity:
1217
+ // skip candidates whose task already has an active invocation by any
1218
+ // agent. The entry stays queued and will be picked up after the active
1219
+ // invocation completes (post-invocation drain via ac-24).
1220
+ if (this._hasActiveInvocationForTask(entry.change.taskRef))
1221
+ continue;
1222
+ candidates.push({ agent, queue, queueIndex: index, entry });
1223
+ }
1224
+ }
1225
+ if (candidates.length === 0) {
1226
+ return null;
1227
+ }
1228
+ candidates.sort((a, b) => this._compareSchedulerCandidates(a, b));
1229
+ const selected = candidates[0];
1230
+ this._recordContinuityDeferrals(selected, candidates);
1231
+ return selected;
1232
+ }
1233
+ /**
1234
+ * Schedule a per-task coalesced drain. If a timer already exists for this
1235
+ * task, it is cleared and reset to the full coalescing window.
1236
+ *
1237
+ * AC: @per-task-dispatch-drain-coalescing ac-1, ac-3
1238
+ */
1239
+ _scheduleCoalescedDrain(taskId) {
1240
+ // Clear any existing timer for this task (reset window)
1241
+ const existing = this.coalesceTimers.get(taskId);
1242
+ if (existing !== undefined) {
1243
+ clearTimeout(existing);
1244
+ }
1245
+ const timer = setTimeout(() => {
1246
+ this.coalesceTimers.delete(taskId);
1247
+ if (!this.running)
1248
+ return;
1249
+ // AC: @per-task-dispatch-drain-coalescing ac-8 — serialize drain execution
1250
+ this._serializedDrain().catch((err) => {
1251
+ console.error("[dispatch] Coalesced drain error:", err);
1252
+ });
1253
+ }, this.coalesceWindowMs);
1254
+ // Unref so it doesn't keep the process alive
1255
+ if (timer && typeof timer === "object" && "unref" in timer) {
1256
+ timer.unref();
1257
+ }
1258
+ this.coalesceTimers.set(taskId, timer);
1259
+ }
1260
+ /**
1261
+ * Serialize drain execution: if a drain is already running, mark that another
1262
+ * drain is pending and return. When the current drain finishes, it runs the
1263
+ * follow-up drain. This prevents concurrent _drainQueues() calls from
1264
+ * racing on queue state.
1265
+ *
1266
+ * AC: @per-task-dispatch-drain-coalescing ac-8
1267
+ */
1268
+ async _serializedDrain() {
1269
+ if (this.drainInProgress) {
1270
+ this.drainPending = true;
1271
+ return;
1272
+ }
1273
+ this.drainInProgress = true;
1274
+ try {
1275
+ const agents = await this._loadAgents();
1276
+ await this._drainQueues(agents);
1277
+ }
1278
+ finally {
1279
+ this.drainInProgress = false;
1280
+ }
1281
+ // If another drain was requested while we were running, do one follow-up.
1282
+ if (this.drainPending) {
1283
+ this.drainPending = false;
1284
+ await this._serializedDrain();
1285
+ }
1286
+ }
1287
+ /**
1288
+ * Drain queues, spawning invocations up to each agent's max_concurrent limit.
1289
+ * AC: @agent-dispatch-engine ac-3, ac-17
1290
+ */
1291
+ async _drainQueues(agents) {
1292
+ // Prevent new invocation starts during/after shutdown.
1293
+ if (!this.running)
1294
+ return;
1295
+ // AC: @agent-dispatch-engine ac-17 - Load current tasks once for staleness + readiness checks
1296
+ let currentTasks;
1297
+ let currentTaskStates;
1298
+ try {
1299
+ const ctx = await initContext(this.projectDir);
1300
+ currentTasks = await loadAllTasks(ctx);
1301
+ currentTaskStates = new Map(currentTasks.map((t) => [t._ulid, t.status]));
1302
+ }
1303
+ catch {
1304
+ // If we can't load tasks, skip staleness checks (best effort)
1305
+ }
1306
+ await this._pruneIneligibleQueueEntries(agents, currentTasks, currentTaskStates);
1307
+ while (this.running) {
1308
+ const candidate = this._selectNextCandidate(agents);
1309
+ if (!candidate) {
1310
+ break;
1311
+ }
1312
+ const [entry] = candidate.queue.splice(candidate.queueIndex, 1);
1313
+ this.queues.set(candidate.agent.id, candidate.queue);
1314
+ const spawned = await this._spawnInvocation(candidate.agent, entry);
1315
+ if (!spawned) {
1316
+ continue;
1317
+ }
1318
+ }
1319
+ }
663
1320
  /**
664
1321
  * Build dispatch-mode prompt guardrails to keep autonomous agents from
665
1322
  * stopping with handoff text instead of performing required actions.
666
1323
  *
667
1324
  * AC: @agent-dispatch-engine ac-13, ac-14, ac-15, ac-16
668
1325
  */
669
- _buildDispatchPrompt(agent, change) {
1326
+ async _buildDispatchPrompt(agent, change, workspace) {
670
1327
  const trigger = (STATUS_TO_EVENT[change.toStatus] ?? "task.ready");
671
1328
  const taskRef = change.taskRef;
672
1329
  // AC: @agent-dispatch-engine ac-16 - Interpolate prompt_template variables
@@ -678,8 +1335,13 @@ export class DispatchEngine {
678
1335
  };
679
1336
  const rawTemplate = agent.prompt_template ?? `Work on task ${taskRef}`;
680
1337
  const basePrompt = interpolateTemplate(rawTemplate, templateVars);
681
- // AC: @agent-dispatch-engine ac-13 - Orientation context
682
- const orientation = buildOrientationContext(taskRef, trigger, change.task);
1338
+ // AC: @review-fix-cycle-diff ac-2 — Compute fix-cycle diff for reviewer orientation
1339
+ let fixCycleDiffSummary = null;
1340
+ if (trigger === "task.pending_review") {
1341
+ fixCycleDiffSummary = await getFixCycleDiffSummary(this.projectDir, taskRef, workspace.metadata.canonicalBranchHead, workspace.cwd);
1342
+ }
1343
+ const orientation = buildOrientationContext(taskRef, trigger, workspace, change.task, undefined, undefined, { fixCycleDiffSummary });
1344
+ const roleEntry = await buildRoleEntryContext(this.projectDir, agent.adapter ?? "claude-agent-acp", trigger, workspace.metadata);
683
1345
  const autonomousPreamble = [
684
1346
  "AUTONOMOUS DISPATCH MODE (no interactive user is available).",
685
1347
  "- Do not ask for confirmation, approval, or next-step handoff.",
@@ -687,6 +1349,7 @@ export class DispatchEngine {
687
1349
  "- Do not end your turn with a recommendations-only summary. Perform the next required action yourself.",
688
1350
  "- Do not end your turn until the expected task transition is complete, or you have explicitly blocked the task with `kspec task block <task> --reason \"...\"`.",
689
1351
  "- If you find an open PR/branch from a different task, create or switch to a dedicated branch for this task before committing to avoid PR conflation.",
1352
+ `- CRITICAL: Your working directory is your assigned workspace (${workspace.cwd}). Run ALL commands (tests, builds, git, kspec, etc.) from this directory. Do NOT cd to the project root or any other directory. The workspace is a full git worktree with the correct branch and project configuration.`,
690
1353
  ];
691
1354
  const triggerSpecific = trigger === "task.pending_review"
692
1355
  ? [
@@ -701,160 +1364,432 @@ export class DispatchEngine {
701
1364
  `- Perform the required commands to move ${taskRef} to the next appropriate state in this same invocation.`,
702
1365
  "- If your workflow includes git or PR steps, execute them directly instead of deferring to a human.",
703
1366
  ];
704
- return `${basePrompt}\n\n${orientation}\n\n${autonomousPreamble.join("\n")}\n\n${triggerSpecific.join("\n")}`;
1367
+ return `${basePrompt}\n\n${orientation}\n\n${roleEntry}\n\n${autonomousPreamble.join("\n")}\n\n${triggerSpecific.join("\n")}`;
1368
+ }
1369
+ _runKspecCommand(args) {
1370
+ const result = spawnSync(process.execPath, [this.kspecCliPath ?? DEFAULT_KSPEC_CLI_PATH, ...args], {
1371
+ cwd: this.cwd,
1372
+ encoding: "utf-8",
1373
+ stdio: "pipe",
1374
+ });
1375
+ return {
1376
+ status: result.status,
1377
+ stdout: result.stdout ?? "",
1378
+ stderr: result.stderr ?? "",
1379
+ };
1380
+ }
1381
+ _taskCommandError(args, result) {
1382
+ const exitDetail = result.status === null ? "terminated by signal" : `exited with status ${result.status}`;
1383
+ const details = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ");
1384
+ return new Error(`Failed to run \`kspec ${args.join(" ")}\`: ${exitDetail}${details ? `. ${details}` : ""}`);
1385
+ }
1386
+ _addTaskNote(taskRef, note) {
1387
+ const args = ["task", "note", taskRef, note];
1388
+ const result = this._runKspecCommand(args);
1389
+ if (result.status !== 0) {
1390
+ console.warn(`[dispatch] Failed to add task note for ${taskRef}: ${this._taskCommandError(args, result).message}`);
1391
+ }
1392
+ }
1393
+ _blockTask(taskRef, reason) {
1394
+ const args = ["task", "block", taskRef, "--reason", reason];
1395
+ const result = this._runKspecCommand(args);
1396
+ if (result.status !== 0) {
1397
+ throw this._taskCommandError(args, result);
1398
+ }
705
1399
  }
706
1400
  /**
707
1401
  * Spawn a single invocation for a queue entry.
708
1402
  * Returns true if an invocation was actually started, false if skipped.
709
1403
  * AC: @agent-dispatch-engine ac-9, ac-10, ac-11, ac-12
710
1404
  */
711
- _spawnInvocation(agent, entry) {
1405
+ async _spawnInvocation(agent, entry) {
1406
+ // Bail out during shutdown — don't provision workspaces or add to
1407
+ // runningInvocations for invocations that will never complete.
1408
+ if (!this.running)
1409
+ return false;
712
1410
  const agentId = agent.id;
713
- // Increment active count
714
- this.activeCount.set(agentId, (this.activeCount.get(agentId) ?? 0) + 1);
715
- // AC: @agent-dispatch-engine ac-10 - Check adapter resolvability before spawn
716
- const adapterId = agent.adapter ?? "claude-agent-acp";
717
- const adapter = getAdapter(adapterId);
718
- if (!adapter) {
719
- console.error(`[dispatch] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Skipping invocation.`);
720
- // Decrement active count since we're not actually running
721
- const currentActive = this.activeCount.get(agentId) ?? 1;
722
- this.activeCount.set(agentId, Math.max(0, currentActive - 1));
723
- // AC: @agent-dispatch-engine ac-10 - Add task note for unresolvable adapter
724
- if (this.kspecCliPath) {
725
- spawnSync(process.execPath, [
726
- this.kspecCliPath,
727
- "task", "note", entry.change.taskRef,
728
- `[AGENT-SKIP] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Invocation skipped.`,
729
- ], { cwd: this.cwd });
1411
+ const inFlightKey = `${agentId}:${entry.change.taskRef}`;
1412
+ this.inFlightTaskKeys.add(inFlightKey);
1413
+ let invocationRegistered = false;
1414
+ let workspace;
1415
+ const role = entry.change.toStatus === "pending_review" ? "reviewer" : "worker";
1416
+ try {
1417
+ try {
1418
+ workspace = await provisionDispatchWorkspace({
1419
+ projectDir: this.projectDir,
1420
+ taskRef: entry.change.taskRef,
1421
+ role,
1422
+ cleanupState: {
1423
+ taskStatus: entry.change.task?.status ?? entry.change.toStatus,
1424
+ },
1425
+ task: entry.change.task
1426
+ ? {
1427
+ title: entry.change.task.title,
1428
+ slugs: entry.change.task.slugs,
1429
+ }
1430
+ : undefined,
1431
+ // AC: @adopt-existing-task-branch-lineage ac-1, ac-2, ac-4
1432
+ // Pass submission linkage so provisioning can adopt an existing branch
1433
+ // when no workspace record exists for review/fix-cycle tasks.
1434
+ submissionLinkage: entry.change.task?.submission_linkage ?? undefined,
1435
+ taskStatus: entry.change.task?.status ?? entry.change.toStatus,
1436
+ });
730
1437
  }
731
- return false;
732
- }
733
- // AC: @agent-dispatch-engine ac-11 - Create abort controller for graceful cancellation
734
- const abortController = new AbortController();
735
- this.invocationAbortControllers.add(abortController);
736
- // AC: @cli-agent-commands ac-6 - Pre-assign session ID for status tracking
737
- const preSessionId = ulid();
738
- const invocationId = ulid();
739
- const trackingRecord = {
740
- invocationId,
741
- sessionId: preSessionId,
742
- agentId,
743
- agentName: agent.name,
744
- taskRef: entry.change.taskRef,
745
- startedAtMs: Date.now(),
746
- };
747
- this.activeInvocationDetails.set(invocationId, trackingRecord);
748
- // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit started event
749
- this.onInvocationEvent?.({
750
- type: "started",
751
- session_id: preSessionId,
752
- agent_id: agentId,
753
- task_id: entry.change.taskRef,
754
- status: "started",
755
- timestamp: Date.now(),
756
- });
757
- // AC: @cli-agent-commands ac-13, @daemon-agent-dispatch ac-8 - stream text chunks to watchers
758
- const taskId = entry.change.taskRef ?? null;
759
- const onUpdate = this.onTextChunk
760
- ? (update) => {
761
- if (update.sessionUpdate === "agent_message_chunk" &&
762
- update.content.type === "text") {
763
- this.onTextChunk(preSessionId, agentId, taskId, update.content.text);
764
- return;
1438
+ catch (err) {
1439
+ const message = err instanceof Error ? err.message : String(err);
1440
+ const guidance = err instanceof DispatchWorkspaceError ? err.suggestion : "Inspect dispatch workspace configuration and git worktree state.";
1441
+ console.error(`[dispatch] Failed to provision workspace for ${entry.change.taskRef}: ${message}`);
1442
+ this._addTaskNote(entry.change.taskRef, `[DISPATCH-WORKSPACE] ${message} Suggested action: ${guidance}`);
1443
+ this._blockTask(entry.change.taskRef, `Dispatch workspace provisioning failed: ${message}. Suggested action: ${guidance}`);
1444
+ return false;
1445
+ }
1446
+ const dispatchEnv = {
1447
+ KSPEC_DISPATCH_BASE_BRANCH: workspace.metadata.baseBranch,
1448
+ KSPEC_DISPATCH_MERGE_TARGET: workspace.metadata.mergeTargetBranch,
1449
+ KSPEC_DISPATCH_CANONICAL_BRANCH: workspace.metadata.canonicalBranch,
1450
+ KSPEC_DISPATCH_WORKTREE_ROOT: workspace.metadata.worktreeRoot,
1451
+ KSPEC_DISPATCH_WORKSPACE_FILE: workspace.metadataPath,
1452
+ };
1453
+ try {
1454
+ const bootstrap = await ensureWorkspaceBootstrap({
1455
+ projectDir: this.projectDir,
1456
+ workspaceDir: workspace.cwd,
1457
+ metadataPath: workspace.metadataPath,
1458
+ metadata: workspace.metadata,
1459
+ role,
1460
+ agent,
1461
+ env: dispatchEnv,
1462
+ });
1463
+ workspace = {
1464
+ ...workspace,
1465
+ metadata: bootstrap.metadata,
1466
+ };
1467
+ }
1468
+ catch (err) {
1469
+ const message = err instanceof Error ? err.message : String(err);
1470
+ const guidance = err instanceof DispatchBootstrapError
1471
+ ? err.suggestion
1472
+ : "Inspect dispatch bootstrap configuration, dependency prerequisites, and workspace health.";
1473
+ console.error(`[dispatch] Failed to bootstrap workspace for ${entry.change.taskRef}: ${message}`);
1474
+ if (this.kspecCliPath) {
1475
+ spawnSync(process.execPath, [
1476
+ this.kspecCliPath,
1477
+ "task", "note", entry.change.taskRef,
1478
+ `[DISPATCH-BOOTSTRAP] ${message} Suggested action: ${guidance}`,
1479
+ ], { cwd: this.cwd });
1480
+ spawnSync(process.execPath, [
1481
+ this.kspecCliPath,
1482
+ "task", "block", entry.change.taskRef,
1483
+ "--reason", `Dispatch bootstrap failed: ${message}`,
1484
+ ], { cwd: this.cwd });
765
1485
  }
766
- // Non-text updates (especially tool events) delimit logical message runs.
767
- // Emit an empty sentinel so watch renderers can end the current line
768
- // without needing to infer boundaries from prose punctuation.
769
- this.onTextChunk(preSessionId, agentId, taskId, "");
1486
+ return false;
770
1487
  }
771
- : undefined;
772
- const options = {
773
- agent,
774
- specDir: this.specDir,
775
- cwd: this.cwd,
776
- taskRef: entry.change.taskRef,
777
- prompt: this._buildDispatchPrompt(agent, entry.change),
778
- trigger: (STATUS_TO_EVENT[entry.change.toStatus] ?? "task.ready"),
779
- kspecCliPath: this.kspecCliPath,
780
- abortSignal: abortController.signal,
781
- sessionId: preSessionId,
782
- onUpdate,
783
- };
784
- // AC: @agent-dispatch-engine ac-12 - Wrap invocation in shadow mutex
785
- const invocationPromise = this.shadowMutex
786
- .runExclusive(async () => {
787
- // AC: @agent-dispatch-engine ac-9 - Retry on transient errors
1488
+ let prompt;
788
1489
  try {
789
- await runInvocation(options);
790
- // Reset retry count on success
791
- entry.retryCount = 0;
792
- // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit completed event
1490
+ prompt = await this._buildDispatchPrompt(agent, entry.change, workspace);
1491
+ }
1492
+ catch (err) {
1493
+ const message = err instanceof Error ? err.message : String(err);
1494
+ const guidance = err instanceof DispatchPromptError
1495
+ ? err.suggestion
1496
+ : "Inspect dispatch role-entry configuration and workspace metadata.";
1497
+ console.error(`[dispatch] Failed to build prompt for ${entry.change.taskRef}: ${message}`);
1498
+ if (this.kspecCliPath) {
1499
+ spawnSync(process.execPath, [
1500
+ this.kspecCliPath,
1501
+ "task", "note", entry.change.taskRef,
1502
+ `[DISPATCH-PROMPT] ${message} Suggested action: ${guidance}`,
1503
+ ], { cwd: this.cwd });
1504
+ }
1505
+ return false;
1506
+ }
1507
+ // Increment active count
1508
+ this.activeCount.set(agentId, (this.activeCount.get(agentId) ?? 0) + 1);
1509
+ // AC: @agent-dispatch-engine ac-10 - Check adapter resolvability before spawn
1510
+ const adapterId = agent.adapter ?? "claude-agent-acp";
1511
+ const adapter = getAdapter(adapterId);
1512
+ if (!adapter) {
1513
+ console.error(`[dispatch] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Skipping invocation.`);
1514
+ // Decrement active count since we're not actually running
1515
+ const currentActive = this.activeCount.get(agentId) ?? 1;
1516
+ this.activeCount.set(agentId, Math.max(0, currentActive - 1));
1517
+ // AC: @agent-dispatch-engine ac-10 - Add task note for unresolvable adapter
1518
+ if (this.kspecCliPath) {
1519
+ spawnSync(process.execPath, [
1520
+ this.kspecCliPath,
1521
+ "task", "note", entry.change.taskRef,
1522
+ `[AGENT-SKIP] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Invocation skipped.`,
1523
+ ], { cwd: this.cwd });
1524
+ }
1525
+ return false;
1526
+ }
1527
+ // AC: @agent-dispatch-engine ac-11 - Create abort controller for graceful cancellation
1528
+ const abortController = new AbortController();
1529
+ this.invocationAbortControllers.add(abortController);
1530
+ // AC: @cli-agent-commands ac-6 - Pre-assign session ID for status tracking
1531
+ const preSessionId = ulid();
1532
+ const invocationId = ulid();
1533
+ const trackingRecord = {
1534
+ invocationId,
1535
+ sessionId: preSessionId,
1536
+ agentId,
1537
+ agentName: agent.name,
1538
+ taskRef: entry.change.taskRef,
1539
+ role,
1540
+ startedAtMs: Date.now(),
1541
+ };
1542
+ this.activeInvocationDetails.set(invocationId, trackingRecord);
1543
+ this.recentTaskAffinityRef = entry.change.taskRef;
1544
+ let startedEventEmitted = false;
1545
+ const emitStartedEvent = () => {
1546
+ if (startedEventEmitted)
1547
+ return;
1548
+ startedEventEmitted = true;
793
1549
  this.onInvocationEvent?.({
794
- type: "completed",
1550
+ type: "started",
795
1551
  session_id: preSessionId,
796
1552
  agent_id: agentId,
797
1553
  task_id: entry.change.taskRef,
798
- status: "completed",
1554
+ task_title: entry.change.task?.title ?? null,
1555
+ status: "started",
799
1556
  timestamp: Date.now(),
800
1557
  });
801
- }
802
- catch (err) {
803
- const retryLimit = agent.budget?.max_retries ?? 3;
804
- if (entry.retryCount < retryLimit) {
805
- entry.retryCount++;
806
- const backoffMs = Math.min(1000 * Math.pow(2, entry.retryCount - 1), 30_000);
807
- entry.nextRetryAt = Date.now() + backoffMs;
808
- console.warn(`[dispatch] Invocation for agent "${agentId}" failed (attempt ${entry.retryCount}/${retryLimit}), retrying in ${backoffMs}ms`, err);
809
- // Re-enqueue for retry while preserving status precedence ordering.
810
- const queue = this.queues.get(agentId) ?? [];
811
- this._insertQueueEntry(queue, entry);
812
- this.queues.set(agentId, queue);
813
- // AC: @agent-dispatch-engine ac-9 - Schedule wake-up to drain retry
814
- setTimeout(() => {
815
- if (this.running) {
816
- this._loadAgents()
817
- .then((agents) => this._drainQueues(agents))
818
- .catch(() => { });
819
- }
820
- }, backoffMs);
1558
+ };
1559
+ // AC: @session-event-broadcast ac-newline-streaming, ac-boundary-flush, ac-per-session-state
1560
+ // AC: @cli-agent-commands ac-13, @daemon-agent-dispatch ac-8 - stream session events to watchers
1561
+ const taskId = entry.change.taskRef ?? null;
1562
+ const taskTitle = entry.change.task?.title ?? null;
1563
+ const sessionCtx = {
1564
+ sessionId: preSessionId,
1565
+ agentId,
1566
+ taskId,
1567
+ taskTitle,
1568
+ };
1569
+ const onUpdate = this.onSessionEvent
1570
+ ? (update) => {
1571
+ emitStartedEvent();
1572
+ this.accumulator.handleUpdate(sessionCtx, update, this.onSessionEvent);
1573
+ }
1574
+ : undefined;
1575
+ const options = {
1576
+ agent,
1577
+ specDir: this.specDir,
1578
+ sessionsDir: path.join(this.projectDir, ".kspec-sessions"),
1579
+ cwd: workspace.cwd,
1580
+ taskRef: entry.change.taskRef,
1581
+ prompt,
1582
+ trigger: (STATUS_TO_EVENT[entry.change.toStatus] ?? "task.ready"),
1583
+ kspecCliPath: this.kspecCliPath,
1584
+ abortSignal: abortController.signal,
1585
+ sessionId: preSessionId,
1586
+ mutationLockFile: getDispatchShadowMutationLockPath(this.projectDir),
1587
+ env: {
1588
+ KSPEC_DISPATCH_BASE_BRANCH: workspace.metadata.baseBranch,
1589
+ KSPEC_DISPATCH_MERGE_TARGET: workspace.metadata.mergeTargetBranch,
1590
+ KSPEC_DISPATCH_CANONICAL_BRANCH: workspace.metadata.canonicalBranch,
1591
+ KSPEC_DISPATCH_CANONICAL_HEAD: workspace.metadata.canonicalBranchHead,
1592
+ KSPEC_DISPATCH_INTEGRATION_TARGET: workspace.metadata.integrationTargetBranch,
1593
+ KSPEC_DISPATCH_INTEGRATION_COMMIT: workspace.metadata.integrationTargetCommit,
1594
+ KSPEC_DISPATCH_PUBLICATION_MODE: workspace.metadata.publicationMode,
1595
+ KSPEC_DISPATCH_INTEGRATION_STATE: workspace.metadata.integrationState,
1596
+ KSPEC_DISPATCH_INTEGRATION_OUTCOME: workspace.metadata.integrationOutcome,
1597
+ KSPEC_DISPATCH_WORKTREE_ROOT: workspace.metadata.worktreeRoot,
1598
+ KSPEC_DISPATCH_WORKSPACE_FILE: workspace.metadataPath,
1599
+ KSPEC_DISPATCH_WORKSPACE_ID: workspace.metadata.workspaceId,
1600
+ KSPEC_DISPATCH_BOOTSTRAP_STATUS: workspace.metadata.bootstrap.status,
1601
+ KSPEC_DISPATCH_BOOTSTRAP_LAST_ROLE: workspace.metadata.bootstrap.lastRole ?? "",
1602
+ },
1603
+ onUpdate,
1604
+ // AC: @session-summary-cache ac-live-counter — increment cache counter on each event append
1605
+ onEventAppended: (sid) => {
1606
+ const sessionsDir = path.join(this.projectDir, ".kspec-sessions");
1607
+ const cache = getSessionCache(sessionsDir);
1608
+ cache.incrementEventCount(sid);
1609
+ },
1610
+ };
1611
+ let resolveInvocationStarted;
1612
+ const invocationStarted = new Promise((resolve) => {
1613
+ resolveInvocationStarted = resolve;
1614
+ });
1615
+ let invocationStartedResolved = false;
1616
+ const markInvocationStarted = () => {
1617
+ if (invocationStartedResolved)
1618
+ return;
1619
+ invocationStartedResolved = true;
1620
+ resolveInvocationStarted();
1621
+ };
1622
+ let terminalEvent = null;
1623
+ let releasedInFlightKey = false;
1624
+ const markActivePromise = this.shadowMutex.runExclusive(async () => {
1625
+ try {
1626
+ const activeWorkspace = await markDispatchWorkspaceActive({
1627
+ projectDir: this.projectDir,
1628
+ taskRef: entry.change.taskRef,
1629
+ role: entry.change.toStatus === "pending_review" ? "reviewer" : "worker",
1630
+ });
1631
+ if (activeWorkspace) {
1632
+ workspace = activeWorkspace;
1633
+ options.env = {
1634
+ ...options.env,
1635
+ KSPEC_DISPATCH_WORKSPACE_FILE: activeWorkspace.metadataPath,
1636
+ KSPEC_DISPATCH_WORKSPACE_ID: activeWorkspace.metadata.workspaceId,
1637
+ };
1638
+ }
821
1639
  }
822
- else {
823
- console.error(`[dispatch] Agent "${agentId}" exceeded retry limit. Dropping invocation.`, err);
824
- // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit failed event when retry limit exceeded
825
- this.onInvocationEvent?.({
826
- type: "failed",
1640
+ catch (err) {
1641
+ console.error(`[dispatch] Failed to mark workspace active for ${entry.change.taskRef}:`, err);
1642
+ }
1643
+ });
1644
+ const invocationPromise = Promise.resolve()
1645
+ .then(async () => {
1646
+ // AC: @agent-dispatch-engine ac-9 - Retry on transient errors
1647
+ try {
1648
+ markInvocationStarted();
1649
+ emitStartedEvent();
1650
+ await markActivePromise;
1651
+ await runInvocation(options);
1652
+ // Reset retry count on success
1653
+ entry.retryCount = 0;
1654
+ entry.starvationDeferrals = 0;
1655
+ this.recentTaskAffinityRef = entry.change.taskRef;
1656
+ terminalEvent = {
1657
+ type: "completed",
827
1658
  session_id: preSessionId,
828
1659
  agent_id: agentId,
829
1660
  task_id: entry.change.taskRef,
830
- status: "failed",
1661
+ task_title: entry.change.task?.title ?? null,
1662
+ status: "completed",
831
1663
  timestamp: Date.now(),
1664
+ };
1665
+ }
1666
+ catch (err) {
1667
+ markInvocationStarted();
1668
+ const retryLimit = agent.budget?.max_retries ?? 3;
1669
+ if (entry.retryCount < retryLimit) {
1670
+ entry.retryCount++;
1671
+ const backoffMs = Math.min(1000 * Math.pow(2, entry.retryCount - 1), 30_000);
1672
+ entry.nextRetryAt = Date.now() + backoffMs;
1673
+ console.warn(`[dispatch] Invocation for agent "${agentId}" failed (attempt ${entry.retryCount}/${retryLimit}), retrying in ${backoffMs}ms`, err);
1674
+ // Re-enqueue for retry while preserving status precedence ordering.
1675
+ const queue = this.queues.get(agentId) ?? [];
1676
+ this._insertQueueEntry(queue, entry);
1677
+ this.queues.set(agentId, queue);
1678
+ // AC: @agent-dispatch-engine ac-9, ac-27 - Schedule wake-up to drain retry
1679
+ // All drains go through _serializedDrain() to prevent concurrent races.
1680
+ setTimeout(() => {
1681
+ if (this.running) {
1682
+ this._serializedDrain()
1683
+ .catch(() => { });
1684
+ }
1685
+ }, backoffMs);
1686
+ }
1687
+ else {
1688
+ console.error(`[dispatch] Agent "${agentId}" exceeded retry limit. Dropping invocation.`, err);
1689
+ terminalEvent = {
1690
+ type: "failed",
1691
+ session_id: preSessionId,
1692
+ agent_id: agentId,
1693
+ task_id: entry.change.taskRef,
1694
+ task_title: entry.change.task?.title ?? null,
1695
+ status: "failed",
1696
+ timestamp: Date.now(),
1697
+ };
1698
+ }
1699
+ }
1700
+ // AC: @session-summary-cache ac-live-counter — discard live counter after session closes
1701
+ // and invalidate cache entry so next list picks up persisted stats
1702
+ {
1703
+ const sessionsDir = path.join(this.projectDir, ".kspec-sessions");
1704
+ const cache = getSessionCache(sessionsDir);
1705
+ cache.discardLiveCounter(preSessionId);
1706
+ cache.invalidate(preSessionId);
1707
+ }
1708
+ try {
1709
+ await this.shadowMutex.runExclusive(async () => {
1710
+ await markDispatchWorkspaceIdle({
1711
+ projectDir: this.projectDir,
1712
+ taskRef: entry.change.taskRef,
1713
+ taskStatus: entry.change.toStatus,
1714
+ });
832
1715
  });
833
1716
  }
1717
+ catch (err) {
1718
+ console.error(`[dispatch] Failed to mark workspace idle for ${entry.change.taskRef}:`, err);
1719
+ }
1720
+ // Clean up active tracking before queue drain runs so completed
1721
+ // invocations do not linger in the active fleet snapshot.
1722
+ const currentActive = this.activeCount.get(agentId) ?? 1;
1723
+ this.activeCount.set(agentId, Math.max(0, currentActive - 1));
1724
+ this.activeInvocationDetails.delete(invocationId);
1725
+ if (entry.change.toStatus === "pending_review") {
1726
+ try {
1727
+ await this.shadowMutex.runExclusive(async () => {
1728
+ await cleanupReviewerDispatchWorkspace(this.projectDir, entry.change.taskRef, entry.change.task
1729
+ ? {
1730
+ title: entry.change.task.title,
1731
+ slugs: entry.change.task.slugs,
1732
+ }
1733
+ : undefined);
1734
+ });
1735
+ }
1736
+ catch (cleanupErr) {
1737
+ const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
1738
+ console.warn(`[dispatch] Failed to clean reviewer snapshot for ${entry.change.taskRef}: ${cleanupMessage}`);
1739
+ }
1740
+ }
1741
+ // Flush any remaining buffered text before emitting terminal event
1742
+ if (this.onSessionEvent) {
1743
+ this.accumulator.endSession(sessionCtx, this.onSessionEvent);
1744
+ }
1745
+ if (terminalEvent) {
1746
+ this.onInvocationEvent?.(terminalEvent);
1747
+ }
1748
+ })
1749
+ .then(async () => {
1750
+ if (!this.running)
1751
+ return;
1752
+ // Release the in-flight marker before re-evaluating tasks from disk so
1753
+ // follow-up reviewer/fix-cycle work for the same task can be requeued
1754
+ // immediately after the prior invocation completes.
1755
+ this.inFlightTaskKeys.delete(inFlightKey);
1756
+ releasedInFlightKey = true;
1757
+ // AC: @agent-dispatch-engine ac-23, ac-24
1758
+ // Re-evaluate all tasks from disk so the drain loop sees tasks that
1759
+ // reached a dispatchable state during the prior invocation (e.g.
1760
+ // pending_review tasks submitted by a worker).
1761
+ try {
1762
+ await this._evaluateAllTasks({ skipIfActive: true });
1763
+ }
1764
+ catch (err) {
1765
+ // AC: @agent-dispatch-engine ac-25
1766
+ console.warn("[dispatch] Post-invocation re-evaluation failed, proceeding with existing queue:", err);
1767
+ }
1768
+ // AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
1769
+ try {
1770
+ await this._serializedDrain();
1771
+ }
1772
+ catch {
1773
+ // Best effort drain
1774
+ }
1775
+ })
1776
+ .finally(() => {
1777
+ if (!releasedInFlightKey) {
1778
+ this.inFlightTaskKeys.delete(inFlightKey);
1779
+ }
1780
+ this.runningInvocations.delete(invocationPromise);
1781
+ this.invocationAbortControllers.delete(abortController);
1782
+ });
1783
+ invocationRegistered = true;
1784
+ this.runningInvocations.add(invocationPromise);
1785
+ await invocationStarted;
1786
+ return true;
1787
+ }
1788
+ finally {
1789
+ if (!invocationRegistered) {
1790
+ this.inFlightTaskKeys.delete(inFlightKey);
834
1791
  }
835
- })
836
- .then(async () => {
837
- // Decrement active count and drain again
838
- const currentActive = this.activeCount.get(agentId) ?? 1;
839
- this.activeCount.set(agentId, Math.max(0, currentActive - 1));
840
- if (!this.running)
841
- return;
842
- // Try to drain more items
843
- try {
844
- const agents = await this._loadAgents();
845
- await this._drainQueues(agents);
846
- }
847
- catch {
848
- // Best effort
849
- }
850
- })
851
- .finally(() => {
852
- this.runningInvocations.delete(invocationPromise);
853
- this.invocationAbortControllers.delete(abortController);
854
- this.activeInvocationDetails.delete(invocationId);
855
- });
856
- this.runningInvocations.add(invocationPromise);
857
- return true;
1792
+ }
858
1793
  }
859
1794
  }
860
1795
  //# sourceMappingURL=dispatch.js.map