@kynetic-ai/spec 0.9.0 → 0.10.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 (291) hide show
  1. package/README.md +2 -1
  2. package/dist/acp/client.d.ts +13 -1
  3. package/dist/acp/client.d.ts.map +1 -1
  4. package/dist/acp/client.js +17 -2
  5. package/dist/acp/client.js.map +1 -1
  6. package/dist/acp/framing.d.ts +12 -1
  7. package/dist/acp/framing.d.ts.map +1 -1
  8. package/dist/acp/framing.js +27 -4
  9. package/dist/acp/framing.js.map +1 -1
  10. package/dist/agent-runtime/dispatch.d.ts +261 -0
  11. package/dist/agent-runtime/dispatch.d.ts.map +1 -0
  12. package/dist/agent-runtime/dispatch.js +791 -0
  13. package/dist/agent-runtime/dispatch.js.map +1 -0
  14. package/dist/agent-runtime/index.d.ts +11 -0
  15. package/dist/agent-runtime/index.d.ts.map +1 -0
  16. package/dist/agent-runtime/index.js +11 -0
  17. package/dist/agent-runtime/index.js.map +1 -0
  18. package/dist/agent-runtime/invocation.d.ts +86 -0
  19. package/dist/agent-runtime/invocation.d.ts.map +1 -0
  20. package/dist/agent-runtime/invocation.js +442 -0
  21. package/dist/agent-runtime/invocation.js.map +1 -0
  22. package/dist/agent-runtime/prompts.d.ts +50 -0
  23. package/dist/agent-runtime/prompts.d.ts.map +1 -0
  24. package/dist/agent-runtime/prompts.js +108 -0
  25. package/dist/agent-runtime/prompts.js.map +1 -0
  26. package/dist/agents/spawner.d.ts.map +1 -1
  27. package/dist/agents/spawner.js +60 -4
  28. package/dist/agents/spawner.js.map +1 -1
  29. package/dist/cli/batch-exec.d.ts.map +1 -1
  30. package/dist/cli/batch-exec.js +183 -81
  31. package/dist/cli/batch-exec.js.map +1 -1
  32. package/dist/cli/batch-write-buffer.d.ts +141 -0
  33. package/dist/cli/batch-write-buffer.d.ts.map +1 -0
  34. package/dist/cli/batch-write-buffer.js +400 -0
  35. package/dist/cli/batch-write-buffer.js.map +1 -0
  36. package/dist/cli/commands/agent.d.ts +20 -0
  37. package/dist/cli/commands/agent.d.ts.map +1 -0
  38. package/dist/cli/commands/agent.js +831 -0
  39. package/dist/cli/commands/agent.js.map +1 -0
  40. package/dist/cli/commands/agents.d.ts +1 -1
  41. package/dist/cli/commands/agents.d.ts.map +1 -1
  42. package/dist/cli/commands/agents.js +2 -1
  43. package/dist/cli/commands/agents.js.map +1 -1
  44. package/dist/cli/commands/batch.js +1 -1
  45. package/dist/cli/commands/batch.js.map +1 -1
  46. package/dist/cli/commands/inbox.d.ts.map +1 -1
  47. package/dist/cli/commands/inbox.js +46 -22
  48. package/dist/cli/commands/inbox.js.map +1 -1
  49. package/dist/cli/commands/index.d.ts +1 -0
  50. package/dist/cli/commands/index.d.ts.map +1 -1
  51. package/dist/cli/commands/index.js +1 -0
  52. package/dist/cli/commands/index.js.map +1 -1
  53. package/dist/cli/commands/init.d.ts.map +1 -1
  54. package/dist/cli/commands/init.js +4 -6
  55. package/dist/cli/commands/init.js.map +1 -1
  56. package/dist/cli/commands/item.d.ts.map +1 -1
  57. package/dist/cli/commands/item.js +34 -17
  58. package/dist/cli/commands/item.js.map +1 -1
  59. package/dist/cli/commands/log.js +1 -1
  60. package/dist/cli/commands/log.js.map +1 -1
  61. package/dist/cli/commands/merge-driver.d.ts.map +1 -1
  62. package/dist/cli/commands/merge-driver.js +8 -3
  63. package/dist/cli/commands/merge-driver.js.map +1 -1
  64. package/dist/cli/commands/meta.d.ts.map +1 -1
  65. package/dist/cli/commands/meta.js +159 -6
  66. package/dist/cli/commands/meta.js.map +1 -1
  67. package/dist/cli/commands/module.d.ts.map +1 -1
  68. package/dist/cli/commands/module.js +2 -1
  69. package/dist/cli/commands/module.js.map +1 -1
  70. package/dist/cli/commands/plan-import.js +19 -3
  71. package/dist/cli/commands/plan-import.js.map +1 -1
  72. package/dist/cli/commands/plan.d.ts.map +1 -1
  73. package/dist/cli/commands/plan.js +87 -43
  74. package/dist/cli/commands/plan.js.map +1 -1
  75. package/dist/cli/commands/ralph.d.ts +5 -51
  76. package/dist/cli/commands/ralph.d.ts.map +1 -1
  77. package/dist/cli/commands/ralph.js +52 -1462
  78. package/dist/cli/commands/ralph.js.map +1 -1
  79. package/dist/cli/commands/search.d.ts.map +1 -1
  80. package/dist/cli/commands/search.js +22 -13
  81. package/dist/cli/commands/search.js.map +1 -1
  82. package/dist/cli/commands/serve.d.ts.map +1 -1
  83. package/dist/cli/commands/serve.js +70 -11
  84. package/dist/cli/commands/serve.js.map +1 -1
  85. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  86. package/dist/cli/commands/session/checkpoint.js +7 -2
  87. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  88. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  89. package/dist/cli/commands/session/commands.js +15 -0
  90. package/dist/cli/commands/session/commands.js.map +1 -1
  91. package/dist/cli/commands/session/context.d.ts.map +1 -1
  92. package/dist/cli/commands/session/context.js +10 -5
  93. package/dist/cli/commands/session/context.js.map +1 -1
  94. package/dist/cli/commands/session/log.d.ts +1 -0
  95. package/dist/cli/commands/session/log.d.ts.map +1 -1
  96. package/dist/cli/commands/session/log.js +124 -8
  97. package/dist/cli/commands/session/log.js.map +1 -1
  98. package/dist/cli/commands/session/stale-close.d.ts +17 -0
  99. package/dist/cli/commands/session/stale-close.d.ts.map +1 -0
  100. package/dist/cli/commands/session/stale-close.js +378 -0
  101. package/dist/cli/commands/session/stale-close.js.map +1 -0
  102. package/dist/cli/commands/setup.d.ts +4 -0
  103. package/dist/cli/commands/setup.d.ts.map +1 -1
  104. package/dist/cli/commands/setup.js +150 -6
  105. package/dist/cli/commands/setup.js.map +1 -1
  106. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  107. package/dist/cli/commands/skill-crud.js +4 -3
  108. package/dist/cli/commands/skill-crud.js.map +1 -1
  109. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  110. package/dist/cli/commands/skill-diff.js +15 -0
  111. package/dist/cli/commands/skill-diff.js.map +1 -1
  112. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  113. package/dist/cli/commands/skill-install.js +50 -18
  114. package/dist/cli/commands/skill-install.js.map +1 -1
  115. package/dist/cli/commands/task.d.ts.map +1 -1
  116. package/dist/cli/commands/task.js +552 -323
  117. package/dist/cli/commands/task.js.map +1 -1
  118. package/dist/cli/commands/tasks.js +1 -1
  119. package/dist/cli/commands/tasks.js.map +1 -1
  120. package/dist/cli/commands/triage.d.ts.map +1 -1
  121. package/dist/cli/commands/triage.js +37 -13
  122. package/dist/cli/commands/triage.js.map +1 -1
  123. package/dist/cli/commands/validate.d.ts.map +1 -1
  124. package/dist/cli/commands/validate.js +99 -50
  125. package/dist/cli/commands/validate.js.map +1 -1
  126. package/dist/cli/help/content.d.ts.map +1 -1
  127. package/dist/cli/help/content.js +5 -0
  128. package/dist/cli/help/content.js.map +1 -1
  129. package/dist/cli/index.d.ts.map +1 -1
  130. package/dist/cli/index.js +2 -1
  131. package/dist/cli/index.js.map +1 -1
  132. package/dist/cli/output.d.ts.map +1 -1
  133. package/dist/cli/output.js +5 -1
  134. package/dist/cli/output.js.map +1 -1
  135. package/dist/cli/validators.d.ts +4 -0
  136. package/dist/cli/validators.d.ts.map +1 -1
  137. package/dist/cli/validators.js +12 -0
  138. package/dist/cli/validators.js.map +1 -1
  139. package/dist/daemon/project-context.ts +22 -0
  140. package/dist/daemon/routes/agent-dispatch.ts +272 -0
  141. package/dist/daemon/server.ts +55 -20
  142. package/dist/daemon/websocket/handler.ts +67 -6
  143. package/dist/daemon/websocket/lifecycle.ts +19 -0
  144. package/dist/daemon/websocket/pubsub.ts +74 -3
  145. package/dist/export/html.d.ts.map +1 -1
  146. package/dist/export/html.js +5 -2
  147. package/dist/export/html.js.map +1 -1
  148. package/dist/export/triage.d.ts +1 -1
  149. package/dist/export/triage.d.ts.map +1 -1
  150. package/dist/export/triage.js +5 -3
  151. package/dist/export/triage.js.map +1 -1
  152. package/dist/parser/alignment.d.ts.map +1 -1
  153. package/dist/parser/alignment.js +6 -3
  154. package/dist/parser/alignment.js.map +1 -1
  155. package/dist/parser/assess.js +1 -1
  156. package/dist/parser/assess.js.map +1 -1
  157. package/dist/parser/config.d.ts +6 -6
  158. package/dist/parser/meta.d.ts.map +1 -1
  159. package/dist/parser/meta.js +9 -8
  160. package/dist/parser/meta.js.map +1 -1
  161. package/dist/parser/plan-document.d.ts +12 -12
  162. package/dist/parser/plans.d.ts +7 -0
  163. package/dist/parser/plans.d.ts.map +1 -1
  164. package/dist/parser/plans.js +100 -15
  165. package/dist/parser/plans.js.map +1 -1
  166. package/dist/parser/refs.d.ts +5 -0
  167. package/dist/parser/refs.d.ts.map +1 -1
  168. package/dist/parser/refs.js +17 -12
  169. package/dist/parser/refs.js.map +1 -1
  170. package/dist/parser/shadow.d.ts +1 -1
  171. package/dist/parser/shadow.d.ts.map +1 -1
  172. package/dist/parser/shadow.js +241 -76
  173. package/dist/parser/shadow.js.map +1 -1
  174. package/dist/parser/skill-render.d.ts.map +1 -1
  175. package/dist/parser/skill-render.js +6 -3
  176. package/dist/parser/skill-render.js.map +1 -1
  177. package/dist/parser/validate.d.ts.map +1 -1
  178. package/dist/parser/validate.js +70 -108
  179. package/dist/parser/validate.js.map +1 -1
  180. package/dist/parser/yaml.d.ts +24 -5
  181. package/dist/parser/yaml.d.ts.map +1 -1
  182. package/dist/parser/yaml.js +228 -66
  183. package/dist/parser/yaml.js.map +1 -1
  184. package/dist/schema/meta.d.ts +442 -119
  185. package/dist/schema/meta.d.ts.map +1 -1
  186. package/dist/schema/meta.js +55 -0
  187. package/dist/schema/meta.js.map +1 -1
  188. package/dist/schema/plan.d.ts +22 -22
  189. package/dist/schema/spec.d.ts +39 -39
  190. package/dist/schema/task.d.ts +43 -32
  191. package/dist/schema/task.d.ts.map +1 -1
  192. package/dist/schema/task.js +5 -0
  193. package/dist/schema/task.js.map +1 -1
  194. package/dist/sessions/store.d.ts +112 -0
  195. package/dist/sessions/store.d.ts.map +1 -1
  196. package/dist/sessions/store.js +414 -22
  197. package/dist/sessions/store.js.map +1 -1
  198. package/dist/sessions/types.d.ts +75 -17
  199. package/dist/sessions/types.d.ts.map +1 -1
  200. package/dist/sessions/types.js +51 -1
  201. package/dist/sessions/types.js.map +1 -1
  202. package/dist/triage/actions.d.ts +1 -0
  203. package/dist/triage/actions.d.ts.map +1 -1
  204. package/dist/triage/actions.js +34 -7
  205. package/dist/triage/actions.js.map +1 -1
  206. package/dist/utils/commit.js +1 -1
  207. package/dist/utils/commit.js.map +1 -1
  208. package/dist/web-ui/_app/env.js +1 -0
  209. package/dist/web-ui/_app/immutable/assets/0.BxCxvrZR.css +1 -0
  210. package/dist/web-ui/_app/immutable/assets/select-trigger.CV-KWLNP.css +1 -0
  211. package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +1 -0
  212. package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +1 -0
  213. package/dist/web-ui/_app/immutable/chunks/BCkp8Hs8.js +1 -0
  214. package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +1 -0
  215. package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +1 -0
  216. package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +1 -0
  217. package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +1 -0
  218. package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +1 -0
  219. package/dist/web-ui/_app/immutable/chunks/D1ArdqNb.js +1 -0
  220. package/dist/web-ui/_app/immutable/chunks/D28BF5MJ.js +1 -0
  221. package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +1 -0
  222. package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +1 -0
  223. package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +2 -0
  224. package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +1 -0
  225. package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +1 -0
  226. package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +1 -0
  227. package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +1 -0
  228. package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +1 -0
  229. package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +1 -0
  230. package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +2 -0
  231. package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +1 -0
  232. package/dist/web-ui/_app/immutable/chunks/i-XnOIX0.js +1 -0
  233. package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +1 -0
  234. package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +1 -0
  235. package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +1 -0
  236. package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +5 -0
  237. package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +2 -0
  238. package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +1 -0
  239. package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +1 -0
  240. package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +1 -0
  241. package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +1 -0
  242. package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +1 -0
  243. package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +1 -0
  244. package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +1 -0
  245. package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +1 -0
  246. package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.js +1 -0
  247. package/dist/web-ui/_app/version.json +1 -0
  248. package/dist/web-ui/index.html +36 -0
  249. package/dist/web-ui/robots.txt +3 -0
  250. package/package.json +3 -2
  251. package/plugin/.claude-plugin/marketplace.json +1 -1
  252. package/plugin/.claude-plugin/plugin.json +1 -1
  253. package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +1 -1
  254. package/plugin/plugins/kspec/skills/{observations → observe}/SKILL.md +1 -1
  255. package/plugin/plugins/kspec/skills/plan/SKILL.md +1 -1
  256. package/plugin/plugins/kspec/skills/task-work/SKILL.md +26 -3
  257. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  258. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +1 -1
  259. package/templates/agents-sections/01-quick-start.md +1 -0
  260. package/templates/agents-sections/06-ralph-loop.md +64 -11
  261. package/templates/skills/create-workflow/SKILL.md +1 -1
  262. package/templates/skills/manifest.yaml +1 -1
  263. package/templates/skills/plan/SKILL.md +1 -1
  264. package/templates/skills/task-work/SKILL.md +26 -3
  265. package/templates/skills/triage-inbox/SKILL.md +1 -1
  266. package/templates/skills/writing-specs/SKILL.md +1 -1
  267. package/dist/ralph/cli-renderer.d.ts +0 -27
  268. package/dist/ralph/cli-renderer.d.ts.map +0 -1
  269. package/dist/ralph/cli-renderer.js +0 -250
  270. package/dist/ralph/cli-renderer.js.map +0 -1
  271. package/dist/ralph/events.d.ts +0 -65
  272. package/dist/ralph/events.d.ts.map +0 -1
  273. package/dist/ralph/events.js +0 -600
  274. package/dist/ralph/events.js.map +0 -1
  275. package/dist/ralph/index.d.ts +0 -11
  276. package/dist/ralph/index.d.ts.map +0 -1
  277. package/dist/ralph/index.js +0 -16
  278. package/dist/ralph/index.js.map +0 -1
  279. package/dist/ralph/loop-errors.d.ts +0 -83
  280. package/dist/ralph/loop-errors.d.ts.map +0 -1
  281. package/dist/ralph/loop-errors.js +0 -150
  282. package/dist/ralph/loop-errors.js.map +0 -1
  283. package/dist/ralph/subagent.d.ts +0 -96
  284. package/dist/ralph/subagent.d.ts.map +0 -1
  285. package/dist/ralph/subagent.js +0 -195
  286. package/dist/ralph/subagent.js.map +0 -1
  287. package/dist/ralph/wrap-up.d.ts +0 -127
  288. package/dist/ralph/wrap-up.d.ts.map +0 -1
  289. package/dist/ralph/wrap-up.js +0 -271
  290. package/dist/ralph/wrap-up.js.map +0 -1
  291. /package/templates/skills/{observations → observe}/SKILL.md +0 -0
