@kynetic-ai/spec 0.10.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 (487) 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 +150 -10
  7. package/dist/agent-runtime/dispatch.d.ts.map +1 -1
  8. package/dist/agent-runtime/dispatch.js +1248 -244
  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 +172 -60
  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/meta.d.ts.map +1 -1
  70. package/dist/cli/commands/meta.js +10 -1
  71. package/dist/cli/commands/meta.js.map +1 -1
  72. package/dist/cli/commands/plan-import.d.ts +3 -3
  73. package/dist/cli/commands/plan-import.d.ts.map +1 -1
  74. package/dist/cli/commands/plan-import.js +213 -528
  75. package/dist/cli/commands/plan-import.js.map +1 -1
  76. package/dist/cli/commands/plan.d.ts.map +1 -1
  77. package/dist/cli/commands/plan.js +533 -83
  78. package/dist/cli/commands/plan.js.map +1 -1
  79. package/dist/cli/commands/review.d.ts +14 -0
  80. package/dist/cli/commands/review.d.ts.map +1 -0
  81. package/dist/cli/commands/review.js +1142 -0
  82. package/dist/cli/commands/review.js.map +1 -0
  83. package/dist/cli/commands/serve.d.ts +1 -0
  84. package/dist/cli/commands/serve.d.ts.map +1 -1
  85. package/dist/cli/commands/serve.js +33 -10
  86. package/dist/cli/commands/serve.js.map +1 -1
  87. package/dist/cli/commands/session/checkpoint.d.ts +2 -4
  88. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  89. package/dist/cli/commands/session/checkpoint.js +6 -107
  90. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  91. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  92. package/dist/cli/commands/session/commands.js +33 -23
  93. package/dist/cli/commands/session/commands.js.map +1 -1
  94. package/dist/cli/commands/session/compact.js +4 -4
  95. package/dist/cli/commands/session/compact.js.map +1 -1
  96. package/dist/cli/commands/session/create.js +2 -2
  97. package/dist/cli/commands/session/create.js.map +1 -1
  98. package/dist/cli/commands/session/format.d.ts.map +1 -1
  99. package/dist/cli/commands/session/format.js +1 -6
  100. package/dist/cli/commands/session/format.js.map +1 -1
  101. package/dist/cli/commands/session/log.d.ts +32 -7
  102. package/dist/cli/commands/session/log.d.ts.map +1 -1
  103. package/dist/cli/commands/session/log.js +166 -60
  104. package/dist/cli/commands/session/log.js.map +1 -1
  105. package/dist/cli/commands/session/migrate.d.ts +9 -0
  106. package/dist/cli/commands/session/migrate.d.ts.map +1 -0
  107. package/dist/cli/commands/session/migrate.js +46 -0
  108. package/dist/cli/commands/session/migrate.js.map +1 -0
  109. package/dist/cli/commands/session/stale-close.d.ts.map +1 -1
  110. package/dist/cli/commands/session/stale-close.js +5 -8
  111. package/dist/cli/commands/session/stale-close.js.map +1 -1
  112. package/dist/cli/commands/session/types.d.ts +1 -1
  113. package/dist/cli/commands/session/types.d.ts.map +1 -1
  114. package/dist/cli/commands/setup.d.ts +2 -2
  115. package/dist/cli/commands/setup.d.ts.map +1 -1
  116. package/dist/cli/commands/setup.js +287 -257
  117. package/dist/cli/commands/setup.js.map +1 -1
  118. package/dist/cli/commands/shadow.d.ts.map +1 -1
  119. package/dist/cli/commands/shadow.js +147 -31
  120. package/dist/cli/commands/shadow.js.map +1 -1
  121. package/dist/cli/commands/skill-crud.d.ts +7 -0
  122. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  123. package/dist/cli/commands/skill-crud.js +41 -18
  124. package/dist/cli/commands/skill-crud.js.map +1 -1
  125. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  126. package/dist/cli/commands/skill-diff.js +29 -3
  127. package/dist/cli/commands/skill-diff.js.map +1 -1
  128. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  129. package/dist/cli/commands/skill-install.js +5 -4
  130. package/dist/cli/commands/skill-install.js.map +1 -1
  131. package/dist/cli/commands/task.d.ts.map +1 -1
  132. package/dist/cli/commands/task.js +359 -49
  133. package/dist/cli/commands/task.js.map +1 -1
  134. package/dist/cli/commands/trait.d.ts.map +1 -1
  135. package/dist/cli/commands/trait.js +5 -27
  136. package/dist/cli/commands/trait.js.map +1 -1
  137. package/dist/cli/commands/validate.d.ts.map +1 -1
  138. package/dist/cli/commands/validate.js +113 -52
  139. package/dist/cli/commands/validate.js.map +1 -1
  140. package/dist/cli/index.d.ts.map +1 -1
  141. package/dist/cli/index.js +69 -2
  142. package/dist/cli/index.js.map +1 -1
  143. package/dist/cli/output.d.ts +26 -0
  144. package/dist/cli/output.d.ts.map +1 -1
  145. package/dist/cli/output.js +108 -1
  146. package/dist/cli/output.js.map +1 -1
  147. package/dist/cli/sync-mode.d.ts +44 -0
  148. package/dist/cli/sync-mode.d.ts.map +1 -0
  149. package/dist/cli/sync-mode.js +64 -0
  150. package/dist/cli/sync-mode.js.map +1 -0
  151. package/dist/daemon/middleware/project-context.ts +25 -7
  152. package/dist/daemon/project-context.ts +18 -0
  153. package/dist/daemon/routes/agent-dispatch.ts +107 -23
  154. package/dist/daemon/routes/aggregation.ts +184 -0
  155. package/dist/daemon/routes/inbox.ts +5 -0
  156. package/dist/daemon/routes/items.ts +167 -0
  157. package/dist/daemon/routes/meta.ts +141 -1
  158. package/dist/daemon/routes/plans.ts +147 -0
  159. package/dist/daemon/routes/projects.ts +28 -6
  160. package/dist/daemon/routes/ref-resolution.ts +119 -0
  161. package/dist/daemon/routes/refs.ts +42 -0
  162. package/dist/daemon/routes/session-related.ts +140 -0
  163. package/dist/daemon/routes/sessions.ts +581 -0
  164. package/dist/daemon/routes/tasks.ts +257 -2
  165. package/dist/daemon/routes/triage.ts +40 -1
  166. package/dist/daemon/routes/validation.ts +1 -1
  167. package/dist/daemon/server.ts +165 -50
  168. package/dist/daemon/session-sync.ts +11 -0
  169. package/dist/daemon/shadow-sync.ts +11 -0
  170. package/dist/daemon/watcher.ts +56 -5
  171. package/dist/daemon/websocket/project-resolution.ts +77 -0
  172. package/dist/export/json.d.ts.map +1 -1
  173. package/dist/export/json.js +104 -1
  174. package/dist/export/json.js.map +1 -1
  175. package/dist/export/types.d.ts +52 -1
  176. package/dist/export/types.d.ts.map +1 -1
  177. package/dist/index.d.ts +1 -0
  178. package/dist/index.d.ts.map +1 -1
  179. package/dist/index.js +1 -0
  180. package/dist/index.js.map +1 -1
  181. package/dist/parser/agent-detection.d.ts +1 -1
  182. package/dist/parser/agent-detection.d.ts.map +1 -1
  183. package/dist/parser/agent-detection.js +10 -0
  184. package/dist/parser/agent-detection.js.map +1 -1
  185. package/dist/parser/alignment.d.ts.map +1 -1
  186. package/dist/parser/alignment.js +4 -2
  187. package/dist/parser/alignment.js.map +1 -1
  188. package/dist/parser/config.d.ts +397 -2
  189. package/dist/parser/config.d.ts.map +1 -1
  190. package/dist/parser/config.js +125 -3
  191. package/dist/parser/config.js.map +1 -1
  192. package/dist/parser/dispatch-workspaces.d.ts +18 -0
  193. package/dist/parser/dispatch-workspaces.d.ts.map +1 -0
  194. package/dist/parser/dispatch-workspaces.js +209 -0
  195. package/dist/parser/dispatch-workspaces.js.map +1 -0
  196. package/dist/parser/doctor.d.ts.map +1 -1
  197. package/dist/parser/doctor.js +27 -8
  198. package/dist/parser/doctor.js.map +1 -1
  199. package/dist/parser/file-lock.d.ts.map +1 -1
  200. package/dist/parser/file-lock.js +9 -2
  201. package/dist/parser/file-lock.js.map +1 -1
  202. package/dist/parser/index.d.ts +6 -0
  203. package/dist/parser/index.d.ts.map +1 -1
  204. package/dist/parser/index.js +6 -0
  205. package/dist/parser/index.js.map +1 -1
  206. package/dist/parser/plans.d.ts.map +1 -1
  207. package/dist/parser/plans.js +1 -0
  208. package/dist/parser/plans.js.map +1 -1
  209. package/dist/parser/refs.d.ts +8 -1
  210. package/dist/parser/refs.d.ts.map +1 -1
  211. package/dist/parser/refs.js +27 -1
  212. package/dist/parser/refs.js.map +1 -1
  213. package/dist/parser/review-operations.d.ts +72 -0
  214. package/dist/parser/review-operations.d.ts.map +1 -0
  215. package/dist/parser/review-operations.js +185 -0
  216. package/dist/parser/review-operations.js.map +1 -0
  217. package/dist/parser/review-task-integration.d.ts +78 -0
  218. package/dist/parser/review-task-integration.d.ts.map +1 -0
  219. package/dist/parser/review-task-integration.js +173 -0
  220. package/dist/parser/review-task-integration.js.map +1 -0
  221. package/dist/parser/review-threads.d.ts +101 -0
  222. package/dist/parser/review-threads.d.ts.map +1 -0
  223. package/dist/parser/review-threads.js +222 -0
  224. package/dist/parser/review-threads.js.map +1 -0
  225. package/dist/parser/review-validation.d.ts +69 -0
  226. package/dist/parser/review-validation.d.ts.map +1 -0
  227. package/dist/parser/review-validation.js +207 -0
  228. package/dist/parser/review-validation.js.map +1 -0
  229. package/dist/parser/reviews.d.ts +58 -0
  230. package/dist/parser/reviews.d.ts.map +1 -0
  231. package/dist/parser/reviews.js +230 -0
  232. package/dist/parser/reviews.js.map +1 -0
  233. package/dist/parser/session-branch.d.ts +91 -0
  234. package/dist/parser/session-branch.d.ts.map +1 -0
  235. package/dist/parser/session-branch.js +565 -0
  236. package/dist/parser/session-branch.js.map +1 -0
  237. package/dist/parser/session-sync-scheduler.d.ts +53 -0
  238. package/dist/parser/session-sync-scheduler.d.ts.map +1 -0
  239. package/dist/parser/session-sync-scheduler.js +100 -0
  240. package/dist/parser/session-sync-scheduler.js.map +1 -0
  241. package/dist/parser/setup-status.d.ts +7 -1
  242. package/dist/parser/setup-status.d.ts.map +1 -1
  243. package/dist/parser/setup-status.js +104 -39
  244. package/dist/parser/setup-status.js.map +1 -1
  245. package/dist/parser/shadow-sync-scheduler.d.ts +71 -0
  246. package/dist/parser/shadow-sync-scheduler.d.ts.map +1 -0
  247. package/dist/parser/shadow-sync-scheduler.js +139 -0
  248. package/dist/parser/shadow-sync-scheduler.js.map +1 -0
  249. package/dist/parser/shadow.d.ts +121 -14
  250. package/dist/parser/shadow.d.ts.map +1 -1
  251. package/dist/parser/shadow.js +752 -27
  252. package/dist/parser/shadow.js.map +1 -1
  253. package/dist/parser/skill-render.d.ts +24 -0
  254. package/dist/parser/skill-render.d.ts.map +1 -1
  255. package/dist/parser/skill-render.js +98 -26
  256. package/dist/parser/skill-render.js.map +1 -1
  257. package/dist/parser/validate.d.ts +43 -3
  258. package/dist/parser/validate.d.ts.map +1 -1
  259. package/dist/parser/validate.js +204 -30
  260. package/dist/parser/validate.js.map +1 -1
  261. package/dist/parser/yaml.d.ts +47 -11
  262. package/dist/parser/yaml.d.ts.map +1 -1
  263. package/dist/parser/yaml.js +329 -149
  264. package/dist/parser/yaml.js.map +1 -1
  265. package/dist/review/checks.d.ts +97 -0
  266. package/dist/review/checks.d.ts.map +1 -0
  267. package/dist/review/checks.js +175 -0
  268. package/dist/review/checks.js.map +1 -0
  269. package/dist/review/index.d.ts +3 -0
  270. package/dist/review/index.d.ts.map +1 -0
  271. package/dist/review/index.js +3 -0
  272. package/dist/review/index.js.map +1 -0
  273. package/dist/review/subject-bindings.d.ts +83 -0
  274. package/dist/review/subject-bindings.d.ts.map +1 -0
  275. package/dist/review/subject-bindings.js +175 -0
  276. package/dist/review/subject-bindings.js.map +1 -0
  277. package/dist/schema/common.d.ts +26 -0
  278. package/dist/schema/common.d.ts.map +1 -1
  279. package/dist/schema/common.js +13 -0
  280. package/dist/schema/common.js.map +1 -1
  281. package/dist/schema/dispatch-workspace.d.ts +2643 -0
  282. package/dist/schema/dispatch-workspace.d.ts.map +1 -0
  283. package/dist/schema/dispatch-workspace.js +187 -0
  284. package/dist/schema/dispatch-workspace.js.map +1 -0
  285. package/dist/schema/inbox.d.ts +8 -8
  286. package/dist/schema/index.d.ts +2 -0
  287. package/dist/schema/index.d.ts.map +1 -1
  288. package/dist/schema/index.js +2 -0
  289. package/dist/schema/index.js.map +1 -1
  290. package/dist/schema/meta.d.ts +663 -116
  291. package/dist/schema/meta.d.ts.map +1 -1
  292. package/dist/schema/meta.js +28 -0
  293. package/dist/schema/meta.js.map +1 -1
  294. package/dist/schema/plan.d.ts +30 -19
  295. package/dist/schema/plan.d.ts.map +1 -1
  296. package/dist/schema/plan.js +3 -1
  297. package/dist/schema/plan.js.map +1 -1
  298. package/dist/schema/review-records.d.ts +2676 -0
  299. package/dist/schema/review-records.d.ts.map +1 -0
  300. package/dist/schema/review-records.js +232 -0
  301. package/dist/schema/review-records.js.map +1 -0
  302. package/dist/schema/spec.d.ts +32 -14
  303. package/dist/schema/spec.d.ts.map +1 -1
  304. package/dist/schema/spec.js +5 -0
  305. package/dist/schema/spec.js.map +1 -1
  306. package/dist/schema/task.d.ts +187 -29
  307. package/dist/schema/task.d.ts.map +1 -1
  308. package/dist/schema/task.js +12 -2
  309. package/dist/schema/task.js.map +1 -1
  310. package/dist/schema/triage.d.ts +22 -22
  311. package/dist/sessions/cache.d.ts +119 -0
  312. package/dist/sessions/cache.d.ts.map +1 -0
  313. package/dist/sessions/cache.js +284 -0
  314. package/dist/sessions/cache.js.map +1 -0
  315. package/dist/sessions/index.d.ts +1 -0
  316. package/dist/sessions/index.d.ts.map +1 -1
  317. package/dist/sessions/index.js +2 -0
  318. package/dist/sessions/index.js.map +1 -1
  319. package/dist/sessions/legacy.d.ts +77 -0
  320. package/dist/sessions/legacy.d.ts.map +1 -0
  321. package/dist/sessions/legacy.js +146 -0
  322. package/dist/sessions/legacy.js.map +1 -0
  323. package/dist/sessions/store.d.ts +115 -71
  324. package/dist/sessions/store.d.ts.map +1 -1
  325. package/dist/sessions/store.js +357 -182
  326. package/dist/sessions/store.js.map +1 -1
  327. package/dist/sessions/types.d.ts +44 -16
  328. package/dist/sessions/types.d.ts.map +1 -1
  329. package/dist/sessions/types.js +11 -2
  330. package/dist/sessions/types.js.map +1 -1
  331. package/dist/strings/errors.d.ts +32 -0
  332. package/dist/strings/errors.d.ts.map +1 -1
  333. package/dist/strings/errors.js +17 -0
  334. package/dist/strings/errors.js.map +1 -1
  335. package/dist/strings/labels.d.ts +1 -0
  336. package/dist/strings/labels.d.ts.map +1 -1
  337. package/dist/strings/labels.js +1 -0
  338. package/dist/strings/labels.js.map +1 -1
  339. package/dist/utils/activity.d.ts +101 -0
  340. package/dist/utils/activity.d.ts.map +1 -0
  341. package/dist/utils/activity.js +408 -0
  342. package/dist/utils/activity.js.map +1 -0
  343. package/dist/utils/git.d.ts +31 -0
  344. package/dist/utils/git.d.ts.map +1 -1
  345. package/dist/utils/git.js +87 -0
  346. package/dist/utils/git.js.map +1 -1
  347. package/dist/utils/index.d.ts +2 -0
  348. package/dist/utils/index.d.ts.map +1 -1
  349. package/dist/utils/index.js +1 -0
  350. package/dist/utils/index.js.map +1 -1
  351. package/dist/web-ui/_app/immutable/assets/0.tmlwn-Ih.css +1 -0
  352. package/dist/web-ui/_app/immutable/assets/9.BwwJybWx.css +1 -0
  353. package/dist/web-ui/_app/immutable/chunks/2KqE8gtn.js +1 -0
  354. package/dist/web-ui/_app/immutable/chunks/70-t_QvE.js +1 -0
  355. package/dist/web-ui/_app/immutable/chunks/AiWQj974.js +1 -0
  356. package/dist/web-ui/_app/immutable/chunks/B25nWFyA.js +5 -0
  357. package/dist/web-ui/_app/immutable/chunks/B2bcA_Q_.js +1 -0
  358. package/dist/web-ui/_app/immutable/chunks/B5e5HYyB.js +1 -0
  359. package/dist/web-ui/_app/immutable/chunks/B7-5z6eA.js +1 -0
  360. package/dist/web-ui/_app/immutable/chunks/B7bGmhK0.js +1 -0
  361. package/dist/web-ui/_app/immutable/chunks/B8tYZKAE.js +1 -0
  362. package/dist/web-ui/_app/immutable/chunks/BFGAyJjD.js +1 -0
  363. package/dist/web-ui/_app/immutable/chunks/BG0850zf.js +1 -0
  364. package/dist/web-ui/_app/immutable/chunks/BG8eSzAd.js +1 -0
  365. package/dist/web-ui/_app/immutable/chunks/BIMxXS8I.js +1 -0
  366. package/dist/web-ui/_app/immutable/chunks/BSzL1fpU.js +1 -0
  367. package/dist/web-ui/_app/immutable/chunks/BYtjHfeq.js +1 -0
  368. package/dist/web-ui/_app/immutable/chunks/{D1ArdqNb.js → Bp5pFYXL.js} +1 -1
  369. package/dist/web-ui/_app/immutable/chunks/BsJFsuAT.js +1 -0
  370. package/dist/web-ui/_app/immutable/chunks/BvpNHcD6.js +1 -0
  371. package/dist/web-ui/_app/immutable/chunks/BypqA25-.js +1 -0
  372. package/dist/web-ui/_app/immutable/chunks/C0w6WDm5.js +1 -0
  373. package/dist/web-ui/_app/immutable/chunks/C5_PAZ0y.js +1 -0
  374. package/dist/web-ui/_app/immutable/chunks/CDRO15Iv.js +1 -0
  375. package/dist/web-ui/_app/immutable/chunks/CF1CoqD5.js +1 -0
  376. package/dist/web-ui/_app/immutable/chunks/CS2sa4_m.js +1 -0
  377. package/dist/web-ui/_app/immutable/chunks/CWUQwB9H.js +1 -0
  378. package/dist/web-ui/_app/immutable/chunks/CY5FDdSU.js +1 -0
  379. package/dist/web-ui/_app/immutable/chunks/C_7MTDoj.js +1 -0
  380. package/dist/web-ui/_app/immutable/chunks/CaAJD3dl.js +1 -0
  381. package/dist/web-ui/_app/immutable/chunks/{i-XnOIX0.js → ChB5iyEL.js} +1 -1
  382. package/dist/web-ui/_app/immutable/chunks/ChQD-6N8.js +1 -0
  383. package/dist/web-ui/_app/immutable/chunks/{BCkp8Hs8.js → CqbsoCwA.js} +1 -1
  384. package/dist/web-ui/_app/immutable/chunks/DCeJW50p.js +1 -0
  385. package/dist/web-ui/_app/immutable/chunks/DJtZNgcs.js +1 -0
  386. package/dist/web-ui/_app/immutable/chunks/DKIeaprD.js +1 -0
  387. package/dist/web-ui/_app/immutable/chunks/DLd2uVIA.js +1 -0
  388. package/dist/web-ui/_app/immutable/chunks/DW_subyT.js +2 -0
  389. package/dist/web-ui/_app/immutable/chunks/DbU6lVn0.js +1 -0
  390. package/dist/web-ui/_app/immutable/chunks/Dc7ZCC5m.js +1 -0
  391. package/dist/web-ui/_app/immutable/chunks/Dd5umPsk.js +2 -0
  392. package/dist/web-ui/_app/immutable/chunks/Dg_zDpDS.js +1 -0
  393. package/dist/web-ui/_app/immutable/chunks/Dgqu8Yuc.js +1 -0
  394. package/dist/web-ui/_app/immutable/chunks/DmxsPZTB.js +1 -0
  395. package/dist/web-ui/_app/immutable/chunks/DphTaFUB.js +1 -0
  396. package/dist/web-ui/_app/immutable/chunks/DqK4iHp0.js +1 -0
  397. package/dist/web-ui/_app/immutable/chunks/DqT6OH_u.js +2 -0
  398. package/dist/web-ui/_app/immutable/chunks/Ds9I9wQb.js +1 -0
  399. package/dist/web-ui/_app/immutable/chunks/Du5ng3u4.js +1 -0
  400. package/dist/web-ui/_app/immutable/chunks/DxJw79Wi.js +1 -0
  401. package/dist/web-ui/_app/immutable/chunks/GFTX8GgV.js +1 -0
  402. package/dist/web-ui/_app/immutable/chunks/HNjs76Zz.js +1 -0
  403. package/dist/web-ui/_app/immutable/chunks/HVMjDi4_.js +1 -0
  404. package/dist/web-ui/_app/immutable/chunks/P0A_fJvS.js +1 -0
  405. package/dist/web-ui/_app/immutable/chunks/T3vGWjIL.js +1 -0
  406. package/dist/web-ui/_app/immutable/chunks/VTmrX9Qu.js +1 -0
  407. package/dist/web-ui/_app/immutable/chunks/Xvwhx_F1.js +1 -0
  408. package/dist/web-ui/_app/immutable/chunks/Yyz1XMQA.js +1 -0
  409. package/dist/web-ui/_app/immutable/chunks/dh5HeqUr.js +1 -0
  410. package/dist/web-ui/_app/immutable/chunks/fZMteyca.js +62 -0
  411. package/dist/web-ui/_app/immutable/chunks/{D28BF5MJ.js → gPrj-hqC.js} +1 -1
  412. package/dist/web-ui/_app/immutable/chunks/htcWMiYN.js +1 -0
  413. package/dist/web-ui/_app/immutable/chunks/oTsvd9y4.js +1 -0
  414. package/dist/web-ui/_app/immutable/chunks/qJfLUwU4.js +1 -0
  415. package/dist/web-ui/_app/immutable/chunks/xCtiO_JE.js +1 -0
  416. package/dist/web-ui/_app/immutable/chunks/y4GeEH6k.js +1 -0
  417. package/dist/web-ui/_app/immutable/entry/app.C4h_eOn6.js +2 -0
  418. package/dist/web-ui/_app/immutable/entry/start.CQFTf9ep.js +1 -0
  419. package/dist/web-ui/_app/immutable/nodes/0.Dh1xO970.js +1 -0
  420. package/dist/web-ui/_app/immutable/nodes/1.l75D3Opx.js +1 -0
  421. package/dist/web-ui/_app/immutable/nodes/10.DBidBPc-.js +1 -0
  422. package/dist/web-ui/_app/immutable/nodes/11.Ab0gUKWe.js +1 -0
  423. package/dist/web-ui/_app/immutable/nodes/12.CMsnoxfs.js +1 -0
  424. package/dist/web-ui/_app/immutable/nodes/13.D8YKuknB.js +1 -0
  425. package/dist/web-ui/_app/immutable/nodes/14.DZ0aan7y.js +1 -0
  426. package/dist/web-ui/_app/immutable/nodes/15.CUIKreDL.js +2 -0
  427. package/dist/web-ui/_app/immutable/nodes/16.BWc8--BO.js +1 -0
  428. package/dist/web-ui/_app/immutable/nodes/2.CDUonbuh.js +1 -0
  429. package/dist/web-ui/_app/immutable/nodes/3.Ctg3M00i.js +1 -0
  430. package/dist/web-ui/_app/immutable/nodes/4.Ci-JDwbA.js +2 -0
  431. package/dist/web-ui/_app/immutable/nodes/5.CTyEDAq0.js +1 -0
  432. package/dist/web-ui/_app/immutable/nodes/6.BTZZqsAb.js +1 -0
  433. package/dist/web-ui/_app/immutable/nodes/7.BI52g_Jo.js +137 -0
  434. package/dist/web-ui/_app/immutable/nodes/8.3hZPaB9x.js +1 -0
  435. package/dist/web-ui/_app/immutable/nodes/9.DS49kvwl.js +29 -0
  436. package/dist/web-ui/_app/version.json +1 -1
  437. package/dist/web-ui/favicon-192.png +0 -0
  438. package/dist/web-ui/favicon-32.png +0 -0
  439. package/dist/web-ui/favicon.ico +0 -0
  440. package/dist/web-ui/index.html +14 -11
  441. package/package.json +14 -7
  442. package/plugin/.claude-plugin/marketplace.json +1 -1
  443. package/plugin/.claude-plugin/plugin.json +1 -1
  444. package/plugin/plugins/kspec/skills/merge/SKILL.md +127 -0
  445. package/plugin/plugins/kspec/skills/plan/SKILL.md +55 -26
  446. package/plugin/plugins/kspec/skills/review/SKILL.md +350 -133
  447. package/plugin/plugins/kspec/skills/task-work/SKILL.md +96 -106
  448. package/templates/agents-sections/04-pr-workflow.md +15 -12
  449. package/templates/agents-sections/06-ralph-loop.md +15 -10
  450. package/templates/skills/manifest.yaml +25 -7
  451. package/templates/skills/merge/SKILL.md +120 -0
  452. package/templates/skills/plan/SKILL.md +55 -26
  453. package/templates/skills/review/SKILL.md +346 -130
  454. package/templates/skills/task-work/SKILL.md +93 -103
  455. package/dist/web-ui/_app/immutable/assets/0.BxCxvrZR.css +0 -1
  456. package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +0 -1
  457. package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +0 -1
  458. package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +0 -1
  459. package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +0 -1
  460. package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +0 -1
  461. package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +0 -1
  462. package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +0 -1
  463. package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +0 -1
  464. package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +0 -1
  465. package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +0 -2
  466. package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +0 -1
  467. package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +0 -1
  468. package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +0 -1
  469. package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +0 -1
  470. package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +0 -1
  471. package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +0 -1
  472. package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +0 -2
  473. package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +0 -1
  474. package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +0 -1
  475. package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +0 -1
  476. package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +0 -1
  477. package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +0 -5
  478. package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +0 -2
  479. package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +0 -1
  480. package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +0 -1
  481. package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +0 -1
  482. package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +0 -1
  483. package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +0 -1
  484. package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +0 -1
  485. package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +0 -1
  486. package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +0 -1
  487. package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.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.
