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