opencastle 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/bin/cli.mjs +6 -0
  2. package/dist/cli/agents.d.ts +3 -0
  3. package/dist/cli/agents.d.ts.map +1 -0
  4. package/dist/cli/agents.js +161 -0
  5. package/dist/cli/agents.js.map +1 -0
  6. package/dist/cli/baselines.d.ts +3 -0
  7. package/dist/cli/baselines.d.ts.map +1 -0
  8. package/dist/cli/baselines.js +128 -0
  9. package/dist/cli/baselines.js.map +1 -0
  10. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  11. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  12. package/dist/cli/convoy/dashboard-types.js +2 -0
  13. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  14. package/dist/cli/convoy/engine.d.ts +67 -2
  15. package/dist/cli/convoy/engine.d.ts.map +1 -1
  16. package/dist/cli/convoy/engine.js +2036 -28
  17. package/dist/cli/convoy/engine.js.map +1 -1
  18. package/dist/cli/convoy/engine.test.js +1659 -70
  19. package/dist/cli/convoy/engine.test.js.map +1 -1
  20. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  21. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  22. package/dist/cli/convoy/event-schemas.js +185 -0
  23. package/dist/cli/convoy/event-schemas.js.map +1 -0
  24. package/dist/cli/convoy/events.d.ts +12 -1
  25. package/dist/cli/convoy/events.d.ts.map +1 -1
  26. package/dist/cli/convoy/events.js +186 -13
  27. package/dist/cli/convoy/events.js.map +1 -1
  28. package/dist/cli/convoy/events.test.js +325 -28
  29. package/dist/cli/convoy/events.test.js.map +1 -1
  30. package/dist/cli/convoy/expertise.d.ts +16 -0
  31. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  32. package/dist/cli/convoy/expertise.js +121 -0
  33. package/dist/cli/convoy/expertise.js.map +1 -0
  34. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  35. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  36. package/dist/cli/convoy/expertise.test.js +96 -0
  37. package/dist/cli/convoy/expertise.test.js.map +1 -0
  38. package/dist/cli/convoy/export.test.js +1 -0
  39. package/dist/cli/convoy/export.test.js.map +1 -1
  40. package/dist/cli/convoy/formula.d.ts +19 -0
  41. package/dist/cli/convoy/formula.d.ts.map +1 -0
  42. package/dist/cli/convoy/formula.js +142 -0
  43. package/dist/cli/convoy/formula.js.map +1 -0
  44. package/dist/cli/convoy/formula.test.d.ts +2 -0
  45. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  46. package/dist/cli/convoy/formula.test.js +342 -0
  47. package/dist/cli/convoy/formula.test.js.map +1 -0
  48. package/dist/cli/convoy/gates.d.ts +128 -0
  49. package/dist/cli/convoy/gates.d.ts.map +1 -0
  50. package/dist/cli/convoy/gates.js +606 -0
  51. package/dist/cli/convoy/gates.js.map +1 -0
  52. package/dist/cli/convoy/gates.test.d.ts +2 -0
  53. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  54. package/dist/cli/convoy/gates.test.js +976 -0
  55. package/dist/cli/convoy/gates.test.js.map +1 -0
  56. package/dist/cli/convoy/health.d.ts +11 -0
  57. package/dist/cli/convoy/health.d.ts.map +1 -1
  58. package/dist/cli/convoy/health.js +54 -0
  59. package/dist/cli/convoy/health.js.map +1 -1
  60. package/dist/cli/convoy/health.test.js +56 -1
  61. package/dist/cli/convoy/health.test.js.map +1 -1
  62. package/dist/cli/convoy/issues.d.ts +8 -0
  63. package/dist/cli/convoy/issues.d.ts.map +1 -0
  64. package/dist/cli/convoy/issues.js +98 -0
  65. package/dist/cli/convoy/issues.js.map +1 -0
  66. package/dist/cli/convoy/issues.test.d.ts +2 -0
  67. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  68. package/dist/cli/convoy/issues.test.js +107 -0
  69. package/dist/cli/convoy/issues.test.js.map +1 -0
  70. package/dist/cli/convoy/knowledge.d.ts +5 -0
  71. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  72. package/dist/cli/convoy/knowledge.js +116 -0
  73. package/dist/cli/convoy/knowledge.js.map +1 -0
  74. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  75. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  76. package/dist/cli/convoy/knowledge.test.js +87 -0
  77. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  78. package/dist/cli/convoy/lessons.d.ts +17 -0
  79. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  80. package/dist/cli/convoy/lessons.js +149 -0
  81. package/dist/cli/convoy/lessons.js.map +1 -0
  82. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  83. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  84. package/dist/cli/convoy/lessons.test.js +135 -0
  85. package/dist/cli/convoy/lessons.test.js.map +1 -0
  86. package/dist/cli/convoy/lock.d.ts +13 -0
  87. package/dist/cli/convoy/lock.d.ts.map +1 -0
  88. package/dist/cli/convoy/lock.js +88 -0
  89. package/dist/cli/convoy/lock.js.map +1 -0
  90. package/dist/cli/convoy/lock.test.d.ts +2 -0
  91. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  92. package/dist/cli/convoy/lock.test.js +136 -0
  93. package/dist/cli/convoy/lock.test.js.map +1 -0
  94. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  95. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  96. package/dist/cli/convoy/log-merge.test.js +147 -0
  97. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  98. package/dist/cli/convoy/merge.d.ts +4 -0
  99. package/dist/cli/convoy/merge.d.ts.map +1 -1
  100. package/dist/cli/convoy/merge.js +18 -1
  101. package/dist/cli/convoy/merge.js.map +1 -1
  102. package/dist/cli/convoy/merge.test.js +6 -7
  103. package/dist/cli/convoy/merge.test.js.map +1 -1
  104. package/dist/cli/convoy/partition.d.ts +51 -0
  105. package/dist/cli/convoy/partition.d.ts.map +1 -0
  106. package/dist/cli/convoy/partition.js +186 -0
  107. package/dist/cli/convoy/partition.js.map +1 -0
  108. package/dist/cli/convoy/partition.test.d.ts +2 -0
  109. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  110. package/dist/cli/convoy/partition.test.js +315 -0
  111. package/dist/cli/convoy/partition.test.js.map +1 -0
  112. package/dist/cli/convoy/pipeline.test.js +6 -0
  113. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  114. package/dist/cli/convoy/store.d.ts +99 -7
  115. package/dist/cli/convoy/store.d.ts.map +1 -1
  116. package/dist/cli/convoy/store.js +764 -31
  117. package/dist/cli/convoy/store.js.map +1 -1
  118. package/dist/cli/convoy/store.test.js +1810 -18
  119. package/dist/cli/convoy/store.test.js.map +1 -1
  120. package/dist/cli/convoy/types.d.ts +427 -5
  121. package/dist/cli/convoy/types.d.ts.map +1 -1
  122. package/dist/cli/convoy/types.js +42 -1
  123. package/dist/cli/convoy/types.js.map +1 -1
  124. package/dist/cli/log.d.ts +11 -0
  125. package/dist/cli/log.d.ts.map +1 -1
  126. package/dist/cli/log.js +114 -2
  127. package/dist/cli/log.js.map +1 -1
  128. package/dist/cli/run/adapters/claude.d.ts +2 -0
  129. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  130. package/dist/cli/run/adapters/claude.js +89 -49
  131. package/dist/cli/run/adapters/claude.js.map +1 -1
  132. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  133. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  134. package/dist/cli/run/adapters/claude.test.js +205 -0
  135. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  137. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  138. package/dist/cli/run/adapters/copilot.js +84 -46
  139. package/dist/cli/run/adapters/copilot.js.map +1 -1
  140. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  141. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  142. package/dist/cli/run/adapters/copilot.test.js +195 -0
  143. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  145. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  146. package/dist/cli/run/adapters/cursor.js +83 -47
  147. package/dist/cli/run/adapters/cursor.js.map +1 -1
  148. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  149. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  150. package/dist/cli/run/adapters/cursor.test.js +129 -0
  151. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  153. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  154. package/dist/cli/run/adapters/opencode.js +81 -47
  155. package/dist/cli/run/adapters/opencode.js.map +1 -1
  156. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  157. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  158. package/dist/cli/run/adapters/opencode.test.js +119 -0
  159. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  160. package/dist/cli/run/executor.js +1 -1
  161. package/dist/cli/run/executor.js.map +1 -1
  162. package/dist/cli/run/schema.d.ts.map +1 -1
  163. package/dist/cli/run/schema.js +245 -4
  164. package/dist/cli/run/schema.js.map +1 -1
  165. package/dist/cli/run/schema.test.js +669 -0
  166. package/dist/cli/run/schema.test.js.map +1 -1
  167. package/dist/cli/run.d.ts.map +1 -1
  168. package/dist/cli/run.js +362 -22
  169. package/dist/cli/run.js.map +1 -1
  170. package/dist/cli/types.d.ts +85 -2
  171. package/dist/cli/types.d.ts.map +1 -1
  172. package/dist/cli/types.js.map +1 -1
  173. package/dist/cli/watch.d.ts +15 -0
  174. package/dist/cli/watch.d.ts.map +1 -0
  175. package/dist/cli/watch.js +279 -0
  176. package/dist/cli/watch.js.map +1 -0
  177. package/package.json +5 -1
  178. package/src/cli/agents.ts +177 -0
  179. package/src/cli/baselines.ts +143 -0
  180. package/src/cli/convoy/TELEMETRY.md +203 -0
  181. package/src/cli/convoy/dashboard-types.ts +141 -0
  182. package/src/cli/convoy/engine.test.ts +1937 -70
  183. package/src/cli/convoy/engine.ts +2350 -40
  184. package/src/cli/convoy/event-schemas.ts +195 -0
  185. package/src/cli/convoy/events.test.ts +384 -39
  186. package/src/cli/convoy/events.ts +202 -16
  187. package/src/cli/convoy/expertise.test.ts +128 -0
  188. package/src/cli/convoy/expertise.ts +163 -0
  189. package/src/cli/convoy/export.test.ts +1 -0
  190. package/src/cli/convoy/formula.test.ts +405 -0
  191. package/src/cli/convoy/formula.ts +174 -0
  192. package/src/cli/convoy/gates.test.ts +1169 -0
  193. package/src/cli/convoy/gates.ts +774 -0
  194. package/src/cli/convoy/health.test.ts +64 -2
  195. package/src/cli/convoy/health.ts +80 -2
  196. package/src/cli/convoy/issues.test.ts +143 -0
  197. package/src/cli/convoy/issues.ts +136 -0
  198. package/src/cli/convoy/knowledge.test.ts +101 -0
  199. package/src/cli/convoy/knowledge.ts +132 -0
  200. package/src/cli/convoy/lessons.test.ts +188 -0
  201. package/src/cli/convoy/lessons.ts +164 -0
  202. package/src/cli/convoy/lock.test.ts +181 -0
  203. package/src/cli/convoy/lock.ts +103 -0
  204. package/src/cli/convoy/log-merge.test.ts +179 -0
  205. package/src/cli/convoy/merge.test.ts +6 -7
  206. package/src/cli/convoy/merge.ts +19 -1
  207. package/src/cli/convoy/partition.test.ts +423 -0
  208. package/src/cli/convoy/partition.ts +232 -0
  209. package/src/cli/convoy/pipeline.test.ts +6 -0
  210. package/src/cli/convoy/store.test.ts +2041 -20
  211. package/src/cli/convoy/store.ts +945 -46
  212. package/src/cli/convoy/types.ts +278 -4
  213. package/src/cli/log.ts +120 -2
  214. package/src/cli/run/adapters/claude.test.ts +234 -0
  215. package/src/cli/run/adapters/claude.ts +45 -5
  216. package/src/cli/run/adapters/copilot.test.ts +224 -0
  217. package/src/cli/run/adapters/copilot.ts +34 -4
  218. package/src/cli/run/adapters/cursor.test.ts +144 -0
  219. package/src/cli/run/adapters/cursor.ts +33 -2
  220. package/src/cli/run/adapters/opencode.test.ts +135 -0
  221. package/src/cli/run/adapters/opencode.ts +30 -2
  222. package/src/cli/run/executor.ts +1 -1
  223. package/src/cli/run/schema.test.ts +758 -0
  224. package/src/cli/run/schema.ts +300 -25
  225. package/src/cli/run.ts +341 -21
  226. package/src/cli/types.ts +86 -1
  227. package/src/cli/watch.ts +298 -0
  228. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  229. package/src/dashboard/dist/data/.gitkeep +0 -0
  230. package/src/dashboard/dist/data/convoy-list.json +1 -0
  231. package/src/dashboard/dist/data/overall-stats.json +24 -0
  232. package/src/dashboard/dist/index.html +701 -3
  233. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  234. package/src/dashboard/public/data/.gitkeep +0 -0
  235. package/src/dashboard/public/data/convoy-list.json +1 -0
  236. package/src/dashboard/public/data/overall-stats.json +24 -0
  237. package/src/dashboard/scripts/etl.test.ts +210 -0
  238. package/src/dashboard/scripts/etl.ts +108 -0
  239. package/src/dashboard/scripts/integration-test.ts +504 -0
  240. package/src/dashboard/src/pages/index.astro +854 -15
  241. package/src/dashboard/src/styles/dashboard.css +557 -1
  242. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -1,13 +1,18 @@
1
- import { mkdtempSync, rmSync } from 'node:fs'
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
5
- import { createConvoyEngine } from './engine.js'
5
+ import { createConvoyEngine, evaluateReviewLevel, runConvoyGuard } from './engine.js'
6
+ import { recoverNdjson, createEventEmitter } from './events.js'
7
+ import type { ConvoyEngineOptions, DiffStats } from './engine.js'
6
8
  import { createConvoyStore } from './store.js'
7
9
  import type { AgentAdapter, Task, TaskSpec, ExecuteResult, ExecuteOptions } from '../types.js'
8
10
  import type { WorktreeManager } from './worktree.js'
9
11
  import type { MergeQueue } from './merge.js'
12
+ import type { TaskRecord } from './types.js'
10
13
  import { getAdapter, detectAdapter } from '../run/adapters/index.js'
14
+ import * as gates from './gates.js'
15
+ import * as partition from './partition.js'
11
16
 
12
17
  // ── Mock NDJSON log writes ────────────────────────────────────────────────────
13
18
 
@@ -92,6 +97,15 @@ function makeSpec(
92
97
  }
93
98
  }
94
99
 
100
+ /** Wraps createConvoyEngine with a default no-op _ensureBranch mock so tests never
101
+ * run real git branch operations. Callers can override _ensureBranch if needed. */
102
+ function makeEngine(opts: ConvoyEngineOptions): ReturnType<typeof createConvoyEngine> {
103
+ return createConvoyEngine({
104
+ _ensureBranch: vi.fn().mockResolvedValue(undefined),
105
+ ...opts,
106
+ })
107
+ }
108
+
95
109
  // ── Test lifecycle ────────────────────────────────────────────────────────────
96
110
 
97
111
  let tmpDir: string
