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.
- package/bin/cli.mjs +6 -0
- package/dist/cli/agents.d.ts +3 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +161 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/baselines.d.ts +3 -0
- package/dist/cli/baselines.d.ts.map +1 -0
- package/dist/cli/baselines.js +128 -0
- package/dist/cli/baselines.js.map +1 -0
- package/dist/cli/convoy/dashboard-types.d.ts +146 -0
- package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
- package/dist/cli/convoy/dashboard-types.js +2 -0
- package/dist/cli/convoy/dashboard-types.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +67 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2036 -28
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1659 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/event-schemas.d.ts +9 -0
- package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
- package/dist/cli/convoy/event-schemas.js +185 -0
- package/dist/cli/convoy/event-schemas.js.map +1 -0
- package/dist/cli/convoy/events.d.ts +12 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +186 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +325 -28
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/expertise.d.ts +16 -0
- package/dist/cli/convoy/expertise.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.js +121 -0
- package/dist/cli/convoy/expertise.js.map +1 -0
- package/dist/cli/convoy/expertise.test.d.ts +2 -0
- package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.test.js +96 -0
- package/dist/cli/convoy/expertise.test.js.map +1 -0
- package/dist/cli/convoy/export.test.js +1 -0
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/formula.d.ts +19 -0
- package/dist/cli/convoy/formula.d.ts.map +1 -0
- package/dist/cli/convoy/formula.js +142 -0
- package/dist/cli/convoy/formula.js.map +1 -0
- package/dist/cli/convoy/formula.test.d.ts +2 -0
- package/dist/cli/convoy/formula.test.d.ts.map +1 -0
- package/dist/cli/convoy/formula.test.js +342 -0
- package/dist/cli/convoy/formula.test.js.map +1 -0
- package/dist/cli/convoy/gates.d.ts +128 -0
- package/dist/cli/convoy/gates.d.ts.map +1 -0
- package/dist/cli/convoy/gates.js +606 -0
- package/dist/cli/convoy/gates.js.map +1 -0
- package/dist/cli/convoy/gates.test.d.ts +2 -0
- package/dist/cli/convoy/gates.test.d.ts.map +1 -0
- package/dist/cli/convoy/gates.test.js +976 -0
- package/dist/cli/convoy/gates.test.js.map +1 -0
- package/dist/cli/convoy/health.d.ts +11 -0
- package/dist/cli/convoy/health.d.ts.map +1 -1
- package/dist/cli/convoy/health.js +54 -0
- package/dist/cli/convoy/health.js.map +1 -1
- package/dist/cli/convoy/health.test.js +56 -1
- package/dist/cli/convoy/health.test.js.map +1 -1
- package/dist/cli/convoy/issues.d.ts +8 -0
- package/dist/cli/convoy/issues.d.ts.map +1 -0
- package/dist/cli/convoy/issues.js +98 -0
- package/dist/cli/convoy/issues.js.map +1 -0
- package/dist/cli/convoy/issues.test.d.ts +2 -0
- package/dist/cli/convoy/issues.test.d.ts.map +1 -0
- package/dist/cli/convoy/issues.test.js +107 -0
- package/dist/cli/convoy/issues.test.js.map +1 -0
- package/dist/cli/convoy/knowledge.d.ts +5 -0
- package/dist/cli/convoy/knowledge.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.js +116 -0
- package/dist/cli/convoy/knowledge.js.map +1 -0
- package/dist/cli/convoy/knowledge.test.d.ts +2 -0
- package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.test.js +87 -0
- package/dist/cli/convoy/knowledge.test.js.map +1 -0
- package/dist/cli/convoy/lessons.d.ts +17 -0
- package/dist/cli/convoy/lessons.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.js +149 -0
- package/dist/cli/convoy/lessons.js.map +1 -0
- package/dist/cli/convoy/lessons.test.d.ts +2 -0
- package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.test.js +135 -0
- package/dist/cli/convoy/lessons.test.js.map +1 -0
- package/dist/cli/convoy/lock.d.ts +13 -0
- package/dist/cli/convoy/lock.d.ts.map +1 -0
- package/dist/cli/convoy/lock.js +88 -0
- package/dist/cli/convoy/lock.js.map +1 -0
- package/dist/cli/convoy/lock.test.d.ts +2 -0
- package/dist/cli/convoy/lock.test.d.ts.map +1 -0
- package/dist/cli/convoy/lock.test.js +136 -0
- package/dist/cli/convoy/lock.test.js.map +1 -0
- package/dist/cli/convoy/log-merge.test.d.ts +2 -0
- package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
- package/dist/cli/convoy/log-merge.test.js +147 -0
- package/dist/cli/convoy/log-merge.test.js.map +1 -0
- package/dist/cli/convoy/merge.d.ts +4 -0
- package/dist/cli/convoy/merge.d.ts.map +1 -1
- package/dist/cli/convoy/merge.js +18 -1
- package/dist/cli/convoy/merge.js.map +1 -1
- package/dist/cli/convoy/merge.test.js +6 -7
- package/dist/cli/convoy/merge.test.js.map +1 -1
- package/dist/cli/convoy/partition.d.ts +51 -0
- package/dist/cli/convoy/partition.d.ts.map +1 -0
- package/dist/cli/convoy/partition.js +186 -0
- package/dist/cli/convoy/partition.js.map +1 -0
- package/dist/cli/convoy/partition.test.d.ts +2 -0
- package/dist/cli/convoy/partition.test.d.ts.map +1 -0
- package/dist/cli/convoy/partition.test.js +315 -0
- package/dist/cli/convoy/partition.test.js.map +1 -0
- package/dist/cli/convoy/pipeline.test.js +6 -0
- package/dist/cli/convoy/pipeline.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +99 -7
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +764 -31
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1810 -18
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +427 -5
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/convoy/types.js +42 -1
- package/dist/cli/convoy/types.js.map +1 -1
- package/dist/cli/log.d.ts +11 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +114 -2
- package/dist/cli/log.js.map +1 -1
- package/dist/cli/run/adapters/claude.d.ts +2 -0
- package/dist/cli/run/adapters/claude.d.ts.map +1 -1
- package/dist/cli/run/adapters/claude.js +89 -49
- package/dist/cli/run/adapters/claude.js.map +1 -1
- package/dist/cli/run/adapters/claude.test.d.ts +2 -0
- package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude.test.js +205 -0
- package/dist/cli/run/adapters/claude.test.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +1 -0
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +84 -46
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
- package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/copilot.test.js +195 -0
- package/dist/cli/run/adapters/copilot.test.js.map +1 -0
- package/dist/cli/run/adapters/cursor.d.ts +1 -0
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/run/adapters/cursor.js +83 -47
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
- package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/cursor.test.js +129 -0
- package/dist/cli/run/adapters/cursor.test.js.map +1 -0
- package/dist/cli/run/adapters/opencode.d.ts +1 -0
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/run/adapters/opencode.js +81 -47
- package/dist/cli/run/adapters/opencode.js.map +1 -1
- package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
- package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/opencode.test.js +119 -0
- package/dist/cli/run/adapters/opencode.test.js.map +1 -0
- package/dist/cli/run/executor.js +1 -1
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +245 -4
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +669 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +362 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +85 -2
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js.map +1 -1
- package/dist/cli/watch.d.ts +15 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +279 -0
- package/dist/cli/watch.js.map +1 -0
- package/package.json +5 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/TELEMETRY.md +203 -0
- package/src/cli/convoy/dashboard-types.ts +141 -0
- package/src/cli/convoy/engine.test.ts +1937 -70
- package/src/cli/convoy/engine.ts +2350 -40
- package/src/cli/convoy/event-schemas.ts +195 -0
- package/src/cli/convoy/events.test.ts +384 -39
- package/src/cli/convoy/events.ts +202 -16
- package/src/cli/convoy/expertise.test.ts +128 -0
- package/src/cli/convoy/expertise.ts +163 -0
- package/src/cli/convoy/export.test.ts +1 -0
- package/src/cli/convoy/formula.test.ts +405 -0
- package/src/cli/convoy/formula.ts +174 -0
- package/src/cli/convoy/gates.test.ts +1169 -0
- package/src/cli/convoy/gates.ts +774 -0
- package/src/cli/convoy/health.test.ts +64 -2
- package/src/cli/convoy/health.ts +80 -2
- package/src/cli/convoy/issues.test.ts +143 -0
- package/src/cli/convoy/issues.ts +136 -0
- package/src/cli/convoy/knowledge.test.ts +101 -0
- package/src/cli/convoy/knowledge.ts +132 -0
- package/src/cli/convoy/lessons.test.ts +188 -0
- package/src/cli/convoy/lessons.ts +164 -0
- package/src/cli/convoy/lock.test.ts +181 -0
- package/src/cli/convoy/lock.ts +103 -0
- package/src/cli/convoy/log-merge.test.ts +179 -0
- package/src/cli/convoy/merge.test.ts +6 -7
- package/src/cli/convoy/merge.ts +19 -1
- package/src/cli/convoy/partition.test.ts +423 -0
- package/src/cli/convoy/partition.ts +232 -0
- package/src/cli/convoy/pipeline.test.ts +6 -0
- package/src/cli/convoy/store.test.ts +2041 -20
- package/src/cli/convoy/store.ts +945 -46
- package/src/cli/convoy/types.ts +278 -4
- package/src/cli/log.ts +120 -2
- package/src/cli/run/adapters/claude.test.ts +234 -0
- package/src/cli/run/adapters/claude.ts +45 -5
- package/src/cli/run/adapters/copilot.test.ts +224 -0
- package/src/cli/run/adapters/copilot.ts +34 -4
- package/src/cli/run/adapters/cursor.test.ts +144 -0
- package/src/cli/run/adapters/cursor.ts +33 -2
- package/src/cli/run/adapters/opencode.test.ts +135 -0
- package/src/cli/run/adapters/opencode.ts +30 -2
- package/src/cli/run/executor.ts +1 -1
- package/src/cli/run/schema.test.ts +758 -0
- package/src/cli/run/schema.ts +300 -25
- package/src/cli/run.ts +341 -21
- package/src/cli/types.ts +86 -1
- package/src/cli/watch.ts +298 -0
- package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
- package/src/dashboard/dist/data/.gitkeep +0 -0
- package/src/dashboard/dist/data/convoy-list.json +1 -0
- package/src/dashboard/dist/data/overall-stats.json +24 -0
- package/src/dashboard/dist/index.html +701 -3
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/public/data/.gitkeep +0 -0
- package/src/dashboard/public/data/convoy-list.json +1 -0
- package/src/dashboard/public/data/overall-stats.json +24 -0
- package/src/dashboard/scripts/etl.test.ts +210 -0
- package/src/dashboard/scripts/etl.ts +108 -0
- package/src/dashboard/scripts/integration-test.ts +504 -0
- package/src/dashboard/src/pages/index.astro +854 -15
- package/src/dashboard/src/styles/dashboard.css +557 -1
- 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
})
|