@@ -172,9 +418,14 @@ export class DispatchEngine {
172
418
  specDir;
173
419
  cwd;
174
420
  dedupWindowMs;
421
+ reconcileIntervalMs;
422
+ /** AC: @per-task-dispatch-drain-coalescing ac-4 */
423
+ coalesceWindowMs;
175
424
  kspecCliPath;
176
425
  onInvocationEvent;
177
- onTextChunk;
426
+ onSessionEvent;
427
+ /** Per-session text accumulator for newline-boundary streaming. */
428
+ accumulator = new SessionEventAccumulator();
178
429
  /** Queue of pending dispatch entries, per agent id */
179
430
  queues = new Map();
180
431
  /** Count of active (running) invocations per agent id */
@@ -193,16 +444,35 @@ export class DispatchEngine {
193
444
  invocationAbortControllers = new Set();
194
445
  /** Per-invocation tracking records for status display */
195
446
  activeInvocationDetails = new Map();
447
+ /** Task refs currently between queue removal and active tracking registration */
448
+ inFlightTaskKeys = new Set();
196
449
  /** Monotonic enqueue sequence for deterministic queue ordering */
197
450
  nextQueueSequence = 0;
451
+ /** Last task selected/completed, used as continuity affinity signal. */
452
+ recentTaskAffinityRef = null;
453
+ /** Timer handle for periodic reconciliation. AC: @agent-dispatch-engine ac-20 */
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;
198
463
  constructor(options) {
199
464
  this.projectDir = options.projectDir;
200
465
  this.specDir = options.specDir ?? path.join(options.projectDir, ".kspec");
201
466
  this.cwd = options.cwd ?? options.projectDir;
202
467
  this.dedupWindowMs = options.dedupWindowMs ?? 2000;
468
+ this.reconcileIntervalMs = (options.reconcileIntervalMs === null || options.reconcileIntervalMs === 0)
469
+ ? 0
470
+ : (options.reconcileIntervalMs ?? 60_000);
471
+ // AC: @per-task-dispatch-drain-coalescing ac-4
472
+ this.coalesceWindowMs = options.coalesceWindowMs ?? 5000;
203
473
  this.kspecCliPath = options.kspecCliPath;
204
474
  this.onInvocationEvent = options.onInvocationEvent;
205
- this.onTextChunk = options.onTextChunk;
475
+ this.onSessionEvent = options.onSessionEvent;
206
476
  }
207
477
  // ─── Public API ─────────────────────────────────────────────────────────────
208
478
  /**
@@ -213,8 +483,36 @@ export class DispatchEngine {
213
483
  */
214
484
  async start() {
215
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
+ });
216
500
  // AC: @agent-dispatch-engine ac-8 - Bootstrap: evaluate existing task states
217
501
  await this._bootstrap();
502
+ // AC: @agent-dispatch-engine ac-19, ac-20 - Start periodic reconciliation
503
+ if (this.reconcileIntervalMs > 0) {
504
+ this.reconcileTimer = setInterval(() => {
505
+ if (this.running) {
506
+ const p = this._reconcile().catch((err) => {
507
+ console.error("[dispatch] Reconciliation error:", err);
508
+ }).finally(() => {
509
+ this.inFlightReconciles.delete(p);
510
+ });
511
+ this.inFlightReconciles.add(p);
512
+ }
513
+ }, this.reconcileIntervalMs);
514
+ this.reconcileTimer.unref();
515
+ }
218
516
  }