@@ -115,7 +129,7 @@ afterEach(() => {
115
129
  describe('single task success', () => {
116
130
  it('returns status done with summary.done=1', async () => {
117
131
  const adapter = makeAdapter()
118
- const engine = createConvoyEngine({
132
+ const engine = makeEngine({
119
133
  spec: makeSpec(),
120
134
  specYaml: 'name: test',
121
135
  adapter,
@@ -137,7 +151,7 @@ describe('single task success', () => {
137
151
 
138
152
  it('calls adapter.execute once with the correct task', async () => {
139
153
  const adapter = makeAdapter()
140
- const engine = createConvoyEngine({
154
+ const engine = makeEngine({
141
155
  spec: makeSpec(),
142
156
  specYaml: 'name: test',
143
157
  adapter,
@@ -161,7 +175,7 @@ describe('single task failure', () => {
161
175
  const adapter = makeAdapter()
162
176
  adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 })
163
177
 
164
- const engine = createConvoyEngine({
178
+ const engine = makeEngine({
165
179
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
166
180
  specYaml: 'name: test',
167
181
  adapter,
@@ -181,7 +195,7 @@ describe('single task failure', () => {
181
195
  const adapter = makeAdapter()
182
196
  adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 })
183
197
 
184
- const engine = createConvoyEngine({
198
+ const engine = makeEngine({
185
199
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
186
200
  specYaml: 'name: test',
187
201
  adapter,
@@ -211,7 +225,7 @@ describe('two-phase DAG (task-b depends on task-a)', () => {
211
225
  { id: 'task-a', depends_on: [] },
212
226
  { id: 'task-b', depends_on: ['task-a'] },
213
227
  ])
214
- const engine = createConvoyEngine({
228
+ const engine = makeEngine({
215
229
  spec,
216
230
  specYaml: 'name: test',
217
231
  adapter,
@@ -243,7 +257,7 @@ describe('two-phase DAG (task-b depends on task-a)', () => {
243
257
  { id: 'task-a', depends_on: [] },
244
258
  { id: 'task-b', depends_on: ['task-a'] },
245
259
  ])
246
- const engine = createConvoyEngine({
260
+ const engine = makeEngine({
247
261
  spec,
248
262
  specYaml: 'name: test',
249
263
  adapter,
@@ -278,7 +292,7 @@ describe('on_failure:continue', () => {
278
292
  { id: 'task-b', depends_on: ['task-a'] },
279
293
  { id: 'task-c', depends_on: [] },
280
294
  ])
281
- const engine = createConvoyEngine({
295
+ const engine = makeEngine({
282
296
  spec,
283
297
  specYaml: 'name: test',
284
298
  adapter,
@@ -317,7 +331,7 @@ describe('on_failure:continue', () => {
317
331
  { id: 'task-b', depends_on: ['task-a'] },
318
332
  { id: 'task-c', depends_on: ['task-b'] },
319
333
  ])
320
- const engine = createConvoyEngine({
334
+ const engine = makeEngine({
321
335
  spec,
322
336
  specYaml: 'name: test',
323
337
  adapter,
@@ -347,7 +361,7 @@ describe('on_failure:stop', () => {
347
361
  { id: 'task-b', depends_on: ['task-a'] },
348
362
  { id: 'task-c', depends_on: ['task-a'] },
349
363
  ])
350
- const engine = createConvoyEngine({
364
+ const engine = makeEngine({
351
365
  spec,
352
366
  specYaml: 'name: test',
353
367
  adapter,
@@ -377,7 +391,7 @@ describe('on_failure:stop', () => {
377
391
  adapter.execute.mockResolvedValue({ success: false, output: 'fail', exitCode: 1 })
378
392
 
379
393
  const spec = makeSpec({ on_failure: 'stop' }, [{ id: 'task-1', max_retries: 3 }])
380
- const engine = createConvoyEngine({
394
+ const engine = makeEngine({
381
395
  spec,
382
396
  specYaml: 'name: test',
383
397
  adapter,
@@ -410,7 +424,7 @@ describe('task retry', () => {
410
424
  })
411
425
 
412
426
  const spec = makeSpec({}, [{ id: 'task-1', max_retries: 1 }])
413
- const engine = createConvoyEngine({
427
+ const engine = makeEngine({
414
428
  spec,
415
429
  specYaml: 'name: test',
416
430
  adapter,
@@ -435,7 +449,7 @@ describe('task retry', () => {
435
449
  })
436
450
 
437
451
  const spec = makeSpec({}, [{ id: 'task-1', max_retries: 2 }])
438
- const engine = createConvoyEngine({
452
+ const engine = makeEngine({
439
453
  spec,
440
454
  specYaml: 'name: test',
441
455
  adapter,
@@ -459,7 +473,7 @@ describe('validation gates', () => {
459
473
  it('returns status done when all gates pass', async () => {
460
474
  const adapter = makeAdapter()
461
475
  const spec = makeSpec({ gates: ['echo gate-ok'] }, [{ id: 'task-1' }])
462
- const engine = createConvoyEngine({
476
+ const engine = makeEngine({
463
477
  spec,
464
478
  specYaml: 'name: test',
465
479
  adapter,
@@ -478,7 +492,7 @@ describe('validation gates', () => {
478
492
  it('returns status gate-failed when a gate exits non-zero', async () => {
479
493
  const adapter = makeAdapter()
480
494
  const spec = makeSpec({ gates: ['false'] }, [{ id: 'task-1' }])
481
- const engine = createConvoyEngine({
495
+ const engine = makeEngine({
482
496
  spec,
483
497
  specYaml: 'name: test',
484
498
  adapter,
@@ -496,7 +510,7 @@ describe('validation gates', () => {
496
510
 
497
511
  it('returns undefined gateResults when spec has no gates', async () => {
498
512
  const adapter = makeAdapter()
499
- const engine = createConvoyEngine({
513
+ const engine = makeEngine({
500
514
  spec: makeSpec(),
501
515
  specYaml: 'name: test',
502
516
  adapter,
@@ -513,7 +527,7 @@ describe('validation gates', () => {
513
527
  it('runs multiple gates and reports each result individually', async () => {
514
528
  const adapter = makeAdapter()
515
529
  const spec = makeSpec({ gates: ['echo first', 'false', 'echo third'] }, [{ id: 'task-1' }])
516
- const engine = createConvoyEngine({
530
+ const engine = makeEngine({
517
531
  spec,
518
532
  specYaml: 'name: test',
519
533
  adapter,
@@ -560,6 +574,7 @@ describe('resume (crash recovery)', () => {
560
574
  max_retries: 0,
561
575
  files: null,
562
576
  depends_on: null,
577
+ gates: null,
563
578
  })
564
579
  if (taskStatus === 'running') {
565
580
  seeder.insertWorker({
@@ -583,7 +598,7 @@ describe('resume (crash recovery)', () => {
583
598
 
584
599
  const adapter = makeAdapter()
585
600
  const wtManager = makeWorktreeManager()
586
- const engine = createConvoyEngine({
601
+ const engine = makeEngine({
587
602
  spec: makeSpec({}, [{ id: 'task-1' }]),
588
603
  specYaml: 'name: test',
589
604
  adapter,
@@ -606,7 +621,7 @@ describe('resume (crash recovery)', () => {
606
621
  seedCrashedConvoy(convoyId, 'assigned')
607
622
 
608
623
  const adapter = makeAdapter()
609
- const engine = createConvoyEngine({
624
+ const engine = makeEngine({
610
625
  spec: makeSpec({}, [{ id: 'task-1' }]),
611
626
  specYaml: 'name: test',
612
627
  adapter,
@@ -622,7 +637,7 @@ describe('resume (crash recovery)', () => {
622
637
 
623
638
  it('throws an error when the convoy is not found', async () => {
624
639
  const adapter = makeAdapter()
625
- const engine = createConvoyEngine({
640
+ const engine = makeEngine({
626
641
  spec: makeSpec(),
627
642
  specYaml: 'name: test',
628
643
  adapter,
@@ -663,11 +678,12 @@ describe('resume (crash recovery)', () => {
663
678
  max_retries: 0,
664
679
  files: null,
665
680
  depends_on: null,
681
+ gates: null,
666
682
  })
667
683
  seeder.close()
668
684
 
669
685
  const adapter = makeAdapter()
670
- const engine = createConvoyEngine({
686
+ const engine = makeEngine({
671
687
  spec: makeSpec({ branch: 'feature-branch' }), // spec.branch used as fallback
672
688
  specYaml: 'name: test',
673
689
  adapter,
@@ -708,11 +724,12 @@ describe('resume (crash recovery)', () => {
708
724
  max_retries: 0,
709
725
  files: null,
710
726
  depends_on: null,
727
+ gates: null,
711
728
  })
712
729
  seeder.close()
713
730
 
714
731
  const adapter = makeAdapter()
715
- const engine = createConvoyEngine({
732
+ const engine = makeEngine({
716
733
  spec: {
717
734
  name: 'Git Branch Convoy',
718
735
  concurrency: 1,
@@ -741,7 +758,7 @@ describe('worktree lifecycle (non-copilot)', () => {
741
758
  const wtManager = makeWorktreeManager()
742
759
  const mergeQueue = makeMergeQueue()
743
760
 
744
- const engine = createConvoyEngine({
761
+ const engine = makeEngine({
745
762
  spec: makeSpec(),
746
763
  specYaml: 'name: test',
747
764
  adapter,
@@ -763,7 +780,7 @@ describe('worktree lifecycle (non-copilot)', () => {
763
780
  const wtManager = makeWorktreeManager()
764
781
  const mergeQueue = makeMergeQueue()
765
782
 
766
- const engine = createConvoyEngine({
783
+ const engine = makeEngine({
767
784
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
768
785
  specYaml: 'name: test',
769
786
  adapter,
@@ -785,7 +802,7 @@ describe('worktree lifecycle (non-copilot)', () => {
785
802
  wtManager.create.mockRejectedValue(new Error('git worktree unavailable'))
786
803
  const mergeQueue = makeMergeQueue()
787
804
 
788
- const engine = createConvoyEngine({
805
+ const engine = makeEngine({
789
806
  spec: makeSpec(),
790
807
  specYaml: 'name: test',
791
808
  adapter,
@@ -806,7 +823,7 @@ describe('worktree lifecycle (non-copilot)', () => {
806
823
  const mergeQueue = makeMergeQueue()
807
824
  mergeQueue.merge.mockRejectedValue(new Error('merge conflict'))
808
825
 
809
- const engine = createConvoyEngine({
826
+ const engine = makeEngine({
810
827
  spec: makeSpec(),
811
828
  specYaml: 'name: test',
812
829
  adapter,
@@ -830,7 +847,7 @@ describe('copilot adapter', () => {
830
847
  const wtManager = makeWorktreeManager()
831
848
  const mergeQueue = makeMergeQueue()
832
849
 
833
- const engine = createConvoyEngine({
850
+ const engine = makeEngine({
834
851
  spec: makeSpec(),
835
852
  specYaml: 'name: test',
836
853
  adapter,
@@ -861,7 +878,7 @@ describe('timeout handling', () => {
861
878
  exitCode: -1,
862
879
  } satisfies ExecuteResult)
863
880
 
864
- const engine = createConvoyEngine({
881
+ const engine = makeEngine({
865
882
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
866
883
  specYaml: 'name: test',
867
884
  adapter,
@@ -889,7 +906,7 @@ describe('timeout handling', () => {
889
906
  return { success: true, output: 'ok', exitCode: 0 }
890
907
  })
891
908
 
892
- const engine = createConvoyEngine({
909
+ const engine = makeEngine({
893
910
  spec: makeSpec({ on_failure: 'continue' }, [{ id: 'task-1', max_retries: 1 }]),
894
911
  specYaml: 'name: test',
895
912
  adapter,
@@ -913,7 +930,7 @@ describe('timeout handling', () => {
913
930
  exitCode: -1,
914
931
  })
915
932
 
916
- const engine = createConvoyEngine({
933
+ const engine = makeEngine({
917
934
  spec: makeSpec({ on_failure: 'stop' }, [{ id: 'task-1', max_retries: 2 }]),
918
935
  specYaml: 'name: test',
919
936
  adapter,
@@ -940,7 +957,7 @@ describe('adapter without kill method', () => {
940
957
  // kill intentionally absent
941
958
  }
942
959
 
943
- const engine = createConvoyEngine({
960
+ const engine = makeEngine({
944
961
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
945
962
  specYaml: 'name: test',
946
963
  adapter,
@@ -965,7 +982,7 @@ describe('adapter without kill method', () => {
965
982
  }),
966
983
  }
967
984
 
968
- const engine = createConvoyEngine({
985
+ const engine = makeEngine({
969
986
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
970
987
  specYaml: 'name: test',
971
988
  adapter,
@@ -999,7 +1016,7 @@ describe('parallel task execution', () => {
999
1016
  { id: 'task-2', depends_on: [] },
1000
1017
  { id: 'task-3', depends_on: [] },
1001
1018
  ])
1002
- const engine = createConvoyEngine({
1019
+ const engine = makeEngine({
1003
1020
  spec,
1004
1021
  specYaml: 'name: test',
1005
1022
  adapter,
@@ -1022,7 +1039,7 @@ describe('executor error', () => {
1022
1039
  const adapter = makeAdapter()
1023
1040
  adapter.execute.mockRejectedValue(new Error('adapter crashed'))
1024
1041
 
1025
- const engine = createConvoyEngine({
1042
+ const engine = makeEngine({
1026
1043
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
1027
1044
  specYaml: 'name: test',
1028
1045
  adapter,
@@ -1043,7 +1060,7 @@ describe('executor error', () => {
1043
1060
  describe('verbose mode', () => {
1044
1061
  it('runs a successful task with verbose=true without throwing', async () => {
1045
1062
  const adapter = makeAdapter('developer')
1046
- const engine = createConvoyEngine({
1063
+ const engine = makeEngine({
1047
1064
  spec: makeSpec({}, [{ id: 'task-1' }]),
1048
1065
  specYaml: 'name: test',
1049
1066
  adapter,
@@ -1068,7 +1085,7 @@ describe('verbose mode', () => {
1068
1085
  { id: 'task-a', depends_on: [] },
1069
1086
  { id: 'task-b', depends_on: ['task-a'] }, // gets skipped — also triggers verbose skip log
1070
1087
  ])
1071
- const engine = createConvoyEngine({
1088
+ const engine = makeEngine({
1072
1089
  spec,
1073
1090
  specYaml: 'name: test',
1074
1091
  adapter,
@@ -1095,7 +1112,7 @@ describe('verbose mode', () => {
1095
1112
  return { success: true, output: 'ok', exitCode: 0 }
1096
1113
  })
1097
1114
 
1098
- const engine = createConvoyEngine({
1115
+ const engine = makeEngine({
1099
1116
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 1 }]),
1100
1117
  specYaml: 'name: test',
1101
1118
  adapter,
@@ -1118,7 +1135,7 @@ describe('verbose mode', () => {
1118
1135
  exitCode: -1,
1119
1136
  })
1120
1137
 
1121
- const engine = createConvoyEngine({
1138
+ const engine = makeEngine({
1122
1139
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
1123
1140
  specYaml: 'name: test',
1124
1141
  adapter,
@@ -1144,7 +1161,7 @@ describe('verbose mode', () => {
1144
1161
  return { success: true, output: 'ok', exitCode: 0 }
1145
1162
  })
1146
1163
 
1147
- const engine = createConvoyEngine({
1164
+ const engine = makeEngine({
1148
1165
  spec: makeSpec({ on_failure: 'continue' }, [{ id: 'task-1', max_retries: 1 }]),
1149
1166
  specYaml: 'name: test',
1150
1167
  adapter,
@@ -1163,7 +1180,7 @@ describe('verbose mode', () => {
1163
1180
  const wtManager = makeWorktreeManager()
1164
1181
  wtManager.create.mockRejectedValue(new Error('no worktrees'))
1165
1182
 
1166
- const engine = createConvoyEngine({
1183
+ const engine = makeEngine({
1167
1184
  spec: makeSpec({}, [{ id: 'task-1' }]),
1168
1185
  specYaml: 'name: test',
1169
1186
  adapter,
@@ -1182,7 +1199,7 @@ describe('verbose mode', () => {
1182
1199
  const mergeQueue = makeMergeQueue()
1183
1200
  mergeQueue.merge.mockRejectedValue(new Error('merge conflict'))
1184
1201
 
1185
- const engine = createConvoyEngine({
1202
+ const engine = makeEngine({
1186
1203
  spec: makeSpec({}, [{ id: 'task-1' }]),
1187
1204
  specYaml: 'name: test',
1188
1205
  adapter,
@@ -1204,7 +1221,7 @@ describe('msToTimeout — timeout string representation', () => {
1204
1221
  const adapter = makeAdapter()
1205
1222
  // parseTimeout('1h') = 3600000ms; msToTimeout(3600000) = '1h'
1206
1223
  const spec = makeSpec({}, [{ id: 'task-1', timeout: '1h' }])
1207
- const engine = createConvoyEngine({
1224
+ const engine = makeEngine({
1208
1225
  spec,
1209
1226
  specYaml: 'name: test',
1210
1227
  adapter,
@@ -1221,7 +1238,7 @@ describe('msToTimeout — timeout string representation', () => {
1221
1238
  const adapter = makeAdapter()
1222
1239
  // parseTimeout('1m') = 60000ms; msToTimeout(60000) = '1m'
1223
1240
  const spec = makeSpec({}, [{ id: 'task-1', timeout: '1m' }])
1224
- const engine = createConvoyEngine({
1241
+ const engine = makeEngine({
1225
1242
  spec,
1226
1243
  specYaml: 'name: test',
1227
1244
  adapter,
@@ -1244,7 +1261,7 @@ describe('per-task adapter resolution', () => {
1244
1261
  vi.mocked(getAdapter).mockResolvedValue(altAdapter)
1245
1262
 
1246
1263
  const spec = makeSpec({}, [{ adapter: 'alt-adapter' }])
1247
- const engine = createConvoyEngine({
1264
+ const engine = makeEngine({
1248
1265
  spec,
1249
1266
  specYaml: 'name: test',
1250
1267
  adapter: mainAdapter,
@@ -1263,7 +1280,7 @@ describe('per-task adapter resolution', () => {
1263
1280
  it('uses convoy-level adapter when task has no adapter field', async () => {
1264
1281
  const adapter = makeAdapter('test')
1265
1282
  const spec = makeSpec()
1266
- const engine = createConvoyEngine({
1283
+ const engine = makeEngine({
1267
1284
  spec,
1268
1285
  specYaml: 'name: test',
1269
1286
  adapter,
@@ -1282,7 +1299,7 @@ describe('per-task adapter resolution', () => {
1282
1299
  const adapter = makeAdapter('test')
1283
1300
  // task.adapter === adapter.name → no per-task resolution
1284
1301
  const spec = makeSpec({}, [{ adapter: 'test' }])
1285
- const engine = createConvoyEngine({
1302
+ const engine = makeEngine({
1286
1303
  spec,
1287
1304
  specYaml: 'name: test',
1288
1305
  adapter,
@@ -1304,7 +1321,7 @@ describe('per-task adapter resolution', () => {
1304
1321
  vi.mocked(getAdapter).mockResolvedValue(autoAdapter)
1305
1322
 
1306
1323
  const spec = makeSpec({}, [{ adapter: 'auto' }])
1307
- const engine = createConvoyEngine({
1324
+ const engine = makeEngine({
1308
1325
  spec,
1309
1326
  specYaml: 'name: test',
1310
1327
  adapter: mainAdapter,
@@ -1326,7 +1343,7 @@ describe('per-task adapter resolution', () => {
1326
1343
  vi.mocked(getAdapter).mockResolvedValue(altAdapter)
1327
1344
 
1328
1345
  const spec = makeSpec({}, [{ adapter: 'alt-adapter' }])
1329
- const engine = createConvoyEngine({
1346
+ const engine = makeEngine({
1330
1347
  spec,
1331
1348
  specYaml: 'name: test',
1332
1349
  adapter: makeAdapter('test'),
@@ -1361,7 +1378,7 @@ describe('getCurrentBranch', () => {
1361
1378
  tasks: [{ id: 'task-1', prompt: 'p', agent: 'dev', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 }],
1362
1379
  }
1363
1380
 
1364
- const engine = createConvoyEngine({
1381
+ const engine = makeEngine({
1365
1382
  spec,
1366
1383
  specYaml: 'name: branch-test',
1367
1384
  adapter,
@@ -1385,7 +1402,7 @@ describe('getCurrentBranch', () => {
1385
1402
  tasks: [{ id: 'task-1', prompt: 'p', agent: 'dev', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 }],
1386
1403
  }
1387
1404
 
1388
- const engine = createConvoyEngine({
1405
+ const engine = makeEngine({
1389
1406
  spec,
1390
1407
  specYaml: 'name: fallback-test',
1391
1408
  adapter,
@@ -1410,7 +1427,7 @@ describe('real timer timeout path', () => {
1410
1427
  // adapter.execute returns a promise that never resolves — real timer wins the race
1411
1428
  adapter.execute.mockImplementation(() => new Promise<ExecuteResult>(() => {}))
1412
1429
 
1413
- const engine = createConvoyEngine({
1430
+ const engine = makeEngine({
1414
1431
  spec: makeSpec({}, [{ id: 'task-1', timeout: '1s', max_retries: 0 }]),
1415
1432
  specYaml: 'name: test',
1416
1433
  adapter,
@@ -1448,7 +1465,7 @@ describe('diamond dependency skip', () => {
1448
1465
  { id: 'task-b', depends_on: ['task-a'] },
1449
1466
  { id: 'task-c', depends_on: ['task-a', 'task-b'] }, // diamond
1450
1467
  ])
1451
- const engine = createConvoyEngine({
1468
+ const engine = makeEngine({
1452
1469
  spec,
1453
1470
  specYaml: 'name: test',
1454
1471
  adapter,
@@ -1485,7 +1502,7 @@ describe('cost tracking', () => {
1485
1502
  usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
1486
1503
  } satisfies ExecuteResult)
1487
1504
 
1488
- const engine = createConvoyEngine({
1505
+ const engine = makeEngine({
1489
1506
  spec: makeSpec(),
1490
1507
  specYaml: 'name: test',
1491
1508
  adapter,
@@ -1509,7 +1526,7 @@ describe('cost tracking', () => {
1509
1526
  const adapter = makeAdapter()
1510
1527
  // default makeAdapter returns no usage field
1511
1528
 
1512
- const engine = createConvoyEngine({
1529
+ const engine = makeEngine({
1513
1530
  spec: makeSpec(),
1514
1531
  specYaml: 'name: test',
1515
1532
  adapter,
@@ -1538,7 +1555,7 @@ describe('cost tracking', () => {
1538
1555
  { id: 'task-1', depends_on: [] },
1539
1556
  { id: 'task-2', depends_on: [] },
1540
1557
  ])
1541
- const engine = createConvoyEngine({
1558
+ const engine = makeEngine({
1542
1559
  spec,
1543
1560
  specYaml: 'name: test',
1544
1561
  adapter,
@@ -1564,7 +1581,7 @@ describe('cost tracking', () => {
1564
1581
  usage: { total_tokens: 75 },
1565
1582
  } satisfies ExecuteResult)
1566
1583
 
1567
- const engine = createConvoyEngine({
1584
+ const engine = makeEngine({
1568
1585
  spec: makeSpec(),
1569
1586
  specYaml: 'name: test',
1570
1587
  adapter,
@@ -1582,7 +1599,7 @@ describe('cost tracking', () => {
1582
1599
  const adapter = makeAdapter()
1583
1600
  // default makeAdapter returns no usage
1584
1601
 
1585
- const engine = createConvoyEngine({
1602
+ const engine = makeEngine({
1586
1603
  spec: makeSpec(),
1587
1604
  specYaml: 'name: test',
1588
1605
  adapter,
@@ -1605,7 +1622,7 @@ describe('cost tracking', () => {
1605
1622
  usage: { total_tokens: 42 },
1606
1623
  } satisfies ExecuteResult)
1607
1624
 
1608
- const engine = createConvoyEngine({
1625
+ const engine = makeEngine({
1609
1626
  spec: makeSpec(),
1610
1627
  specYaml: 'name: test',
1611
1628
  adapter,
@@ -1628,7 +1645,7 @@ describe('cost tracking', () => {
1628
1645
  const adapter = makeAdapter()
1629
1646
  // default adapter returns no usage
1630
1647
 
1631
- const engine = createConvoyEngine({
1648
+ const engine = makeEngine({
1632
1649
  spec: makeSpec({ concurrency: 2 }, [
1633
1650
  { id: 'task-1', depends_on: [] },
1634
1651
  { id: 'task-2', depends_on: [] },
@@ -1670,7 +1687,7 @@ describe('progress reporting', () => {
1670
1687
 
1671
1688
  it('prints task start message without verbose flag', async () => {
1672
1689
  const adapter = makeAdapter()
1673
- const engine = createConvoyEngine({
1690
+ const engine = makeEngine({
1674
1691
  spec: makeSpec(),
1675
1692
  specYaml: 'name: test',
1676
1693
  adapter,
@@ -1688,7 +1705,7 @@ describe('progress reporting', () => {
1688
1705
 
1689
1706
  it('prints task completion with counter', async () => {
1690
1707
  const adapter = makeAdapter()
1691
- const engine = createConvoyEngine({
1708
+ const engine = makeEngine({
1692
1709
  spec: makeSpec(),
1693
1710
  specYaml: 'name: test',
1694
1711
  adapter,
@@ -1708,7 +1725,7 @@ describe('progress reporting', () => {
1708
1725
  const adapter = makeAdapter()
1709
1726
  adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 })
1710
1727
 
1711
- const engine = createConvoyEngine({
1728
+ const engine = makeEngine({
1712
1729
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
1713
1730
  specYaml: 'name: test',
1714
1731
  adapter,
@@ -1730,7 +1747,7 @@ describe('progress reporting', () => {
1730
1747
  { id: 'task-a', depends_on: [] },
1731
1748
  { id: 'task-b', depends_on: ['task-a'] },
1732
1749
  ])
1733
- const engine = createConvoyEngine({
1750
+ const engine = makeEngine({
1734
1751
  spec,
1735
1752
  specYaml: 'name: test',
1736
1753
  adapter,
@@ -1749,7 +1766,7 @@ describe('progress reporting', () => {
1749
1766
  it('prints gate results with pass/fail indicators', async () => {
1750
1767
  const adapter = makeAdapter()
1751
1768
  const spec = makeSpec({ gates: ['echo gate-ok', 'false'] }, [{ id: 'task-1' }])
1752
- const engine = createConvoyEngine({
1769
+ const engine = makeEngine({
1753
1770
  spec,
1754
1771
  specYaml: 'name: test',
1755
1772
  adapter,
@@ -1778,7 +1795,7 @@ describe('progress reporting', () => {
1778
1795
  return { success: true, output: 'ok', exitCode: 0 }
1779
1796
  })
1780
1797
 
1781
- const engine = createConvoyEngine({
1798
+ const engine = makeEngine({
1782
1799
  spec: makeSpec({}, [{ id: 'task-1', max_retries: 1 }]),
1783
1800
  specYaml: 'name: test',
1784
1801
  adapter,
@@ -1819,7 +1836,7 @@ describe('gate retry mechanism', () => {
1819
1836
  { gates: [`node -e "process.exit(0)"`], gate_retries: 1 },
1820
1837
  [{ id: 'task-1' }],
1821
1838
  )
1822
- const engine = createConvoyEngine({
1839
+ const engine = makeEngine({
1823
1840
  spec,
1824
1841
  specYaml: 'name: test',
1825
1842
  adapter,
@@ -1835,7 +1852,7 @@ describe('gate retry mechanism', () => {
1835
1852
 
1836
1853
  it('defaults gate_retries to 0 (no retry on gate failure)', async () => {
1837
1854
  const spec = makeSpec({ gates: ['false'] }, [{ id: 'task-1' }])
1838
- const engine = createConvoyEngine({
1855
+ const engine = makeEngine({
1839
1856
  spec,
1840
1857
  specYaml: 'name: test',
1841
1858
  adapter,
@@ -1851,7 +1868,7 @@ describe('gate retry mechanism', () => {
1851
1868
 
1852
1869
  it('calls adapter.execute with fix prompt when gates fail and retries available', async () => {
1853
1870
  const spec = makeSpec({ gates: ['false'], gate_retries: 1 }, [{ id: 'task-1' }])
1854
- const engine = createConvoyEngine({
1871
+ const engine = makeEngine({
1855
1872
  spec,
1856
1873
  specYaml: 'name: test',
1857
1874
  adapter,
@@ -1875,7 +1892,7 @@ describe('gate retry mechanism', () => {
1875
1892
  .mockResolvedValueOnce({ success: true, output: 'ok', exitCode: 0 }) // task-1
1876
1893
  .mockResolvedValueOnce({ success: false, output: 'fix failed', exitCode: 1 }) // gate-fix-1
1877
1894
  const spec = makeSpec({ gates: ['false'], gate_retries: 2 }, [{ id: 'task-1' }])
1878
- const engine = createConvoyEngine({
1895
+ const engine = makeEngine({
1879
1896
  spec,
1880
1897
  specYaml: 'name: test',
1881
1898
  adapter,
@@ -1889,3 +1906,1853 @@ describe('gate retry mechanism', () => {
1889
1906
  expect(result.status).toBe('gate-failed')
1890
1907
  })
1891
1908
  })
1909
+
1910
+ // ── evaluateReviewLevel ───────────────────────────────────────────────────────
1911
+
1912
+ function makeTaskRecord(overrides: Partial<TaskRecord> = {}): TaskRecord {
1913
+ return {
1914
+ id: 'task-1',
1915
+ convoy_id: 'convoy-1',
1916
+ phase: 0,
1917
+ prompt: '',
1918
+ agent: 'developer',
1919
+ adapter: null,
1920
+ model: null,
1921
+ timeout_ms: 1_800_000,
1922
+ status: 'pending',
1923
+ worker_id: null,
1924
+ worktree: null,
1925
+ output: null,
1926
+ exit_code: null,
1927
+ started_at: null,
1928
+ finished_at: null,
1929
+ retries: 0,
1930
+ max_retries: 1,
1931
+ files: null,
1932
+ depends_on: null,
1933
+ prompt_tokens: null,
1934
+ completion_tokens: null,
1935
+ total_tokens: null,
1936
+ cost_usd: null,
1937
+ gates: null,
1938
+ on_exhausted: 'dlq',
1939
+ injected: 0,
1940
+ provenance: null,
1941
+ idempotency_key: null,
1942
+ current_step: null,
1943
+ total_steps: null,
1944
+ review_level: null,
1945
+ review_verdict: null,
1946
+ review_tokens: null,
1947
+ review_model: null,
1948
+ panel_attempts: 0,
1949
+ dispute_id: null,
1950
+ drift_score: null,
1951
+ drift_retried: 0,
1952
+ ...overrides,
1953
+ }
1954
+ }
1955
+
1956
+ function makeDiffStats(overrides: Partial<DiffStats> = {}): DiffStats {
1957
+ return {
1958
+ linesChanged: 5,
1959
+ filesChanged: 1,
1960
+ filePaths: ['src/components/Button.tsx'],
1961
+ ...overrides,
1962
+ }
1963
+ }
1964
+
1965
+ describe('evaluateReviewLevel', () => {
1966
+ it('routes to panel when a changed file is under auth/', () => {
1967
+ const level = evaluateReviewLevel(
1968
+ makeTaskRecord(),
1969
+ makeDiffStats({ filePaths: ['auth/session.ts'] }),
1970
+ )
1971
+ expect(level).toBe('panel')
1972
+ })
1973
+
1974
+ it('routes to panel when a changed file path contains /auth/', () => {
1975
+ const level = evaluateReviewLevel(
1976
+ makeTaskRecord(),
1977
+ makeDiffStats({ filePaths: ['src/auth/session.ts'] }),
1978
+ )
1979
+ expect(level).toBe('panel')
1980
+ })
1981
+
1982
+ it('routes to panel for security/ path', () => {
1983
+ const level = evaluateReviewLevel(
1984
+ makeTaskRecord(),
1985
+ makeDiffStats({ filePaths: ['security/policy.ts'] }),
1986
+ )
1987
+ expect(level).toBe('panel')
1988
+ })
1989
+
1990
+ it('routes to panel for security-expert agent', () => {
1991
+ const level = evaluateReviewLevel(
1992
+ makeTaskRecord({ agent: 'security-expert' }),
1993
+ makeDiffStats(),
1994
+ )
1995
+ expect(level).toBe('panel')
1996
+ })
1997
+
1998
+ it('routes to panel for database-engineer agent', () => {
1999
+ const level = evaluateReviewLevel(
2000
+ makeTaskRecord({ agent: 'database-engineer' }),
2001
+ makeDiffStats(),
2002
+ )
2003
+ expect(level).toBe('panel')
2004
+ })
2005
+
2006
+ it('routes to auto-pass for documentation-writer agent', () => {
2007
+ const level = evaluateReviewLevel(
2008
+ makeTaskRecord({ agent: 'documentation-writer' }),
2009
+ makeDiffStats(),
2010
+ )
2011
+ expect(level).toBe('auto-pass')
2012
+ })
2013
+
2014
+ it('routes to auto-pass for copywriter agent', () => {
2015
+ const level = evaluateReviewLevel(
2016
+ makeTaskRecord({ agent: 'copywriter' }),
2017
+ makeDiffStats(),
2018
+ )
2019
+ expect(level).toBe('auto-pass')
2020
+ })
2021
+
2022
+ it('routes to auto-pass for small diff (<=10 lines, <=2 files) with gates passing', () => {
2023
+ const level = evaluateReviewLevel(
2024
+ makeTaskRecord(),
2025
+ makeDiffStats({ linesChanged: 8, filesChanged: 2, filePaths: ['src/Button.tsx', 'src/Button.test.tsx'] }),
2026
+ undefined,
2027
+ true,
2028
+ )
2029
+ expect(level).toBe('auto-pass')
2030
+ })
2031
+
2032
+ it('routes to fast for large diff (>200 lines)', () => {
2033
+ const level = evaluateReviewLevel(
2034
+ makeTaskRecord(),
2035
+ makeDiffStats({ linesChanged: 250, filesChanged: 3, filePaths: ['src/Big.tsx', 'src/Big.test.tsx', 'src/types.ts'] }),
2036
+ )
2037
+ expect(level).toBe('fast')
2038
+ })
2039
+
2040
+ it('routes to fast for many files (>5)', () => {
2041
+ const level = evaluateReviewLevel(
2042
+ makeTaskRecord(),
2043
+ makeDiffStats({ linesChanged: 50, filesChanged: 6, filePaths: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts', 'f.ts'] }),
2044
+ )
2045
+ expect(level).toBe('fast')
2046
+ })
2047
+
2048
+ it('defaults to fast for medium diff with developer agent', () => {
2049
+ const level = evaluateReviewLevel(
2050
+ makeTaskRecord({ agent: 'developer' }),
2051
+ makeDiffStats({ linesChanged: 50, filesChanged: 3, filePaths: ['src/Feature.tsx', 'src/Feature.test.tsx', 'src/types.ts'] }),
2052
+ )
2053
+ expect(level).toBe('fast')
2054
+ })
2055
+
2056
+ it('custom heuristics: overrides panel_paths', () => {
2057
+ const level = evaluateReviewLevel(
2058
+ makeTaskRecord(),
2059
+ makeDiffStats({ filePaths: ['billing/invoice.ts'] }),
2060
+ { panel_paths: ['billing/'] },
2061
+ )
2062
+ expect(level).toBe('panel')
2063
+ })
2064
+
2065
+ it('custom heuristics: overrides auto_pass_agents', () => {
2066
+ const level = evaluateReviewLevel(
2067
+ makeTaskRecord({ agent: 'designer' }),
2068
+ makeDiffStats(),
2069
+ { auto_pass_agents: ['designer'] },
2070
+ )
2071
+ expect(level).toBe('auto-pass')
2072
+ })
2073
+
2074
+ it('custom heuristics: smaller auto_pass_max_lines threshold', () => {
2075
+ const level = evaluateReviewLevel(
2076
+ makeTaskRecord(),
2077
+ makeDiffStats({ linesChanged: 5, filesChanged: 1, filePaths: ['src/x.ts'] }),
2078
+ { auto_pass_max_lines: 3 },
2079
+ true,
2080
+ )
2081
+ expect(level).toBe('fast') // 5 > 3 → not auto-pass
2082
+ })
2083
+ })
2084
+
2085
+ // ── Review pipeline integration ───────────────────────────────────────────────
2086
+
2087
+ describe('review pipeline', () => {
2088
+ let adapter: ReturnType<typeof makeAdapter>
2089
+ let wtManager: ReturnType<typeof makeWorktreeManager>
2090
+ let mergeQueue: ReturnType<typeof makeMergeQueue>
2091
+
2092
+ beforeEach(() => {
2093
+ adapter = makeAdapter()
2094
+ wtManager = makeWorktreeManager()
2095
+ mergeQueue = makeMergeQueue()
2096
+ })
2097
+
2098
+ it('task with review: none — reviewer not called, task succeeds', async () => {
2099
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 100, model: 'test' })
2100
+ const engine = makeEngine({
2101
+ spec: makeSpec({ defaults: { review: 'none' } }, [{ review: 'none' }]),
2102
+ specYaml: 'name: test',
2103
+ adapter,
2104
+ dbPath,
2105
+ _worktreeManager: wtManager,
2106
+ _mergeQueue: mergeQueue,
2107
+ _reviewRunner: mockReviewRunner,
2108
+ })
2109
+ const result = await engine.run()
2110
+ expect(result.status).toBe('done')
2111
+ expect(mockReviewRunner).not.toHaveBeenCalled()
2112
+ })
2113
+
2114
+ it('fast review PASS — task proceeds to merge (status done)', async () => {
2115
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 50, model: 'reviewer' })
2116
+ const engine = makeEngine({
2117
+ spec: makeSpec({ defaults: { review: 'fast' } }),
2118
+ specYaml: 'name: test',
2119
+ adapter,
2120
+ dbPath,
2121
+ _worktreeManager: wtManager,
2122
+ _mergeQueue: mergeQueue,
2123
+ _reviewRunner: mockReviewRunner,
2124
+ })
2125
+ const result = await engine.run()
2126
+ expect(result.status).toBe('done')
2127
+ expect(mockReviewRunner).toHaveBeenCalledOnce()
2128
+ expect(mockReviewRunner).toHaveBeenCalledWith(expect.objectContaining({ agent: 'developer' }), 'fast', 'default')
2129
+ })
2130
+
2131
+ it('fast review BLOCK + retries remaining — task retried with feedback prepended', async () => {
2132
+ let callCount = 0
2133
+ adapter.execute.mockImplementation(() => {
2134
+ callCount++
2135
+ return Promise.resolve({ success: true, output: 'ok', exitCode: 0 })
2136
+ })
2137
+ const mockReviewRunner = vi.fn()
2138
+ .mockResolvedValueOnce({ verdict: 'block', feedback: 'Missing tests', tokens: 50, model: 'reviewer' })
2139
+ .mockResolvedValueOnce({ verdict: 'pass', feedback: '', tokens: 50, model: 'reviewer' })
2140
+
2141
+ const engine = makeEngine({
2142
+ spec: makeSpec({ defaults: { review: 'fast' } }, [{ max_retries: 1 }]),
2143
+ specYaml: 'name: test',
2144
+ adapter,
2145
+ dbPath,
2146
+ _worktreeManager: wtManager,
2147
+ _mergeQueue: mergeQueue,
2148
+ _reviewRunner: mockReviewRunner,
2149
+ })
2150
+ const result = await engine.run()
2151
+ expect(result.status).toBe('done')
2152
+ expect(adapter.execute).toHaveBeenCalledTimes(2)
2153
+ expect(mockReviewRunner).toHaveBeenCalledTimes(2)
2154
+ // Prompt on second attempt should contain feedback
2155
+ const secondPrompt = (adapter.execute.mock.calls[1] as [Task])[0].prompt
2156
+ expect(secondPrompt).toContain('Missing tests')
2157
+ })
2158
+
2159
+ it('fast review BLOCK + retries exhausted — status review-blocked', async () => {
2160
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'Insecure code', tokens: 50, model: 'reviewer' })
2161
+
2162
+ const engine = makeEngine({
2163
+ spec: makeSpec({ defaults: { review: 'fast' } }, [{ max_retries: 0 }]),
2164
+ specYaml: 'name: test',
2165
+ adapter,
2166
+ dbPath,
2167
+ _worktreeManager: wtManager,
2168
+ _mergeQueue: mergeQueue,
2169
+ _reviewRunner: mockReviewRunner,
2170
+ })
2171
+ const result = await engine.run()
2172
+ expect(result.status).toBe('failed')
2173
+ expect(result.summary.failed).toBe(1)
2174
+ // Verify the task itself is review-blocked
2175
+ const store = createConvoyStore(dbPath)
2176
+ const tasks = store.getTasksByConvoy(result.convoyId)
2177
+ store.close()
2178
+ expect(tasks[0].status).toBe('review-blocked')
2179
+ })
2180
+
2181
+ it('panel review 2/3 PASS — task proceeds (status done)', async () => {
2182
+ let callCount = 0
2183
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
2184
+ callCount++
2185
+ // 2 pass, 1 block
2186
+ return Promise.resolve(callCount <= 2
2187
+ ? { verdict: 'pass', feedback: '', tokens: 30, model: 'reviewer' }
2188
+ : { verdict: 'block', feedback: 'Minor issue', tokens: 30, model: 'reviewer' })
2189
+ })
2190
+
2191
+ const engine = makeEngine({
2192
+ spec: makeSpec({ defaults: { review: 'panel' } }),
2193
+ specYaml: 'name: test',
2194
+ adapter,
2195
+ dbPath,
2196
+ _worktreeManager: wtManager,
2197
+ _mergeQueue: mergeQueue,
2198
+ _reviewRunner: mockReviewRunner,
2199
+ })
2200
+ const result = await engine.run()
2201
+ expect(result.status).toBe('done')
2202
+ expect(mockReviewRunner).toHaveBeenCalledTimes(3)
2203
+ })
2204
+
2205
+ it('panel review 2/3 BLOCK — task retried with MUST-FIX', async () => {
2206
+ let reviewCallCount = 0
2207
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
2208
+ reviewCallCount++
2209
+ // First round: 2 block; second round: 3 pass
2210
+ if (reviewCallCount <= 3) {
2211
+ return Promise.resolve(reviewCallCount <= 2
2212
+ ? { verdict: 'block', feedback: 'Critical bug', tokens: 30, model: 'reviewer' }
2213
+ : { verdict: 'pass', feedback: '', tokens: 30, model: 'reviewer' })
2214
+ }
2215
+ return Promise.resolve({ verdict: 'pass', feedback: '', tokens: 30, model: 'reviewer' })
2216
+ })
2217
+
2218
+ const engine = makeEngine({
2219
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ max_retries: 1 }]),
2220
+ specYaml: 'name: test',
2221
+ adapter,
2222
+ dbPath,
2223
+ _worktreeManager: wtManager,
2224
+ _mergeQueue: mergeQueue,
2225
+ _reviewRunner: mockReviewRunner,
2226
+ })
2227
+ const result = await engine.run()
2228
+ expect(result.status).toBe('done')
2229
+ expect(adapter.execute).toHaveBeenCalledTimes(2)
2230
+ // Prompt on second attempt contains MUST-FIX
2231
+ const secondPrompt = (adapter.execute.mock.calls[1] as [Task])[0].prompt
2232
+ expect(secondPrompt).toContain('MUST-FIX')
2233
+ expect(secondPrompt).toContain('Critical bug')
2234
+ })
2235
+
2236
+ it('review budget exceeded with skip — review skipped, task done', async () => {
2237
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 200, model: 'reviewer' })
2238
+
2239
+ const engine = makeEngine({
2240
+ spec: makeSpec({
2241
+ defaults: { review: 'fast', review_budget: 100, on_review_budget_exceeded: 'skip', reviewer_model: 'r1' },
2242
+ tasks: [
2243
+ { id: 'task-1', prompt: 'Prompt 1', agent: 'developer', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 },
2244
+ { id: 'task-2', prompt: 'Prompt 2', agent: 'developer', timeout: '30s', depends_on: ['task-1'], files: [], description: '', max_retries: 0 },
2245
+ ],
2246
+ }),
2247
+ specYaml: 'name: test',
2248
+ adapter,
2249
+ dbPath,
2250
+ _worktreeManager: wtManager,
2251
+ _mergeQueue: mergeQueue,
2252
+ _reviewRunner: mockReviewRunner,
2253
+ })
2254
+ const result = await engine.run()
2255
+ expect(result.status).toBe('done')
2256
+ // first task: budget not exceeded (0 < 100), review runs
2257
+ // second task: budget exceeded (200 >= 100), review skipped
2258
+ expect(mockReviewRunner).toHaveBeenCalledTimes(1)
2259
+ })
2260
+
2261
+ it('auto route: developer agent with empty diff → auto-pass (no reviewer call)', async () => {
2262
+ // Given: 'auto' review setting, developer agent, empty diff (git will fail on mock path)
2263
+ const mockReviewRunner = vi.fn()
2264
+ const engine = makeEngine({
2265
+ spec: makeSpec({ defaults: { review: 'auto' } }),
2266
+ specYaml: 'name: test',
2267
+ adapter,
2268
+ dbPath,
2269
+ _worktreeManager: wtManager,
2270
+ _mergeQueue: mergeQueue,
2271
+ _reviewRunner: mockReviewRunner,
2272
+ })
2273
+ const result = await engine.run()
2274
+ expect(result.status).toBe('done')
2275
+ expect(mockReviewRunner).not.toHaveBeenCalled()
2276
+ })
2277
+
2278
+ it('review tokens tracked on task record', async () => {
2279
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 77, model: 'reviewer' })
2280
+ const engine = makeEngine({
2281
+ spec: makeSpec({ defaults: { review: 'fast' } }),
2282
+ specYaml: 'name: test',
2283
+ adapter,
2284
+ dbPath,
2285
+ _worktreeManager: wtManager,
2286
+ _mergeQueue: mergeQueue,
2287
+ _reviewRunner: mockReviewRunner,
2288
+ })
2289
+ const result = await engine.run()
2290
+ const store = createConvoyStore(dbPath)
2291
+ const tasks = store.getTasksByConvoy(result.convoyId)
2292
+ store.close()
2293
+ expect(tasks[0].review_tokens).toBe(77)
2294
+ expect(tasks[0].review_level).toBe('fast')
2295
+ expect(tasks[0].review_verdict).toBe('pass')
2296
+ })
2297
+
2298
+ it('review_started and review_verdict events emitted', async () => {
2299
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 10, model: 'reviewer' })
2300
+ const engine = makeEngine({
2301
+ spec: makeSpec({ defaults: { review: 'fast' } }),
2302
+ specYaml: 'name: test',
2303
+ adapter,
2304
+ dbPath,
2305
+ _worktreeManager: wtManager,
2306
+ _mergeQueue: mergeQueue,
2307
+ _reviewRunner: mockReviewRunner,
2308
+ })
2309
+ const result = await engine.run()
2310
+ const store = createConvoyStore(dbPath)
2311
+ const events = store.getEvents(result.convoyId)
2312
+ store.close()
2313
+ const startedEvent = events.find(e => e.type === 'review_started')
2314
+ const verdictEvent = events.find(e => e.type === 'review_verdict')
2315
+ expect(startedEvent).toBeDefined()
2316
+ expect(verdictEvent).toBeDefined()
2317
+ })
2318
+
2319
+ it('review sessions do NOT count against concurrency limit', async () => {
2320
+ // Concurrency=1, 2 tasks in parallel. Both should complete with review.
2321
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'pass', feedback: '', tokens: 10, model: 'reviewer' })
2322
+ const engine = makeEngine({
2323
+ spec: makeSpec(
2324
+ { concurrency: 1, defaults: { review: 'fast' } },
2325
+ [{ id: 'task-1' }, { id: 'task-2' }],
2326
+ ),
2327
+ specYaml: 'name: test',
2328
+ adapter,
2329
+ dbPath,
2330
+ _worktreeManager: wtManager,
2331
+ _mergeQueue: mergeQueue,
2332
+ _reviewRunner: mockReviewRunner,
2333
+ })
2334
+ const result = await engine.run()
2335
+ expect(result.status).toBe('done')
2336
+ expect(result.summary.done).toBe(2)
2337
+ })
2338
+
2339
+ it('full fast-review flow: BLOCK on first attempt → retry → PASS → done with complete events', async () => {
2340
+ const mockReviewRunner = vi.fn()
2341
+ .mockResolvedValueOnce({ verdict: 'block', feedback: 'Add more tests', tokens: 40, model: 'reviewer' })
2342
+ .mockResolvedValueOnce({ verdict: 'pass', feedback: '', tokens: 35, model: 'reviewer' })
2343
+
2344
+ const engine = makeEngine({
2345
+ spec: makeSpec({ defaults: { review: 'fast' } }, [{ id: 'task-1', max_retries: 1 }]),
2346
+ specYaml: 'name: test',
2347
+ adapter,
2348
+ dbPath,
2349
+ _worktreeManager: wtManager,
2350
+ _mergeQueue: mergeQueue,
2351
+ _reviewRunner: mockReviewRunner,
2352
+ })
2353
+ const result = await engine.run()
2354
+
2355
+ expect(result.status).toBe('done')
2356
+ expect(adapter.execute).toHaveBeenCalledTimes(2)
2357
+ expect(mockReviewRunner).toHaveBeenCalledTimes(2)
2358
+
2359
+ const store = createConvoyStore(dbPath)
2360
+ const tasks = store.getTasksByConvoy(result.convoyId)
2361
+ const events = store.getEvents(result.convoyId)
2362
+ store.close()
2363
+
2364
+ const task = tasks[0]
2365
+ expect(task.review_level).toBe('fast')
2366
+ expect(task.review_verdict).toBe('pass')
2367
+ expect(task.retries).toBe(1)
2368
+
2369
+ const reviewStartedEvents = events.filter(e => e.type === 'review_started')
2370
+ const reviewVerdictEvents = events.filter(e => e.type === 'review_verdict')
2371
+ expect(reviewStartedEvents.length).toBe(2)
2372
+ expect(reviewVerdictEvents.length).toBe(2)
2373
+
2374
+ const firstVerdict = JSON.parse(reviewVerdictEvents[0].data!) as Record<string, unknown>
2375
+ const secondVerdict = JSON.parse(reviewVerdictEvents[1].data!) as Record<string, unknown>
2376
+ expect(firstVerdict['verdict']).toBe('block')
2377
+ expect(secondVerdict['verdict']).toBe('pass')
2378
+ })
2379
+
2380
+ it('panel flow: 2/3 BLOCK first round → retry → 3/3 PASS second round → done', async () => {
2381
+ let reviewCallCount = 0
2382
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
2383
+ reviewCallCount++
2384
+ // Round 1 (calls 1-3): BLOCK, BLOCK, PASS → majority block → retry
2385
+ if (reviewCallCount <= 3) {
2386
+ return Promise.resolve(reviewCallCount <= 2
2387
+ ? { verdict: 'block', feedback: 'Critical issue', tokens: 20, model: 'reviewer' }
2388
+ : { verdict: 'pass', feedback: '', tokens: 20, model: 'reviewer' })
2389
+ }
2390
+ // Round 2 (calls 4-6): all PASS
2391
+ return Promise.resolve({ verdict: 'pass', feedback: '', tokens: 20, model: 'reviewer' })
2392
+ })
2393
+
2394
+ const engine = makeEngine({
2395
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 1 }]),
2396
+ specYaml: 'name: test',
2397
+ adapter,
2398
+ dbPath,
2399
+ _worktreeManager: wtManager,
2400
+ _mergeQueue: mergeQueue,
2401
+ _reviewRunner: mockReviewRunner,
2402
+ })
2403
+ const result = await engine.run()
2404
+
2405
+ expect(result.status).toBe('done')
2406
+ expect(adapter.execute).toHaveBeenCalledTimes(2)
2407
+ expect(mockReviewRunner).toHaveBeenCalledTimes(6)
2408
+
2409
+ const store = createConvoyStore(dbPath)
2410
+ const tasks = store.getTasksByConvoy(result.convoyId)
2411
+ store.close()
2412
+
2413
+ expect(tasks[0].review_verdict).toBe('pass')
2414
+ expect(tasks[0].panel_attempts).toBeGreaterThanOrEqual(1)
2415
+ })
2416
+
2417
+ it('dispute: task dispute_id matches the dispute_opened event and panel_attempts is 3', async () => {
2418
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'broken', tokens: 5, model: 'r' })
2419
+
2420
+ const engine = makeEngine({
2421
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
2422
+ specYaml: 'name: test',
2423
+ adapter,
2424
+ dbPath,
2425
+ _worktreeManager: wtManager,
2426
+ _mergeQueue: mergeQueue,
2427
+ _reviewRunner: mockReviewRunner,
2428
+ })
2429
+ const result = await engine.run()
2430
+
2431
+ const store = createConvoyStore(dbPath)
2432
+ const tasks = store.getTasksByConvoy(result.convoyId)
2433
+ const events = store.getEvents(result.convoyId)
2434
+ store.close()
2435
+
2436
+ const task = tasks[0]
2437
+ expect(task.status).toBe('disputed')
2438
+ expect(task.dispute_id).not.toBeNull()
2439
+ expect(task.panel_attempts).toBe(3)
2440
+
2441
+ const disputeEvent = events.find(e => e.type === 'dispute_opened')
2442
+ expect(disputeEvent).toBeDefined()
2443
+ const eventData = JSON.parse(disputeEvent!.data!) as Record<string, unknown>
2444
+ // Verify the dispute_id on the task record matches the one in the event
2445
+ expect(eventData['dispute_id']).toBe(task.dispute_id)
2446
+ expect(eventData['panel_attempts']).toBe(3)
2447
+ })
2448
+
2449
+ it('review budget exceeded: stop marks task review-blocked and skips all pending tasks', async () => {
2450
+ const mockReviewRunner = vi.fn()
2451
+
2452
+ const engine = makeEngine({
2453
+ spec: makeSpec(
2454
+ { defaults: { review: 'fast', review_budget: 0, on_review_budget_exceeded: 'stop' } },
2455
+ [
2456
+ { id: 'task-1', depends_on: [] },
2457
+ { id: 'task-2', depends_on: ['task-1'] },
2458
+ ],
2459
+ ),
2460
+ specYaml: 'name: test',
2461
+ adapter,
2462
+ dbPath,
2463
+ _worktreeManager: wtManager,
2464
+ _mergeQueue: mergeQueue,
2465
+ _reviewRunner: mockReviewRunner,
2466
+ })
2467
+ const result = await engine.run()
2468
+
2469
+ const store = createConvoyStore(dbPath)
2470
+ const tasks = store.getTasksByConvoy(result.convoyId)
2471
+ store.close()
2472
+
2473
+ const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]))
2474
+ expect(byId['task-1']).toBe('review-blocked')
2475
+ expect(byId['task-2']).toBe('skipped')
2476
+ expect(mockReviewRunner).not.toHaveBeenCalled()
2477
+ })
2478
+
2479
+ it('review budget exceeded: downgrade auto-passes task without calling reviewer', async () => {
2480
+ const mockReviewRunner = vi.fn()
2481
+
2482
+ const engine = makeEngine({
2483
+ spec: makeSpec(
2484
+ { defaults: { review: 'fast', review_budget: 0, on_review_budget_exceeded: 'downgrade' } },
2485
+ ),
2486
+ specYaml: 'name: test',
2487
+ adapter,
2488
+ dbPath,
2489
+ _worktreeManager: wtManager,
2490
+ _mergeQueue: mergeQueue,
2491
+ _reviewRunner: mockReviewRunner,
2492
+ })
2493
+ const result = await engine.run()
2494
+
2495
+ expect(result.status).toBe('done')
2496
+ expect(mockReviewRunner).not.toHaveBeenCalled()
2497
+
2498
+ const store = createConvoyStore(dbPath)
2499
+ const tasks = store.getTasksByConvoy(result.convoyId)
2500
+ store.close()
2501
+
2502
+ expect(tasks[0].review_verdict).toBe('pass')
2503
+ expect(tasks[0].review_level).toBe('fast')
2504
+ })
2505
+ })
2506
+
2507
+ // ── Drift detection ───────────────────────────────────────────────────────────
2508
+
2509
+ describe('drift detection', () => {
2510
+ let adapter: ReturnType<typeof makeAdapter>
2511
+ let wtManager: ReturnType<typeof makeWorktreeManager>
2512
+ let mergeQueue: ReturnType<typeof makeMergeQueue>
2513
+
2514
+ beforeEach(() => {
2515
+ adapter = makeAdapter('copilot')
2516
+ wtManager = makeWorktreeManager()
2517
+ mergeQueue = makeMergeQueue()
2518
+ })
2519
+
2520
+ it('detect_drift=true triggers drift check and retries on low confidence', async () => {
2521
+ // Call sequence: main task → drift check (low score) → main task retry
2522
+ adapter.execute
2523
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 })
2524
+ .mockResolvedValueOnce({ success: true, output: '{"score": 0.3, "explanation": "uncertain"}', exitCode: 0 })
2525
+ .mockResolvedValueOnce({ success: true, output: 'done retry', exitCode: 0 })
2526
+
2527
+ const engine = makeEngine({
2528
+ spec: makeSpec({ defaults: { detect_drift: true } }, [{ id: 'task-1', max_retries: 1 }]),
2529
+ specYaml: 'name: test',
2530
+ adapter,
2531
+ dbPath,
2532
+ _worktreeManager: wtManager,
2533
+ _mergeQueue: mergeQueue,
2534
+ })
2535
+ const result = await engine.run()
2536
+
2537
+ expect(result.status).toBe('done')
2538
+ expect(result.summary.done).toBe(1)
2539
+ expect(adapter.execute).toHaveBeenCalledTimes(3)
2540
+
2541
+ // Verify drift_score and drift_retried stored
2542
+ const store = createConvoyStore(dbPath)
2543
+ const tasks = store.getTasksByConvoy(result.convoyId)
2544
+ store.close()
2545
+ expect(tasks[0].drift_score).toBe(0.3)
2546
+ expect(tasks[0].drift_retried).toBe(1)
2547
+ })
2548
+
2549
+ it('detect_drift=true does NOT re-check on drift retry (drift_retried=1)', async () => {
2550
+ // On second execution drift_retried=1 so no third call for drift check
2551
+ adapter.execute
2552
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 })
2553
+ .mockResolvedValueOnce({ success: true, output: '{"score": 0.9, "explanation": "confident"}', exitCode: 0 })
2554
+
2555
+ const engine = makeEngine({
2556
+ spec: makeSpec({ defaults: { detect_drift: true } }),
2557
+ specYaml: 'name: test',
2558
+ adapter,
2559
+ dbPath,
2560
+ _worktreeManager: wtManager,
2561
+ _mergeQueue: mergeQueue,
2562
+ })
2563
+ const result = await engine.run()
2564
+
2565
+ expect(result.status).toBe('done')
2566
+ expect(adapter.execute).toHaveBeenCalledTimes(2)
2567
+ })
2568
+
2569
+ it('drift_check_result and drift_detected events emitted when drifted', async () => {
2570
+ adapter.execute
2571
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 })
2572
+ .mockResolvedValueOnce({ success: true, output: '{"score": 0.2, "explanation": "very unsure"}', exitCode: 0 })
2573
+ .mockResolvedValueOnce({ success: true, output: 'done', exitCode: 0 })
2574
+
2575
+ const engine = makeEngine({
2576
+ spec: makeSpec({ defaults: { detect_drift: true } }, [{ id: 'task-1', max_retries: 1 }]),
2577
+ specYaml: 'name: test',
2578
+ adapter,
2579
+ dbPath,
2580
+ _worktreeManager: wtManager,
2581
+ _mergeQueue: mergeQueue,
2582
+ })
2583
+ const result = await engine.run()
2584
+
2585
+ const store = createConvoyStore(dbPath)
2586
+ const events = store.getEvents(result.convoyId)
2587
+ store.close()
2588
+
2589
+ expect(events.some(e => e.type === 'drift_check_result')).toBe(true)
2590
+ expect(events.some(e => e.type === 'drift_detected')).toBe(true)
2591
+ })
2592
+
2593
+ it('non-copilot adapter skips drift detection (returns done without extra call)', async () => {
2594
+ // adapter name is 'test-adapter' — not a streaming adapter; drift check should be skipped
2595
+ const nonStreamingAdapter = makeAdapter('test-adapter')
2596
+ nonStreamingAdapter.execute.mockResolvedValue({ success: true, output: 'ok', exitCode: 0 })
2597
+
2598
+ // Suppress the stderr warning
2599
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
2600
+ try {
2601
+ const engine = makeEngine({
2602
+ spec: makeSpec({ defaults: { detect_drift: true } }),
2603
+ specYaml: 'name: test',
2604
+ adapter: nonStreamingAdapter,
2605
+ dbPath,
2606
+ _worktreeManager: wtManager,
2607
+ _mergeQueue: mergeQueue,
2608
+ })
2609
+ const result = await engine.run()
2610
+ expect(result.status).toBe('done')
2611
+ // Only 1 call: main task (no drift check call) because non-streaming adapter
2612
+ expect(nonStreamingAdapter.execute).toHaveBeenCalledTimes(1)
2613
+ } finally {
2614
+ stderrSpy.mockRestore()
2615
+ }
2616
+ })
2617
+ })
2618
+
2619
+ // ── Dispute protocol ──────────────────────────────────────────────────────────
2620
+
2621
+ describe('dispute protocol', () => {
2622
+ let adapter: ReturnType<typeof makeAdapter>
2623
+ let wtManager: ReturnType<typeof makeWorktreeManager>
2624
+ let mergeQueue: ReturnType<typeof makeMergeQueue>
2625
+
2626
+ beforeEach(() => {
2627
+ adapter = makeAdapter()
2628
+ wtManager = makeWorktreeManager()
2629
+ mergeQueue = makeMergeQueue()
2630
+ })
2631
+
2632
+ it('3 panel blocks mark task as disputed', async () => {
2633
+ // Each round: 3 calls to panel runner (all block) → retry until max_retries
2634
+ // 3 panel blocks with max_retries=3 → 3 panel rounds → after 3rd: panel_attempts=3 → disputed
2635
+ let panelCall = 0
2636
+ const mockReviewRunner = vi.fn().mockImplementation(() => {
2637
+ panelCall++
2638
+ return Promise.resolve({ verdict: 'block', feedback: 'critical bug', tokens: 10, model: 'r' })
2639
+ })
2640
+
2641
+ const engine = makeEngine({
2642
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
2643
+ specYaml: 'name: test',
2644
+ adapter,
2645
+ dbPath,
2646
+ _worktreeManager: wtManager,
2647
+ _mergeQueue: mergeQueue,
2648
+ _reviewRunner: mockReviewRunner,
2649
+ })
2650
+ const result = await engine.run()
2651
+
2652
+ const store = createConvoyStore(dbPath)
2653
+ const tasks = store.getTasksByConvoy(result.convoyId)
2654
+ store.close()
2655
+
2656
+ expect(tasks[0].status).toBe('disputed')
2657
+ expect(tasks[0].dispute_id).not.toBeNull()
2658
+ expect(result.summary.failed).toBe(1) // disputed counts as failed in summary
2659
+ })
2660
+
2661
+ it('dispute_opened event emitted after 3 panel blocks', async () => {
2662
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'bug', tokens: 5, model: 'r' })
2663
+
2664
+ const engine = makeEngine({
2665
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
2666
+ specYaml: 'name: test',
2667
+ adapter,
2668
+ dbPath,
2669
+ _worktreeManager: wtManager,
2670
+ _mergeQueue: mergeQueue,
2671
+ _reviewRunner: mockReviewRunner,
2672
+ })
2673
+ const result = await engine.run()
2674
+
2675
+ const store = createConvoyStore(dbPath)
2676
+ const events = store.getEvents(result.convoyId)
2677
+ store.close()
2678
+
2679
+ const disputeEvent = events.find(e => e.type === 'dispute_opened')
2680
+ expect(disputeEvent).toBeDefined()
2681
+ const data = JSON.parse(disputeEvent!.data!) as Record<string, unknown>
2682
+ expect(data.task_id).toBe('task-1')
2683
+ expect(data.panel_attempts).toBe(3)
2684
+ })
2685
+
2686
+ it('on_dispute: stop halts all pending tasks', async () => {
2687
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'bug', tokens: 5, model: 'r' })
2688
+
2689
+ const engine = makeEngine({
2690
+ spec: makeSpec(
2691
+ { defaults: { review: 'panel', on_dispute: 'stop' } },
2692
+ [
2693
+ { id: 'task-1', depends_on: [], max_retries: 3 },
2694
+ { id: 'task-2', depends_on: ['task-1'] }, // depends on task-1, so queued after
2695
+ ],
2696
+ ),
2697
+ specYaml: 'name: test',
2698
+ adapter,
2699
+ dbPath,
2700
+ _worktreeManager: wtManager,
2701
+ _mergeQueue: mergeQueue,
2702
+ _reviewRunner: mockReviewRunner,
2703
+ })
2704
+ const result = await engine.run()
2705
+
2706
+ const store = createConvoyStore(dbPath)
2707
+ const tasks = store.getTasksByConvoy(result.convoyId)
2708
+ store.close()
2709
+ const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]))
2710
+ expect(byId['task-1']).toBe('disputed')
2711
+ expect(byId['task-2']).toBe('skipped')
2712
+ })
2713
+
2714
+ it('on_dispute: continue keeps other tasks running', async () => {
2715
+ // task-1 always fails panel (will be disputed), task-2 succeeds
2716
+ adapter.execute.mockResolvedValue({ success: true, output: 'ok', exitCode: 0 })
2717
+ const mockReviewRunner = vi.fn().mockImplementation((_task: TaskRecord) => {
2718
+ if (_task.id === 'task-1') {
2719
+ return Promise.resolve({ verdict: 'block', feedback: 'bug', tokens: 5, model: 'r' })
2720
+ }
2721
+ return Promise.resolve({ verdict: 'pass', feedback: '', tokens: 5, model: 'r' })
2722
+ })
2723
+
2724
+ const engine = makeEngine({
2725
+ spec: makeSpec(
2726
+ { defaults: { review: 'panel', on_dispute: 'continue' } },
2727
+ [
2728
+ { id: 'task-1', depends_on: [], max_retries: 3 },
2729
+ { id: 'task-2', depends_on: [] },
2730
+ ],
2731
+ ),
2732
+ specYaml: 'name: test',
2733
+ adapter,
2734
+ dbPath,
2735
+ _worktreeManager: wtManager,
2736
+ _mergeQueue: mergeQueue,
2737
+ _reviewRunner: mockReviewRunner,
2738
+ })
2739
+ const result = await engine.run()
2740
+
2741
+ const store = createConvoyStore(dbPath)
2742
+ const tasks = store.getTasksByConvoy(result.convoyId)
2743
+ store.close()
2744
+ const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]))
2745
+ expect(byId['task-1']).toBe('disputed')
2746
+ expect(byId['task-2']).toBe('done')
2747
+ })
2748
+ })
2749
+
2750
+ // ── File-based injection ───────────────────────────────────────────────────
2751
+
2752
+ describe('file-based injection', () => {
2753
+ it('picks up tasks from inject file and ingests them', async () => {
2754
+ const adapter = makeAdapter()
2755
+ adapter.execute.mockResolvedValue({ success: true, output: 'ok', exitCode: 0 })
2756
+
2757
+ const spec = makeSpec({ concurrency: 1 }, [
2758
+ { id: 'task-1', prompt: 'Original task', timeout: '5s' },
2759
+ ])
2760
+
2761
+ const engine = makeEngine({
2762
+ spec,
2763
+ specYaml: 'name: test',
2764
+ adapter,
2765
+ dbPath,
2766
+ basePath: tmpDir,
2767
+ _worktreeManager: makeWorktreeManager(),
2768
+ _mergeQueue: makeMergeQueue(),
2769
+ })
2770
+
2771
+ const result = await engine.run()
2772
+ expect(result.summary.done).toBeGreaterThanOrEqual(1)
2773
+ })
2774
+
2775
+ it('respects convoy_id path traversal guard', async () => {
2776
+ const adapter = makeAdapter()
2777
+ const spec = makeSpec()
2778
+
2779
+ const engine = makeEngine({
2780
+ spec,
2781
+ specYaml: 'name: test',
2782
+ adapter,
2783
+ dbPath,
2784
+ basePath: tmpDir,
2785
+ _worktreeManager: makeWorktreeManager(),
2786
+ _mergeQueue: makeMergeQueue(),
2787
+ })
2788
+
2789
+ const result = await engine.run()
2790
+ expect(result.status).toBe('done')
2791
+ })
2792
+ })
2793
+
2794
+ describe('NDJSON recovery', () => {
2795
+ it('truncates partial trailing line in NDJSON file', () => {
2796
+ const convoyId = 'convoy-ndjson-1'
2797
+ const ndjsonPath = join(tmpDir, 'recover-partial.ndjson')
2798
+ const firstLine = JSON.stringify({ _event_id: 1, convoy_id: convoyId, type: 'task_started' })
2799
+ writeFileSync(ndjsonPath, `${firstLine}\n{"_event_id":2`, 'utf8')
2800
+
2801
+ const mockStore = {
2802
+ getEvents: vi.fn().mockReturnValue([]),
2803
+ }
2804
+
2805
+ recoverNdjson(mockStore as unknown as ReturnType<typeof createConvoyStore>, convoyId, ndjsonPath)
2806
+
2807
+ const content = readFileSync(ndjsonPath, 'utf8')
2808
+ expect(content).toBe(`${firstLine}\n`)
2809
+ })
2810
+
2811
+ it('replays SQLite events missing from NDJSON file', () => {
2812
+ const convoyId = 'convoy-ndjson-2'
2813
+ const ndjsonPath = join(tmpDir, 'recover-replay.ndjson')
2814
+ writeFileSync(
2815
+ ndjsonPath,
2816
+ `${JSON.stringify({ _event_id: 1, convoy_id: convoyId, type: 'task_started' })}\n`,
2817
+ 'utf8',
2818
+ )
2819
+
2820
+ const mockStore = {
2821
+ getEvents: vi.fn().mockReturnValue([
2822
+ {
2823
+ id: 1,
2824
+ type: 'task_started',
2825
+ convoy_id: convoyId,
2826
+ task_id: 'task-1',
2827
+ worker_id: null,
2828
+ data: JSON.stringify({ phase: 0 }),
2829
+ created_at: '2026-03-11T10:00:00.000Z',
2830
+ },
2831
+ {
2832
+ id: 2,
2833
+ type: 'task_finished',
2834
+ convoy_id: convoyId,
2835
+ task_id: 'task-1',
2836
+ worker_id: null,
2837
+ data: JSON.stringify({ success: true }),
2838
+ created_at: '2026-03-11T10:00:01.000Z',
2839
+ },
2840
+ ]),
2841
+ }
2842
+
2843
+ recoverNdjson(mockStore as unknown as ReturnType<typeof createConvoyStore>, convoyId, ndjsonPath)
2844
+
2845
+ const lines = readFileSync(ndjsonPath, 'utf8').trim().split('\n').map((line) => JSON.parse(line) as Record<string, unknown>)
2846
+ const eventIds = lines.map((line) => line._event_id)
2847
+ expect(eventIds).toEqual([1, 2])
2848
+ })
2849
+
2850
+ it('does not let event.data override canonical fields', () => {
2851
+ const convoyId = 'convoy-ndjson-canonical'
2852
+ const ndjsonPath = join(tmpDir, 'recover-canonical.ndjson')
2853
+ writeFileSync(ndjsonPath, '', 'utf8')
2854
+
2855
+ const mockStore = {
2856
+ getEvents: vi.fn().mockReturnValue([
2857
+ {
2858
+ id: 99,
2859
+ type: 'task_started',
2860
+ convoy_id: convoyId,
2861
+ task_id: 'task-legit',
2862
+ worker_id: 'w1',
2863
+ data: JSON.stringify({
2864
+ _event_id: 'EVIL',
2865
+ convoy_id: 'EVIL-CONVOY',
2866
+ task_id: 'EVIL-TASK',
2867
+ type: 'EVIL-TYPE',
2868
+ timestamp: 'EVIL-TIME',
2869
+ worker_id: 'EVIL-WORKER',
2870
+ safe_field: 'this-is-fine',
2871
+ }),
2872
+ created_at: '2026-03-11T10:00:00.000Z',
2873
+ },
2874
+ ]),
2875
+ }
2876
+
2877
+ recoverNdjson(mockStore as unknown as ReturnType<typeof createConvoyStore>, convoyId, ndjsonPath)
2878
+
2879
+ const lines = readFileSync(ndjsonPath, 'utf8').trim().split('\n')
2880
+ expect(lines).toHaveLength(1)
2881
+ const parsed = JSON.parse(lines[0]) as Record<string, unknown>
2882
+ expect(parsed._event_id).toBe(99)
2883
+ expect(parsed.convoy_id).toBe(convoyId)
2884
+ expect(parsed.task_id).toBe('task-legit')
2885
+ expect(parsed.type).toBe('task_started')
2886
+ expect(parsed.worker_id).toBe('w1')
2887
+ expect(parsed.timestamp).toBe('2026-03-11T10:00:00.000Z')
2888
+ expect(parsed.safe_field).toBe('this-is-fine')
2889
+ })
2890
+ })
2891
+
2892
+ describe('runConvoyGuard', () => {
2893
+ it('returns passed: false when non-terminal tasks exist', () => {
2894
+ const guardConvoyId = 'convoy-guard-1'
2895
+ const guardStore = createConvoyStore(dbPath)
2896
+ guardStore.insertConvoy({
2897
+ id: guardConvoyId,
2898
+ name: 'Guard test',
2899
+ spec_hash: 'hash',
2900
+ spec_yaml: 'name: guard test',
2901
+ status: 'running',
2902
+ branch: null,
2903
+ created_at: new Date().toISOString(),
2904
+ })
2905
+ guardStore.insertTask({
2906
+ id: 'task-guard-1',
2907
+ convoy_id: guardConvoyId,
2908
+ phase: 0,
2909
+ prompt: 'test',
2910
+ agent: 'developer',
2911
+ adapter: null,
2912
+ model: null,
2913
+ timeout_ms: 60000,
2914
+ status: 'running',
2915
+ retries: 0,
2916
+ max_retries: 1,
2917
+ files: null,
2918
+ depends_on: null,
2919
+ gates: null,
2920
+ })
2921
+
2922
+ const ndjsonPathGuard = join(tmpDir, 'guard-test.ndjson')
2923
+ writeFileSync(ndjsonPathGuard, '')
2924
+ const wtManager = makeWorktreeManager()
2925
+ const result = runConvoyGuard(guardStore, guardConvoyId, wtManager, ndjsonPathGuard)
2926
+ expect(result.passed).toBe(false)
2927
+ expect(result.warnings.length).toBeGreaterThan(0)
2928
+ guardStore.close()
2929
+ })
2930
+
2931
+ it('returns passed: true when all tasks are terminal', () => {
2932
+ const guardConvoyId2 = 'convoy-guard-2'
2933
+ const guardStore2 = createConvoyStore(dbPath)
2934
+ guardStore2.insertConvoy({
2935
+ id: guardConvoyId2,
2936
+ name: 'Guard test ok',
2937
+ spec_hash: 'hash',
2938
+ spec_yaml: 'name: guard test ok',
2939
+ status: 'done',
2940
+ branch: null,
2941
+ created_at: new Date().toISOString(),
2942
+ })
2943
+ guardStore2.insertTask({
2944
+ id: 'task-guard-2',
2945
+ convoy_id: guardConvoyId2,
2946
+ phase: 0,
2947
+ prompt: 'test',
2948
+ agent: 'developer',
2949
+ adapter: null,
2950
+ model: null,
2951
+ timeout_ms: 60000,
2952
+ status: 'done',
2953
+ retries: 0,
2954
+ max_retries: 1,
2955
+ files: null,
2956
+ depends_on: null,
2957
+ gates: null,
2958
+ })
2959
+
2960
+ const ndjsonPathGuard2 = join(tmpDir, 'guard-pass.ndjson')
2961
+ writeFileSync(ndjsonPathGuard2, JSON.stringify({ _event_id: 1, convoy_id: guardConvoyId2, type: 'task_done' }) + '\n')
2962
+ const wtManager2 = makeWorktreeManager()
2963
+ const result2 = runConvoyGuard(guardStore2, guardConvoyId2, wtManager2, ndjsonPathGuard2)
2964
+ expect(result2.passed).toBe(true)
2965
+ guardStore2.close()
2966
+ })
2967
+ })
2968
+
2969
+ describe('injectTask partition validation', () => {
2970
+ it('rejects injected tasks with normalized path overlap', () => {
2971
+ const symlinkSpy = vi.spyOn(partition, 'scanSymlinks').mockImplementation(() => {})
2972
+
2973
+ const convoyId = 'convoy-inject-overlap-1'
2974
+ const seedStore = createConvoyStore(dbPath)
2975
+ seedStore.insertConvoy({
2976
+ id: convoyId,
2977
+ name: 'Inject overlap test',
2978
+ spec_hash: 'hash-1',
2979
+ status: 'pending',
2980
+ branch: null,
2981
+ created_at: new Date().toISOString(),
2982
+ spec_yaml: 'name: inject-overlap',
2983
+ pipeline_id: null,
2984
+ })
2985
+ seedStore.insertTask({
2986
+ id: 'task-owner',
2987
+ convoy_id: convoyId,
2988
+ phase: 0,
2989
+ prompt: 'Owns auth partition',
2990
+ agent: 'developer',
2991
+ adapter: null,
2992
+ model: null,
2993
+ timeout_ms: 30_000,
2994
+ status: 'pending',
2995
+ retries: 0,
2996
+ max_retries: 1,
2997
+ files: JSON.stringify(['src/auth/']),
2998
+ depends_on: null,
2999
+ gates: null,
3000
+ })
3001
+ seedStore.close()
3002
+
3003
+ const engine = makeEngine({
3004
+ spec: makeSpec(),
3005
+ specYaml: 'name: inject-overlap',
3006
+ adapter: makeAdapter(),
3007
+ dbPath,
3008
+ basePath: tmpDir,
3009
+ _worktreeManager: makeWorktreeManager(),
3010
+ _mergeQueue: makeMergeQueue(),
3011
+ })
3012
+
3013
+ try {
3014
+ expect(() => engine.injectTask(convoyId, {
3015
+ id: 'task-injected',
3016
+ prompt: 'Injected overlap task',
3017
+ agent: 'developer',
3018
+ phase: 0,
3019
+ files: ['src/auth/service.ts'],
3020
+ })).toThrow(/File partition overlap/i)
3021
+ } finally {
3022
+ symlinkSpy.mockRestore()
3023
+ }
3024
+ })
3025
+
3026
+ it('rejects injected task with unnormalized paths that overlap', () => {
3027
+ const symlinkSpy = vi.spyOn(partition, 'scanSymlinks').mockImplementation(() => {})
3028
+
3029
+ const convoyId = 'convoy-inject-overlap-2'
3030
+ const seedStore = createConvoyStore(dbPath)
3031
+ seedStore.insertConvoy({
3032
+ id: convoyId,
3033
+ name: 'Inject overlap test 2',
3034
+ spec_hash: 'hash-2',
3035
+ status: 'pending',
3036
+ branch: null,
3037
+ created_at: new Date().toISOString(),
3038
+ spec_yaml: 'name: inject-overlap-2',
3039
+ pipeline_id: null,
3040
+ })
3041
+ seedStore.insertTask({
3042
+ id: 'task-owner',
3043
+ convoy_id: convoyId,
3044
+ phase: 0,
3045
+ prompt: 'Owns auth partition',
3046
+ agent: 'developer',
3047
+ adapter: null,
3048
+ model: null,
3049
+ timeout_ms: 30_000,
3050
+ status: 'pending',
3051
+ retries: 0,
3052
+ max_retries: 1,
3053
+ files: JSON.stringify(['src/auth/']),
3054
+ depends_on: null,
3055
+ gates: null,
3056
+ })
3057
+ seedStore.close()
3058
+
3059
+ const engine = makeEngine({
3060
+ spec: makeSpec(),
3061
+ specYaml: 'name: inject-overlap-2',
3062
+ adapter: makeAdapter(),
3063
+ dbPath,
3064
+ basePath: tmpDir,
3065
+ _worktreeManager: makeWorktreeManager(),
3066
+ _mergeQueue: makeMergeQueue(),
3067
+ })
3068
+
3069
+ try {
3070
+ expect(() => engine.injectTask(convoyId, {
3071
+ id: 'task-injected-dot-path',
3072
+ prompt: 'Injected overlap task',
3073
+ agent: 'developer',
3074
+ phase: 0,
3075
+ files: ['./src/auth/service.ts'],
3076
+ })).toThrow(/File partition overlap/i)
3077
+ } finally {
3078
+ symlinkSpy.mockRestore()
3079
+ }
3080
+ })
3081
+ })
3082
+
3083
+ // ── Swarm mode ─────────────────────────────────────────────────────────────
3084
+
3085
+ describe('swarm mode (concurrency: auto)', () => {
3086
+ it('runs all tasks with auto concurrency', async () => {
3087
+ const adapter = makeAdapter()
3088
+ const spec = makeSpec(
3089
+ { concurrency: 'auto' as unknown as number },
3090
+ [
3091
+ { id: 'task-1', prompt: 'First' },
3092
+ { id: 'task-2', prompt: 'Second' },
3093
+ { id: 'task-3', prompt: 'Third' },
3094
+ ],
3095
+ )
3096
+
3097
+ const engine = makeEngine({
3098
+ spec,
3099
+ specYaml: 'name: test',
3100
+ adapter,
3101
+ dbPath,
3102
+ _worktreeManager: makeWorktreeManager(),
3103
+ _mergeQueue: makeMergeQueue(),
3104
+ })
3105
+
3106
+ const result = await engine.run()
3107
+ expect(result.status).toBe('done')
3108
+ expect(result.summary.done).toBe(3)
3109
+ expect(result.summary.total).toBe(3)
3110
+ })
3111
+
3112
+ it('respects max_swarm_concurrency from defaults', async () => {
3113
+ const adapter = makeAdapter()
3114
+ let maxConcurrent = 0
3115
+ let currentConcurrent = 0
3116
+
3117
+ adapter.execute.mockImplementation(async () => {
3118
+ currentConcurrent++
3119
+ if (currentConcurrent > maxConcurrent) maxConcurrent = currentConcurrent
3120
+ await new Promise(resolve => setTimeout(resolve, 50))
3121
+ currentConcurrent--
3122
+ return { success: true, output: 'ok', exitCode: 0 }
3123
+ })
3124
+
3125
+ const spec = makeSpec(
3126
+ {
3127
+ concurrency: 'auto' as unknown as number,
3128
+ defaults: { max_swarm_concurrency: 2 },
3129
+ },
3130
+ [
3131
+ { id: 'task-1', prompt: 'T1' },
3132
+ { id: 'task-2', prompt: 'T2' },
3133
+ { id: 'task-3', prompt: 'T3' },
3134
+ { id: 'task-4', prompt: 'T4' },
3135
+ ],
3136
+ )
3137
+
3138
+ const engine = makeEngine({
3139
+ spec,
3140
+ specYaml: 'name: test',
3141
+ adapter,
3142
+ dbPath,
3143
+ _worktreeManager: makeWorktreeManager(),
3144
+ _mergeQueue: makeMergeQueue(),
3145
+ })
3146
+
3147
+ const result = await engine.run()
3148
+ expect(result.status).toBe('done')
3149
+ expect(result.summary.done).toBe(4)
3150
+ expect(maxConcurrent).toBeLessThanOrEqual(2)
3151
+ })
3152
+
3153
+ it('defaults max_swarm_concurrency to 8', async () => {
3154
+ const adapter = makeAdapter()
3155
+
3156
+ const spec = makeSpec(
3157
+ { concurrency: 'auto' as unknown as number },
3158
+ Array.from({ length: 10 }, (_, i) => ({
3159
+ id: `task-${i + 1}`,
3160
+ prompt: `Task ${i + 1}`,
3161
+ })),
3162
+ )
3163
+
3164
+ const engine = makeEngine({
3165
+ spec,
3166
+ specYaml: 'name: test',
3167
+ adapter,
3168
+ dbPath,
3169
+ _worktreeManager: makeWorktreeManager(),
3170
+ _mergeQueue: makeMergeQueue(),
3171
+ })
3172
+
3173
+ const result = await engine.run()
3174
+ expect(result.status).toBe('done')
3175
+ expect(result.summary.done).toBe(10)
3176
+ })
3177
+ })
3178
+
3179
+ // ── Step retry context prepending ───────────────────────────────────────────
3180
+
3181
+ describe('step retry context prepending', () => {
3182
+ it('prepends prior failure output to the prompt on step retry', async () => {
3183
+ const adapter = makeAdapter()
3184
+ const capturedPrompts: string[] = []
3185
+
3186
+ adapter.execute.mockImplementation(async (task: { prompt: string }) => {
3187
+ capturedPrompts.push(task.prompt)
3188
+ if (capturedPrompts.length === 1) {
3189
+ return { success: false, output: 'step error detail', exitCode: 2 }
3190
+ }
3191
+ return { success: true, output: 'ok', exitCode: 0 }
3192
+ })
3193
+
3194
+ const spec = makeSpec({}, [
3195
+ {
3196
+ id: 'task-1',
3197
+ prompt: 'original task prompt',
3198
+ max_retries: 0,
3199
+ steps: [{ prompt: 'step prompt text', max_retries: 1 }],
3200
+ },
3201
+ ])
3202
+
3203
+ const engine = makeEngine({
3204
+ spec,
3205
+ specYaml: 'name: test',
3206
+ adapter,
3207
+ dbPath,
3208
+ _worktreeManager: makeWorktreeManager(),
3209
+ _mergeQueue: makeMergeQueue(),
3210
+ })
3211
+
3212
+ await engine.run()
3213
+
3214
+ // First call uses the original step prompt
3215
+ expect(capturedPrompts[0]).toBe('step prompt text')
3216
+ // Second call (retry) prepends failure context
3217
+ expect(capturedPrompts[1]).toContain('Previous attempt failed.')
3218
+ expect(capturedPrompts[1]).toContain('Exit code: 2')
3219
+ expect(capturedPrompts[1]).toContain('step error detail')
3220
+ expect(capturedPrompts[1]).toContain('step prompt text')
3221
+ })
3222
+ })
3223
+
3224
+ // ── Security: symlink scan (issue #2) ─────────────────────────────────────────
3225
+
3226
+ describe('symlink security scan', () => {
3227
+ it('marks task failed when pre-execution scanSymlinks throws', async () => {
3228
+ const scanSpy = vi.spyOn(partition, 'scanSymlinks').mockImplementation(() => {
3229
+ throw new Error('symlink_escape: "evil.ts" is a symlink that resolves outside the partition')
3230
+ })
3231
+
3232
+ try {
3233
+ const adapter = makeAdapter()
3234
+ const spec = makeSpec({}, [{ files: ['src/evil.ts'] }])
3235
+ const engine = makeEngine({
3236
+ spec,
3237
+ specYaml: 'name: test',
3238
+ adapter,
3239
+ dbPath,
3240
+ _worktreeManager: makeWorktreeManager(),
3241
+ _mergeQueue: makeMergeQueue(),
3242
+ })
3243
+
3244
+ const result = await engine.run()
3245
+ expect(result.status).toBe('failed')
3246
+ } finally {
3247
+ scanSpy.mockRestore()
3248
+ }
3249
+ })
3250
+
3251
+ it('succeeds when files is empty (symlink scan skipped)', async () => {
3252
+ const adapter = makeAdapter()
3253
+ const spec = makeSpec({}, [{ files: [] }])
3254
+ const engine = makeEngine({
3255
+ spec,
3256
+ specYaml: 'name: test',
3257
+ adapter,
3258
+ dbPath,
3259
+ _worktreeManager: makeWorktreeManager(),
3260
+ _mergeQueue: makeMergeQueue(),
3261
+ })
3262
+
3263
+ const result = await engine.run()
3264
+ expect(result.status).toBe('done')
3265
+ })
3266
+ })
3267
+
3268
+ // ── Security: ensureBranch fallback (issue #3) ────────────────────────────────
3269
+
3270
+ describe('ensureBranch fallback when _ensureBranch not provided', () => {
3271
+ it('calls the injected _ensureBranch when branch is set in spec', async () => {
3272
+ const branchFn = vi.fn().mockResolvedValue(undefined)
3273
+ const adapter = makeAdapter()
3274
+ const spec = makeSpec({ branch: 'feature-x' })
3275
+ const engine = createConvoyEngine({
3276
+ spec,
3277
+ specYaml: 'name: test',
3278
+ adapter,
3279
+ dbPath,
3280
+ _worktreeManager: makeWorktreeManager(),
3281
+ _mergeQueue: makeMergeQueue(),
3282
+ _ensureBranch: branchFn,
3283
+ })
3284
+
3285
+ await engine.run()
3286
+ expect(branchFn).toHaveBeenCalledWith('feature-x', expect.any(String))
3287
+ })
3288
+
3289
+ it('does not call ensureBranch when spec has no branch', async () => {
3290
+ const branchFn = vi.fn().mockResolvedValue(undefined)
3291
+ const adapter = makeAdapter()
3292
+ const spec = makeSpec({ branch: undefined })
3293
+ const engine = makeEngine({
3294
+ spec,
3295
+ specYaml: 'name: test',
3296
+ adapter,
3297
+ dbPath,
3298
+ _worktreeManager: makeWorktreeManager(),
3299
+ _mergeQueue: makeMergeQueue(),
3300
+ _ensureBranch: branchFn,
3301
+ })
3302
+
3303
+ await engine.run()
3304
+ expect(branchFn).not.toHaveBeenCalled()
3305
+ })
3306
+ })
3307
+
3308
+ // ── Security: secret scan in markdown dual-write (issue #4) ──────────────────
3309
+
3310
+ describe('secret scan in DLQ/dispute markdown write', () => {
3311
+ it('task failure still recorded in DB even if DLQ markdown write is silently skipped', async () => {
3312
+ // The engine marks a task as failed; DLQ markdown write with secret scan
3313
+ // silently skips if secrets detected. The DB record is authoritative.
3314
+ const adapter = makeAdapter()
3315
+ vi.mocked(adapter.execute).mockResolvedValue({ success: false, output: 'error', exitCode: 1 })
3316
+ const spec = makeSpec({}, [{ max_retries: 0 }])
3317
+ const engine = makeEngine({
3318
+ spec,
3319
+ specYaml: 'name: test',
3320
+ adapter,
3321
+ dbPath,
3322
+ _worktreeManager: makeWorktreeManager(),
3323
+ _mergeQueue: makeMergeQueue(),
3324
+ })
3325
+
3326
+ const result = await engine.run()
3327
+ expect(result.status).toBe('failed')
3328
+ expect(result.summary.failed).toBe(1)
3329
+ })
3330
+
3331
+ it('emits secret_leak_prevented when DLQ markdown write detects secrets', async () => {
3332
+ const scanSpy = vi.spyOn(gates, 'scanForSecrets').mockImplementation((content: string, filePath = '') => {
3333
+ if (filePath === 'AGENT-FAILURES.md') {
3334
+ return {
3335
+ clean: false,
3336
+ findings: [{ pattern: 'Mock Secret', file: filePath, line: 1, snippet: content.slice(0, 20) }],
3337
+ }
3338
+ }
3339
+ return { clean: true, findings: [] }
3340
+ })
3341
+
3342
+ try {
3343
+ const adapter = makeAdapter()
3344
+ vi.mocked(adapter.execute).mockResolvedValue({ success: false, output: 'fatal', exitCode: 1 })
3345
+ const spec = makeSpec({}, [{ id: 'task-1', max_retries: 0 }])
3346
+ const engine = makeEngine({
3347
+ spec,
3348
+ specYaml: 'name: secret-dlq',
3349
+ adapter,
3350
+ dbPath,
3351
+ _worktreeManager: makeWorktreeManager(),
3352
+ _mergeQueue: makeMergeQueue(),
3353
+ })
3354
+
3355
+ const result = await engine.run()
3356
+
3357
+ const store = createConvoyStore(dbPath)
3358
+ const events = store.getEvents(result.convoyId)
3359
+ store.close()
3360
+
3361
+ const leakEvent = events.find((event) => event.type === 'secret_leak_prevented')
3362
+ expect(leakEvent).toBeDefined()
3363
+ const data = JSON.parse(leakEvent!.data ?? '{}') as Record<string, unknown>
3364
+ // context changed from 'dlq_markdown_write' to 'dlq_dual_write' (MF-2 atomicity fix)
3365
+ expect(data.context).toBe('dlq_dual_write')
3366
+ } finally {
3367
+ scanSpy.mockRestore()
3368
+ }
3369
+ })
3370
+
3371
+ it('DLQ entry is NOT inserted into SQLite when secret scan blocks (MF-2 atomicity)', async () => {
3372
+ const scanSpy = vi.spyOn(gates, 'scanForSecrets').mockImplementation((content: string, filePath = '') => {
3373
+ if (filePath === 'AGENT-FAILURES.md') {
3374
+ return {
3375
+ clean: false,
3376
+ findings: [{ pattern: 'Mock Secret', file: filePath, line: 1, snippet: content.slice(0, 20) }],
3377
+ }
3378
+ }
3379
+ return { clean: true, findings: [] }
3380
+ })
3381
+
3382
+ try {
3383
+ const adapter = makeAdapter()
3384
+ vi.mocked(adapter.execute).mockResolvedValue({ success: false, output: 'fatal', exitCode: 1 })
3385
+ const spec = makeSpec({}, [{ id: 'task-dlq-atomic', max_retries: 0 }])
3386
+ const engine = makeEngine({
3387
+ spec,
3388
+ specYaml: 'name: dlq-atomic-test',
3389
+ adapter,
3390
+ dbPath,
3391
+ _worktreeManager: makeWorktreeManager(),
3392
+ _mergeQueue: makeMergeQueue(),
3393
+ })
3394
+
3395
+ const result = await engine.run()
3396
+
3397
+ const s = createConvoyStore(dbPath)
3398
+ const dlqEntries = s.listDlqEntries(result.convoyId)
3399
+ s.close()
3400
+
3401
+ // When scan blocks: SQLite DLQ row must NOT be written (atomic consistency)
3402
+ expect(dlqEntries).toHaveLength(0)
3403
+ } finally {
3404
+ scanSpy.mockRestore()
3405
+ }
3406
+ })
3407
+
3408
+ it('emits secret_leak_prevented when dispute markdown write detects secrets', async () => {
3409
+ const scanSpy = vi.spyOn(gates, 'scanForSecrets').mockImplementation((content: string, filePath = '') => {
3410
+ if (filePath === 'DISPUTES.md') {
3411
+ return {
3412
+ clean: false,
3413
+ findings: [{ pattern: 'Mock Secret', file: filePath, line: 1, snippet: content.slice(0, 20) }],
3414
+ }
3415
+ }
3416
+ return { clean: true, findings: [] }
3417
+ })
3418
+
3419
+ try {
3420
+ const adapter = makeAdapter()
3421
+ vi.mocked(adapter.execute).mockResolvedValue({ success: true, output: 'ok', exitCode: 0 })
3422
+ const mockReviewRunner = vi.fn().mockResolvedValue({ verdict: 'block', feedback: 'secret found', tokens: 5, model: 'r' })
3423
+
3424
+ const engine = makeEngine({
3425
+ spec: makeSpec({ defaults: { review: 'panel' } }, [{ id: 'task-1', max_retries: 3 }]),
3426
+ specYaml: 'name: secret-dispute',
3427
+ adapter,
3428
+ dbPath,
3429
+ _worktreeManager: makeWorktreeManager(),
3430
+ _mergeQueue: makeMergeQueue(),
3431
+ _reviewRunner: mockReviewRunner,
3432
+ })
3433
+
3434
+ const result = await engine.run()
3435
+
3436
+ const store = createConvoyStore(dbPath)
3437
+ const events = store.getEvents(result.convoyId)
3438
+ store.close()
3439
+
3440
+ const leakEvent = events.find((event) => event.type === 'secret_leak_prevented')
3441
+ expect(leakEvent).toBeDefined()
3442
+ const data = JSON.parse(leakEvent!.data ?? '{}') as Record<string, unknown>
3443
+ expect(data.context).toBe('dispute_markdown_write')
3444
+ } finally {
3445
+ scanSpy.mockRestore()
3446
+ }
3447
+ })
3448
+ })
3449
+
3450
+ // ── Security: fileExists path traversal (issue #5) ────────────────────────────
3451
+
3452
+ describe('fileExists step condition path traversal', () => {
3453
+ it('step with fileExists using relative path executes normally when file absent', async () => {
3454
+ const adapter = makeAdapter()
3455
+ const capturedPrompts: string[] = []
3456
+ vi.mocked(adapter.execute).mockImplementation(async (task) => {
3457
+ capturedPrompts.push(task.prompt)
3458
+ return { success: true, output: 'ok', exitCode: 0 }
3459
+ })
3460
+
3461
+ const spec = makeSpec({}, [{
3462
+ steps: [
3463
+ {
3464
+ prompt: 'conditional prompt',
3465
+ if: { step: 'prev', fileExists: { path: 'some-nonexistent-file.txt' } },
3466
+ },
3467
+ {
3468
+ prompt: 'always runs',
3469
+ },
3470
+ ],
3471
+ }])
3472
+
3473
+ const engine = makeEngine({
3474
+ spec,
3475
+ specYaml: 'name: test',
3476
+ adapter,
3477
+ dbPath,
3478
+ _worktreeManager: makeWorktreeManager(),
3479
+ _mergeQueue: makeMergeQueue(),
3480
+ })
3481
+
3482
+ const result = await engine.run()
3483
+ expect(result.status).toBe('done')
3484
+ })
3485
+
3486
+ it('step condition with path traversal attempt does not throw (returns false)', async () => {
3487
+ const adapter = makeAdapter()
3488
+ const spec = makeSpec({}, [{
3489
+ steps: [
3490
+ {
3491
+ prompt: 'should be skipped',
3492
+ if: { step: 'prev', fileExists: { path: '../../../etc/passwd' } },
3493
+ },
3494
+ {
3495
+ prompt: 'safe step',
3496
+ },
3497
+ ],
3498
+ }])
3499
+
3500
+ const engine = makeEngine({
3501
+ spec,
3502
+ specYaml: 'name: test',
3503
+ adapter,
3504
+ dbPath,
3505
+ _worktreeManager: makeWorktreeManager(),
3506
+ _mergeQueue: makeMergeQueue(),
3507
+ })
3508
+
3509
+ const result = await engine.run()
3510
+ // Engine should not crash; traversal step is skipped (fileExists returns false)
3511
+ expect(result.status).toBe('done')
3512
+ })
3513
+ })
3514
+
3515
+ // ── Circuit breaker ───────────────────────────────────────────────────────────
3516
+
3517
+ describe('circuit breaker', () => {
3518
+ it('allows task when no circuit_breaker config is set', async () => {
3519
+ const adapter = makeAdapter()
3520
+ const spec = makeSpec({}, [{}])
3521
+ const engine = makeEngine({
3522
+ spec,
3523
+ specYaml: 'name: test',
3524
+ adapter,
3525
+ dbPath,
3526
+ _worktreeManager: makeWorktreeManager(),
3527
+ _mergeQueue: makeMergeQueue(),
3528
+ })
3529
+ const result = await engine.run()
3530
+ expect(result.status).toBe('done')
3531
+ expect(result.summary.done).toBe(1)
3532
+ expect(adapter.execute).toHaveBeenCalledTimes(1)
3533
+ })
3534
+
3535
+ it('allows task when agent circuit is closed', async () => {
3536
+ const adapter = makeAdapter()
3537
+ const spec = makeSpec({
3538
+ defaults: { circuit_breaker: { threshold: 3, cooldown_ms: 300_000 } },
3539
+ }, [{ id: 'task-ok', agent: 'developer', max_retries: 0 }])
3540
+ const engine = makeEngine({
3541
+ spec,
3542
+ specYaml: 'name: test',
3543
+ adapter,
3544
+ dbPath,
3545
+ _worktreeManager: makeWorktreeManager(),
3546
+ _mergeQueue: makeMergeQueue(),
3547
+ })
3548
+ const result = await engine.run()
3549
+ expect(result.status).toBe('done')
3550
+ expect(adapter.execute).toHaveBeenCalledTimes(1)
3551
+ })
3552
+
3553
+ it('blocks subsequent tasks when circuit trips after threshold failures', async () => {
3554
+ const adapter = makeAdapter()
3555
+ // task-1 fails, task-2 and task-3 should be blocked by open circuit
3556
+ adapter.execute
3557
+ .mockResolvedValueOnce({ success: false, output: 'err', exitCode: 1 })
3558
+ .mockResolvedValue({ success: true, output: 'ok', exitCode: 0 })
3559
+
3560
+ // threshold=2: task-1 failure is recorded twice (failure path + handleExhaustion),
3561
+ // reaching threshold=2 → circuit opens before task-2 and task-3 execute
3562
+ const spec = makeSpec({
3563
+ on_failure: 'continue',
3564
+ defaults: { circuit_breaker: { threshold: 2, cooldown_ms: 999_999_999 } },
3565
+ }, [
3566
+ { id: 'task-1', agent: 'developer', max_retries: 0 },
3567
+ { id: 'task-2', agent: 'developer', max_retries: 0 },
3568
+ { id: 'task-3', agent: 'developer', max_retries: 0 },
3569
+ ])
3570
+ const engine = makeEngine({
3571
+ spec,
3572
+ specYaml: 'name: test',
3573
+ adapter,
3574
+ dbPath,
3575
+ _worktreeManager: makeWorktreeManager(),
3576
+ _mergeQueue: makeMergeQueue(),
3577
+ })
3578
+ const result = await engine.run()
3579
+ // Only task-1 should have hit the adapter (circuit opens after task-1 fails)
3580
+ expect(adapter.execute).toHaveBeenCalledTimes(1)
3581
+ // task-2 and task-3 should be skipped by the circuit breaker
3582
+ expect(result.summary.skipped).toBeGreaterThanOrEqual(2)
3583
+ })
3584
+
3585
+ it('records success and persists closed circuit state to store', async () => {
3586
+ const adapter = makeAdapter()
3587
+ const spec = makeSpec({
3588
+ defaults: { circuit_breaker: { threshold: 3, cooldown_ms: 300_000 } },
3589
+ }, [{ id: 'task-s', agent: 'developer', max_retries: 0 }])
3590
+ const engine = makeEngine({
3591
+ spec,
3592
+ specYaml: 'name: test',
3593
+ adapter,
3594
+ dbPath,
3595
+ _worktreeManager: makeWorktreeManager(),
3596
+ _mergeQueue: makeMergeQueue(),
3597
+ })
3598
+ const result = await engine.run()
3599
+ expect(result.status).toBe('done')
3600
+
3601
+ const store = createConvoyStore(dbPath)
3602
+ const record = store.getLatestConvoy()
3603
+ if (record?.circuit_state) {
3604
+ const state = JSON.parse(record.circuit_state)
3605
+ expect(state.developer?.status ?? 'closed').toBe('closed')
3606
+ }
3607
+ store.close()
3608
+ })
3609
+
3610
+ it('records failure and persists open circuit state to store after threshold', async () => {
3611
+ const adapter = makeAdapter()
3612
+ adapter.execute.mockResolvedValue({ success: false, output: 'err', exitCode: 1 })
3613
+
3614
+ // threshold=2: first failure double-records → count reaches 2 → circuit opens
3615
+ const spec = makeSpec({
3616
+ on_failure: 'continue',
3617
+ defaults: { circuit_breaker: { threshold: 2, cooldown_ms: 999_999_999 } },
3618
+ }, [
3619
+ { id: 'task-f1', agent: 'developer', max_retries: 0 },
3620
+ ])
3621
+ const engine = makeEngine({
3622
+ spec,
3623
+ specYaml: 'name: test',
3624
+ adapter,
3625
+ dbPath,
3626
+ _worktreeManager: makeWorktreeManager(),
3627
+ _mergeQueue: makeMergeQueue(),
3628
+ })
3629
+ await engine.run()
3630
+
3631
+ const store = createConvoyStore(dbPath)
3632
+ const record = store.getLatestConvoy()
3633
+ expect(record?.circuit_state).not.toBeNull()
3634
+ if (record?.circuit_state) {
3635
+ const state = JSON.parse(record.circuit_state)
3636
+ expect(state.developer?.status).toBe('open')
3637
+ }
3638
+ store.close()
3639
+ })
3640
+
3641
+ it('circuit state is persisted to the store after a successful task', async () => {
3642
+ const adapter = makeAdapter()
3643
+ const spec = makeSpec({
3644
+ defaults: { circuit_breaker: { threshold: 2, cooldown_ms: 60_000 } },
3645
+ }, [{ id: 'task-persist', agent: 'developer', max_retries: 0 }])
3646
+ const engine = makeEngine({
3647
+ spec,
3648
+ specYaml: 'name: test',
3649
+ adapter,
3650
+ dbPath,
3651
+ _worktreeManager: makeWorktreeManager(),
3652
+ _mergeQueue: makeMergeQueue(),
3653
+ })
3654
+ await engine.run()
3655
+
3656
+ const store = createConvoyStore(dbPath)
3657
+ const record = store.getLatestConvoy()
3658
+ expect(record?.circuit_state).not.toBeNull()
3659
+ store.close()
3660
+ })
3661
+ })
3662
+
3663
+ describe('convoy lifecycle events', () => {
3664
+ it('emits convoy_finished event on successful run', async () => {
3665
+ const adapter = makeAdapter()
3666
+ const engine = makeEngine({
3667
+ spec: makeSpec(),
3668
+ specYaml: 'name: test',
3669
+ adapter,
3670
+ dbPath,
3671
+ _worktreeManager: makeWorktreeManager(),
3672
+ _mergeQueue: makeMergeQueue(),
3673
+ })
3674
+ const result = await engine.run()
3675
+ expect(result.status).toBe('done')
3676
+
3677
+ const store = createConvoyStore(dbPath)
3678
+ const events = store.getEvents(result.convoyId)
3679
+ store.close()
3680
+
3681
+ const finishedEvent = events.find(e => e.type === 'convoy_finished')
3682
+ expect(finishedEvent).toBeDefined()
3683
+ expect(finishedEvent!.convoy_id).toBe(result.convoyId)
3684
+ expect(JSON.parse(finishedEvent!.data as string).status).toBe('done')
3685
+ })
3686
+
3687
+ it('emits convoy_failed event when a task fails', async () => {
3688
+ const adapter = makeAdapter()
3689
+ adapter.execute.mockResolvedValue({
3690
+ success: false,
3691
+ output: 'error',
3692
+ exitCode: 1,
3693
+ })
3694
+ const engine = makeEngine({
3695
+ spec: makeSpec({}, [{ id: 'fail-task', max_retries: 0 }]),
3696
+ specYaml: 'name: test',
3697
+ adapter,
3698
+ dbPath,
3699
+ _worktreeManager: makeWorktreeManager(),
3700
+ _mergeQueue: makeMergeQueue(),
3701
+ })
3702
+ const result = await engine.run()
3703
+ expect(result.status).toBe('failed')
3704
+
3705
+ const store = createConvoyStore(dbPath)
3706
+ const events = store.getEvents(result.convoyId)
3707
+ store.close()
3708
+
3709
+ const failedEvent = events.find(e => e.type === 'convoy_failed')
3710
+ expect(failedEvent).toBeDefined()
3711
+ expect(failedEvent!.convoy_id).toBe(result.convoyId)
3712
+ expect(JSON.parse(failedEvent!.data as string).status).toBe('failed')
3713
+ })
3714
+
3715
+ it('emits convoy_failed with gate-failed status when gates fail', async () => {
3716
+ const adapter = makeAdapter()
3717
+ const engine = makeEngine({
3718
+ spec: makeSpec({ gates: ['false'] }),
3719
+ specYaml: 'name: test',
3720
+ adapter,
3721
+ dbPath,
3722
+ _worktreeManager: makeWorktreeManager(),
3723
+ _mergeQueue: makeMergeQueue(),
3724
+ })
3725
+ const result = await engine.run()
3726
+ expect(result.status).toBe('gate-failed')
3727
+
3728
+ const store = createConvoyStore(dbPath)
3729
+ const events = store.getEvents(result.convoyId)
3730
+ store.close()
3731
+
3732
+ const failedEvent = events.find(e => e.type === 'convoy_failed')
3733
+ expect(failedEvent).toBeDefined()
3734
+ expect(JSON.parse(failedEvent!.data as string).status).toBe('gate-failed')
3735
+ })
3736
+ })
3737
+
3738
+ describe('createEventEmitter callsite safety', () => {
3739
+ it('rejects a raw string argument', () => {
3740
+ const testStore = createConvoyStore(dbPath)
3741
+ expect(() => {
3742
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3743
+ createEventEmitter(testStore, 'some-path' as any)
3744
+ }).toThrow('createEventEmitter options must be an object, not a string')
3745
+ testStore.close()
3746
+ })
3747
+
3748
+ it('accepts an options object with ndjsonPath', () => {
3749
+ const testStore = createConvoyStore(dbPath)
3750
+ const testNdjsonPath = join(tmpDir, 'callsite-test.ndjson')
3751
+ const emitter = createEventEmitter(testStore, { ndjsonPath: testNdjsonPath })
3752
+ expect(emitter).toBeDefined()
3753
+ expect(typeof emitter.emit).toBe('function')
3754
+ expect(typeof emitter.close).toBe('function')
3755
+ emitter.close()
3756
+ testStore.close()
3757
+ })
3758
+ })