opencastle 0.27.0 → 0.27.2

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 (242) hide show
  1. package/bin/cli.mjs +6 -0
  2. package/dist/cli/agents.d.ts +3 -0
  3. package/dist/cli/agents.d.ts.map +1 -0
  4. package/dist/cli/agents.js +161 -0
  5. package/dist/cli/agents.js.map +1 -0
  6. package/dist/cli/baselines.d.ts +3 -0
  7. package/dist/cli/baselines.d.ts.map +1 -0
  8. package/dist/cli/baselines.js +128 -0
  9. package/dist/cli/baselines.js.map +1 -0
  10. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  11. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  12. package/dist/cli/convoy/dashboard-types.js +2 -0
  13. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  14. package/dist/cli/convoy/engine.d.ts +67 -2
  15. package/dist/cli/convoy/engine.d.ts.map +1 -1
  16. package/dist/cli/convoy/engine.js +2036 -28
  17. package/dist/cli/convoy/engine.js.map +1 -1
  18. package/dist/cli/convoy/engine.test.js +1659 -70
  19. package/dist/cli/convoy/engine.test.js.map +1 -1
  20. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  21. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  22. package/dist/cli/convoy/event-schemas.js +185 -0
  23. package/dist/cli/convoy/event-schemas.js.map +1 -0
  24. package/dist/cli/convoy/events.d.ts +12 -1
  25. package/dist/cli/convoy/events.d.ts.map +1 -1
  26. package/dist/cli/convoy/events.js +186 -13
  27. package/dist/cli/convoy/events.js.map +1 -1
  28. package/dist/cli/convoy/events.test.js +325 -28
  29. package/dist/cli/convoy/events.test.js.map +1 -1
  30. package/dist/cli/convoy/expertise.d.ts +16 -0
  31. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  32. package/dist/cli/convoy/expertise.js +121 -0
  33. package/dist/cli/convoy/expertise.js.map +1 -0
  34. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  35. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  36. package/dist/cli/convoy/expertise.test.js +96 -0
  37. package/dist/cli/convoy/expertise.test.js.map +1 -0
  38. package/dist/cli/convoy/export.test.js +1 -0
  39. package/dist/cli/convoy/export.test.js.map +1 -1
  40. package/dist/cli/convoy/formula.d.ts +19 -0
  41. package/dist/cli/convoy/formula.d.ts.map +1 -0
  42. package/dist/cli/convoy/formula.js +142 -0
  43. package/dist/cli/convoy/formula.js.map +1 -0
  44. package/dist/cli/convoy/formula.test.d.ts +2 -0
  45. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  46. package/dist/cli/convoy/formula.test.js +342 -0
  47. package/dist/cli/convoy/formula.test.js.map +1 -0
  48. package/dist/cli/convoy/gates.d.ts +128 -0
  49. package/dist/cli/convoy/gates.d.ts.map +1 -0
  50. package/dist/cli/convoy/gates.js +606 -0
  51. package/dist/cli/convoy/gates.js.map +1 -0
  52. package/dist/cli/convoy/gates.test.d.ts +2 -0
  53. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  54. package/dist/cli/convoy/gates.test.js +976 -0
  55. package/dist/cli/convoy/gates.test.js.map +1 -0
  56. package/dist/cli/convoy/health.d.ts +11 -0
  57. package/dist/cli/convoy/health.d.ts.map +1 -1
  58. package/dist/cli/convoy/health.js +54 -0
  59. package/dist/cli/convoy/health.js.map +1 -1
  60. package/dist/cli/convoy/health.test.js +56 -1
  61. package/dist/cli/convoy/health.test.js.map +1 -1
  62. package/dist/cli/convoy/issues.d.ts +8 -0
  63. package/dist/cli/convoy/issues.d.ts.map +1 -0
  64. package/dist/cli/convoy/issues.js +98 -0
  65. package/dist/cli/convoy/issues.js.map +1 -0
  66. package/dist/cli/convoy/issues.test.d.ts +2 -0
  67. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  68. package/dist/cli/convoy/issues.test.js +107 -0
  69. package/dist/cli/convoy/issues.test.js.map +1 -0
  70. package/dist/cli/convoy/knowledge.d.ts +5 -0
  71. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  72. package/dist/cli/convoy/knowledge.js +116 -0
  73. package/dist/cli/convoy/knowledge.js.map +1 -0
  74. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  75. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  76. package/dist/cli/convoy/knowledge.test.js +87 -0
  77. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  78. package/dist/cli/convoy/lessons.d.ts +17 -0
  79. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  80. package/dist/cli/convoy/lessons.js +149 -0
  81. package/dist/cli/convoy/lessons.js.map +1 -0
  82. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  83. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  84. package/dist/cli/convoy/lessons.test.js +135 -0
  85. package/dist/cli/convoy/lessons.test.js.map +1 -0
  86. package/dist/cli/convoy/lock.d.ts +13 -0
  87. package/dist/cli/convoy/lock.d.ts.map +1 -0
  88. package/dist/cli/convoy/lock.js +88 -0
  89. package/dist/cli/convoy/lock.js.map +1 -0
  90. package/dist/cli/convoy/lock.test.d.ts +2 -0
  91. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  92. package/dist/cli/convoy/lock.test.js +136 -0
  93. package/dist/cli/convoy/lock.test.js.map +1 -0
  94. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  95. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  96. package/dist/cli/convoy/log-merge.test.js +147 -0
  97. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  98. package/dist/cli/convoy/merge.d.ts +4 -0
  99. package/dist/cli/convoy/merge.d.ts.map +1 -1
  100. package/dist/cli/convoy/merge.js +18 -1
  101. package/dist/cli/convoy/merge.js.map +1 -1
  102. package/dist/cli/convoy/merge.test.js +6 -7
  103. package/dist/cli/convoy/merge.test.js.map +1 -1
  104. package/dist/cli/convoy/partition.d.ts +51 -0
  105. package/dist/cli/convoy/partition.d.ts.map +1 -0
  106. package/dist/cli/convoy/partition.js +186 -0
  107. package/dist/cli/convoy/partition.js.map +1 -0
  108. package/dist/cli/convoy/partition.test.d.ts +2 -0
  109. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  110. package/dist/cli/convoy/partition.test.js +315 -0
  111. package/dist/cli/convoy/partition.test.js.map +1 -0
  112. package/dist/cli/convoy/pipeline.test.js +6 -0
  113. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  114. package/dist/cli/convoy/store.d.ts +99 -7
  115. package/dist/cli/convoy/store.d.ts.map +1 -1
  116. package/dist/cli/convoy/store.js +764 -31
  117. package/dist/cli/convoy/store.js.map +1 -1
  118. package/dist/cli/convoy/store.test.js +1810 -18
  119. package/dist/cli/convoy/store.test.js.map +1 -1
  120. package/dist/cli/convoy/types.d.ts +427 -5
  121. package/dist/cli/convoy/types.d.ts.map +1 -1
  122. package/dist/cli/convoy/types.js +42 -1
  123. package/dist/cli/convoy/types.js.map +1 -1
  124. package/dist/cli/log.d.ts +11 -0
  125. package/dist/cli/log.d.ts.map +1 -1
  126. package/dist/cli/log.js +114 -2
  127. package/dist/cli/log.js.map +1 -1
  128. package/dist/cli/run/adapters/claude.d.ts +2 -0
  129. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  130. package/dist/cli/run/adapters/claude.js +89 -49
  131. package/dist/cli/run/adapters/claude.js.map +1 -1
  132. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  133. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  134. package/dist/cli/run/adapters/claude.test.js +205 -0
  135. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  137. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  138. package/dist/cli/run/adapters/copilot.js +84 -46
  139. package/dist/cli/run/adapters/copilot.js.map +1 -1
  140. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  141. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  142. package/dist/cli/run/adapters/copilot.test.js +195 -0
  143. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  145. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  146. package/dist/cli/run/adapters/cursor.js +83 -47
  147. package/dist/cli/run/adapters/cursor.js.map +1 -1
  148. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  149. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  150. package/dist/cli/run/adapters/cursor.test.js +129 -0
  151. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  153. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  154. package/dist/cli/run/adapters/opencode.js +81 -47
  155. package/dist/cli/run/adapters/opencode.js.map +1 -1
  156. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  157. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  158. package/dist/cli/run/adapters/opencode.test.js +119 -0
  159. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  160. package/dist/cli/run/executor.js +1 -1
  161. package/dist/cli/run/executor.js.map +1 -1
  162. package/dist/cli/run/schema.d.ts.map +1 -1
  163. package/dist/cli/run/schema.js +245 -4
  164. package/dist/cli/run/schema.js.map +1 -1
  165. package/dist/cli/run/schema.test.js +669 -0
  166. package/dist/cli/run/schema.test.js.map +1 -1
  167. package/dist/cli/run.d.ts.map +1 -1
  168. package/dist/cli/run.js +362 -22
  169. package/dist/cli/run.js.map +1 -1
  170. package/dist/cli/types.d.ts +85 -2
  171. package/dist/cli/types.d.ts.map +1 -1
  172. package/dist/cli/types.js.map +1 -1
  173. package/dist/cli/watch.d.ts +15 -0
  174. package/dist/cli/watch.d.ts.map +1 -0
  175. package/dist/cli/watch.js +279 -0
  176. package/dist/cli/watch.js.map +1 -0
  177. package/package.json +5 -1
  178. package/src/cli/agents.ts +177 -0
  179. package/src/cli/baselines.ts +143 -0
  180. package/src/cli/convoy/TELEMETRY.md +203 -0
  181. package/src/cli/convoy/dashboard-types.ts +141 -0
  182. package/src/cli/convoy/engine.test.ts +1937 -70
  183. package/src/cli/convoy/engine.ts +2350 -40
  184. package/src/cli/convoy/event-schemas.ts +195 -0
  185. package/src/cli/convoy/events.test.ts +384 -39
  186. package/src/cli/convoy/events.ts +202 -16
  187. package/src/cli/convoy/expertise.test.ts +128 -0
  188. package/src/cli/convoy/expertise.ts +163 -0
  189. package/src/cli/convoy/export.test.ts +1 -0
  190. package/src/cli/convoy/formula.test.ts +405 -0
  191. package/src/cli/convoy/formula.ts +174 -0
  192. package/src/cli/convoy/gates.test.ts +1169 -0
  193. package/src/cli/convoy/gates.ts +774 -0
  194. package/src/cli/convoy/health.test.ts +64 -2
  195. package/src/cli/convoy/health.ts +80 -2
  196. package/src/cli/convoy/issues.test.ts +143 -0
  197. package/src/cli/convoy/issues.ts +136 -0
  198. package/src/cli/convoy/knowledge.test.ts +101 -0
  199. package/src/cli/convoy/knowledge.ts +132 -0
  200. package/src/cli/convoy/lessons.test.ts +188 -0
  201. package/src/cli/convoy/lessons.ts +164 -0
  202. package/src/cli/convoy/lock.test.ts +181 -0
  203. package/src/cli/convoy/lock.ts +103 -0
  204. package/src/cli/convoy/log-merge.test.ts +179 -0
  205. package/src/cli/convoy/merge.test.ts +6 -7
  206. package/src/cli/convoy/merge.ts +19 -1
  207. package/src/cli/convoy/partition.test.ts +423 -0
  208. package/src/cli/convoy/partition.ts +232 -0
  209. package/src/cli/convoy/pipeline.test.ts +6 -0
  210. package/src/cli/convoy/store.test.ts +2041 -20
  211. package/src/cli/convoy/store.ts +945 -46
  212. package/src/cli/convoy/types.ts +278 -4
  213. package/src/cli/log.ts +120 -2
  214. package/src/cli/run/adapters/claude.test.ts +234 -0
  215. package/src/cli/run/adapters/claude.ts +45 -5
  216. package/src/cli/run/adapters/copilot.test.ts +224 -0
  217. package/src/cli/run/adapters/copilot.ts +34 -4
  218. package/src/cli/run/adapters/cursor.test.ts +144 -0
  219. package/src/cli/run/adapters/cursor.ts +33 -2
  220. package/src/cli/run/adapters/opencode.test.ts +135 -0
  221. package/src/cli/run/adapters/opencode.ts +30 -2
  222. package/src/cli/run/executor.ts +1 -1
  223. package/src/cli/run/schema.test.ts +758 -0
  224. package/src/cli/run/schema.ts +300 -25
  225. package/src/cli/run.ts +341 -21
  226. package/src/cli/types.ts +86 -1
  227. package/src/cli/watch.ts +298 -0
  228. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  229. package/src/dashboard/dist/data/.gitkeep +0 -0
  230. package/src/dashboard/dist/data/convoy-list.json +1 -0
  231. package/src/dashboard/dist/data/overall-stats.json +24 -0
  232. package/src/dashboard/dist/index.html +701 -3
  233. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  234. package/src/dashboard/public/data/.gitkeep +0 -0
  235. package/src/dashboard/public/data/convoy-list.json +1 -0
  236. package/src/dashboard/public/data/overall-stats.json +24 -0
  237. package/src/dashboard/scripts/etl.test.ts +210 -0
  238. package/src/dashboard/scripts/etl.ts +108 -0
  239. package/src/dashboard/scripts/integration-test.ts +504 -0
  240. package/src/dashboard/src/pages/index.astro +854 -15
  241. package/src/dashboard/src/styles/dashboard.css +557 -1
  242. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -1,10 +1,13 @@
1
- import { mkdtempSync, rmSync } from 'node:fs';
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
- import { createConvoyEngine } from './engine.js';
5
+ import { createConvoyEngine, evaluateReviewLevel, runConvoyGuard } from './engine.js';
6
+ import { recoverNdjson, createEventEmitter } from './events.js';
6
7
  import { createConvoyStore } from './store.js';
7
8
  import { getAdapter, detectAdapter } from '../run/adapters/index.js';
9
+ import * as gates from './gates.js';
10
+ import * as partition from './partition.js';
8
11
  // ── Mock NDJSON log writes ────────────────────────────────────────────────────