219
517
  /**
220
518
  * Handle a task state change event from any source (file watcher or API).
@@ -229,21 +527,54 @@ export class DispatchEngine {
229
527
  return;
230
528
  }
231
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
+ }
232
552
  // AC: @agent-dispatch-engine ac-1 - Match against dispatch rules
233
553
  const agents = await this._loadAgents();
234
554
  const eventType = STATUS_TO_EVENT[change.toStatus];
235
555
  if (!eventType)
236
556
  return;
237
- // Load task data for filter evaluation if not provided
557
+ // Load all tasks for filter evaluation (needed for dependency checks)
558
+ let allTasks;
238
559
  let taskData = change.task;
239
560
  if (!taskData && change.taskId) {
240
561
  try {
241
562
  const ctx = await initContext(this.projectDir);
242
- const tasks = await loadAllTasks(ctx);
243
- taskData = tasks.find((t) => t._ulid === change.taskId);
563
+ allTasks = await loadAllTasks(ctx);
564
+ taskData = allTasks.find((t) => t._ulid === change.taskId);
565
+ }
566
+ catch {
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);
244
575
  }
245
576
  catch {
246
- // Can't load task, filter evaluation will be lenient
577
+ // Can't load tasks, dependency check will be skipped
247
578
  }
248
579
  }
249
580
  // Make loaded task available for prompt building (AC: @agent-dispatch-engine ac-13)
@@ -255,14 +586,22 @@ export class DispatchEngine {
255
586
  if (rule.on !== eventType)
256
587
  continue;
257
588
  // AC: @agent-dispatch-engine ac-6 - Apply filters
258
- if (!this._matchesFilter(change, rule, taskData))
589
+ if (!this._matchesFilter(change, rule, taskData, allTasks))
259
590
  continue;
260
591
  // AC: @agent-dispatch-engine ac-2 - Each matching agent queued independently
261
592
  this._enqueue(agent, change);
262
593
  }
263
594
  }
264
- // Drain queues after enqueuing
265
- 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
+ }
266
605
  }
267
606
  /**
268
607
  * Handle file watcher notification: diff previous vs current task states.
@@ -310,15 +649,39 @@ export class DispatchEngine {
310
649
  */
