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