opencastle 0.26.1 → 0.27.1

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