311
650
  async stop() {
312
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();
657
+ // AC: @agent-dispatch-engine ac-20 - Stop periodic reconciliation
658
+ if (this.reconcileTimer !== null) {
659
+ clearInterval(this.reconcileTimer);
660
+ this.reconcileTimer = null;
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();
313
674
  // AC: @agent-dispatch-engine ac-11 - Send graceful cancel to all active invocations
314
675
  for (const controller of this.invocationAbortControllers) {
315
676
  controller.abort();
316
677
  }
317
- // 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.
318
682
  if (this.runningInvocations.size > 0) {
319
683
  await Promise.allSettled(Array.from(this.runningInvocations));
320
684
  }
321
- this.queues.clear();
322
685
  this.activeCount.clear();
323
686
  this.recentEvents.clear();
324
687
  this.invocationAbortControllers.clear();
@@ -331,6 +694,9 @@ export class DispatchEngine {
331
694
  getShadowMutex() {
332
695
  return this.shadowMutex;
333
696
  }
697
+ getCwd() {
698
+ return this.cwd;
699
+ }
334
700
  /**
335
701
  * Returns current engine status info including per-invocation details.
336
702
  * AC: @cli-agent-commands ac-6
@@ -365,44 +731,148 @@ export class DispatchEngine {
365
731
  * AC: @agent-dispatch-engine ac-8
366
732
  */
367
733
  async _bootstrap() {
734
+ try {
735
+ const enqueued = await this._evaluateAllTasks({ skipIfActive: false });
736
+ if (enqueued > 0) {
737
+ // AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
738
+ await this._serializedDrain();
739
+ }
740
+ }
741
+ catch (err) {
742
+ console.error("[dispatch] Bootstrap error:", err);
743
+ }
744
+ }
745
+ /**
746
+ * Periodic reconciliation: re-evaluate all task states against dispatch rules.
747
+ * Enqueues tasks that match but have no active or queued invocation.
748
+ * AC: @agent-dispatch-engine ac-19
749
+ */
750
+ async _reconcile() {
368
751
  try {
369
752
  const ctx = await initContext(this.projectDir);
370
753
  const tasks = await loadAllTasks(ctx);
371
- const agents = await this._loadAgents();
372
- const now = Date.now();
373
- // Seed prevTaskStates so subsequent file watcher diffs work correctly
374
- for (const task of tasks) {
375
- this.prevTaskStates.set(task._ulid, task.status);
376
- }
377
- // Evaluate each task against each agent's dispatch rules
378
- for (const task of tasks) {
379
- const currentStatus = task.status;
380
- const eventType = STATUS_TO_EVENT[currentStatus];
381
- if (!eventType)
382
- continue;
383
- for (const agent of agents) {
384
- for (const rule of (agent.dispatch ?? [])) {
385
- if (rule.on !== eventType)
386
- continue;
387
- const change = {
388
- taskId: task._ulid,
389
- taskRef: `@${task._ulid}`,
390
- fromStatus: currentStatus, // bootstrap: treated as "just entered"
391
- toStatus: currentStatus,
392
- timestamp: now,
393
- task,
394
- };
395
- if (!this._matchesFilter(change, rule, task))
396
- continue;
397
- this._enqueue(agent, change);
398
- }
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
+ });
765
+ const enqueued = await this._evaluateAllTasks({ skipIfActive: true });
766
+ if (enqueued > 0) {
767
+ console.log(`[dispatch] Reconciliation enqueued ${enqueued} task(s)`);
768
+ // AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
769
+ await this._serializedDrain();
770
+ }
771
+ }
772
+ /**
773
+ * Shared logic for bootstrap and reconciliation: load all tasks, seed
774
+ * prevTaskStates, and enqueue tasks matching agent dispatch rules.
775
+ *
776
+ * When skipIfActive is true (reconciliation), tasks with an existing
777
+ * active or queued invocation are skipped.
778
+ *
779
+ * AC: @agent-dispatch-engine ac-8, ac-19
780
+ */
781
+ async _evaluateAllTasks(opts) {
782
+ const ctx = await initContext(this.projectDir);
783
+ const tasks = await loadAllTasks(ctx);
784
+ const agents = await this._loadAgents();
785
+ const now = Date.now();
786
+ let enqueued = 0;
787
+ // Seed/update prevTaskStates so file watcher diffs work correctly
788
+ for (const task of tasks) {
789
+ this.prevTaskStates.set(task._ulid, task.status);
790
+ }
791
+ for (const task of tasks) {
792
+ const currentStatus = task.status;
793
+ const eventType = STATUS_TO_EVENT[currentStatus];
794
+ if (!eventType)
795
+ continue;
796
+ for (const agent of agents) {
797
+ for (const rule of (agent.dispatch ?? [])) {
798
+ if (rule.on !== eventType)
799
+ continue;
800
+ const change = {
801
+ taskId: task._ulid,
802
+ taskRef: `@${task._ulid}`,
803
+ fromStatus: currentStatus,
804
+ toStatus: currentStatus,
805
+ timestamp: now,
806
+ task,
807
+ };
808
+ if (!this._matchesFilter(change, rule, task, tasks))
809
+ continue;
810
+ if (opts.skipIfActive && this._hasActiveOrQueuedInvocation(agent.id, task._ulid))
811
+ continue;
812
+ this._enqueue(agent, change);
813
+ enqueued++;
399
814
  }
400
815
  }
401
- await this._drainQueues(agents);
402
816
  }
403
- catch (err) {
404
- console.error("[dispatch] Bootstrap error:", err);
817
+ return enqueued;
818
+ }
819
+ /**
820
+ * Check if an agent already has an active or queued invocation for a task.
821
+ * AC: @agent-dispatch-engine ac-19
822
+ */
823
+ _hasActiveOrQueuedInvocation(agentId, taskId) {
824
+ if (this.inFlightTaskKeys.has(`${agentId}:@${taskId}`)) {
825
+ return true;
826
+ }
827
+ // Check active invocations
828
+ for (const record of this.activeInvocationDetails.values()) {
829
+ if (record.agentId === agentId && record.taskRef === `@${taskId}`) {
830
+ return true;
831
+ }
832
+ }
833
+ // Check queued entries
834
+ const queue = this.queues.get(agentId) ?? [];
835
+ return queue.some((entry) => entry.change.taskId === taskId);
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
+ }
405
874
  }
875
+ return false;
406
876
  }
407
877
  /**
408
878
  * Load agent definitions from meta context.
@@ -419,19 +889,42 @@ export class DispatchEngine {
419
889
  }
420
890
  /**
421
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
+ *
422
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
423
900
  */
424
- _matchesFilter(change, rule, task) {
425
- if (!rule.filter)
426
- 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";
427
905
  // We need the task to evaluate filters — if not provided, reject to avoid
428
906
  // enqueuing non-matching tasks (AC-6: all filters must match)
429
907
  if (!task)
908
+ return !rule.filter && !defaultsToEligible;
909
+ // Any unresolved blocker excludes the candidate from scheduling.
910
+ if ((task.blocked_by ?? []).length > 0) {
430
911
  return false;
431
- 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);
432
925
  // Automation filter
433
- if (filter.automation !== undefined) {
434
- if (task.automation !== filter.automation) {
926
+ if (effectiveAutomation !== undefined) {
927
+ if (task.automation !== effectiveAutomation) {
435
928
  return false;
436
929
  }
437
930
  }
@@ -442,9 +935,11 @@ export class DispatchEngine {
442
935
  return false;
443
936
  }
444
937
  }
445
- // Priority filter
938
+ // Priority filter — threshold semantics: task priority at or above (numerically <=)
939
+ // AC: @ui-agent-dispatch ac-8
446
940
  if (filter.priority !== undefined) {
447
- if (task.priority !== filter.priority) {
941
+ const taskPriority = task.priority;
942
+ if (taskPriority === undefined || taskPriority > filter.priority) {
448
943
  return false;
449
944
  }
450
945
  }
@@ -497,6 +992,7 @@ export class DispatchEngine {
497
992
  nextRetryAt: 0,
498
993
  enqueuedAtMs: Date.now(),
499
994
  sequence: this.nextQueueSequence++,
995
+ starvationDeferrals: 0,
500
996
  };
501
997
  this._insertQueueEntry(queue, entry);
502
998
  this.queues.set(agent.id, queue);
@@ -514,90 +1010,320 @@ export class DispatchEngine {
514
1010
  queue.splice(insertAt, 0, entry);
515
1011
  }
516
1012
  /**
517
- * Compare queue entries by dispatch precedence, then by enqueue sequence.
1013
+ * Compare queue entries by dispatch precedence, numeric task priority, then FIFO.
518
1014
  * AC: @dispatch-in-progress-priority ac-1
519
1015
  */
520
1016
  _compareQueueEntries(a, b) {
521
1017
  const statusDelta = STATUS_PRECEDENCE[a.change.toStatus] - STATUS_PRECEDENCE[b.change.toStatus];
522
1018
  if (statusDelta !== 0)
523
1019
  return statusDelta;
1020
+ const priorityDelta = this._taskPriorityForEntry(a) - this._taskPriorityForEntry(b);
1021
+ if (priorityDelta !== 0)
1022
+ return priorityDelta;
524
1023
  return a.sequence - b.sequence;
525
1024
  }
526
- /**
527
- * Drain queues, spawning invocations up to each agent's max_concurrent limit.
528
- * AC: @agent-dispatch-engine ac-3, ac-17
529
- */
530
- async _drainQueues(agents) {
531
- // Prevent new invocation starts during/after shutdown.
532
- 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;
533
1062
  return;
534
- // AC: @agent-dispatch-engine ac-17 - Load current task states once for staleness checks
535
- let currentTaskStates;
536
- try {
537
- const ctx = await initContext(this.projectDir);
538
- const tasks = await loadAllTasks(ctx);
539
- currentTaskStates = new Map(tasks.map((t) => [t._ulid, t.status]));
540
1063
  }
541
- catch {
542
- // 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;
543
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]));
544
1147
  for (const agent of agents) {
545
- const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
546
- const active = this.activeCount.get(agent.id) ?? 0;
547
1148
  const queue = this.queues.get(agent.id) ?? [];
548
- // AC: @agent-dispatch-engine ac-17 - Discard stale entries before spawning.
549
- // Only discard when we have positive evidence the task moved: either the task
550
- // exists on disk with a different status, or the task was previously tracked
551
- // (in prevTaskStates) but is no longer found (deleted).
552
- if (currentTaskStates) {
553
- const before = queue.length;
554
- for (let i = queue.length - 1; i >= 0; i--) {
555
- const entry = queue[i];
556
- const currentStatus = currentTaskStates.get(entry.change.taskId);
557
- const expectedEvent = STATUS_TO_EVENT[entry.change.toStatus];
558
- if (!expectedEvent)
559
- 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) {
560
1156
  if (currentStatus === undefined) {
561
- // Task not on disk — only discard if we previously knew about it
562
- // (it was deleted). Tasks from pure handleStateChange events without
563
- // on-disk presence should still be processed.
564
1157
  if (this.prevTaskStates.has(entry.change.taskId)) {
1158
+ discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task no longer exists on disk`);
565
1159
  queue.splice(i, 1);
1160
+ continue;
566
1161
  }
567
1162
  }
568
1163
  else {
569
1164
  const currentEvent = STATUS_TO_EVENT[currentStatus];
570
1165
  if (currentEvent !== expectedEvent) {
1166
+ discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task state changed to ${currentStatus}`);
571
1167
  queue.splice(i, 1);
1168
+ continue;
572
1169
  }
573
1170
  }
574
1171
  }
575
- if (before > queue.length) {
576
- 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
+ }
577
1187
  }
578
- }
579
- let slots = maxConcurrent - active;
580
- while (slots > 0 && queue.length > 0) {
581
- const now = Date.now();
582
- const nextReadyIndex = queue.findIndex((entry) => entry.nextRetryAt <= now);
583
- if (nextReadyIndex === -1) {
584
- 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);
585
1192
  }
586
- const [entry] = queue.splice(nextReadyIndex, 1);
587
- const spawned = this._spawnInvocation(agent, entry);
588
- if (spawned)
589
- 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}"`);
590
1199
  }
591
1200
  this.queues.set(agent.id, queue);
592
1201
  }
593
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
+ }
594
1320
  /**
595
1321
  * Build dispatch-mode prompt guardrails to keep autonomous agents from
596
1322
  * stopping with handoff text instead of performing required actions.
597
1323
  *
598
1324
  * AC: @agent-dispatch-engine ac-13, ac-14, ac-15, ac-16
599
1325
  */