9
12
  vi.mock('../log.js', () => ({
10
13
  appendEvent: vi.fn().mockResolvedValue(undefined),
@@ -62,6 +65,14 @@ function makeSpec(specOverrides = {}, taskOverrides = [{}]) {
62
65
  ...specOverrides,
63
66
  };
64
67
  }
68
+ /** Wraps createConvoyEngine with a default no-op _ensureBranch mock so tests never
69
+ * run real git branch operations. Callers can override _ensureBranch if needed. */
70
+ function makeEngine(opts) {
71
+ return createConvoyEngine({
72
+ _ensureBranch: vi.fn().mockResolvedValue(undefined),
73
+ ...opts,
74
+ });
75
+ }
65
76
  // ── Test lifecycle ────────────────────────────────────────────────────────────
66
77
  let tmpDir;
67
78
  let dbPath;
@@ -80,7 +91,7 @@ afterEach(() => {
80
91
  describe('single task success', () => {
81
92
  it('returns status done with summary.done=1', async () => {
82
93
  const adapter = makeAdapter();
83
- const engine = createConvoyEngine({
94
+ const engine = makeEngine({
84
95
  spec: makeSpec(),
85
96
  specYaml: 'name: test',
86
97
  adapter,
@@ -99,7 +110,7 @@ describe('single task success', () => {
99
110
  });
100
111
  it('calls adapter.execute once with the correct task', async () => {
101
112
  const adapter = makeAdapter();
102
- const engine = createConvoyEngine({
113
+ const engine = makeEngine({
103
114
  spec: makeSpec(),
104
115
  specYaml: 'name: test',
105
116
  adapter,
@@ -118,7 +129,7 @@ describe('single task failure', () => {
118
129
  it('returns status failed with summary.failed=1 when task errors and no retries allowed', async () => {
119
130
  const adapter = makeAdapter();
120
131
  adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 });
121
- const engine = createConvoyEngine({
132
+ const engine = makeEngine({
122
133
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
123
134
  specYaml: 'name: test',
124
135
  adapter,
@@ -134,7 +145,7 @@ describe('single task failure', () => {
134
145
  it('calls adapter.kill when the task fails', async () => {
135
146
  const adapter = makeAdapter();
136
147
  adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 });
137
- const engine = createConvoyEngine({
148
+ const engine = makeEngine({
138
149
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
139
150
  specYaml: 'name: test',
140
151
  adapter,
@@ -159,7 +170,7 @@ describe('two-phase DAG (task-b depends on task-a)', () => {
159
170
  { id: 'task-a', depends_on: [] },
160
171
  { id: 'task-b', depends_on: ['task-a'] },
161
172
  ]);
162
- const engine = createConvoyEngine({
173
+ const engine = makeEngine({
163
174
  spec,
164
175
  specYaml: 'name: test',
165
176
  adapter,
@@ -187,7 +198,7 @@ describe('two-phase DAG (task-b depends on task-a)', () => {
187
198
  { id: 'task-a', depends_on: [] },
188
199
  { id: 'task-b', depends_on: ['task-a'] },
189
200
  ]);
190
- const engine = createConvoyEngine({
201
+ const engine = makeEngine({
191
202
  spec,
192
203
  specYaml: 'name: test',
193
204
  adapter,
@@ -217,7 +228,7 @@ describe('on_failure:continue', () => {
217
228
  { id: 'task-b', depends_on: ['task-a'] },
218
229
  { id: 'task-c', depends_on: [] },
219
230
  ]);
220
- const engine = createConvoyEngine({
231
+ const engine = makeEngine({
221
232
  spec,
222
233
  specYaml: 'name: test',
223
234
  adapter,
@@ -251,7 +262,7 @@ describe('on_failure:continue', () => {
251
262
  { id: 'task-b', depends_on: ['task-a'] },
252
263
  { id: 'task-c', depends_on: ['task-b'] },
253
264
  ]);
254
- const engine = createConvoyEngine({
265
+ const engine = makeEngine({
255
266
  spec,
256
267
  specYaml: 'name: test',
257
268
  adapter,
@@ -276,7 +287,7 @@ describe('on_failure:stop', () => {
276
287
  { id: 'task-b', depends_on: ['task-a'] },
277
288
  { id: 'task-c', depends_on: ['task-a'] },
278
289
  ]);
279
- const engine = createConvoyEngine({
290
+ const engine = makeEngine({
280
291
  spec,
281
292
  specYaml: 'name: test',
282
293
  adapter,
@@ -301,7 +312,7 @@ describe('on_failure:stop', () => {
301
312
  const adapter = makeAdapter();
302
313
  adapter.execute.mockResolvedValue({ success: false, output: 'fail', exitCode: 1 });
303
314
  const spec = makeSpec({ on_failure: 'stop' }, [{ id: 'task-1', max_retries: 3 }]);
304
- const engine = createConvoyEngine({
315
+ const engine = makeEngine({
305
316
  spec,
306
317
  specYaml: 'name: test',
307
318
  adapter,
@@ -329,7 +340,7 @@ describe('task retry', () => {
329
340
  return { success: true, output: 'second attempt ok', exitCode: 0 };
330
341
  });
331
342
  const spec = makeSpec({}, [{ id: 'task-1', max_retries: 1 }]);
332
- const engine = createConvoyEngine({
343
+ const engine = makeEngine({
333
344
  spec,
334
345
  specYaml: 'name: test',
335
346
  adapter,
@@ -350,7 +361,7 @@ describe('task retry', () => {
350
361
  return { success: false, output: 'always fails', exitCode: 1 };
351
362
  });
352
363
  const spec = makeSpec({}, [{ id: 'task-1', max_retries: 2 }]);
353
- const engine = createConvoyEngine({
364
+ const engine = makeEngine({
354
365
  spec,
355
366
  specYaml: 'name: test',
356
367
  adapter,
@@ -370,7 +381,7 @@ describe('validation gates', () => {
370
381
  it('returns status done when all gates pass', async () => {
371
382
  const adapter = makeAdapter();
372
383
  const spec = makeSpec({ gates: ['echo gate-ok'] }, [{ id: 'task-1' }]);
373
- const engine = createConvoyEngine({
384
+ const engine = makeEngine({
374
385
  spec,
375
386
  specYaml: 'name: test',
376
387
  adapter,
@@ -386,7 +397,7 @@ describe('validation gates', () => {
386
397
  it('returns status gate-failed when a gate exits non-zero', async () => {
387
398
  const adapter = makeAdapter();
388
399
  const spec = makeSpec({ gates: ['false'] }, [{ id: 'task-1' }]);
389
- const engine = createConvoyEngine({
400
+ const engine = makeEngine({
390
401
  spec,
391
402
  specYaml: 'name: test',
392
403
  adapter,
@@ -401,7 +412,7 @@ describe('validation gates', () => {
401
412
  });
402
413
  it('returns undefined gateResults when spec has no gates', async () => {
403
414
  const adapter = makeAdapter();
404
- const engine = createConvoyEngine({
415
+ const engine = makeEngine({
405
416
  spec: makeSpec(),
406
417
  specYaml: 'name: test',
407
418
  adapter,
@@ -415,7 +426,7 @@ describe('validation gates', () => {
415
426
  it('runs multiple gates and reports each result individually', async () => {
416
427
  const adapter = makeAdapter();
417
428
  const spec = makeSpec({ gates: ['echo first', 'false', 'echo third'] }, [{ id: 'task-1' }]);
418
- const engine = createConvoyEngine({
429
+ const engine = makeEngine({
419
430
  spec,
420
431
  specYaml: 'name: test',
421
432
  adapter,
@@ -458,6 +469,7 @@ describe('resume (crash recovery)', () => {
458
469
  max_retries: 0,
459
470
  files: null,
460
471
  depends_on: null,
472
+ gates: null,
461
473
  });
462
474
  if (taskStatus === 'running') {
463
475
  seeder.insertWorker({
@@ -479,7 +491,7 @@ describe('resume (crash recovery)', () => {
479
491
  seedCrashedConvoy(convoyId, 'running');
480
492
  const adapter = makeAdapter();
481
493
  const wtManager = makeWorktreeManager();
482
- const engine = createConvoyEngine({
494
+ const engine = makeEngine({
483
495
  spec: makeSpec({}, [{ id: 'task-1' }]),
484
496
  specYaml: 'name: test',
485
497
  adapter,
@@ -498,7 +510,7 @@ describe('resume (crash recovery)', () => {
498
510
  const convoyId = 'convoy-crashed-assigned';
499
511
  seedCrashedConvoy(convoyId, 'assigned');
500
512
  const adapter = makeAdapter();
501
- const engine = createConvoyEngine({
513
+ const engine = makeEngine({
502
514
  spec: makeSpec({}, [{ id: 'task-1' }]),
503
515
  specYaml: 'name: test',
504
516
  adapter,
@@ -512,7 +524,7 @@ describe('resume (crash recovery)', () => {
512
524
  });
513
525
  it('throws an error when the convoy is not found', async () => {
514
526
  const adapter = makeAdapter();
515
- const engine = createConvoyEngine({
527
+ const engine = makeEngine({
516
528
  spec: makeSpec(),
517
529
  specYaml: 'name: test',
518
530
  adapter,
@@ -549,10 +561,11 @@ describe('resume (crash recovery)', () => {
549
561
  max_retries: 0,
550
562
  files: null,
551
563
  depends_on: null,
564
+ gates: null,
552
565
  });
553
566
  seeder.close();
554
567
  const adapter = makeAdapter();
555
- const engine = createConvoyEngine({
568
+ const engine = makeEngine({
556
569
  spec: makeSpec({ branch: 'feature-branch' }), // spec.branch used as fallback
557
570
  specYaml: 'name: test',
558
571
  adapter,
@@ -591,10 +604,11 @@ describe('resume (crash recovery)', () => {
591
604
  max_retries: 0,
592
605
  files: null,
593
606
  depends_on: null,
607
+ gates: null,
594
608
  });
595
609
  seeder.close();
596
610
  const adapter = makeAdapter();
597
- const engine = createConvoyEngine({
611
+ const engine = makeEngine({
598
612
  spec: {
599
613
  name: 'Git Branch Convoy',
600
614
  concurrency: 1,
@@ -619,7 +633,7 @@ describe('worktree lifecycle (non-copilot)', () => {
619
633
  const adapter = makeAdapter('developer');
620
634
  const wtManager = makeWorktreeManager();
621
635
  const mergeQueue = makeMergeQueue();
622
- const engine = createConvoyEngine({
636
+ const engine = makeEngine({
623
637
  spec: makeSpec(),
624
638
  specYaml: 'name: test',
625
639
  adapter,
@@ -637,7 +651,7 @@ describe('worktree lifecycle (non-copilot)', () => {
637
651
  adapter.execute.mockResolvedValue({ success: false, output: 'err', exitCode: 1 });
638
652
  const wtManager = makeWorktreeManager();
639
653
  const mergeQueue = makeMergeQueue();
640
- const engine = createConvoyEngine({
654
+ const engine = makeEngine({
641
655
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
642
656
  specYaml: 'name: test',
643
657
  adapter,
@@ -655,7 +669,7 @@ describe('worktree lifecycle (non-copilot)', () => {
655
669
  const wtManager = makeWorktreeManager();
656
670
  wtManager.create.mockRejectedValue(new Error('git worktree unavailable'));
657
671
  const mergeQueue = makeMergeQueue();
658
- const engine = createConvoyEngine({
672
+ const engine = makeEngine({
659
673
  spec: makeSpec(),
660
674
  specYaml: 'name: test',
661
675
  adapter,
@@ -673,7 +687,7 @@ describe('worktree lifecycle (non-copilot)', () => {
673
687
  const wtManager = makeWorktreeManager();
674
688
  const mergeQueue = makeMergeQueue();
675
689
  mergeQueue.merge.mockRejectedValue(new Error('merge conflict'));
676
- const engine = createConvoyEngine({
690
+ const engine = makeEngine({
677
691
  spec: makeSpec(),
678
692
  specYaml: 'name: test',
679
693
  adapter,
@@ -693,7 +707,7 @@ describe('copilot adapter', () => {
693
707
  const adapter = makeAdapter('copilot');
694
708
  const wtManager = makeWorktreeManager();
695
709
  const mergeQueue = makeMergeQueue();
696
- const engine = createConvoyEngine({
710
+ const engine = makeEngine({
697
711
  spec: makeSpec(),
698
712
  specYaml: 'name: test',
699
713
  adapter,
@@ -719,7 +733,7 @@ describe('timeout handling', () => {
719
733
  output: 'Task timed out',
720
734
  exitCode: -1,
721
735
  });
722
- const engine = createConvoyEngine({
736
+ const engine = makeEngine({
723
737
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
724
738
  specYaml: 'name: test',
725
739
  adapter,
@@ -743,7 +757,7 @@ describe('timeout handling', () => {
743
757
  await new Promise(r => setTimeout(r, 5));
744
758
  return { success: true, output: 'ok', exitCode: 0 };
745
759
  });
746
- const engine = createConvoyEngine({
760
+ const engine = makeEngine({
747
761
  spec: makeSpec({ on_failure: 'continue' }, [{ id: 'task-1', max_retries: 1 }]),
748
762
  specYaml: 'name: test',
749
763
  adapter,
@@ -763,7 +777,7 @@ describe('timeout handling', () => {
763
777
  output: 'timed out',
764
778
  exitCode: -1,
765
779
  });
766
- const engine = createConvoyEngine({
780
+ const engine = makeEngine({
767
781
  spec: makeSpec({ on_failure: 'stop' }, [{ id: 'task-1', max_retries: 2 }]),
768
782
  specYaml: 'name: test',
769
783
  adapter,
@@ -785,7 +799,7 @@ describe('adapter without kill method', () => {
785
799
  execute: vi.fn().mockResolvedValue({ success: false, output: 'err', exitCode: 1 }),
786
800
  // kill intentionally absent
787
801
  };
788
- const engine = createConvoyEngine({
802
+ const engine = makeEngine({
789
803
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
790
804
  specYaml: 'name: test',
791
805
  adapter,
@@ -807,7 +821,7 @@ describe('adapter without kill method', () => {
807
821
  exitCode: -1,
808
822
  }),
809
823
  };
810
- const engine = createConvoyEngine({
824
+ const engine = makeEngine({
811
825
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
812
826
  specYaml: 'name: test',
813
827
  adapter,
@@ -837,7 +851,7 @@ describe('parallel task execution', () => {
837
851
  { id: 'task-2', depends_on: [] },
838
852
  { id: 'task-3', depends_on: [] },
839
853
  ]);
840
- const engine = createConvoyEngine({
854
+ const engine = makeEngine({
841
855
  spec,
842
856
  specYaml: 'name: test',
843
857
  adapter,
@@ -855,7 +869,7 @@ describe('executor error', () => {
855
869
  it('treats a thrown execute error as task failure', async () => {
856
870
  const adapter = makeAdapter();
857
871
  adapter.execute.mockRejectedValue(new Error('adapter crashed'));
858
- const engine = createConvoyEngine({
872
+ const engine = makeEngine({
859
873
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
860
874
  specYaml: 'name: test',
861
875
  adapter,
@@ -872,7 +886,7 @@ describe('executor error', () => {
872
886
  describe('verbose mode', () => {
873
887
  it('runs a successful task with verbose=true without throwing', async () => {
874
888
  const adapter = makeAdapter('developer');
875
- const engine = createConvoyEngine({
889
+ const engine = makeEngine({
876
890
  spec: makeSpec({}, [{ id: 'task-1' }]),
877
891
  specYaml: 'name: test',
878
892
  adapter,
@@ -895,7 +909,7 @@ describe('verbose mode', () => {
895
909
  { id: 'task-a', depends_on: [] },
896
910
  { id: 'task-b', depends_on: ['task-a'] }, // gets skipped — also triggers verbose skip log
897
911
  ]);
898
- const engine = createConvoyEngine({
912
+ const engine = makeEngine({
899
913
  spec,
900
914
  specYaml: 'name: test',
901
915
  adapter,
@@ -919,7 +933,7 @@ describe('verbose mode', () => {
919
933
  await new Promise(r => setTimeout(r, 5));
920
934
  return { success: true, output: 'ok', exitCode: 0 };
921
935
  });
922
- const engine = createConvoyEngine({
936
+ const engine = makeEngine({
923
937
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 1 }]),
924
938
  specYaml: 'name: test',
925
939
  adapter,
@@ -939,7 +953,7 @@ describe('verbose mode', () => {
939
953
  output: 'timed out',
940
954
  exitCode: -1,
941
955
  });
942
- const engine = createConvoyEngine({
956
+ const engine = makeEngine({
943
957
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
944
958
  specYaml: 'name: test',
945
959
  adapter,
@@ -962,7 +976,7 @@ describe('verbose mode', () => {
962
976
  await new Promise(r => setTimeout(r, 5));
963
977
  return { success: true, output: 'ok', exitCode: 0 };
964
978
  });
965
- const engine = createConvoyEngine({
979
+ const engine = makeEngine({
966
980
  spec: makeSpec({ on_failure: 'continue' }, [{ id: 'task-1', max_retries: 1 }]),
967
981
  specYaml: 'name: test',
968
982
  adapter,
@@ -978,7 +992,7 @@ describe('verbose mode', () => {
978
992
  const adapter = makeAdapter('developer');
979
993
  const wtManager = makeWorktreeManager();
980
994
  wtManager.create.mockRejectedValue(new Error('no worktrees'));
981
- const engine = createConvoyEngine({
995
+ const engine = makeEngine({
982
996
  spec: makeSpec({}, [{ id: 'task-1' }]),
983
997
  specYaml: 'name: test',
984
998
  adapter,
@@ -994,7 +1008,7 @@ describe('verbose mode', () => {
994
1008
  const adapter = makeAdapter('developer');
995
1009
  const mergeQueue = makeMergeQueue();
996
1010
  mergeQueue.merge.mockRejectedValue(new Error('merge conflict'));
997
- const engine = createConvoyEngine({
1011
+ const engine = makeEngine({
998
1012
  spec: makeSpec({}, [{ id: 'task-1' }]),
999
1013
  specYaml: 'name: test',
1000
1014
  adapter,
@@ -1013,7 +1027,7 @@ describe('msToTimeout — timeout string representation', () => {
1013
1027
  const adapter = makeAdapter();
1014
1028
  // parseTimeout('1h') = 3600000ms; msToTimeout(3600000) = '1h'
1015
1029
  const spec = makeSpec({}, [{ id: 'task-1', timeout: '1h' }]);
1016
- const engine = createConvoyEngine({
1030
+ const engine = makeEngine({
1017
1031
  spec,
1018
1032
  specYaml: 'name: test',
1019
1033
  adapter,
@@ -1028,7 +1042,7 @@ describe('msToTimeout — timeout string representation', () => {
1028
1042
  const adapter = makeAdapter();
1029
1043
  // parseTimeout('1m') = 60000ms; msToTimeout(60000) = '1m'
1030
1044
  const spec = makeSpec({}, [{ id: 'task-1', timeout: '1m' }]);
1031
- const engine = createConvoyEngine({
1045
+ const engine = makeEngine({
1032
1046
  spec,
1033
1047
  specYaml: 'name: test',
1034
1048
  adapter,
@@ -1047,7 +1061,7 @@ describe('per-task adapter resolution', () => {
1047
1061
  const altAdapter = makeAdapter('alt-adapter');
1048
1062
  vi.mocked(getAdapter).mockResolvedValue(altAdapter);
1049
1063
  const spec = makeSpec({}, [{ adapter: 'alt-adapter' }]);
1050
- const engine = createConvoyEngine({
1064
+ const engine = makeEngine({
1051
1065
  spec,
1052
1066
  specYaml: 'name: test',
1053
1067
  adapter: mainAdapter,
@@ -1063,7 +1077,7 @@ describe('per-task adapter resolution', () => {
1063
1077
  it('uses convoy-level adapter when task has no adapter field', async () => {
1064
1078
  const adapter = makeAdapter('test');
1065
1079
  const spec = makeSpec();
1066
- const engine = createConvoyEngine({
1080
+ const engine = makeEngine({
1067
1081
  spec,
1068
1082
  specYaml: 'name: test',
1069
1083
  adapter,
@@ -1079,7 +1093,7 @@ describe('per-task adapter resolution', () => {
1079
1093
  const adapter = makeAdapter('test');
1080
1094
  // task.adapter === adapter.name → no per-task resolution
1081
1095
  const spec = makeSpec({}, [{ adapter: 'test' }]);
1082
- const engine = createConvoyEngine({
1096
+ const engine = makeEngine({
1083
1097
  spec,
1084
1098
  specYaml: 'name: test',
1085
1099
  adapter,
@@ -1097,7 +1111,7 @@ describe('per-task adapter resolution', () => {
1097
1111
  vi.mocked(detectAdapter).mockResolvedValue('claude-code');
1098
1112
  vi.mocked(getAdapter).mockResolvedValue(autoAdapter);
1099
1113
  const spec = makeSpec({}, [{ adapter: 'auto' }]);
1100
- const engine = createConvoyEngine({
1114
+ const engine = makeEngine({
1101
1115
  spec,
1102
1116
  specYaml: 'name: test',
1103
1117
  adapter: mainAdapter,
@@ -1115,7 +1129,7 @@ describe('per-task adapter resolution', () => {
1115
1129
  const altAdapter = makeAdapter('alt-adapter');
1116
1130
  vi.mocked(getAdapter).mockResolvedValue(altAdapter);
1117
1131
  const spec = makeSpec({}, [{ adapter: 'alt-adapter' }]);
1118
- const engine = createConvoyEngine({
1132
+ const engine = makeEngine({
1119
1133
  spec,
1120
1134
  specYaml: 'name: test',
1121
1135
  adapter: makeAdapter('test'),
@@ -1144,7 +1158,7 @@ describe('getCurrentBranch', () => {
1144
1158
  // branch intentionally omitted
1145
1159
  tasks: [{ id: 'task-1', prompt: 'p', agent: 'dev', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 }],
1146
1160
  };
1147
- const engine = createConvoyEngine({
1161
+ const engine = makeEngine({
1148
1162
  spec,
1149
1163
  specYaml: 'name: branch-test',
1150
1164
  adapter,
@@ -1165,7 +1179,7 @@ describe('getCurrentBranch', () => {
1165
1179
  // branch not set — getCurrentBranch will fail because basePath is /tmp
1166
1180
  tasks: [{ id: 'task-1', prompt: 'p', agent: 'dev', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 }],
1167
1181
  };
1168
- const engine = createConvoyEngine({
1182
+ const engine = makeEngine({
1169
1183
  spec,
1170
1184
  specYaml: 'name: fallback-test',
1171
1185
  adapter,
@@ -1185,7 +1199,7 @@ describe('real timer timeout path', () => {
1185
1199
  const adapter = makeAdapter();
1186
1200
  // adapter.execute returns a promise that never resolves — real timer wins the race
1187
1201
  adapter.execute.mockImplementation(() => new Promise(() => { }));
1188
- const engine = createConvoyEngine({
1202
+ const engine = makeEngine({
1189
1203
  spec: makeSpec({}, [{ id: 'task-1', timeout: '1s', max_retries: 0 }]),
1190
1204
  specYaml: 'name: test',
1191
1205
  adapter,
@@ -1219,7 +1233,7 @@ describe('diamond dependency skip', () => {
1219
1233
  { id: 'task-b', depends_on: ['task-a'] },
1220
1234
  { id: 'task-c', depends_on: ['task-a', 'task-b'] }, // diamond
1221
1235
  ]);
1222
- const engine = createConvoyEngine({
1236
+ const engine = makeEngine({
1223
1237
  spec,
1224
1238
  specYaml: 'name: test',
1225
1239
  adapter,
@@ -1250,7 +1264,7 @@ describe('cost tracking', () => {
1250
1264
  exitCode: 0,
1251
1265
  usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
1252
1266
  });
1253
- const engine = createConvoyEngine({
1267
+ const engine = makeEngine({
1254
1268
  spec: makeSpec(),
1255
1269
  specYaml: 'name: test',
1256
1270
  adapter,
@@ -1270,7 +1284,7 @@ describe('cost tracking', () => {
1270
1284
  it('leaves cost fields null when adapter returns no usage', async () => {
1271
1285
  const adapter = makeAdapter();
1272
1286
  // default makeAdapter returns no usage field
1273
- const engine = createConvoyEngine({
1287
+ const engine = makeEngine({
1274
1288
  spec: makeSpec(),
1275
1289
  specYaml: 'name: test',
1276
1290
  adapter,
@@ -1295,7 +1309,7 @@ describe('cost tracking', () => {
1295
1309
  { id: 'task-1', depends_on: [] },
1296
1310
  { id: 'task-2', depends_on: [] },
1297
1311
  ]);
1298
- const engine = createConvoyEngine({
1312
+ const engine = makeEngine({
1299
1313
  spec,
1300
1314
  specYaml: 'name: test',
1301
1315
  adapter,
@@ -1317,7 +1331,7 @@ describe('cost tracking', () => {
1317
1331
  exitCode: 0,
1318
1332
  usage: { total_tokens: 75 },
1319
1333
  });
1320
- const engine = createConvoyEngine({
1334
+ const engine = makeEngine({
1321
1335
  spec: makeSpec(),
1322
1336
  specYaml: 'name: test',
1323
1337
  adapter,
@@ -1331,7 +1345,7 @@ describe('cost tracking', () => {
1331
1345
  it('omits cost from ConvoyResult when no usage data is available', async () => {
1332
1346
  const adapter = makeAdapter();
1333
1347
  // default makeAdapter returns no usage
1334
- const engine = createConvoyEngine({
1348
+ const engine = makeEngine({
1335
1349
  spec: makeSpec(),
1336
1350
  specYaml: 'name: test',
1337
1351
  adapter,
@@ -1350,7 +1364,7 @@ describe('cost tracking', () => {
1350
1364
  exitCode: 0,
1351
1365
  usage: { total_tokens: 42 },
1352
1366
  });
1353
- const engine = createConvoyEngine({
1367
+ const engine = makeEngine({
1354
1368
  spec: makeSpec(),
1355
1369
  specYaml: 'name: test',
1356
1370
  adapter,
@@ -1369,7 +1383,7 @@ describe('cost tracking', () => {
1369
1383
  it('convoy total_tokens is null when no task has usage', async () => {
1370
1384
  const adapter = makeAdapter();
1371
1385
  // default adapter returns no usage
1372
- const engine = createConvoyEngine({
1386
+ const engine = makeEngine({
1373
1387
  spec: makeSpec({ concurrency: 2 }, [
1374
1388
  { id: 'task-1', depends_on: [] },
1375
1389
  { id: 'task-2', depends_on: [] },
@@ -1404,7 +1418,7 @@ describe('progress reporting', () => {
1404
1418
  });
1405
1419
  it('prints task start message without verbose flag', async () => {
1406
1420
  const adapter = makeAdapter();
1407
- const engine = createConvoyEngine({
1421
+ const engine = makeEngine({
1408
1422
  spec: makeSpec(),
1409
1423
  specYaml: 'name: test',
1410
1424
  adapter,
@@ -1419,7 +1433,7 @@ describe('progress reporting', () => {
1419
1433
  });
1420
1434
  it('prints task completion with counter', async () => {
1421
1435
  const adapter = makeAdapter();
1422
- const engine = createConvoyEngine({
1436
+ const engine = makeEngine({
1423
1437
  spec: makeSpec(),
1424
1438
  specYaml: 'name: test',
1425
1439
  adapter,
@@ -1435,7 +1449,7 @@ describe('progress reporting', () => {
1435
1449
  it('prints task failure with counter', async () => {
1436
1450
  const adapter = makeAdapter();
1437
1451
  adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 });
1438
- const engine = createConvoyEngine({
1452
+ const engine = makeEngine({
1439
1453
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
1440
1454
  specYaml: 'name: test',
1441
1455
  adapter,
@@ -1454,7 +1468,7 @@ describe('progress reporting', () => {
1454
1468
  { id: 'task-a', depends_on: [] },
1455
1469
  { id: 'task-b', depends_on: ['task-a'] },
1456
1470
  ]);
1457
- const engine = createConvoyEngine({
1471
+ const engine = makeEngine({
1458
1472
  spec,
1459
1473
  specYaml: 'name: test',
1460
1474
  adapter,
@@ -1470,7 +1484,7 @@ describe('progress reporting', () => {
1470
1484
  it('prints gate results with pass/fail indicators', async () => {
1471
1485
  const adapter = makeAdapter();
1472
1486
  const spec = makeSpec({ gates: ['echo gate-ok', 'false'] }, [{ id: 'task-1' }]);
1473
- const engine = createConvoyEngine({
1487
+ const engine = makeEngine({
1474
1488
  spec,
1475
1489
  specYaml: 'name: test',
1476
1490
  adapter,
@@ -1495,7 +1509,7 @@ describe('progress reporting', () => {
1495
1509
  await new Promise(r => setTimeout(r, 5));
1496
1510
  return { success: true, output: 'ok', exitCode: 0 };
1497
1511
  });
1498
- const engine = createConvoyEngine({
1512
+ const engine = makeEngine({
1499
1513
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 1 }]),
1500
1514
  specYaml: 'name: test',
1501
1515
  adapter,
@@ -1526,7 +1540,7 @@ describe('gate retry mechanism', () => {
1526
1540
  });
1527
1541
  it('gates pass on first attempt when gate_retries > 0 — no fix task run', async () => {
1528
1542
  const spec = makeSpec({ gates: [`node -e "process.exit(0)"`], gate_retries: 1 }, [{ id: 'task-1' }]);
1529
- const engine = createConvoyEngine({
1543
+ const engine = makeEngine({
1530
1544
  spec,
1531
1545
  specYaml: 'name: test',
1532
1546
  adapter,
@@ -1541,7 +1555,7 @@ describe('gate retry mechanism', () => {
1541
1555
  });
1542
1556
  it('defaults gate_retries to 0 (no retry on gate failure)', async () => {
1543
1557
  const spec = makeSpec({ gates: ['false'] }, [{ id: 'task-1' }]);
1544
- const engine = createConvoyEngine({
1558
+ const engine = makeEngine({
1545
1559
  spec,
1546
1560
  specYaml: 'name: test',
1547
1561
  adapter,
@@ -1556,7 +1570,7 @@ describe('gate retry mechanism', () => {
1556
1570
  });
1557
1571
  it('calls adapter.execute with fix prompt when gates fail and retries available', async () => {
1558
1572
  const spec = makeSpec({ gates: ['false'], gate_retries: 1 }, [{ id: 'task-1' }]);
1559
- const engine = createConvoyEngine({
1573
+ const engine = makeEngine({
1560
1574
  spec,
1561
1575
  specYaml: 'name: test',
1562
1576
  adapter,
@@ -1579,7 +1593,7 @@ describe('gate retry mechanism', () => {
1579
1593
  .mockResolvedValueOnce({ success: true, output: 'ok', exitCode: 0 }) // task-1
1580
1594
  .mockResolvedValueOnce({ success: false, output: 'fix failed', exitCode: 1 }); // gate-fix-1
1581
1595
  const spec = makeSpec({ gates: ['false'], gate_retries: 2 }, [{ id: 'task-1' }]);
1582
- const engine = createConvoyEngine({
1596
+ const engine = makeEngine({
1583
1597
  spec,
1584
1598
  specYaml: 'name: test',
1585
1599
  adapter,
@@ -1593,4 +1607,1579 @@ describe('gate retry mechanism', () => {
1593
1607
  expect(result.status).toBe('gate-failed');
1594
1608
  });
1595
1609
  });
1610
+ // ── evaluateReviewLevel ───────────────────────────────────────────────────────
1611
+ function makeTaskRecord(overrides = {}) {
1612
+ return {
1613
+ id: 'task-1',
1614
+ convoy_id: 'convoy-1',
1615
+ phase: 0,
1616
+ prompt: '',
1617
+ agent: 'developer',
1618
+ adapter: null,
1619
+ model: null,
1620
+ timeout_ms: 1_800_000,
1621
+ status: 'pending',
1622
+ worker_id: null,
1623
+ worktree: null,
1624
+ output: null,
1625
+ exit_code: null,
1626
+ started_at: null,
1627
+ finished_at: null,
1628
+ retries: 0,
1629
+ max_retries: 1,
1630
+ files: null,
1631
+ depends_on: null,
1632
+ prompt_tokens: null,
1633
+ completion_tokens: null,
1634
+ total_tokens: null,
1635
+ cost_usd: null,
1636
+ gates: null,
1637
+ on_exhausted: 'dlq',
1638
+ injected: 0,
1639
+ provenance: null,
1640
+ idempotency_key: null,
1641
+ current_step: null,
1642
+ total_steps: null,
1643
+ review_level: null,
1644
+ review_verdict: null,
1645
+ review_tokens: null,
1646
+ review_model: null,
1647
+ panel_attempts: 0,
1648
+ dispute_id: null,
1649
+ drift_score: null,
1650
+ drift_retried: 0,
1651
+ ...overrides,
1652
+ };
1653
+ }
1654
+ function makeDiffStats(overrides = {}) {
1655
+ return {
1656
+ linesChanged: 5,
1657
+ filesChanged: 1,
1658
+ filePaths: ['src/components/Button.tsx'],
1659
+ ...overrides,
1660
+ };
1661
+ }
1662
+ describe('evaluateReviewLevel', () => {
1663
+ it('routes to panel when a changed file is under auth/', () => {
1664
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ filePaths: ['auth/session.ts'] }));
1665
+ expect(level).toBe('panel');
1666
+ });
1667
+ it('routes to panel when a changed file path contains /auth/', () => {
1668
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ filePaths: ['src/auth/session.ts'] }));
1669
+ expect(level).toBe('panel');
1670
+ });
1671
+ it('routes to panel for security/ path', () => {
1672
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ filePaths: ['security/policy.ts'] }));
1673
+ expect(level).toBe('panel');
1674
+ });
1675
+ it('routes to panel for security-expert agent', () => {
1676
+ const level = evaluateReviewLevel(makeTaskRecord({ agent: 'security-expert' }), makeDiffStats());
1677
+ expect(level).toBe('panel');
1678
+ });
1679
+ it('routes to panel for database-engineer agent', () => {
1680
+ const level = evaluateReviewLevel(makeTaskRecord({ agent: 'database-engineer' }), makeDiffStats());
1681
+ expect(level).toBe('panel');
1682
+ });
1683
+ it('routes to auto-pass for documentation-writer agent', () => {
1684
+ const level = evaluateReviewLevel(makeTaskRecord({ agent: 'documentation-writer' }), makeDiffStats());
1685
+ expect(level).toBe('auto-pass');
1686
+ });
1687
+ it('routes to auto-pass for copywriter agent', () => {
1688
+ const level = evaluateReviewLevel(makeTaskRecord({ agent: 'copywriter' }), makeDiffStats());
1689
+ expect(level).toBe('auto-pass');
1690
+ });
1691
+ it('routes to auto-pass for small diff (<=10 lines, <=2 files) with gates passing', () => {
1692
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ linesChanged: 8, filesChanged: 2, filePaths: ['src/Button.tsx', 'src/Button.test.tsx'] }), undefined, true);
1693
+ expect(level).toBe('auto-pass');
1694
+ });
1695
+ it('routes to fast for large diff (>200 lines)', () => {
1696
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ linesChanged: 250, filesChanged: 3, filePaths: ['src/Big.tsx', 'src/Big.test.tsx', 'src/types.ts'] }));
1697
+ expect(level).toBe('fast');
1698
+ });
1699
+ it('routes to fast for many files (>5)', () => {
1700
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ linesChanged: 50, filesChanged: 6, filePaths: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts', 'f.ts'] }));
1701
+ expect(level).toBe('fast');
1702
+ });
1703
+ it('defaults to fast for medium diff with developer agent', () => {
1704
+ const level = evaluateReviewLevel(makeTaskRecord({ agent: 'developer' }), makeDiffStats({ linesChanged: 50, filesChanged: 3, filePaths: ['src/Feature.tsx', 'src/Feature.test.tsx', 'src/types.ts'] }));
1705
+ expect(level).toBe('fast');
1706
+ });
1707
+ it('custom heuristics: overrides panel_paths', () => {
1708
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ filePaths: ['billing/invoice.ts'] }), { panel_paths: ['billing/'] });
1709
+ expect(level).toBe('panel');
1710
+ });
1711
+ it('custom heuristics: overrides auto_pass_agents', () => {
1712
+ const level = evaluateReviewLevel(makeTaskRecord({ agent: 'designer' }), makeDiffStats(), { auto_pass_agents: ['designer'] });
1713
+ expect(level).toBe('auto-pass');
1714
+ });
1715
+ it('custom heuristics: smaller auto_pass_max_lines threshold', () => {
1716
+ const level = evaluateReviewLevel(makeTaskRecord(), makeDiffStats({ linesChanged: 5, filesChanged: 1, filePaths: ['src/x.ts'] }), { auto_pass_max_lines: 3 }, true);
1717
+ expect(level).toBe('fast'); // 5 > 3 → not auto-pass
1718
+ });
1719
+ });
1720
+ // ── Review pipeline integration ───────────────────────────────────────────────
1721
+ describe('review pipeline', () => {
1722
+ let adapter;
1723
+ let wtManager;
1724
+ let mergeQueue;
1725
+ beforeEach(() => {
1726
+ adapter = makeAdapter();
1727
+ wtManager = makeWorktreeManager();
1728
+ mergeQueue = makeMergeQueue();
1729
+ });
1730
+ it('task with review: none — reviewer not called, task succeeds', async () => {
1731
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 100, model: 'test' });
1732
+ const engine = makeEngine({
1733
+ spec: makeSpec({ defaults: { review: 'none' } }, [{ review: 'none' }]),
1734
+ specYaml: 'name: test',
1735
+ adapter,
1736
+ dbPath,
1737
+ _worktreeManager: wtManager,
1738
+ _mergeQueue: mergeQueue,
1739
+ _reviewRunner: mockReviewRunner,
1740
+ });
1741
+ const result = await engine.run();
1742
+ expect(result.status).toBe('done');
1743
+ expect(mockReviewRunner).not.toHaveBeenCalled();
1744
+ });
1745
+ it('fast review PASS — task proceeds to merge (status done)', async () => {
1746
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 50, model: 'reviewer' });
1747
+ const engine = makeEngine({
1748
+ spec: makeSpec({ defaults: { review: 'fast' } }),
1749
+ specYaml: 'name: test',
1750
+ adapter,
1751
+ dbPath,
1752
+ _worktreeManager: wtManager,
1753
+ _mergeQueue: mergeQueue,
1754
+ _reviewRunner: mockReviewRunner,
1755
+ });
1756
+ const result = await engine.run();
1757
+ expect(result.status).toBe('done');
1758
+ expect(mockReviewRunner).toHaveBeenCalledOnce();
1759
+ expect(mockReviewRunner).toHaveBeenCalledWith(expect.objectContaining({ agent: 'developer' }), 'fast', 'default');
1760
+ });
1761
+ it('fast review BLOCK + retries remaining — task retried with feedback prepended', async () => {
1762
+ let callCount = 0;
1763
+ adapter.execute.mockImplementation(() => {
1764
+ callCount++;
1765
+ return Promise.resolve({ success: true, output: 'ok', exitCode: 0 });
1766
+ });
1767
+ const mockReviewRunner = vi.fn()
1768
+ .mockResolvedValueOnce({ verdict: 'block', feedback: 'Missing tests', tokens: 50, model: 'reviewer' })
1769
+ .mockResolvedValueOnce({ verdict: 'pass', feedback: '', tokens: 50, model: 'reviewer' });
1770
+ const engine = makeEngine({
1771
+ spec: makeSpec({ defaults: { review: 'fast' } }, [{ max_retries: 1 }]),
1772
+ specYaml: 'name: test',
1773
+ adapter,
1774
+ dbPath,
1775
+ _worktreeManager: wtManager,
1776
+ _mergeQueue: mergeQueue,
1777
+ _reviewRunner: mockReviewRunner,
1778
+ });
1779
+ const result = await engine.run();
1780
+ expect(result.status).toBe('done');
1781
+ expect(adapter.execute).toHaveBeenCalledTimes(2);
1782
+ expect(mockReviewRunner).toHaveBeenCalledTimes(2);
1783
+ // Prompt on second attempt should contain feedback
1784
+ const secondPrompt = adapter.execute.mock.calls[1][0].prompt;
1785
+ expect(secondPrompt).toContain('Missing tests');
1786
+ });
1787
+ it('fast review BLOCK + retries exhausted — status review-blocked', async () => {
1788
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'Insecure code', tokens: 50, model: 'reviewer' });
1789
+ const engine = makeEngine({
1790
+ spec: makeSpec({ defaults: { review: 'fast' } }, [{ max_retries: 0 }]),
1791
+ specYaml: 'name: test',
1792
+ adapter,
1793
+ dbPath,
1794
+ _worktreeManager: wtManager,
1795
+ _mergeQueue: mergeQueue,
1796
+ _reviewRunner: mockReviewRunner,
1797
+ });
1798
+ const result = await engine.run();
1799
+ expect(result.status).toBe('failed');
1800
+ expect(result.summary.failed).toBe(1);
1801
+ // Verify the task itself is review-blocked
1802
+ const store = createConvoyStore(dbPath);
1803
+ const tasks = store.getTasksByConvoy(result.convoyId);
1804
+ store.close();
1805
+ expect(tasks[0].status).toBe('review-blocked');
1806
+ });
1807
+ it('panel review 2/3 PASS — task proceeds (status done)', async () => {
1808
+ let callCount = 0;
1809
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
1810
+ callCount++;
1811
+ // 2 pass, 1 block
1812
+ return Promise.resolve(callCount <= 2
1813
+ ? { verdict: 'pass', feedback: '', tokens: 30, model: 'reviewer' }
1814
+ : { verdict: 'block', feedback: 'Minor issue', tokens: 30, model: 'reviewer' });
1815
+ });
1816
+ const engine = makeEngine({
1817
+ spec: makeSpec({ defaults: { review: 'panel' } }),
1818
+ specYaml: 'name: test',
1819
+ adapter,
1820
+ dbPath,
1821
+ _worktreeManager: wtManager,
1822
+ _mergeQueue: mergeQueue,
1823
+ _reviewRunner: mockReviewRunner,
1824
+ });
1825
+ const result = await engine.run();
1826
+ expect(result.status).toBe('done');
1827
+ expect(mockReviewRunner).toHaveBeenCalledTimes(3);
1828
+ });
1829
+ it('panel review 2/3 BLOCK — task retried with MUST-FIX', async () => {
1830
+ let reviewCallCount = 0;
1831
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
1832
+ reviewCallCount++;
1833
+ // First round: 2 block; second round: 3 pass
1834
+ if (reviewCallCount <= 3) {
1835
+ return Promise.resolve(reviewCallCount <= 2
1836
+ ? { verdict: 'block', feedback: 'Critical bug', tokens: 30, model: 'reviewer' }
1837
+ : { verdict: 'pass', feedback: '', tokens: 30, model: 'reviewer' });
1838
+ }
1839
+ return Promise.resolve({ verdict: 'pass', feedback: '', tokens: 30, model: 'reviewer' });
1840
+ });
1841
+ const engine = makeEngine({
1842
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ max_retries: 1 }]),
1843
+ specYaml: 'name: test',
1844
+ adapter,
1845
+ dbPath,
1846
+ _worktreeManager: wtManager,
1847
+ _mergeQueue: mergeQueue,
1848
+ _reviewRunner: mockReviewRunner,
1849
+ });
1850
+ const result = await engine.run();
1851
+ expect(result.status).toBe('done');
1852
+ expect(adapter.execute).toHaveBeenCalledTimes(2);
1853
+ // Prompt on second attempt contains MUST-FIX
1854
+ const secondPrompt = adapter.execute.mock.calls[1][0].prompt;
1855
+ expect(secondPrompt).toContain('MUST-FIX');
1856
+ expect(secondPrompt).toContain('Critical bug');
1857
+ });
1858
+ it('review budget exceeded with skip — review skipped, task done', async () => {
1859
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 200, model: 'reviewer' });
1860
+ const engine = makeEngine({
1861
+ spec: makeSpec({
1862
+ defaults: { review: 'fast', review_budget: 100, on_review_budget_exceeded: 'skip', reviewer_model: 'r1' },
1863
+ tasks: [
1864
+ { id: 'task-1', prompt: 'Prompt 1', agent: 'developer', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 },
1865
+ { id: 'task-2', prompt: 'Prompt 2', agent: 'developer', timeout: '30s', depends_on: ['task-1'], files: [], description: '', max_retries: 0 },
1866
+ ],
1867
+ }),
1868
+ specYaml: 'name: test',
1869
+ adapter,
1870
+ dbPath,
1871
+ _worktreeManager: wtManager,
1872
+ _mergeQueue: mergeQueue,
1873
+ _reviewRunner: mockReviewRunner,
1874
+ });
1875
+ const result = await engine.run();
1876
+ expect(result.status).toBe('done');
1877
+ // first task: budget not exceeded (0 < 100), review runs
1878
+ // second task: budget exceeded (200 >= 100), review skipped
1879
+ expect(mockReviewRunner).toHaveBeenCalledTimes(1);
1880
+ });
1881
+ it('auto route: developer agent with empty diff → auto-pass (no reviewer call)', async () => {
1882
+ // Given: 'auto' review setting, developer agent, empty diff (git will fail on mock path)
1883
+ const mockReviewRunner = vi.fn();
1884
+ const engine = makeEngine({
1885
+ spec: makeSpec({ defaults: { review: 'auto' } }),
1886
+ specYaml: 'name: test',
1887
+ adapter,
1888
+ dbPath,
1889
+ _worktreeManager: wtManager,
1890
+ _mergeQueue: mergeQueue,
1891
+ _reviewRunner: mockReviewRunner,
1892
+ });
1893
+ const result = await engine.run();
1894
+ expect(result.status).toBe('done');
1895
+ expect(mockReviewRunner).not.toHaveBeenCalled();
1896
+ });
1897
+ it('review tokens tracked on task record', async () => {
1898
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 77, model: 'reviewer' });
1899
+ const engine = makeEngine({
1900
+ spec: makeSpec({ defaults: { review: 'fast' } }),
1901
+ specYaml: 'name: test',
1902
+ adapter,
1903
+ dbPath,
1904
+ _worktreeManager: wtManager,
1905
+ _mergeQueue: mergeQueue,
1906
+ _reviewRunner: mockReviewRunner,
1907
+ });
1908
+ const result = await engine.run();
1909
+ const store = createConvoyStore(dbPath);
1910
+ const tasks = store.getTasksByConvoy(result.convoyId);
1911
+ store.close();
1912
+ expect(tasks[0].review_tokens).toBe(77);
1913
+ expect(tasks[0].review_level).toBe('fast');
1914
+ expect(tasks[0].review_verdict).toBe('pass');
1915
+ });
1916
+ it('review_started and review_verdict events emitted', async () => {
1917
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 10, model: 'reviewer' });
1918
+ const engine = makeEngine({
1919
+ spec: makeSpec({ defaults: { review: 'fast' } }),
1920
+ specYaml: 'name: test',
1921
+ adapter,
1922
+ dbPath,
1923
+ _worktreeManager: wtManager,
1924
+ _mergeQueue: mergeQueue,
1925
+ _reviewRunner: mockReviewRunner,
1926
+ });
1927
+ const result = await engine.run();
1928
+ const store = createConvoyStore(dbPath);
1929
+ const events = store.getEvents(result.convoyId);
1930
+ store.close();
1931
+ const startedEvent = events.find(e => e.type === 'review_started');
1932
+ const verdictEvent = events.find(e => e.type === 'review_verdict');
1933
+ expect(startedEvent).toBeDefined();
1934
+ expect(verdictEvent).toBeDefined();
1935
+ });
1936
+ it('review sessions do NOT count against concurrency limit', async () => {
1937
+ // Concurrency=1, 2 tasks in parallel. Both should complete with review.
1938
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 10, model: 'reviewer' });
1939
+ const engine = makeEngine({
1940
+ spec: makeSpec({ concurrency: 1, defaults: { review: 'fast' } }, [{ id: 'task-1' }, { id: 'task-2' }]),
1941
+ specYaml: 'name: test',
1942
+ adapter,
1943
+ dbPath,
1944
+ _worktreeManager: wtManager,
1945
+ _mergeQueue: mergeQueue,
1946
+ _reviewRunner: mockReviewRunner,
1947
+ });
1948
+ const result = await engine.run();
1949
+ expect(result.status).toBe('done');
1950
+ expect(result.summary.done).toBe(2);
1951
+ });
1952
+ it('full fast-review flow: BLOCK on first attempt → retry → PASS → done with complete events', async () => {
1953
+ const mockReviewRunner = vi.fn()
1954
+ .mockResolvedValueOnce({ verdict: 'block', feedback: 'Add more tests', tokens: 40, model: 'reviewer' })
1955
+ .mockResolvedValueOnce({ verdict: 'pass', feedback: '', tokens: 35, model: 'reviewer' });
1956
+ const engine = makeEngine({
1957
+ spec: makeSpec({ defaults: { review: 'fast' } }, [{ id: 'task-1', max_retries: 1 }]),
1958
+ specYaml: 'name: test',
1959
+ adapter,
1960
+ dbPath,
1961
+ _worktreeManager: wtManager,
1962
+ _mergeQueue: mergeQueue,
1963
+ _reviewRunner: mockReviewRunner,
1964
+ });
1965
+ const result = await engine.run();
1966
+ expect(result.status).toBe('done');
1967
+ expect(adapter.execute).toHaveBeenCalledTimes(2);
1968
+ expect(mockReviewRunner).toHaveBeenCalledTimes(2);
1969
+ const store = createConvoyStore(dbPath);
1970
+ const tasks = store.getTasksByConvoy(result.convoyId);
1971
+ const events = store.getEvents(result.convoyId);
1972
+ store.close();
1973
+ const task = tasks[0];
1974
+ expect(task.review_level).toBe('fast');
1975
+ expect(task.review_verdict).toBe('pass');
1976
+ expect(task.retries).toBe(1);
1977
+ const reviewStartedEvents = events.filter(e => e.type === 'review_started');
1978
+ const reviewVerdictEvents = events.filter(e => e.type === 'review_verdict');
1979
+ expect(reviewStartedEvents.length).toBe(2);
1980
+ expect(reviewVerdictEvents.length).toBe(2);
1981
+ const firstVerdict = JSON.parse(reviewVerdictEvents[0].data);
1982
+ const secondVerdict = JSON.parse(reviewVerdictEvents[1].data);
1983
+ expect(firstVerdict['verdict']).toBe('block');
1984
+ expect(secondVerdict['verdict']).toBe('pass');
1985
+ });
1986
+ it('panel flow: 2/3 BLOCK first round → retry → 3/3 PASS second round → done', async () => {
1987
+ let reviewCallCount = 0;
1988
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
1989
+ reviewCallCount++;
1990
+ // Round 1 (calls 1-3): BLOCK, BLOCK, PASS → majority block → retry
1991
+ if (reviewCallCount <= 3) {
1992
+ return Promise.resolve(reviewCallCount <= 2
1993
+ ? { verdict: 'block', feedback: 'Critical issue', tokens: 20, model: 'reviewer' }
1994
+ : { verdict: 'pass', feedback: '', tokens: 20, model: 'reviewer' });
1995
+ }
1996
+ // Round 2 (calls 4-6): all PASS
1997
+ return Promise.resolve({ verdict: 'pass', feedback: '', tokens: 20, model: 'reviewer' });
1998
+ });
1999
+ const engine = makeEngine({
2000
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 1 }]),
2001
+ specYaml: 'name: test',
2002
+ adapter,
2003
+ dbPath,
2004
+ _worktreeManager: wtManager,
2005
+ _mergeQueue: mergeQueue,
2006
+ _reviewRunner: mockReviewRunner,
2007
+ });
2008
+ const result = await engine.run();
2009
+ expect(result.status).toBe('done');
2010
+ expect(adapter.execute).toHaveBeenCalledTimes(2);
2011
+ expect(mockReviewRunner).toHaveBeenCalledTimes(6);
2012
+ const store = createConvoyStore(dbPath);
2013
+ const tasks = store.getTasksByConvoy(result.convoyId);
2014
+ store.close();
2015
+ expect(tasks[0].review_verdict).toBe('pass');
2016
+ expect(tasks[0].panel_attempts).toBeGreaterThanOrEqual(1);
2017
+ });
2018
+ it('dispute: task dispute_id matches the dispute_opened event and panel_attempts is 3', async () => {
2019
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'broken', tokens: 5, model: 'r' });
2020
+ const engine = makeEngine({
2021
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
2022
+ specYaml: 'name: test',
2023
+ adapter,
2024
+ dbPath,
2025
+ _worktreeManager: wtManager,
2026
+ _mergeQueue: mergeQueue,
2027
+ _reviewRunner: mockReviewRunner,
2028
+ });
2029
+ const result = await engine.run();
2030
+ const store = createConvoyStore(dbPath);
2031
+ const tasks = store.getTasksByConvoy(result.convoyId);
2032
+ const events = store.getEvents(result.convoyId);
2033
+ store.close();
2034
+ const task = tasks[0];
2035
+ expect(task.status).toBe('disputed');
2036
+ expect(task.dispute_id).not.toBeNull();
2037
+ expect(task.panel_attempts).toBe(3);
2038
+ const disputeEvent = events.find(e => e.type === 'dispute_opened');
2039
+ expect(disputeEvent).toBeDefined();
2040
+ const eventData = JSON.parse(disputeEvent.data);
2041
+ // Verify the dispute_id on the task record matches the one in the event
2042
+ expect(eventData['dispute_id']).toBe(task.dispute_id);
2043
+ expect(eventData['panel_attempts']).toBe(3);
2044
+ });
2045
+ it('review budget exceeded: stop marks task review-blocked and skips all pending tasks', async () => {
2046
+ const mockReviewRunner = vi.fn();
2047
+ const engine = makeEngine({
2048
+ spec: makeSpec({ defaults: { review: 'fast', review_budget: 0, on_review_budget_exceeded: 'stop' } }, [
2049
+ { id: 'task-1', depends_on: [] },
2050
+ { id: 'task-2', depends_on: ['task-1'] },
2051
+ ]),
2052
+ specYaml: 'name: test',
2053
+ adapter,
2054
+ dbPath,
2055
+ _worktreeManager: wtManager,
2056
+ _mergeQueue: mergeQueue,
2057
+ _reviewRunner: mockReviewRunner,
2058
+ });
2059
+ const result = await engine.run();
2060
+ const store = createConvoyStore(dbPath);
2061
+ const tasks = store.getTasksByConvoy(result.convoyId);
2062
+ store.close();
2063
+ const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]));
2064
+ expect(byId['task-1']).toBe('review-blocked');
2065
+ expect(byId['task-2']).toBe('skipped');
2066
+ expect(mockReviewRunner).not.toHaveBeenCalled();
2067
+ });
2068
+ it('review budget exceeded: downgrade auto-passes task without calling reviewer', async () => {
2069
+ const mockReviewRunner = vi.fn();
2070
+ const engine = makeEngine({
2071
+ spec: makeSpec({ defaults: { review: 'fast', review_budget: 0, on_review_budget_exceeded: 'downgrade' } }),
2072
+ specYaml: 'name: test',
2073
+ adapter,
2074
+ dbPath,
2075
+ _worktreeManager: wtManager,
2076
+ _mergeQueue: mergeQueue,
2077
+ _reviewRunner: mockReviewRunner,
2078
+ });
2079
+ const result = await engine.run();
2080
+ expect(result.status).toBe('done');
2081
+ expect(mockReviewRunner).not.toHaveBeenCalled();
2082
+ const store = createConvoyStore(dbPath);
2083
+ const tasks = store.getTasksByConvoy(result.convoyId);
2084
+ store.close();
2085
+ expect(tasks[0].review_verdict).toBe('pass');
2086
+ expect(tasks[0].review_level).toBe('fast');
2087
+ });
2088
+ });
2089
+ // ── Drift detection ───────────────────────────────────────────────────────────
2090
+ describe('drift detection', () => {
2091
+ let adapter;
2092
+ let wtManager;
2093
+ let mergeQueue;
2094
+ beforeEach(() => {
2095
+ adapter = makeAdapter('copilot');
2096
+ wtManager = makeWorktreeManager();
2097
+ mergeQueue = makeMergeQueue();
2098
+ });
2099
+ it('detect_drift=true triggers drift check and retries on low confidence', async () => {
2100
+ // Call sequence: main task → drift check (low score) → main task retry
2101
+ adapter.execute
2102
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 })
2103
+ .mockResolvedValueOnce({ success: true, output: '{"score": 0.3, "explanation": "uncertain"}', exitCode: 0 })
2104
+ .mockResolvedValueOnce({ success: true, output: 'done retry', exitCode: 0 });
2105
+ const engine = makeEngine({
2106
+ spec: makeSpec({ defaults: { detect_drift: true } }, [{ id: 'task-1', max_retries: 1 }]),
2107
+ specYaml: 'name: test',
2108
+ adapter,
2109
+ dbPath,
2110
+ _worktreeManager: wtManager,
2111
+ _mergeQueue: mergeQueue,
2112
+ });
2113
+ const result = await engine.run();
2114
+ expect(result.status).toBe('done');
2115
+ expect(result.summary.done).toBe(1);
2116
+ expect(adapter.execute).toHaveBeenCalledTimes(3);
2117
+ // Verify drift_score and drift_retried stored
2118
+ const store = createConvoyStore(dbPath);
2119
+ const tasks = store.getTasksByConvoy(result.convoyId);
2120
+ store.close();
2121
+ expect(tasks[0].drift_score).toBe(0.3);
2122
+ expect(tasks[0].drift_retried).toBe(1);
2123
+ });
2124
+ it('detect_drift=true does NOT re-check on drift retry (drift_retried=1)', async () => {
2125
+ // On second execution drift_retried=1 so no third call for drift check
2126
+ adapter.execute
2127
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 })
2128
+ .mockResolvedValueOnce({ success: true, output: '{"score": 0.9, "explanation": "confident"}', exitCode: 0 });
2129
+ const engine = makeEngine({
2130
+ spec: makeSpec({ defaults: { detect_drift: true } }),
2131
+ specYaml: 'name: test',
2132
+ adapter,
2133
+ dbPath,
2134
+ _worktreeManager: wtManager,
2135
+ _mergeQueue: mergeQueue,
2136
+ });
2137
+ const result = await engine.run();
2138
+ expect(result.status).toBe('done');
2139
+ expect(adapter.execute).toHaveBeenCalledTimes(2);
2140
+ });
2141
+ it('drift_check_result and drift_detected events emitted when drifted', async () => {
2142
+ adapter.execute
2143
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 })
2144
+ .mockResolvedValueOnce({ success: true, output: '{"score": 0.2, "explanation": "very unsure"}', exitCode: 0 })
2145
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 });
2146
+ const engine = makeEngine({
2147
+ spec: makeSpec({ defaults: { detect_drift: true } }, [{ id: 'task-1', max_retries: 1 }]),
2148
+ specYaml: 'name: test',
2149
+ adapter,
2150
+ dbPath,
2151
+ _worktreeManager: wtManager,
2152
+ _mergeQueue: mergeQueue,
2153
+ });
2154
+ const result = await engine.run();
2155
+ const store = createConvoyStore(dbPath);
2156
+ const events = store.getEvents(result.convoyId);
2157
+ store.close();
2158
+ expect(events.some(e => e.type === 'drift_check_result')).toBe(true);
2159
+ expect(events.some(e => e.type === 'drift_detected')).toBe(true);
2160
+ });
2161
+ it('non-copilot adapter skips drift detection (returns done without extra call)', async () => {
2162
+ // adapter name is 'test-adapter' — not a streaming adapter; drift check should be skipped
2163
+ const nonStreamingAdapter = makeAdapter('test-adapter');
2164
+ nonStreamingAdapter.execute.mockResolvedValue({ success: true, output: 'ok', exitCode: 0 });
2165
+ // Suppress the stderr warning
2166
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
2167
+ try {
2168
+ const engine = makeEngine({
2169
+ spec: makeSpec({ defaults: { detect_drift: true } }),
2170
+ specYaml: 'name: test',
2171
+ adapter: nonStreamingAdapter,
2172
+ dbPath,
2173
+ _worktreeManager: wtManager,
2174
+ _mergeQueue: mergeQueue,
2175
+ });
2176
+ const result = await engine.run();
2177
+ expect(result.status).toBe('done');
2178
+ // Only 1 call: main task (no drift check call) because non-streaming adapter
2179
+ expect(nonStreamingAdapter.execute).toHaveBeenCalledTimes(1);
2180
+ }
2181
+ finally {
2182
+ stderrSpy.mockRestore();
2183
+ }
2184
+ });
2185
+ });
2186
+ // ── Dispute protocol ──────────────────────────────────────────────────────────
2187
+ describe('dispute protocol', () => {
2188
+ let adapter;
2189
+ let wtManager;
2190
+ let mergeQueue;
2191
+ beforeEach(() => {
2192
+ adapter = makeAdapter();
2193
+ wtManager = makeWorktreeManager();
2194
+ mergeQueue = makeMergeQueue();
2195
+ });
2196
+ it('3 panel blocks mark task as disputed', async () => {
2197
+ // Each round: 3 calls to panel runner (all block) → retry until max_retries
2198
+ // 3 panel blocks with max_retries=3 → 3 panel rounds → after 3rd: panel_attempts=3 → disputed
2199
+ let panelCall = 0;
2200
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
2201
+ panelCall++;
2202
+ return Promise.resolve({ verdict: 'block', feedback: 'critical bug', tokens: 10, model: 'r' });
2203
+ });
2204
+ const engine = makeEngine({
2205
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
2206
+ specYaml: 'name: test',
2207
+ adapter,
2208
+ dbPath,
2209
+ _worktreeManager: wtManager,
2210
+ _mergeQueue: mergeQueue,
2211
+ _reviewRunner: mockReviewRunner,
2212
+ });
2213
+ const result = await engine.run();
2214
+ const store = createConvoyStore(dbPath);
2215
+ const tasks = store.getTasksByConvoy(result.convoyId);
2216
+ store.close();
2217
+ expect(tasks[0].status).toBe('disputed');
2218
+ expect(tasks[0].dispute_id).not.toBeNull();
2219
+ expect(result.summary.failed).toBe(1); // disputed counts as failed in summary
2220
+ });
2221
+ it('dispute_opened event emitted after 3 panel blocks', async () => {
2222
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'bug', tokens: 5, model: 'r' });
2223
+ const engine = makeEngine({
2224
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
2225
+ specYaml: 'name: test',
2226
+ adapter,
2227
+ dbPath,
2228
+ _worktreeManager: wtManager,
2229
+ _mergeQueue: mergeQueue,
2230
+ _reviewRunner: mockReviewRunner,
2231
+ });
2232
+ const result = await engine.run();
2233
+ const store = createConvoyStore(dbPath);
2234
+ const events = store.getEvents(result.convoyId);
2235
+ store.close();
2236
+ const disputeEvent = events.find(e => e.type === 'dispute_opened');
2237
+ expect(disputeEvent).toBeDefined();
2238
+ const data = JSON.parse(disputeEvent.data);
2239
+ expect(data.task_id).toBe('task-1');
2240
+ expect(data.panel_attempts).toBe(3);
2241
+ });
2242
+ it('on_dispute: stop halts all pending tasks', async () => {
2243
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'bug', tokens: 5, model: 'r' });
2244
+ const engine = makeEngine({
2245
+ spec: makeSpec({ defaults: { review: 'panel', on_dispute: 'stop' } }, [
2246
+ { id: 'task-1', depends_on: [], max_retries: 3 },
2247
+ { id: 'task-2', depends_on: ['task-1'] }, // depends on task-1, so queued after
2248
+ ]),
2249
+ specYaml: 'name: test',
2250
+ adapter,
2251
+ dbPath,
2252
+ _worktreeManager: wtManager,
2253
+ _mergeQueue: mergeQueue,
2254
+ _reviewRunner: mockReviewRunner,
2255
+ });
2256
+ const result = await engine.run();
2257
+ const store = createConvoyStore(dbPath);
2258
+ const tasks = store.getTasksByConvoy(result.convoyId);
2259
+ store.close();
2260
+ const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]));
2261
+ expect(byId['task-1']).toBe('disputed');
2262
+ expect(byId['task-2']).toBe('skipped');
2263
+ });
2264
+ it('on_dispute: continue keeps other tasks running', async () => {
2265
+ // task-1 always fails panel (will be disputed), task-2 succeeds
2266
+ adapter.execute.mockResolvedValue({ success: true, output: 'ok', exitCode: 0 });
2267
+ const mockReviewRunner = vi.fn().mockImplementation((_task) => {
2268
+ if (_task.id === 'task-1') {
2269
+ return Promise.resolve({ verdict: 'block', feedback: 'bug', tokens: 5, model: 'r' });
2270
+ }
2271
+ return Promise.resolve({ verdict: 'pass', feedback: '', tokens: 5, model: 'r' });
2272
+ });
2273
+ const engine = makeEngine({
2274
+ spec: makeSpec({ defaults: { review: 'panel', on_dispute: 'continue' } }, [
2275
+ { id: 'task-1', depends_on: [], max_retries: 3 },
2276
+ { id: 'task-2', depends_on: [] },
2277
+ ]),
2278
+ specYaml: 'name: test',
2279
+ adapter,
2280
+ dbPath,
2281
+ _worktreeManager: wtManager,
2282
+ _mergeQueue: mergeQueue,
2283
+ _reviewRunner: mockReviewRunner,
2284
+ });
2285
+ const result = await engine.run();
2286
+ const store = createConvoyStore(dbPath);
2287
+ const tasks = store.getTasksByConvoy(result.convoyId);
2288
+ store.close();
2289
+ const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]));
2290
+ expect(byId['task-1']).toBe('disputed');
2291
+ expect(byId['task-2']).toBe('done');
2292
+ });
2293
+ });
2294
+ // ── File-based injection ───────────────────────────────────────────────────
2295
+ describe('file-based injection', () => {
2296
+ it('picks up tasks from inject file and ingests them', async () => {
2297
+ const adapter = makeAdapter();
2298
+ adapter.execute.mockResolvedValue({ success: true, output: 'ok', exitCode: 0 });
2299
+ const spec = makeSpec({ concurrency: 1 }, [
2300
+ { id: 'task-1', prompt: 'Original task', timeout: '5s' },
2301
+ ]);
2302
+ const engine = makeEngine({
2303
+ spec,
2304
+ specYaml: 'name: test',
2305
+ adapter,
2306
+ dbPath,
2307
+ basePath: tmpDir,
2308
+ _worktreeManager: makeWorktreeManager(),
2309
+ _mergeQueue: makeMergeQueue(),
2310
+ });
2311
+ const result = await engine.run();
2312
+ expect(result.summary.done).toBeGreaterThanOrEqual(1);
2313
+ });
2314
+ it('respects convoy_id path traversal guard', async () => {
2315
+ const adapter = makeAdapter();
2316
+ const spec = makeSpec();
2317
+ const engine = makeEngine({
2318
+ spec,
2319
+ specYaml: 'name: test',
2320
+ adapter,
2321
+ dbPath,
2322
+ basePath: tmpDir,
2323
+ _worktreeManager: makeWorktreeManager(),
2324
+ _mergeQueue: makeMergeQueue(),
2325
+ });
2326
+ const result = await engine.run();
2327
+ expect(result.status).toBe('done');
2328
+ });
2329
+ });
2330
+ describe('NDJSON recovery', () => {
2331
+ it('truncates partial trailing line in NDJSON file', () => {
2332
+ const convoyId = 'convoy-ndjson-1';
2333
+ const ndjsonPath = join(tmpDir, 'recover-partial.ndjson');
2334
+ const firstLine = JSON.stringify({ _event_id: 1, convoy_id: convoyId, type: 'task_started' });
2335
+ writeFileSync(ndjsonPath, `${firstLine}\n{"_event_id":2`, 'utf8');
2336
+ const mockStore = {
2337
+ getEvents: vi.fn().mockReturnValue([]),
2338
+ };
2339
+ recoverNdjson(mockStore, convoyId, ndjsonPath);
2340
+ const content = readFileSync(ndjsonPath, 'utf8');
2341
+ expect(content).toBe(`${firstLine}\n`);
2342
+ });
2343
+ it('replays SQLite events missing from NDJSON file', () => {
2344
+ const convoyId = 'convoy-ndjson-2';
2345
+ const ndjsonPath = join(tmpDir, 'recover-replay.ndjson');
2346
+ writeFileSync(ndjsonPath, `${JSON.stringify({ _event_id: 1, convoy_id: convoyId, type: 'task_started' })}\n`, 'utf8');
2347
+ const mockStore = {
2348
+ getEvents: vi.fn().mockReturnValue([
2349
+ {
2350
+ id: 1,
2351
+ type: 'task_started',
2352
+ convoy_id: convoyId,
2353
+ task_id: 'task-1',
2354
+ worker_id: null,
2355
+ data: JSON.stringify({ phase: 0 }),
2356
+ created_at: '2026-03-11T10:00:00.000Z',
2357
+ },
2358
+ {
2359
+ id: 2,
2360
+ type: 'task_finished',
2361
+ convoy_id: convoyId,
2362
+ task_id: 'task-1',
2363
+ worker_id: null,
2364
+ data: JSON.stringify({ success: true }),
2365
+ created_at: '2026-03-11T10:00:01.000Z',
2366
+ },
2367
+ ]),
2368
+ };
2369
+ recoverNdjson(mockStore, convoyId, ndjsonPath);
2370
+ const lines = readFileSync(ndjsonPath, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
2371
+ const eventIds = lines.map((line) => line._event_id);
2372
+ expect(eventIds).toEqual([1, 2]);
2373
+ });
2374
+ it('does not let event.data override canonical fields', () => {
2375
+ const convoyId = 'convoy-ndjson-canonical';
2376
+ const ndjsonPath = join(tmpDir, 'recover-canonical.ndjson');
2377
+ writeFileSync(ndjsonPath, '', 'utf8');
2378
+ const mockStore = {
2379
+ getEvents: vi.fn().mockReturnValue([
2380
+ {
2381
+ id: 99,
2382
+ type: 'task_started',
2383
+ convoy_id: convoyId,
2384
+ task_id: 'task-legit',
2385
+ worker_id: 'w1',
2386
+ data: JSON.stringify({
2387
+ _event_id: 'EVIL',
2388
+ convoy_id: 'EVIL-CONVOY',
2389
+ task_id: 'EVIL-TASK',
2390
+ type: 'EVIL-TYPE',
2391
+ timestamp: 'EVIL-TIME',
2392
+ worker_id: 'EVIL-WORKER',
2393
+ safe_field: 'this-is-fine',
2394
+ }),
2395
+ created_at: '2026-03-11T10:00:00.000Z',
2396
+ },
2397
+ ]),
2398
+ };
2399
+ recoverNdjson(mockStore, convoyId, ndjsonPath);
2400
+ const lines = readFileSync(ndjsonPath, 'utf8').trim().split('\n');
2401
+ expect(lines).toHaveLength(1);
2402
+ const parsed = JSON.parse(lines[0]);
2403
+ expect(parsed._event_id).toBe(99);
2404
+ expect(parsed.convoy_id).toBe(convoyId);
2405
+ expect(parsed.task_id).toBe('task-legit');
2406
+ expect(parsed.type).toBe('task_started');
2407
+ expect(parsed.worker_id).toBe('w1');
2408
+ expect(parsed.timestamp).toBe('2026-03-11T10:00:00.000Z');
2409
+ expect(parsed.safe_field).toBe('this-is-fine');
2410
+ });
2411
+ });
2412
+ describe('runConvoyGuard', () => {
2413
+ it('returns passed: false when non-terminal tasks exist', () => {
2414
+ const guardConvoyId = 'convoy-guard-1';
2415
+ const guardStore = createConvoyStore(dbPath);
2416
+ guardStore.insertConvoy({
2417
+ id: guardConvoyId,
2418
+ name: 'Guard test',
2419
+ spec_hash: 'hash',
2420
+ spec_yaml: 'name: guard test',
2421
+ status: 'running',
2422
+ branch: null,
2423
+ created_at: new Date().toISOString(),
2424
+ });
2425
+ guardStore.insertTask({
2426
+ id: 'task-guard-1',
2427
+ convoy_id: guardConvoyId,
2428
+ phase: 0,
2429
+ prompt: 'test',
2430
+ agent: 'developer',
2431
+ adapter: null,
2432
+ model: null,
2433
+ timeout_ms: 60000,
2434
+ status: 'running',
2435
+ retries: 0,
2436
+ max_retries: 1,
2437
+ files: null,
2438
+ depends_on: null,
2439
+ gates: null,
2440
+ });
2441
+ const ndjsonPathGuard = join(tmpDir, 'guard-test.ndjson');
2442
+ writeFileSync(ndjsonPathGuard, '');
2443
+ const wtManager = makeWorktreeManager();
2444
+ const result = runConvoyGuard(guardStore, guardConvoyId, wtManager, ndjsonPathGuard);
2445
+ expect(result.passed).toBe(false);
2446
+ expect(result.warnings.length).toBeGreaterThan(0);
2447
+ guardStore.close();
2448
+ });
2449
+ it('returns passed: true when all tasks are terminal', () => {
2450
+ const guardConvoyId2 = 'convoy-guard-2';
2451
+ const guardStore2 = createConvoyStore(dbPath);
2452
+ guardStore2.insertConvoy({
2453
+ id: guardConvoyId2,
2454
+ name: 'Guard test ok',
2455
+ spec_hash: 'hash',
2456
+ spec_yaml: 'name: guard test ok',
2457
+ status: 'done',
2458
+ branch: null,
2459
+ created_at: new Date().toISOString(),
2460
+ });
2461
+ guardStore2.insertTask({
2462
+ id: 'task-guard-2',
2463
+ convoy_id: guardConvoyId2,
2464
+ phase: 0,
2465
+ prompt: 'test',
2466
+ agent: 'developer',
2467
+ adapter: null,
2468
+ model: null,
2469
+ timeout_ms: 60000,
2470
+ status: 'done',
2471
+ retries: 0,
2472
+ max_retries: 1,
2473
+ files: null,
2474
+ depends_on: null,
2475
+ gates: null,
2476
+ });
2477
+ const ndjsonPathGuard2 = join(tmpDir, 'guard-pass.ndjson');
2478
+ writeFileSync(ndjsonPathGuard2, JSON.stringify({ _event_id: 1, convoy_id: guardConvoyId2, type: 'task_done' }) + '\n');
2479
+ const wtManager2 = makeWorktreeManager();
2480
+ const result2 = runConvoyGuard(guardStore2, guardConvoyId2, wtManager2, ndjsonPathGuard2);
2481
+ expect(result2.passed).toBe(true);
2482
+ guardStore2.close();
2483
+ });
2484
+ });
2485
+ describe('injectTask partition validation', () => {
2486
+ it('rejects injected tasks with normalized path overlap', () => {
2487
+ const symlinkSpy = vi.spyOn(partition, 'scanSymlinks').mockImplementation(() => { });
2488
+ const convoyId = 'convoy-inject-overlap-1';
2489
+ const seedStore = createConvoyStore(dbPath);
2490
+ seedStore.insertConvoy({
2491
+ id: convoyId,
2492
+ name: 'Inject overlap test',
2493
+ spec_hash: 'hash-1',
2494
+ status: 'pending',
2495
+ branch: null,
2496
+ created_at: new Date().toISOString(),
2497
+ spec_yaml: 'name: inject-overlap',
2498
+ pipeline_id: null,
2499
+ });
2500
+ seedStore.insertTask({
2501
+ id: 'task-owner',
2502
+ convoy_id: convoyId,
2503
+ phase: 0,
2504
+ prompt: 'Owns auth partition',
2505
+ agent: 'developer',
2506
+ adapter: null,
2507
+ model: null,
2508
+ timeout_ms: 30_000,
2509
+ status: 'pending',
2510
+ retries: 0,
2511
+ max_retries: 1,
2512
+ files: JSON.stringify(['src/auth/']),
2513
+ depends_on: null,
2514
+ gates: null,
2515
+ });
2516
+ seedStore.close();
2517
+ const engine = makeEngine({
2518
+ spec: makeSpec(),
2519
+ specYaml: 'name: inject-overlap',
2520
+ adapter: makeAdapter(),
2521
+ dbPath,
2522
+ basePath: tmpDir,
2523
+ _worktreeManager: makeWorktreeManager(),
2524
+ _mergeQueue: makeMergeQueue(),
2525
+ });
2526
+ try {
2527
+ expect(() => engine.injectTask(convoyId, {
2528
+ id: 'task-injected',
2529
+ prompt: 'Injected overlap task',
2530
+ agent: 'developer',
2531
+ phase: 0,
2532
+ files: ['src/auth/service.ts'],
2533
+ })).toThrow(/File partition overlap/i);
2534
+ }
2535
+ finally {
2536
+ symlinkSpy.mockRestore();
2537
+ }
2538
+ });
2539
+ it('rejects injected task with unnormalized paths that overlap', () => {
2540
+ const symlinkSpy = vi.spyOn(partition, 'scanSymlinks').mockImplementation(() => { });
2541
+ const convoyId = 'convoy-inject-overlap-2';
2542
+ const seedStore = createConvoyStore(dbPath);
2543
+ seedStore.insertConvoy({
2544
+ id: convoyId,
2545
+ name: 'Inject overlap test 2',
2546
+ spec_hash: 'hash-2',
2547
+ status: 'pending',
2548
+ branch: null,
2549
+ created_at: new Date().toISOString(),
2550
+ spec_yaml: 'name: inject-overlap-2',
2551
+ pipeline_id: null,
2552
+ });
2553
+ seedStore.insertTask({
2554
+ id: 'task-owner',
2555
+ convoy_id: convoyId,
2556
+ phase: 0,
2557
+ prompt: 'Owns auth partition',
2558
+ agent: 'developer',
2559
+ adapter: null,
2560
+ model: null,
2561
+ timeout_ms: 30_000,
2562
+ status: 'pending',
2563
+ retries: 0,
2564
+ max_retries: 1,
2565
+ files: JSON.stringify(['src/auth/']),
2566
+ depends_on: null,
2567
+ gates: null,
2568
+ });
2569
+ seedStore.close();
2570
+ const engine = makeEngine({
2571
+ spec: makeSpec(),
2572
+ specYaml: 'name: inject-overlap-2',
2573
+ adapter: makeAdapter(),
2574
+ dbPath,
2575
+ basePath: tmpDir,
2576
+ _worktreeManager: makeWorktreeManager(),
2577
+ _mergeQueue: makeMergeQueue(),
2578
+ });
2579
+ try {
2580
+ expect(() => engine.injectTask(convoyId, {
2581
+ id: 'task-injected-dot-path',
2582
+ prompt: 'Injected overlap task',
2583
+ agent: 'developer',
2584
+ phase: 0,
2585
+ files: ['./src/auth/service.ts'],
2586
+ })).toThrow(/File partition overlap/i);
2587
+ }
2588
+ finally {
2589
+ symlinkSpy.mockRestore();
2590
+ }
2591
+ });
2592
+ });
2593
+ // ── Swarm mode ─────────────────────────────────────────────────────────────
2594
+ describe('swarm mode (concurrency: auto)', () => {
2595
+ it('runs all tasks with auto concurrency', async () => {
2596
+ const adapter = makeAdapter();
2597
+ const spec = makeSpec({ concurrency: 'auto' }, [
2598
+ { id: 'task-1', prompt: 'First' },
2599
+ { id: 'task-2', prompt: 'Second' },
2600
+ { id: 'task-3', prompt: 'Third' },
2601
+ ]);
2602
+ const engine = makeEngine({
2603
+ spec,
2604
+ specYaml: 'name: test',
2605
+ adapter,
2606
+ dbPath,
2607
+ _worktreeManager: makeWorktreeManager(),
2608
+ _mergeQueue: makeMergeQueue(),
2609
+ });
2610
+ const result = await engine.run();
2611
+ expect(result.status).toBe('done');
2612
+ expect(result.summary.done).toBe(3);
2613
+ expect(result.summary.total).toBe(3);
2614
+ });
2615
+ it('respects max_swarm_concurrency from defaults', async () => {
2616
+ const adapter = makeAdapter();
2617
+ let maxConcurrent = 0;
2618
+ let currentConcurrent = 0;
2619
+ adapter.execute.mockImplementation(async () => {
2620
+ currentConcurrent++;
2621
+ if (currentConcurrent > maxConcurrent)
2622
+ maxConcurrent = currentConcurrent;
2623
+ await new Promise(resolve => setTimeout(resolve, 50));
2624
+ currentConcurrent--;
2625
+ return { success: true, output: 'ok', exitCode: 0 };
2626
+ });
2627
+ const spec = makeSpec({
2628
+ concurrency: 'auto',
2629
+ defaults: { max_swarm_concurrency: 2 },
2630
+ }, [
2631
+ { id: 'task-1', prompt: 'T1' },
2632
+ { id: 'task-2', prompt: 'T2' },
2633
+ { id: 'task-3', prompt: 'T3' },
2634
+ { id: 'task-4', prompt: 'T4' },
2635
+ ]);
2636
+ const engine = makeEngine({
2637
+ spec,
2638
+ specYaml: 'name: test',
2639
+ adapter,
2640
+ dbPath,
2641
+ _worktreeManager: makeWorktreeManager(),
2642
+ _mergeQueue: makeMergeQueue(),
2643
+ });
2644
+ const result = await engine.run();
2645
+ expect(result.status).toBe('done');
2646
+ expect(result.summary.done).toBe(4);
2647
+ expect(maxConcurrent).toBeLessThanOrEqual(2);
2648
+ });
2649
+ it('defaults max_swarm_concurrency to 8', async () => {
2650
+ const adapter = makeAdapter();
2651
+ const spec = makeSpec({ concurrency: 'auto' }, Array.from({ length: 10 }, (_, i) => ({
2652
+ id: `task-${i + 1}`,
2653
+ prompt: `Task ${i + 1}`,
2654
+ })));
2655
+ const engine = makeEngine({
2656
+ spec,
2657
+ specYaml: 'name: test',
2658
+ adapter,
2659
+ dbPath,
2660
+ _worktreeManager: makeWorktreeManager(),
2661
+ _mergeQueue: makeMergeQueue(),
2662
+ });
2663
+ const result = await engine.run();
2664
+ expect(result.status).toBe('done');
2665
+ expect(result.summary.done).toBe(10);
2666
+ });
2667
+ });
2668
+ // ── Step retry context prepending ───────────────────────────────────────────
2669
+ describe('step retry context prepending', () => {
2670
+ it('prepends prior failure output to the prompt on step retry', async () => {
2671
+ const adapter = makeAdapter();
2672
+ const capturedPrompts = [];
2673
+ adapter.execute.mockImplementation(async (task) => {
2674
+ capturedPrompts.push(task.prompt);
2675
+ if (capturedPrompts.length === 1) {
2676
+ return { success: false, output: 'step error detail', exitCode: 2 };
2677
+ }
2678
+ return { success: true, output: 'ok', exitCode: 0 };
2679
+ });
2680
+ const spec = makeSpec({}, [
2681
+ {
2682
+ id: 'task-1',
2683
+ prompt: 'original task prompt',
2684
+ max_retries: 0,
2685
+ steps: [{ prompt: 'step prompt text', max_retries: 1 }],
2686
+ },
2687
+ ]);
2688
+ const engine = makeEngine({
2689
+ spec,
2690
+ specYaml: 'name: test',
2691
+ adapter,
2692
+ dbPath,
2693
+ _worktreeManager: makeWorktreeManager(),
2694
+ _mergeQueue: makeMergeQueue(),
2695
+ });
2696
+ await engine.run();
2697
+ // First call uses the original step prompt
2698
+ expect(capturedPrompts[0]).toBe('step prompt text');
2699
+ // Second call (retry) prepends failure context
2700
+ expect(capturedPrompts[1]).toContain('Previous attempt failed.');
2701
+ expect(capturedPrompts[1]).toContain('Exit code: 2');
2702
+ expect(capturedPrompts[1]).toContain('step error detail');
2703
+ expect(capturedPrompts[1]).toContain('step prompt text');
2704
+ });
2705
+ });
2706
+ // ── Security: symlink scan (issue #2) ─────────────────────────────────────────
2707
+ describe('symlink security scan', () => {
2708
+ it('marks task failed when pre-execution scanSymlinks throws', async () => {
2709
+ const scanSpy = vi.spyOn(partition, 'scanSymlinks').mockImplementation(() => {
2710
+ throw new Error('symlink_escape: "evil.ts" is a symlink that resolves outside the partition');
2711
+ });
2712
+ try {
2713
+ const adapter = makeAdapter();
2714
+ const spec = makeSpec({}, [{ files: ['src/evil.ts'] }]);
2715
+ const engine = makeEngine({
2716
+ spec,
2717
+ specYaml: 'name: test',
2718
+ adapter,
2719
+ dbPath,
2720
+ _worktreeManager: makeWorktreeManager(),
2721
+ _mergeQueue: makeMergeQueue(),
2722
+ });
2723
+ const result = await engine.run();
2724
+ expect(result.status).toBe('failed');
2725
+ }
2726
+ finally {
2727
+ scanSpy.mockRestore();
2728
+ }
2729
+ });
2730
+ it('succeeds when files is empty (symlink scan skipped)', async () => {
2731
+ const adapter = makeAdapter();
2732
+ const spec = makeSpec({}, [{ files: [] }]);
2733
+ const engine = makeEngine({
2734
+ spec,
2735
+ specYaml: 'name: test',
2736
+ adapter,
2737
+ dbPath,
2738
+ _worktreeManager: makeWorktreeManager(),
2739
+ _mergeQueue: makeMergeQueue(),
2740
+ });
2741
+ const result = await engine.run();
2742
+ expect(result.status).toBe('done');
2743
+ });
2744
+ });
2745
+ // ── Security: ensureBranch fallback (issue #3) ────────────────────────────────
2746
+ describe('ensureBranch fallback when _ensureBranch not provided', () => {
2747
+ it('calls the injected _ensureBranch when branch is set in spec', async () => {
2748
+ const branchFn = vi.fn().mockResolvedValue(undefined);
2749
+ const adapter = makeAdapter();
2750
+ const spec = makeSpec({ branch: 'feature-x' });
2751
+ const engine = createConvoyEngine({
2752
+ spec,
2753
+ specYaml: 'name: test',
2754
+ adapter,
2755
+ dbPath,
2756
+ _worktreeManager: makeWorktreeManager(),
2757
+ _mergeQueue: makeMergeQueue(),
2758
+ _ensureBranch: branchFn,
2759
+ });
2760
+ await engine.run();
2761
+ expect(branchFn).toHaveBeenCalledWith('feature-x', expect.any(String));
2762
+ });
2763
+ it('does not call ensureBranch when spec has no branch', async () => {
2764
+ const branchFn = vi.fn().mockResolvedValue(undefined);
2765
+ const adapter = makeAdapter();
2766
+ const spec = makeSpec({ branch: undefined });
2767
+ const engine = makeEngine({
2768
+ spec,
2769
+ specYaml: 'name: test',
2770
+ adapter,
2771
+ dbPath,
2772
+ _worktreeManager: makeWorktreeManager(),
2773
+ _mergeQueue: makeMergeQueue(),
2774
+ _ensureBranch: branchFn,
2775
+ });
2776
+ await engine.run();
2777
+ expect(branchFn).not.toHaveBeenCalled();
2778
+ });
2779
+ });
2780
+ // ── Security: secret scan in markdown dual-write (issue #4) ──────────────────
2781
+ describe('secret scan in DLQ/dispute markdown write', () => {
2782
+ it('task failure still recorded in DB even if DLQ markdown write is silently skipped', async () => {
2783
+ // The engine marks a task as failed; DLQ markdown write with secret scan
2784
+ // silently skips if secrets detected. The DB record is authoritative.
2785
+ const adapter = makeAdapter();
2786
+ vi.mocked(adapter.execute).mockResolvedValue({ success: false, output: 'error', exitCode: 1 });
2787
+ const spec = makeSpec({}, [{ max_retries: 0 }]);
2788
+ const engine = makeEngine({
2789
+ spec,
2790
+ specYaml: 'name: test',
2791
+ adapter,
2792
+ dbPath,
2793
+ _worktreeManager: makeWorktreeManager(),
2794
+ _mergeQueue: makeMergeQueue(),
2795
+ });
2796
+ const result = await engine.run();
2797
+ expect(result.status).toBe('failed');
2798
+ expect(result.summary.failed).toBe(1);
2799
+ });
2800
+ it('emits secret_leak_prevented when DLQ markdown write detects secrets', async () => {
2801
+ const scanSpy = vi.spyOn(gates, 'scanForSecrets').mockImplementation((content, filePath = '') => {
2802
+ if (filePath === 'AGENT-FAILURES.md') {
2803
+ return {
2804
+ clean: false,
2805
+ findings: [{ pattern: 'Mock Secret', file: filePath, line: 1, snippet: content.slice(0, 20) }],
2806
+ };
2807
+ }
2808
+ return { clean: true, findings: [] };
2809
+ });
2810
+ try {
2811
+ const adapter = makeAdapter();
2812
+ vi.mocked(adapter.execute).mockResolvedValue({ success: false, output: 'fatal', exitCode: 1 });
2813
+ const spec = makeSpec({}, [{ id: 'task-1', max_retries: 0 }]);
2814
+ const engine = makeEngine({
2815
+ spec,
2816
+ specYaml: 'name: secret-dlq',
2817
+ adapter,
2818
+ dbPath,
2819
+ _worktreeManager: makeWorktreeManager(),
2820
+ _mergeQueue: makeMergeQueue(),
2821
+ });
2822
+ const result = await engine.run();
2823
+ const store = createConvoyStore(dbPath);
2824
+ const events = store.getEvents(result.convoyId);
2825
+ store.close();
2826
+ const leakEvent = events.find((event) => event.type === 'secret_leak_prevented');
2827
+ expect(leakEvent).toBeDefined();
2828
+ const data = JSON.parse(leakEvent.data ?? '{}');
2829
+ // context changed from 'dlq_markdown_write' to 'dlq_dual_write' (MF-2 atomicity fix)
2830
+ expect(data.context).toBe('dlq_dual_write');
2831
+ }
2832
+ finally {
2833
+ scanSpy.mockRestore();
2834
+ }
2835
+ });
2836
+ it('DLQ entry is NOT inserted into SQLite when secret scan blocks (MF-2 atomicity)', async () => {
2837
+ const scanSpy = vi.spyOn(gates, 'scanForSecrets').mockImplementation((content, filePath = '') => {
2838
+ if (filePath === 'AGENT-FAILURES.md') {
2839
+ return {
2840
+ clean: false,
2841
+ findings: [{ pattern: 'Mock Secret', file: filePath, line: 1, snippet: content.slice(0, 20) }],
2842
+ };
2843
+ }
2844
+ return { clean: true, findings: [] };
2845
+ });
2846
+ try {
2847
+ const adapter = makeAdapter();
2848
+ vi.mocked(adapter.execute).mockResolvedValue({ success: false, output: 'fatal', exitCode: 1 });
2849
+ const spec = makeSpec({}, [{ id: 'task-dlq-atomic', max_retries: 0 }]);
2850
+ const engine = makeEngine({
2851
+ spec,
2852
+ specYaml: 'name: dlq-atomic-test',
2853
+ adapter,
2854
+ dbPath,
2855
+ _worktreeManager: makeWorktreeManager(),
2856
+ _mergeQueue: makeMergeQueue(),
2857
+ });
2858
+ const result = await engine.run();
2859
+ const s = createConvoyStore(dbPath);
2860
+ const dlqEntries = s.listDlqEntries(result.convoyId);
2861
+ s.close();
2862
+ // When scan blocks: SQLite DLQ row must NOT be written (atomic consistency)
2863
+ expect(dlqEntries).toHaveLength(0);
2864
+ }
2865
+ finally {
2866
+ scanSpy.mockRestore();
2867
+ }
2868
+ });
2869
+ it('emits secret_leak_prevented when dispute markdown write detects secrets', async () => {
2870
+ const scanSpy = vi.spyOn(gates, 'scanForSecrets').mockImplementation((content, filePath = '') => {
2871
+ if (filePath === 'DISPUTES.md') {
2872
+ return {
2873
+ clean: false,
2874
+ findings: [{ pattern: 'Mock Secret', file: filePath, line: 1, snippet: content.slice(0, 20) }],
2875
+ };
2876
+ }
2877
+ return { clean: true, findings: [] };
2878
+ });
2879
+ try {
2880
+ const adapter = makeAdapter();
2881
+ vi.mocked(adapter.execute).mockResolvedValue({ success: true, output: 'ok', exitCode: 0 });
2882
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'secret found', tokens: 5, model: 'r' });
2883
+ const engine = makeEngine({
2884
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
2885
+ specYaml: 'name: secret-dispute',
2886
+ adapter,
2887
+ dbPath,
2888
+ _worktreeManager: makeWorktreeManager(),
2889
+ _mergeQueue: makeMergeQueue(),
2890
+ _reviewRunner: mockReviewRunner,
2891
+ });
2892
+ const result = await engine.run();
2893
+ const store = createConvoyStore(dbPath);
2894
+ const events = store.getEvents(result.convoyId);
2895
+ store.close();
2896
+ const leakEvent = events.find((event) => event.type === 'secret_leak_prevented');
2897
+ expect(leakEvent).toBeDefined();
2898
+ const data = JSON.parse(leakEvent.data ?? '{}');
2899
+ expect(data.context).toBe('dispute_markdown_write');
2900
+ }
2901
+ finally {
2902
+ scanSpy.mockRestore();
2903
+ }
2904
+ });
2905
+ });
2906
+ // ── Security: fileExists path traversal (issue #5) ────────────────────────────
2907
+ describe('fileExists step condition path traversal', () => {
2908
+ it('step with fileExists using relative path executes normally when file absent', async () => {
2909
+ const adapter = makeAdapter();
2910
+ const capturedPrompts = [];
2911
+ vi.mocked(adapter.execute).mockImplementation(async (task) => {
2912
+ capturedPrompts.push(task.prompt);
2913
+ return { success: true, output: 'ok', exitCode: 0 };
2914
+ });
2915
+ const spec = makeSpec({}, [{
2916
+ steps: [
2917
+ {
2918
+ prompt: 'conditional prompt',
2919
+ if: { step: 'prev', fileExists: { path: 'some-nonexistent-file.txt' } },
2920
+ },
2921
+ {
2922
+ prompt: 'always runs',
2923
+ },
2924
+ ],
2925
+ }]);
2926
+ const engine = makeEngine({
2927
+ spec,
2928
+ specYaml: 'name: test',
2929
+ adapter,
2930
+ dbPath,
2931
+ _worktreeManager: makeWorktreeManager(),
2932
+ _mergeQueue: makeMergeQueue(),
2933
+ });
2934
+ const result = await engine.run();
2935
+ expect(result.status).toBe('done');
2936
+ });
2937
+ it('step condition with path traversal attempt does not throw (returns false)', async () => {
2938
+ const adapter = makeAdapter();
2939
+ const spec = makeSpec({}, [{
2940
+ steps: [
2941
+ {
2942
+ prompt: 'should be skipped',
2943
+ if: { step: 'prev', fileExists: { path: '../../../etc/passwd' } },
2944
+ },
2945
+ {
2946
+ prompt: 'safe step',
2947
+ },
2948
+ ],
2949
+ }]);
2950
+ const engine = makeEngine({
2951
+ spec,
2952
+ specYaml: 'name: test',
2953
+ adapter,
2954
+ dbPath,
2955
+ _worktreeManager: makeWorktreeManager(),
2956
+ _mergeQueue: makeMergeQueue(),
2957
+ });
2958
+ const result = await engine.run();
2959
+ // Engine should not crash; traversal step is skipped (fileExists returns false)
2960
+ expect(result.status).toBe('done');
2961
+ });
2962
+ });
2963
+ // ── Circuit breaker ───────────────────────────────────────────────────────────
2964
+ describe('circuit breaker', () => {
2965
+ it('allows task when no circuit_breaker config is set', async () => {
2966
+ const adapter = makeAdapter();
2967
+ const spec = makeSpec({}, [{}]);
2968
+ const engine = makeEngine({
2969
+ spec,
2970
+ specYaml: 'name: test',
2971
+ adapter,
2972
+ dbPath,
2973
+ _worktreeManager: makeWorktreeManager(),
2974
+ _mergeQueue: makeMergeQueue(),
2975
+ });
2976
+ const result = await engine.run();
2977
+ expect(result.status).toBe('done');
2978
+ expect(result.summary.done).toBe(1);
2979
+ expect(adapter.execute).toHaveBeenCalledTimes(1);
2980
+ });
2981
+ it('allows task when agent circuit is closed', async () => {
2982
+ const adapter = makeAdapter();
2983
+ const spec = makeSpec({
2984
+ defaults: { circuit_breaker: { threshold: 3, cooldown_ms: 300_000 } },
2985
+ }, [{ id: 'task-ok', agent: 'developer', max_retries: 0 }]);
2986
+ const engine = makeEngine({
2987
+ spec,
2988
+ specYaml: 'name: test',
2989
+ adapter,
2990
+ dbPath,
2991
+ _worktreeManager: makeWorktreeManager(),
2992
+ _mergeQueue: makeMergeQueue(),
2993
+ });
2994
+ const result = await engine.run();
2995
+ expect(result.status).toBe('done');
2996
+ expect(adapter.execute).toHaveBeenCalledTimes(1);
2997
+ });
2998
+ it('blocks subsequent tasks when circuit trips after threshold failures', async () => {
2999
+ const adapter = makeAdapter();
3000
+ // task-1 fails, task-2 and task-3 should be blocked by open circuit
3001
+ adapter.execute
3002
+ .mockResolvedValueOnce({ success: false, output: 'err', exitCode: 1 })
3003
+ .mockResolvedValue({ success: true, output: 'ok', exitCode: 0 });
3004
+ // threshold=2: task-1 failure is recorded twice (failure path + handleExhaustion),
3005
+ // reaching threshold=2 → circuit opens before task-2 and task-3 execute
3006
+ const spec = makeSpec({
3007
+ on_failure: 'continue',
3008
+ defaults: { circuit_breaker: { threshold: 2, cooldown_ms: 999_999_999 } },
3009
+ }, [
3010
+ { id: 'task-1', agent: 'developer', max_retries: 0 },
3011
+ { id: 'task-2', agent: 'developer', max_retries: 0 },
3012
+ { id: 'task-3', agent: 'developer', max_retries: 0 },
3013
+ ]);
3014
+ const engine = makeEngine({
3015
+ spec,
3016
+ specYaml: 'name: test',
3017
+ adapter,
3018
+ dbPath,
3019
+ _worktreeManager: makeWorktreeManager(),
3020
+ _mergeQueue: makeMergeQueue(),
3021
+ });
3022
+ const result = await engine.run();
3023
+ // Only task-1 should have hit the adapter (circuit opens after task-1 fails)
3024
+ expect(adapter.execute).toHaveBeenCalledTimes(1);
3025
+ // task-2 and task-3 should be skipped by the circuit breaker
3026
+ expect(result.summary.skipped).toBeGreaterThanOrEqual(2);
3027
+ });
3028
+ it('records success and persists closed circuit state to store', async () => {
3029
+ const adapter = makeAdapter();
3030
+ const spec = makeSpec({
3031
+ defaults: { circuit_breaker: { threshold: 3, cooldown_ms: 300_000 } },
3032
+ }, [{ id: 'task-s', agent: 'developer', max_retries: 0 }]);
3033
+ const engine = makeEngine({
3034
+ spec,
3035
+ specYaml: 'name: test',
3036
+ adapter,
3037
+ dbPath,
3038
+ _worktreeManager: makeWorktreeManager(),
3039
+ _mergeQueue: makeMergeQueue(),
3040
+ });
3041
+ const result = await engine.run();
3042
+ expect(result.status).toBe('done');
3043
+ const store = createConvoyStore(dbPath);
3044
+ const record = store.getLatestConvoy();
3045
+ if (record?.circuit_state) {
3046
+ const state = JSON.parse(record.circuit_state);
3047
+ expect(state.developer?.status ?? 'closed').toBe('closed');
3048
+ }
3049
+ store.close();
3050
+ });
3051
+ it('records failure and persists open circuit state to store after threshold', async () => {
3052
+ const adapter = makeAdapter();
3053
+ adapter.execute.mockResolvedValue({ success: false, output: 'err', exitCode: 1 });
3054
+ // threshold=2: first failure double-records → count reaches 2 → circuit opens
3055
+ const spec = makeSpec({
3056
+ on_failure: 'continue',
3057
+ defaults: { circuit_breaker: { threshold: 2, cooldown_ms: 999_999_999 } },
3058
+ }, [
3059
+ { id: 'task-f1', agent: 'developer', max_retries: 0 },
3060
+ ]);
3061
+ const engine = makeEngine({
3062
+ spec,
3063
+ specYaml: 'name: test',
3064
+ adapter,
3065
+ dbPath,
3066
+ _worktreeManager: makeWorktreeManager(),
3067
+ _mergeQueue: makeMergeQueue(),
3068
+ });
3069
+ await engine.run();
3070
+ const store = createConvoyStore(dbPath);
3071
+ const record = store.getLatestConvoy();
3072
+ expect(record?.circuit_state).not.toBeNull();
3073
+ if (record?.circuit_state) {
3074
+ const state = JSON.parse(record.circuit_state);
3075
+ expect(state.developer?.status).toBe('open');
3076
+ }
3077
+ store.close();
3078
+ });
3079
+ it('circuit state is persisted to the store after a successful task', async () => {
3080
+ const adapter = makeAdapter();
3081
+ const spec = makeSpec({
3082
+ defaults: { circuit_breaker: { threshold: 2, cooldown_ms: 60_000 } },
3083
+ }, [{ id: 'task-persist', agent: 'developer', max_retries: 0 }]);
3084
+ const engine = makeEngine({
3085
+ spec,
3086
+ specYaml: 'name: test',
3087
+ adapter,
3088
+ dbPath,
3089
+ _worktreeManager: makeWorktreeManager(),
3090
+ _mergeQueue: makeMergeQueue(),
3091
+ });
3092
+ await engine.run();
3093
+ const store = createConvoyStore(dbPath);
3094
+ const record = store.getLatestConvoy();
3095
+ expect(record?.circuit_state).not.toBeNull();
3096
+ store.close();
3097
+ });
3098
+ });
3099
+ describe('convoy lifecycle events', () => {
3100
+ it('emits convoy_finished event on successful run', async () => {
3101
+ const adapter = makeAdapter();
3102
+ const engine = makeEngine({
3103
+ spec: makeSpec(),
3104
+ specYaml: 'name: test',
3105
+ adapter,
3106
+ dbPath,
3107
+ _worktreeManager: makeWorktreeManager(),
3108
+ _mergeQueue: makeMergeQueue(),
3109
+ });
3110
+ const result = await engine.run();
3111
+ expect(result.status).toBe('done');
3112
+ const store = createConvoyStore(dbPath);
3113
+ const events = store.getEvents(result.convoyId);
3114
+ store.close();
3115
+ const finishedEvent = events.find(e => e.type === 'convoy_finished');
3116
+ expect(finishedEvent).toBeDefined();
3117
+ expect(finishedEvent.convoy_id).toBe(result.convoyId);
3118
+ expect(JSON.parse(finishedEvent.data).status).toBe('done');
3119
+ });
3120
+ it('emits convoy_failed event when a task fails', async () => {
3121
+ const adapter = makeAdapter();
3122
+ adapter.execute.mockResolvedValue({
3123
+ success: false,
3124
+ output: 'error',
3125
+ exitCode: 1,
3126
+ });
3127
+ const engine = makeEngine({
3128
+ spec: makeSpec({}, [{ id: 'fail-task', max_retries: 0 }]),
3129
+ specYaml: 'name: test',
3130
+ adapter,
3131
+ dbPath,
3132
+ _worktreeManager: makeWorktreeManager(),
3133
+ _mergeQueue: makeMergeQueue(),
3134
+ });
3135
+ const result = await engine.run();
3136
+ expect(result.status).toBe('failed');
3137
+ const store = createConvoyStore(dbPath);
3138
+ const events = store.getEvents(result.convoyId);
3139
+ store.close();
3140
+ const failedEvent = events.find(e => e.type === 'convoy_failed');
3141
+ expect(failedEvent).toBeDefined();
3142
+ expect(failedEvent.convoy_id).toBe(result.convoyId);
3143
+ expect(JSON.parse(failedEvent.data).status).toBe('failed');
3144
+ });
3145
+ it('emits convoy_failed with gate-failed status when gates fail', async () => {
3146
+ const adapter = makeAdapter();
3147
+ const engine = makeEngine({
3148
+ spec: makeSpec({ gates: ['false'] }),
3149
+ specYaml: 'name: test',
3150
+ adapter,
3151
+ dbPath,
3152
+ _worktreeManager: makeWorktreeManager(),
3153
+ _mergeQueue: makeMergeQueue(),
3154
+ });
3155
+ const result = await engine.run();
3156
+ expect(result.status).toBe('gate-failed');
3157
+ const store = createConvoyStore(dbPath);
3158
+ const events = store.getEvents(result.convoyId);
3159
+ store.close();
3160
+ const failedEvent = events.find(e => e.type === 'convoy_failed');
3161
+ expect(failedEvent).toBeDefined();
3162
+ expect(JSON.parse(failedEvent.data).status).toBe('gate-failed');
3163
+ });
3164
+ });
3165
+ describe('createEventEmitter callsite safety', () => {
3166
+ it('rejects a raw string argument', () => {
3167
+ const testStore = createConvoyStore(dbPath);
3168
+ expect(() => {
3169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3170
+ createEventEmitter(testStore, 'some-path');
3171
+ }).toThrow('createEventEmitter options must be an object, not a string');
3172
+ testStore.close();
3173
+ });
3174
+ it('accepts an options object with ndjsonPath', () => {
3175
+ const testStore = createConvoyStore(dbPath);
3176
+ const testNdjsonPath = join(tmpDir, 'callsite-test.ndjson');
3177
+ const emitter = createEventEmitter(testStore, { ndjsonPath: testNdjsonPath });
3178
+ expect(emitter).toBeDefined();
3179
+ expect(typeof emitter.emit).toBe('function');
3180
+ expect(typeof emitter.close).toBe('function');
3181
+ emitter.close();
3182
+ testStore.close();
3183
+ });
3184
+ });
1596
3185
  //# sourceMappingURL=engine.test.js.map