@@ -0,0 +1,791 @@
1
+ /**
2
+ * Agent Dispatch Engine
3
+ *
4
+ * Core dispatch runtime for the daemon. Watches for task state changes via
5
+ * file watcher events and direct API event emission from CLI commands. Matches
6
+ * state changes against agent dispatch rules, queues invocations, manages
7
+ * concurrency, and handles deduplication of events from dual sources.
8
+ * Serializes shadow branch mutations when multiple agents run concurrently.
9
+ *
10
+ * AC: @agent-dispatch-engine ac-1 through ac-12
11
+ */
12
+ import * as path from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+ import { ulid } from "ulid";
15
+ import { initContext, loadAllTasks, loadMetaContext, } from "../parser/index.js";
16
+ import { runInvocation } from "./invocation.js";
17
+ import { getAdapter } from "../agents/adapters.js";
18
+ // ─── Simple Mutex ─────────────────────────────────────────────────────────────
19
+ /**
20
+ * A minimal promise-based mutex for serializing async operations.
21
+ * AC: @agent-dispatch-engine ac-12
22
+ */
23
+ class Mutex {
24
+ _queue = [];
25
+ _locked = false;
26
+ async runExclusive(fn) {
27
+ await this._acquire();
28
+ try {
29
+ return await fn();
30
+ }
31
+ finally {
32
+ this._release();
33
+ }
34
+ }
35
+ _acquire() {
36
+ if (!this._locked) {
37
+ this._locked = true;
38
+ return Promise.resolve();
39
+ }
40
+ return new Promise((resolve) => {
41
+ this._queue.push(resolve);
42
+ });
43
+ }
44
+ _release() {
45
+ const next = this._queue.shift();
46
+ if (next) {
47
+ next();
48
+ }
49
+ else {
50
+ this._locked = false;
51
+ }
52
+ }
53
+ }
54
+ /**
55
+ * Mapping from dispatch event names to task statuses.
56
+ * AC: @agent-dispatch-engine ac-1
57
+ */
58
+ const EVENT_TO_STATUS = {
59
+ "task.in_progress": "in_progress",
60
+ "task.ready": "pending",
61
+ "task.needs_work": "needs_work",
62
+ "task.pending_review": "pending_review",
63
+ };
64
+ const STATUS_TO_EVENT = {
65
+ in_progress: "task.in_progress",
66
+ pending: "task.ready",
67
+ needs_work: "task.needs_work",
68
+ pending_review: "task.pending_review",
69
+ blocked: undefined,
70
+ completed: undefined,
71
+ cancelled: undefined,
72
+ };
73
+ /**
74
+ * Dispatch precedence for runnable task statuses.
75
+ * Lower number = higher scheduling priority.
76
+ *
77
+ * AC: @dispatch-in-progress-priority ac-1
78
+ */
79
+ const STATUS_PRECEDENCE = {
80
+ in_progress: 0,
81
+ needs_work: 1,
82
+ pending: 2,
83
+ pending_review: 3,
84
+ blocked: 4,
85
+ completed: 5,
86
+ cancelled: 6,
87
+ };
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
+ }
98
+ /**
99
+ * Human-readable trigger description for orientation context.
100
+ */
101
+ function triggerDescription(trigger) {
102
+ switch (trigger) {
103
+ case "task.ready":
104
+ return "New task assignment.";
105
+ case "task.in_progress":
106
+ return "Continuing in-progress work.";
107
+ case "task.needs_work":
108
+ return "Fix cycle \u2014 this task was kicked back from review. Address the feedback below.";
109
+ case "task.pending_review":
110
+ return "Task submitted for review.";
111
+ default:
112
+ return `Trigger: ${trigger}`;
113
+ }
114
+ }
115
+ /**
116
+ * Format recent notes for inclusion in dispatch prompts.
117
+ * Takes last N notes, truncates each to maxLen characters, strips newlines.
118
+ */
119
+ function formatRecentNotes(notes, count = 3, maxLen = 200) {
120
+ if (!notes || notes.length === 0)
121
+ return "";
122
+ const recent = notes.slice(-count);
123
+ const lines = recent.map((n) => {
124
+ const date = n.created_at.slice(0, 10);
125
+ const author = n.author ? `@${n.author}` : "unknown";
126
+ const content = n.content.replace(/\n/g, " ").slice(0, maxLen);
127
+ return `- [${date}] ${author}: ${content}`;
128
+ });
129
+ return lines.join("\n");
130
+ }
131
+ /**
132
+ * Build orientation context block for a dispatch prompt.
133
+ * Provides the agent with task title, trigger meaning, and relevant context.
134
+ *
135
+ * AC: @agent-dispatch-engine ac-13, ac-14, ac-15
136
+ */
137
+ export function buildOrientationContext(taskRef, trigger, task) {
138
+ const title = task?.title ?? "(unavailable)";
139
+ const lines = [
140
+ "## Task Context",
141
+ `Task: ${taskRef} \u2014 "${title}"`,
142
+ `Trigger: ${triggerDescription(trigger)}`,
143
+ ];
144
+ // AC: @agent-dispatch-engine ac-14 - Include recent notes for fix cycles
145
+ if (trigger === "task.needs_work" && task?.notes && task.notes.length > 0) {
146
+ const noteText = formatRecentNotes(task.notes);
147
+ if (noteText) {
148
+ lines.push("", "Recent notes:", noteText);
149
+ }
150
+ }
151
+ // AC: @agent-dispatch-engine ac-15 - Include review URL for reviewer
152
+ if (trigger === "task.pending_review") {
153
+ const url = task?.review_url ?? "Not provided \u2014 find PR via task notes or git log.";
154
+ lines.push(`Review URL: ${url}`);
155
+ }
156
+ return lines.join("\n");
157
+ }
158
+ // ─── DispatchEngine ───────────────────────────────────────────────────────────
159
+ /**
160
+ * The core dispatch runtime.
161
+ *
162
+ * Lifecycle:
163
+ * 1. Create with new DispatchEngine(options)
164
+ * 2. Call start() to bootstrap and begin processing
165
+ * 3. Feed state changes via handleStateChange()
166
+ * 4. Call stop() for graceful shutdown
167
+ *
168
+ * AC: @agent-dispatch-engine ac-1 through ac-12
169
+ */
170
+ export class DispatchEngine {
171
+ projectDir;
172
+ specDir;
173
+ cwd;
174
+ dedupWindowMs;
175
+ kspecCliPath;
176
+ onInvocationEvent;
177
+ onTextChunk;
178
+ /** Queue of pending dispatch entries, per agent id */
179
+ queues = new Map();
180
+ /** Count of active (running) invocations per agent id */
181
+ activeCount = new Map();
182
+ /** Recent dedup keys with their expiry timestamps */
183
+ recentEvents = new Map();
184
+ /** Previous task states (for file watcher diffing) */
185
+ prevTaskStates = new Map();
186
+ /** Mutex serializing shadow branch mutations */
187
+ shadowMutex = new Mutex();
188
+ /** Whether the engine is currently running */
189
+ running = false;
190
+ /** Set of running invocation promises (for graceful shutdown) */
191
+ runningInvocations = new Set();
192
+ /** AbortControllers for active invocations (for graceful cancel on stop) */
193
+ invocationAbortControllers = new Set();
194
+ /** Per-invocation tracking records for status display */
195
+ activeInvocationDetails = new Map();
196
+ /** Monotonic enqueue sequence for deterministic queue ordering */
197
+ nextQueueSequence = 0;
198
+ constructor(options) {
199
+ this.projectDir = options.projectDir;
200
+ this.specDir = options.specDir ?? path.join(options.projectDir, ".kspec");
201
+ this.cwd = options.cwd ?? options.projectDir;
202
+ this.dedupWindowMs = options.dedupWindowMs ?? 2000;
203
+ this.kspecCliPath = options.kspecCliPath;
204
+ this.onInvocationEvent = options.onInvocationEvent;
205
+ this.onTextChunk = options.onTextChunk;
206
+ }
207
+ // ─── Public API ─────────────────────────────────────────────────────────────
208
+ /**
209
+ * Start the dispatch engine.
210
+ *
211
+ * Loads current task states and evaluates dispatch rules for bootstrap.
212
+ * AC: @agent-dispatch-engine ac-8
213
+ */
214
+ async start() {
215
+ this.running = true;
216
+ // AC: @agent-dispatch-engine ac-8 - Bootstrap: evaluate existing task states
217
+ await this._bootstrap();
218
+ }
219
+ /**
220
+ * Handle a task state change event from any source (file watcher or API).
221
+ *
222
+ * AC: @agent-dispatch-engine ac-1, ac-2, ac-4, ac-5, ac-6, ac-7
223
+ */
224
+ async handleStateChange(change) {
225
+ if (!this.running)
226
+ return;
227
+ // AC: @agent-dispatch-engine ac-7 - Deduplication
228
+ if (this._isDuplicate(change)) {
229
+ return;
230
+ }
231
+ this._recordEvent(change);
232
+ // AC: @agent-dispatch-engine ac-1 - Match against dispatch rules
233
+ const agents = await this._loadAgents();
234
+ const eventType = STATUS_TO_EVENT[change.toStatus];
235
+ if (!eventType)
236
+ return;
237
+ // Load task data for filter evaluation if not provided
238
+ let taskData = change.task;
239
+ if (!taskData && change.taskId) {
240
+ try {
241
+ const ctx = await initContext(this.projectDir);
242
+ const tasks = await loadAllTasks(ctx);
243
+ taskData = tasks.find((t) => t._ulid === change.taskId);
244
+ }
245
+ catch {
246
+ // Can't load task, filter evaluation will be lenient
247
+ }
248
+ }
249
+ // Make loaded task available for prompt building (AC: @agent-dispatch-engine ac-13)
250
+ if (taskData && !change.task) {
251
+ change.task = taskData;
252
+ }
253
+ for (const agent of agents) {
254
+ for (const rule of (agent.dispatch ?? [])) {
255
+ if (rule.on !== eventType)
256
+ continue;
257
+ // AC: @agent-dispatch-engine ac-6 - Apply filters
258
+ if (!this._matchesFilter(change, rule, taskData))
259
+ continue;
260
+ // AC: @agent-dispatch-engine ac-2 - Each matching agent queued independently
261
+ this._enqueue(agent, change);
262
+ }
263
+ }
264
+ // Drain queues after enqueuing
265
+ await this._drainQueues(agents);
266
+ }
267
+ /**
268
+ * Handle file watcher notification: diff previous vs current task states.
269
+ *
270
+ * AC: @agent-dispatch-engine ac-5
271
+ */
272
+ async handleFileChange(specDir) {
273
+ if (!this.running)
274
+ return;
275
+ try {
276
+ const ctx = await initContext(this.projectDir);
277
+ const tasks = await loadAllTasks(ctx);
278
+ const changes = [];
279
+ const now = Date.now();
280
+ for (const task of tasks) {
281
+ const taskId = task._ulid;
282
+ const currentStatus = task.status;
283
+ const prevStatus = this.prevTaskStates.get(taskId);
284
+ if (prevStatus !== undefined && prevStatus !== currentStatus) {
285
+ changes.push({
286
+ taskId,
287
+ taskRef: `@${taskId}`,
288
+ fromStatus: prevStatus,
289
+ toStatus: currentStatus,
290
+ timestamp: now,
291
+ task,
292
+ });
293
+ }
294
+ this.prevTaskStates.set(taskId, currentStatus);
295
+ }
296
+ // Emit change events for detected transitions
297
+ for (const change of changes) {
298
+ await this.handleStateChange(change);
299
+ }
300
+ }
301
+ catch (err) {
302
+ console.error("[dispatch] Error processing file change:", err);
303
+ }
304
+ }
305
+ /**
306
+ * Stop the dispatch engine gracefully.
307
+ *
308
+ * Sends cancel signals to all active invocations and waits for them to finish.
309
+ * AC: @agent-dispatch-engine ac-11
310
+ */
311
+ async stop() {
312
+ this.running = false;
313
+ // AC: @agent-dispatch-engine ac-11 - Send graceful cancel to all active invocations
314
+ for (const controller of this.invocationAbortControllers) {
315
+ controller.abort();
316
+ }
317
+ // Wait for all running invocations to complete (or abort)
318
+ if (this.runningInvocations.size > 0) {
319
+ await Promise.allSettled(Array.from(this.runningInvocations));
320
+ }
321
+ this.queues.clear();
322
+ this.activeCount.clear();
323
+ this.recentEvents.clear();
324
+ this.invocationAbortControllers.clear();
325
+ this.activeInvocationDetails.clear();
326
+ }
327
+ /**
328
+ * Get the shadow mutex for external callers that need to serialize mutations.
329
+ * AC: @agent-dispatch-engine ac-12
330
+ */
331
+ getShadowMutex() {
332
+ return this.shadowMutex;
333
+ }
334
+ /**
335
+ * Returns current engine status info including per-invocation details.
336
+ * AC: @cli-agent-commands ac-6
337
+ */
338
+ getStatus() {
339
+ let active = 0;
340
+ let queued = 0;
341
+ for (const count of this.activeCount.values())
342
+ active += count;
343
+ for (const entries of this.queues.values())
344
+ queued += entries.length;
345
+ const now = Date.now();
346
+ const invocations = Array.from(this.activeInvocationDetails.values()).map((r) => ({
347
+ invocationId: r.invocationId,
348
+ sessionId: r.sessionId,
349
+ agentId: r.agentId,
350
+ agentName: r.agentName,
351
+ taskRef: r.taskRef,
352
+ elapsedMs: now - r.startedAtMs,
353
+ }));
354
+ const queuedItems = Array.from(this.queues.values()).flatMap((entries) => entries.map((e) => ({
355
+ agentId: e.agent.id,
356
+ agentName: e.agent.name,
357
+ taskRef: e.change.taskRef,
358
+ waitMs: now - e.enqueuedAtMs,
359
+ })));
360
+ return { running: this.running, activeInvocations: active, queuedInvocations: queued, invocations, queued: queuedItems };
361
+ }
362
+ // ─── Private helpers ─────────────────────────────────────────────────────────
363
+ /**
364
+ * Bootstrap: evaluate all current tasks against dispatch rules.
365
+ * AC: @agent-dispatch-engine ac-8
366
+ */
367
+ async _bootstrap() {
368
+ try {
369
+ const ctx = await initContext(this.projectDir);
370
+ 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
+ }
399
+ }
400
+ }
401
+ await this._drainQueues(agents);
402
+ }
403
+ catch (err) {
404
+ console.error("[dispatch] Bootstrap error:", err);
405
+ }
406
+ }
407
+ /**
408
+ * Load agent definitions from meta context.
409
+ */
410
+ async _loadAgents() {
411
+ try {
412
+ const ctx = await initContext(this.projectDir);
413
+ const meta = await loadMetaContext(ctx);
414
+ return meta.agents;
415
+ }
416
+ catch {
417
+ return [];
418
+ }
419
+ }
420
+ /**
421
+ * Check if a state change matches a dispatch rule's filters.
422
+ * AC: @agent-dispatch-engine ac-6
423
+ */
424
+ _matchesFilter(change, rule, task) {
425
+ if (!rule.filter)
426
+ return true;
427
+ // We need the task to evaluate filters — if not provided, reject to avoid
428
+ // enqueuing non-matching tasks (AC-6: all filters must match)
429
+ if (!task)
430
+ return false;
431
+ const { filter } = rule;
432
+ // Automation filter
433
+ if (filter.automation !== undefined) {
434
+ if (task.automation !== filter.automation) {
435
+ return false;
436
+ }
437
+ }
438
+ // Tags filter
439
+ if (filter.tags && filter.tags.length > 0) {
440
+ const taskTags = task.tags ?? [];
441
+ if (!filter.tags.every((tag) => taskTags.includes(tag))) {
442
+ return false;
443
+ }
444
+ }
445
+ // Priority filter
446
+ if (filter.priority !== undefined) {
447
+ if (task.priority !== filter.priority) {
448
+ return false;
449
+ }
450
+ }
451
+ return true;
452
+ }
453
+ /**
454
+ * Build a deduplication key for a state change.
455
+ * AC: @agent-dispatch-engine ac-7
456
+ */
457
+ _dedupKey(change) {
458
+ return `${change.taskId}:${change.fromStatus}:${change.toStatus}`;
459
+ }
460
+ /**
461
+ * Check whether this event is a duplicate within the dedup window.
462
+ * AC: @agent-dispatch-engine ac-7
463
+ */
464
+ _isDuplicate(change) {
465
+ const key = this._dedupKey(change);
466
+ const expiry = this.recentEvents.get(key);
467
+ if (expiry === undefined)
468
+ return false;
469
+ return change.timestamp < expiry;
470
+ }
471
+ /**
472
+ * Record a state change for deduplication.
473
+ * AC: @agent-dispatch-engine ac-7
474
+ */
475
+ _recordEvent(change) {
476
+ const key = this._dedupKey(change);
477
+ this.recentEvents.set(key, change.timestamp + this.dedupWindowMs);
478
+ // Prune expired entries periodically
479
+ if (this.recentEvents.size > 1000) {
480
+ const now = Date.now();
481
+ for (const [k, expiry] of this.recentEvents) {
482
+ if (expiry < now)
483
+ this.recentEvents.delete(k);
484
+ }
485
+ }
486
+ }
487
+ /**
488
+ * Enqueue a dispatch entry for an agent.
489
+ * AC: @agent-dispatch-engine ac-3
490
+ */
491
+ _enqueue(agent, change) {
492
+ const queue = this.queues.get(agent.id) ?? [];
493
+ const entry = {
494
+ agent,
495
+ change,
496
+ retryCount: 0,
497
+ nextRetryAt: 0,
498
+ enqueuedAtMs: Date.now(),
499
+ sequence: this.nextQueueSequence++,
500
+ };
501
+ this._insertQueueEntry(queue, entry);
502
+ this.queues.set(agent.id, queue);
503
+ }
504
+ /**
505
+ * Insert an entry into an agent queue using deterministic status precedence.
506
+ * AC: @dispatch-in-progress-priority ac-1
507
+ */
508
+ _insertQueueEntry(queue, entry) {
509
+ const insertAt = queue.findIndex((queued) => this._compareQueueEntries(entry, queued) < 0);
510
+ if (insertAt === -1) {
511
+ queue.push(entry);
512
+ return;
513
+ }
514
+ queue.splice(insertAt, 0, entry);
515
+ }
516
+ /**
517
+ * Compare queue entries by dispatch precedence, then by enqueue sequence.
518
+ * AC: @dispatch-in-progress-priority ac-1
519
+ */
520
+ _compareQueueEntries(a, b) {
521
+ const statusDelta = STATUS_PRECEDENCE[a.change.toStatus] - STATUS_PRECEDENCE[b.change.toStatus];
522
+ if (statusDelta !== 0)
523
+ return statusDelta;
524
+ return a.sequence - b.sequence;
525
+ }
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)
533
+ 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
+ }
541
+ catch {
542
+ // If we can't load tasks, skip staleness checks (best effort)
543
+ }
544
+ for (const agent of agents) {
545
+ const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
546
+ const active = this.activeCount.get(agent.id) ?? 0;
547
+ 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
560
+ 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
+ if (this.prevTaskStates.has(entry.change.taskId)) {
565
+ queue.splice(i, 1);
566
+ }
567
+ }
568
+ else {
569
+ const currentEvent = STATUS_TO_EVENT[currentStatus];
570
+ if (currentEvent !== expectedEvent) {
571
+ queue.splice(i, 1);
572
+ }
573
+ }
574
+ }
575
+ if (before > queue.length) {
576
+ console.log(`[dispatch] Discarded ${before - queue.length} stale queue entries for agent "${agent.id}"`);
577
+ }
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;
585
+ }
586
+ const [entry] = queue.splice(nextReadyIndex, 1);
587
+ const spawned = this._spawnInvocation(agent, entry);
588
+ if (spawned)
589
+ slots--;
590
+ }
591
+ this.queues.set(agent.id, queue);
592
+ }
593
+ }
594
+ /**
595
+ * Build dispatch-mode prompt guardrails to keep autonomous agents from
596
+ * stopping with handoff text instead of performing required actions.
597
+ *
598
+ * AC: @agent-dispatch-engine ac-13, ac-14, ac-15, ac-16
599
+ */
600
+ _buildDispatchPrompt(agent, change) {
601
+ const trigger = (STATUS_TO_EVENT[change.toStatus] ?? "task.ready");
602
+ const taskRef = change.taskRef;
603
+ // AC: @agent-dispatch-engine ac-16 - Interpolate prompt_template variables
604
+ const templateVars = {
605
+ task_ref: taskRef,
606
+ task_title: change.task?.title ?? "(unavailable)",
607
+ trigger,
608
+ review_url: change.task?.review_url ?? "",
609
+ };
610
+ const rawTemplate = agent.prompt_template ?? `Work on task ${taskRef}`;
611
+ const basePrompt = interpolateTemplate(rawTemplate, templateVars);
612
+ // AC: @agent-dispatch-engine ac-13 - Orientation context
613
+ const orientation = buildOrientationContext(taskRef, trigger, change.task);
614
+ const autonomousPreamble = [
615
+ "AUTONOMOUS DISPATCH MODE (no interactive user is available).",
616
+ "- Do not ask for confirmation, approval, or next-step handoff.",
617
+ "- Execute required commands directly in this invocation.",
618
+ "- Do not end your turn with a recommendations-only summary. Perform the next required action yourself.",
619
+ "- 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
+ "- 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.",
621
+ ];
622
+ const triggerSpecific = trigger === "task.pending_review"
623
+ ? [
624
+ `Review flow completion criteria for ${taskRef}:`,
625
+ "- Execute your configured review workflow directly (no handoff).",
626
+ `- If blocking issues are found, transition ${taskRef} out of pending_review appropriately (for example needs_work).`,
627
+ `- If review gates are clean, perform your workflow's completion actions directly in this invocation.`,
628
+ ]
629
+ : [
630
+ `Work flow completion criteria for ${taskRef}:`,
631
+ "- Execute your configured work workflow directly (no handoff).",
632
+ `- Perform the required commands to move ${taskRef} to the next appropriate state in this same invocation.`,
633
+ "- If your workflow includes git or PR steps, execute them directly instead of deferring to a human.",
634
+ ];
635
+ return `${basePrompt}\n\n${orientation}\n\n${autonomousPreamble.join("\n")}\n\n${triggerSpecific.join("\n")}`;
636
+ }
637
+ /**
638
+ * Spawn a single invocation for a queue entry.
639
+ * Returns true if an invocation was actually started, false if skipped.
640
+ * AC: @agent-dispatch-engine ac-9, ac-10, ac-11, ac-12
641
+ */
642
+ _spawnInvocation(agent, entry) {
643
+ 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 });
661
+ }
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;
696
+ }
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, "");
701
+ }
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
719
+ 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
724
+ this.onInvocationEvent?.({
725
+ type: "completed",
726
+ session_id: preSessionId,
727
+ agent_id: agentId,
728
+ task_id: entry.change.taskRef,
729
+ status: "completed",
730
+ timestamp: Date.now(),
731
+ });
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);
752
+ }
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",
758
+ session_id: preSessionId,
759
+ agent_id: agentId,
760
+ task_id: entry.change.taskRef,
761
+ status: "failed",
762
+ timestamp: Date.now(),
763
+ });
764
+ }
765
+ }
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;
789
+ }
790
+ }
791
+ //# sourceMappingURL=dispatch.js.map