600
- _buildDispatchPrompt(agent, change) {
1326
+ async _buildDispatchPrompt(agent, change, workspace) {
601
1327
  const trigger = (STATUS_TO_EVENT[change.toStatus] ?? "task.ready");
602
1328
  const taskRef = change.taskRef;
603
1329
  // AC: @agent-dispatch-engine ac-16 - Interpolate prompt_template variables
@@ -609,8 +1335,13 @@ export class DispatchEngine {
609
1335
  };
610
1336
  const rawTemplate = agent.prompt_template ?? `Work on task ${taskRef}`;
611
1337
  const basePrompt = interpolateTemplate(rawTemplate, templateVars);
612
- // AC: @agent-dispatch-engine ac-13 - Orientation context
613
- 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);
614
1345
  const autonomousPreamble = [
615
1346
  "AUTONOMOUS DISPATCH MODE (no interactive user is available).",
616
1347
  "- Do not ask for confirmation, approval, or next-step handoff.",
@@ -618,6 +1349,7 @@ export class DispatchEngine {
618
1349
  "- Do not end your turn with a recommendations-only summary. Perform the next required action yourself.",
619
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 \"...\"`.",
620
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.`,
621
1353
  ];
622
1354
  const triggerSpecific = trigger === "task.pending_review"
623
1355
  ? [
@@ -632,160 +1364,432 @@ export class DispatchEngine {
632
1364
  `- Perform the required commands to move ${taskRef} to the next appropriate state in this same invocation.`,
633
1365
  "- If your workflow includes git or PR steps, execute them directly instead of deferring to a human.",
634
1366
  ];
635
- 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
+ }
636
1399
  }
637
1400
  /**
638
1401
  * Spawn a single invocation for a queue entry.
639
1402
  * Returns true if an invocation was actually started, false if skipped.
640
1403
  * AC: @agent-dispatch-engine ac-9, ac-10, ac-11, ac-12
641
1404
  */
642
- _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;
643
1410
  const agentId = agent.id;
644
- // Increment active count
645
- this.activeCount.set(agentId, (this.activeCount.get(agentId) ?? 0) + 1);
646
- // AC: @agent-dispatch-engine ac-10 - Check adapter resolvability before spawn
647
- const adapterId = agent.adapter ?? "claude-agent-acp";
648
- const adapter = getAdapter(adapterId);
649
- if (!adapter) {
650
- console.error(`[dispatch] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Skipping invocation.`);
651
- // Decrement active count since we're not actually running
652
- const currentActive = this.activeCount.get(agentId) ?? 1;
653
- this.activeCount.set(agentId, Math.max(0, currentActive - 1));
654
- // AC: @agent-dispatch-engine ac-10 - Add task note for unresolvable adapter
655
- if (this.kspecCliPath) {
656
- spawnSync(process.execPath, [
657
- this.kspecCliPath,
658
- "task", "note", entry.change.taskRef,
659
- `[AGENT-SKIP] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Invocation skipped.`,
660
- ], { 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
+ });
661
1437
  }
662
- return false;
663
- }
664
- // AC: @agent-dispatch-engine ac-11 - Create abort controller for graceful cancellation
665
- const abortController = new AbortController();
666
- this.invocationAbortControllers.add(abortController);
667
- // AC: @cli-agent-commands ac-6 - Pre-assign session ID for status tracking
668
- const preSessionId = ulid();
669
- const invocationId = ulid();
670
- const trackingRecord = {
671
- invocationId,
672
- sessionId: preSessionId,
673
- agentId,
674
- agentName: agent.name,
675
- taskRef: entry.change.taskRef,
676
- startedAtMs: Date.now(),
677
- };
678
- this.activeInvocationDetails.set(invocationId, trackingRecord);
679
- // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit started event
680
- this.onInvocationEvent?.({
681
- type: "started",
682
- session_id: preSessionId,
683
- agent_id: agentId,
684
- task_id: entry.change.taskRef,
685
- status: "started",
686
- timestamp: Date.now(),
687
- });
688
- // AC: @cli-agent-commands ac-13, @daemon-agent-dispatch ac-8 - stream text chunks to watchers
689
- const taskId = entry.change.taskRef ?? null;
690
- const onUpdate = this.onTextChunk
691
- ? (update) => {
692
- if (update.sessionUpdate === "agent_message_chunk" &&
693
- update.content.type === "text") {
694
- this.onTextChunk(preSessionId, agentId, taskId, update.content.text);
695
- 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 });
696
1485
  }
697
- // Non-text updates (especially tool events) delimit logical message runs.
698
- // Emit an empty sentinel so watch renderers can end the current line
699
- // without needing to infer boundaries from prose punctuation.
700
- this.onTextChunk(preSessionId, agentId, taskId, "");
1486
+ return false;
701
1487
  }
702
- : undefined;
703
- const options = {
704
- agent,
705
- specDir: this.specDir,
706
- cwd: this.cwd,
707
- taskRef: entry.change.taskRef,
708
- prompt: this._buildDispatchPrompt(agent, entry.change),
709
- trigger: (STATUS_TO_EVENT[entry.change.toStatus] ?? "task.ready"),
710
- kspecCliPath: this.kspecCliPath,
711
- abortSignal: abortController.signal,
712
- sessionId: preSessionId,
713
- onUpdate,
714
- };
715
- // AC: @agent-dispatch-engine ac-12 - Wrap invocation in shadow mutex
716
- const invocationPromise = this.shadowMutex
717
- .runExclusive(async () => {
718
- // AC: @agent-dispatch-engine ac-9 - Retry on transient errors
1488
+ let prompt;
719
1489
  try {
720
- await runInvocation(options);
721
- // Reset retry count on success
722
- entry.retryCount = 0;
723
- // 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;
724
1549
  this.onInvocationEvent?.({
725
- type: "completed",
1550
+ type: "started",
726
1551
  session_id: preSessionId,
727
1552
  agent_id: agentId,
728
1553
  task_id: entry.change.taskRef,
729
- status: "completed",
1554
+ task_title: entry.change.task?.title ?? null,
1555
+ status: "started",
730
1556
  timestamp: Date.now(),
731
1557
  });
732
- }
733
- catch (err) {
734
- const retryLimit = agent.budget?.max_tasks ?? 3;
735
- if (entry.retryCount < retryLimit) {
736
- entry.retryCount++;
737
- const backoffMs = Math.min(1000 * Math.pow(2, entry.retryCount - 1), 30_000);
738
- entry.nextRetryAt = Date.now() + backoffMs;
739
- console.warn(`[dispatch] Invocation for agent "${agentId}" failed (attempt ${entry.retryCount}/${retryLimit}), retrying in ${backoffMs}ms`, err);
740
- // Re-enqueue for retry while preserving status precedence ordering.
741
- const queue = this.queues.get(agentId) ?? [];
742
- this._insertQueueEntry(queue, entry);
743
- this.queues.set(agentId, queue);
744
- // AC: @agent-dispatch-engine ac-9 - Schedule wake-up to drain retry
745
- setTimeout(() => {
746
- if (this.running) {
747
- this._loadAgents()
748
- .then((agents) => this._drainQueues(agents))
749
- .catch(() => { });
750
- }
751
- }, 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);
752
1573
  }
753
- else {
754
- console.error(`[dispatch] Agent "${agentId}" exceeded retry limit. Dropping invocation.`, err);
755
- // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit failed event when retry limit exceeded
756
- this.onInvocationEvent?.({
757
- type: "failed",
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
+ }
1639
+ }
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",
758
1658
  session_id: preSessionId,
759
1659
  agent_id: agentId,
760
1660
  task_id: entry.change.taskRef,
761
- status: "failed",
1661
+ task_title: entry.change.task?.title ?? null,
1662
+ status: "completed",
762
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
+ });
763
1715
  });
764
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);
765
1791
  }
766
- })
767
- .then(async () => {
768
- // Decrement active count and drain again
769
- const currentActive = this.activeCount.get(agentId) ?? 1;
770
- this.activeCount.set(agentId, Math.max(0, currentActive - 1));
771
- if (!this.running)
772
- return;
773
- // Try to drain more items
774
- try {
775
- const agents = await this._loadAgents();
776
- await this._drainQueues(agents);
777
- }
778
- catch {
779
- // Best effort
780
- }
781
- })
782
- .finally(() => {
783
- this.runningInvocations.delete(invocationPromise);
784
- this.invocationAbortControllers.delete(abortController);
785
- this.activeInvocationDetails.delete(invocationId);
786
- });
787
- this.runningInvocations.add(invocationPromise);
788
- return true;
1792
+ }
789
1793
  }
790
1794
  }
791
1795
  //# sourceMappingURL=dispatch.js.map