opencastle 0.13.0 → 0.14.0
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/dist/cli/convoy/engine.d.ts +38 -0
- package/dist/cli/convoy/engine.d.ts.map +1 -0
- package/dist/cli/convoy/engine.js +416 -0
- package/dist/cli/convoy/engine.js.map +1 -0
- package/dist/cli/convoy/engine.test.d.ts +2 -0
- package/dist/cli/convoy/engine.test.d.ts.map +1 -0
- package/dist/cli/convoy/engine.test.js +1140 -0
- package/dist/cli/convoy/engine.test.js.map +1 -0
- package/dist/cli/convoy/health.d.ts +23 -0
- package/dist/cli/convoy/health.d.ts.map +1 -0
- package/dist/cli/convoy/health.js +69 -0
- package/dist/cli/convoy/health.js.map +1 -0
- package/dist/cli/convoy/health.test.d.ts +2 -0
- package/dist/cli/convoy/health.test.d.ts.map +1 -0
- package/dist/cli/convoy/health.test.js +392 -0
- package/dist/cli/convoy/health.test.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/convoy/engine.test.ts +1349 -0
- package/src/cli/convoy/engine.ts +521 -0
- package/src/cli/convoy/health.test.ts +456 -0
- package/src/cli/convoy/health.ts +111 -0
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
5
|
+
import { createConvoyEngine } from './engine.js'
|
|
6
|
+
import { createConvoyStore } from './store.js'
|
|
7
|
+
import type { AgentAdapter, Task, TaskSpec, ExecuteResult, ExecuteOptions } from '../types.js'
|
|
8
|
+
import type { WorktreeManager } from './worktree.js'
|
|
9
|
+
import type { MergeQueue } from './merge.js'
|
|
10
|
+
|
|
11
|
+
// ── Mock NDJSON log writes ────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
vi.mock('../log.js', () => ({
|
|
14
|
+
appendEvent: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
// ── Fixture helpers ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
type MockAdapter = AgentAdapter & {
|
|
20
|
+
execute: ReturnType<typeof vi.fn>
|
|
21
|
+
kill: ReturnType<typeof vi.fn>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type MockWorktreeManager = WorktreeManager & {
|
|
25
|
+
create: ReturnType<typeof vi.fn>
|
|
26
|
+
remove: ReturnType<typeof vi.fn>
|
|
27
|
+
removeAll: ReturnType<typeof vi.fn>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type MockMergeQueue = MergeQueue & { merge: ReturnType<typeof vi.fn> }
|
|
31
|
+
|
|
32
|
+
function makeAdapter(name = 'test-adapter'): MockAdapter {
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
isAvailable: vi.fn().mockResolvedValue(true),
|
|
36
|
+
execute: vi.fn().mockResolvedValue({
|
|
37
|
+
success: true,
|
|
38
|
+
output: 'ok',
|
|
39
|
+
exitCode: 0,
|
|
40
|
+
} satisfies ExecuteResult),
|
|
41
|
+
kill: vi.fn(),
|
|
42
|
+
} as unknown as MockAdapter
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeWorktreeManager(): MockWorktreeManager {
|
|
46
|
+
return {
|
|
47
|
+
create: vi.fn().mockResolvedValue('/tmp/worktree-mock'),
|
|
48
|
+
remove: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
list: vi.fn().mockResolvedValue([]),
|
|
50
|
+
removeAll: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeMergeQueue(): MockMergeQueue {
|
|
55
|
+
return {
|
|
56
|
+
merge: vi.fn().mockResolvedValue({ success: true, conflicted: false, message: 'ok' }),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build a minimal TaskSpec — branch:'main' avoids a git subprocess call. */
|
|
61
|
+
function makeSpec(
|
|
62
|
+
specOverrides: Partial<TaskSpec> = {},
|
|
63
|
+
taskOverrides: Partial<Task>[] = [{}],
|
|
64
|
+
): TaskSpec {
|
|
65
|
+
const tasks: Task[] = taskOverrides.map((overrides, i) => ({
|
|
66
|
+
id: `task-${i + 1}`,
|
|
67
|
+
prompt: `Prompt for task ${i + 1}`,
|
|
68
|
+
agent: 'developer',
|
|
69
|
+
timeout: '30s',
|
|
70
|
+
depends_on: [],
|
|
71
|
+
files: [],
|
|
72
|
+
description: '',
|
|
73
|
+
max_retries: 0,
|
|
74
|
+
...overrides,
|
|
75
|
+
}))
|
|
76
|
+
return {
|
|
77
|
+
name: 'Test Convoy',
|
|
78
|
+
concurrency: 1,
|
|
79
|
+
on_failure: 'continue',
|
|
80
|
+
adapter: 'test',
|
|
81
|
+
branch: 'main',
|
|
82
|
+
tasks,
|
|
83
|
+
...specOverrides,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Test lifecycle ────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
let tmpDir: string
|
|
90
|
+
let dbPath: string
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'engine-test-'))
|
|
94
|
+
dbPath = join(tmpDir, 'convoy.db')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
vi.clearAllMocks()
|
|
99
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ── 1. Single task success ────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe('single task success', () => {
|
|
105
|
+
it('returns status done with summary.done=1', async () => {
|
|
106
|
+
const adapter = makeAdapter()
|
|
107
|
+
const engine = createConvoyEngine({
|
|
108
|
+
spec: makeSpec(),
|
|
109
|
+
specYaml: 'name: test',
|
|
110
|
+
adapter,
|
|
111
|
+
dbPath,
|
|
112
|
+
_worktreeManager: makeWorktreeManager(),
|
|
113
|
+
_mergeQueue: makeMergeQueue(),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const result = await engine.run()
|
|
117
|
+
|
|
118
|
+
expect(result.status).toBe('done')
|
|
119
|
+
expect(result.summary.total).toBe(1)
|
|
120
|
+
expect(result.summary.done).toBe(1)
|
|
121
|
+
expect(result.summary.failed).toBe(0)
|
|
122
|
+
expect(result.summary.skipped).toBe(0)
|
|
123
|
+
expect(typeof result.convoyId).toBe('string')
|
|
124
|
+
expect(typeof result.duration).toBe('string')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('calls adapter.execute once with the correct task', async () => {
|
|
128
|
+
const adapter = makeAdapter()
|
|
129
|
+
const engine = createConvoyEngine({
|
|
130
|
+
spec: makeSpec(),
|
|
131
|
+
specYaml: 'name: test',
|
|
132
|
+
adapter,
|
|
133
|
+
dbPath,
|
|
134
|
+
_worktreeManager: makeWorktreeManager(),
|
|
135
|
+
_mergeQueue: makeMergeQueue(),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
await engine.run()
|
|
139
|
+
|
|
140
|
+
expect(adapter.execute).toHaveBeenCalledOnce()
|
|
141
|
+
const [task] = adapter.execute.mock.calls[0] as [Task]
|
|
142
|
+
expect(task.id).toBe('task-1')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ── 2. Single task failure ────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
describe('single task failure', () => {
|
|
149
|
+
it('returns status failed with summary.failed=1 when task errors and no retries allowed', async () => {
|
|
150
|
+
const adapter = makeAdapter()
|
|
151
|
+
adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 })
|
|
152
|
+
|
|
153
|
+
const engine = createConvoyEngine({
|
|
154
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
155
|
+
specYaml: 'name: test',
|
|
156
|
+
adapter,
|
|
157
|
+
dbPath,
|
|
158
|
+
_worktreeManager: makeWorktreeManager(),
|
|
159
|
+
_mergeQueue: makeMergeQueue(),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const result = await engine.run()
|
|
163
|
+
|
|
164
|
+
expect(result.status).toBe('failed')
|
|
165
|
+
expect(result.summary.failed).toBe(1)
|
|
166
|
+
expect(result.summary.done).toBe(0)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('calls adapter.kill when the task fails', async () => {
|
|
170
|
+
const adapter = makeAdapter()
|
|
171
|
+
adapter.execute.mockResolvedValue({ success: false, output: 'boom', exitCode: 1 })
|
|
172
|
+
|
|
173
|
+
const engine = createConvoyEngine({
|
|
174
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
175
|
+
specYaml: 'name: test',
|
|
176
|
+
adapter,
|
|
177
|
+
dbPath,
|
|
178
|
+
_worktreeManager: makeWorktreeManager(),
|
|
179
|
+
_mergeQueue: makeMergeQueue(),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await engine.run()
|
|
183
|
+
|
|
184
|
+
expect(adapter.kill).toHaveBeenCalledOnce()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ── 3. Two-phase DAG ─────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe('two-phase DAG (task-b depends on task-a)', () => {
|
|
191
|
+
it('executes task-a before task-b and both succeed', async () => {
|
|
192
|
+
const executeOrder: string[] = []
|
|
193
|
+
const adapter = makeAdapter()
|
|
194
|
+
adapter.execute.mockImplementation((task: Task) => {
|
|
195
|
+
executeOrder.push(task.id)
|
|
196
|
+
return Promise.resolve({ success: true, output: 'ok', exitCode: 0 })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const spec = makeSpec({}, [
|
|
200
|
+
{ id: 'task-a', depends_on: [] },
|
|
201
|
+
{ id: 'task-b', depends_on: ['task-a'] },
|
|
202
|
+
])
|
|
203
|
+
const engine = createConvoyEngine({
|
|
204
|
+
spec,
|
|
205
|
+
specYaml: 'name: test',
|
|
206
|
+
adapter,
|
|
207
|
+
dbPath,
|
|
208
|
+
_worktreeManager: makeWorktreeManager(),
|
|
209
|
+
_mergeQueue: makeMergeQueue(),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const result = await engine.run()
|
|
213
|
+
|
|
214
|
+
expect(result.status).toBe('done')
|
|
215
|
+
expect(result.summary.done).toBe(2)
|
|
216
|
+
expect(executeOrder).toEqual(['task-a', 'task-b'])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not start dependent task until dependency is done', async () => {
|
|
220
|
+
let maxConcurrent = 0
|
|
221
|
+
let active = 0
|
|
222
|
+
const adapter = makeAdapter()
|
|
223
|
+
adapter.execute.mockImplementation(async () => {
|
|
224
|
+
active++
|
|
225
|
+
maxConcurrent = Math.max(maxConcurrent, active)
|
|
226
|
+
await new Promise<void>(r => setTimeout(r, 5))
|
|
227
|
+
active--
|
|
228
|
+
return { success: true, output: 'ok', exitCode: 0 }
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const spec = makeSpec({ concurrency: 4 }, [
|
|
232
|
+
{ id: 'task-a', depends_on: [] },
|
|
233
|
+
{ id: 'task-b', depends_on: ['task-a'] },
|
|
234
|
+
])
|
|
235
|
+
const engine = createConvoyEngine({
|
|
236
|
+
spec,
|
|
237
|
+
specYaml: 'name: test',
|
|
238
|
+
adapter,
|
|
239
|
+
dbPath,
|
|
240
|
+
_worktreeManager: makeWorktreeManager(),
|
|
241
|
+
_mergeQueue: makeMergeQueue(),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
await engine.run()
|
|
245
|
+
|
|
246
|
+
// Even with high concurrency, dependent tasks may not overlap with their dependency
|
|
247
|
+
expect(maxConcurrent).toBeLessThanOrEqual(1)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// ── 4. on_failure:continue ────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
describe('on_failure:continue', () => {
|
|
254
|
+
it('skips dependents of the failed task but continues independent tasks', async () => {
|
|
255
|
+
const adapter = makeAdapter()
|
|
256
|
+
adapter.execute.mockImplementation((task: Task) => {
|
|
257
|
+
if (task.id === 'task-a') {
|
|
258
|
+
return Promise.resolve({ success: false, output: 'fail', exitCode: 1 })
|
|
259
|
+
}
|
|
260
|
+
return Promise.resolve({ success: true, output: 'ok', exitCode: 0 })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// order by id: task-a and task-c are phase 0 (task-a first alphabetically)
|
|
264
|
+
// task-b (depends task-a) is phase 1 and gets skipped
|
|
265
|
+
const spec = makeSpec({ on_failure: 'continue' }, [
|
|
266
|
+
{ id: 'task-a', depends_on: [] },
|
|
267
|
+
{ id: 'task-b', depends_on: ['task-a'] },
|
|
268
|
+
{ id: 'task-c', depends_on: [] },
|
|
269
|
+
])
|
|
270
|
+
const engine = createConvoyEngine({
|
|
271
|
+
spec,
|
|
272
|
+
specYaml: 'name: test',
|
|
273
|
+
adapter,
|
|
274
|
+
dbPath,
|
|
275
|
+
_worktreeManager: makeWorktreeManager(),
|
|
276
|
+
_mergeQueue: makeMergeQueue(),
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const result = await engine.run()
|
|
280
|
+
|
|
281
|
+
expect(result.status).toBe('failed')
|
|
282
|
+
expect(result.summary.failed).toBe(1)
|
|
283
|
+
expect(result.summary.done).toBe(1)
|
|
284
|
+
expect(result.summary.skipped).toBe(1)
|
|
285
|
+
|
|
286
|
+
const store = createConvoyStore(dbPath)
|
|
287
|
+
const tasks = store.getTasksByConvoy(result.convoyId)
|
|
288
|
+
store.close()
|
|
289
|
+
const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]))
|
|
290
|
+
expect(byId['task-a']).toBe('failed')
|
|
291
|
+
expect(byId['task-b']).toBe('skipped')
|
|
292
|
+
expect(byId['task-c']).toBe('done')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('skips transitive dependents recursively (chain a→b→c)', async () => {
|
|
296
|
+
const adapter = makeAdapter()
|
|
297
|
+
adapter.execute.mockImplementation((task: Task) => {
|
|
298
|
+
if (task.id === 'task-a') {
|
|
299
|
+
return Promise.resolve({ success: false, output: 'fail', exitCode: 1 })
|
|
300
|
+
}
|
|
301
|
+
return Promise.resolve({ success: true, output: 'ok', exitCode: 0 })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const spec = makeSpec({ on_failure: 'continue' }, [
|
|
305
|
+
{ id: 'task-a', depends_on: [] },
|
|
306
|
+
{ id: 'task-b', depends_on: ['task-a'] },
|
|
307
|
+
{ id: 'task-c', depends_on: ['task-b'] },
|
|
308
|
+
])
|
|
309
|
+
const engine = createConvoyEngine({
|
|
310
|
+
spec,
|
|
311
|
+
specYaml: 'name: test',
|
|
312
|
+
adapter,
|
|
313
|
+
dbPath,
|
|
314
|
+
_worktreeManager: makeWorktreeManager(),
|
|
315
|
+
_mergeQueue: makeMergeQueue(),
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const result = await engine.run()
|
|
319
|
+
|
|
320
|
+
expect(result.summary.failed).toBe(1)
|
|
321
|
+
expect(result.summary.skipped).toBe(2)
|
|
322
|
+
expect(result.summary.done).toBe(0)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// ── 5. on_failure:stop ────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
describe('on_failure:stop', () => {
|
|
329
|
+
it('skips all pending tasks when on_failure is stop', async () => {
|
|
330
|
+
const adapter = makeAdapter()
|
|
331
|
+
adapter.execute.mockResolvedValue({ success: false, output: 'fail', exitCode: 1 })
|
|
332
|
+
|
|
333
|
+
// task-b and task-c depend on task-a — both pending when task-a fails
|
|
334
|
+
const spec = makeSpec({ on_failure: 'stop' }, [
|
|
335
|
+
{ id: 'task-a', depends_on: [] },
|
|
336
|
+
{ id: 'task-b', depends_on: ['task-a'] },
|
|
337
|
+
{ id: 'task-c', depends_on: ['task-a'] },
|
|
338
|
+
])
|
|
339
|
+
const engine = createConvoyEngine({
|
|
340
|
+
spec,
|
|
341
|
+
specYaml: 'name: test',
|
|
342
|
+
adapter,
|
|
343
|
+
dbPath,
|
|
344
|
+
_worktreeManager: makeWorktreeManager(),
|
|
345
|
+
_mergeQueue: makeMergeQueue(),
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const result = await engine.run()
|
|
349
|
+
|
|
350
|
+
expect(result.status).toBe('failed')
|
|
351
|
+
expect(result.summary.failed).toBe(1)
|
|
352
|
+
expect(result.summary.skipped).toBe(2)
|
|
353
|
+
expect(result.summary.done).toBe(0)
|
|
354
|
+
|
|
355
|
+
const store = createConvoyStore(dbPath)
|
|
356
|
+
const tasks = store.getTasksByConvoy(result.convoyId)
|
|
357
|
+
store.close()
|
|
358
|
+
const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]))
|
|
359
|
+
expect(byId['task-a']).toBe('failed')
|
|
360
|
+
expect(byId['task-b']).toBe('skipped')
|
|
361
|
+
expect(byId['task-c']).toBe('skipped')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('does not retry when on_failure is stop even if max_retries > 0', async () => {
|
|
365
|
+
const adapter = makeAdapter()
|
|
366
|
+
adapter.execute.mockResolvedValue({ success: false, output: 'fail', exitCode: 1 })
|
|
367
|
+
|
|
368
|
+
const spec = makeSpec({ on_failure: 'stop' }, [{ id: 'task-1', max_retries: 3 }])
|
|
369
|
+
const engine = createConvoyEngine({
|
|
370
|
+
spec,
|
|
371
|
+
specYaml: 'name: test',
|
|
372
|
+
adapter,
|
|
373
|
+
dbPath,
|
|
374
|
+
_worktreeManager: makeWorktreeManager(),
|
|
375
|
+
_mergeQueue: makeMergeQueue(),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await engine.run()
|
|
379
|
+
|
|
380
|
+
// No retries — stop mode skips them
|
|
381
|
+
expect(adapter.execute).toHaveBeenCalledOnce()
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// ── 6. Task retry ─────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
describe('task retry', () => {
|
|
388
|
+
it('re-runs a task that fails and succeeds on second attempt', async () => {
|
|
389
|
+
const adapter = makeAdapter()
|
|
390
|
+
// Add small delays so Date.now() advances between worker insertions on retry
|
|
391
|
+
adapter.execute
|
|
392
|
+
.mockImplementationOnce(async () => {
|
|
393
|
+
await new Promise(r => setTimeout(r, 5))
|
|
394
|
+
return { success: false, output: 'first attempt failed', exitCode: 1 }
|
|
395
|
+
})
|
|
396
|
+
.mockImplementationOnce(async () => {
|
|
397
|
+
await new Promise(r => setTimeout(r, 5))
|
|
398
|
+
return { success: true, output: 'second attempt ok', exitCode: 0 }
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const spec = makeSpec({}, [{ id: 'task-1', max_retries: 1 }])
|
|
402
|
+
const engine = createConvoyEngine({
|
|
403
|
+
spec,
|
|
404
|
+
specYaml: 'name: test',
|
|
405
|
+
adapter,
|
|
406
|
+
dbPath,
|
|
407
|
+
_worktreeManager: makeWorktreeManager(),
|
|
408
|
+
_mergeQueue: makeMergeQueue(),
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
const result = await engine.run()
|
|
412
|
+
|
|
413
|
+
expect(result.status).toBe('done')
|
|
414
|
+
expect(result.summary.done).toBe(1)
|
|
415
|
+
expect(adapter.execute).toHaveBeenCalledTimes(2)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('marks task as failed when retries are exhausted', async () => {
|
|
419
|
+
const adapter = makeAdapter()
|
|
420
|
+
// Small delay ensures Date.now() advances between each worker insertion on retry
|
|
421
|
+
adapter.execute.mockImplementation(async () => {
|
|
422
|
+
await new Promise(r => setTimeout(r, 5))
|
|
423
|
+
return { success: false, output: 'always fails', exitCode: 1 }
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
const spec = makeSpec({}, [{ id: 'task-1', max_retries: 2 }])
|
|
427
|
+
const engine = createConvoyEngine({
|
|
428
|
+
spec,
|
|
429
|
+
specYaml: 'name: test',
|
|
430
|
+
adapter,
|
|
431
|
+
dbPath,
|
|
432
|
+
_worktreeManager: makeWorktreeManager(),
|
|
433
|
+
_mergeQueue: makeMergeQueue(),
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const result = await engine.run()
|
|
437
|
+
|
|
438
|
+
// 1 original + 2 retries = 3 total calls
|
|
439
|
+
expect(adapter.execute).toHaveBeenCalledTimes(3)
|
|
440
|
+
expect(result.status).toBe('failed')
|
|
441
|
+
expect(result.summary.failed).toBe(1)
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// ── 7. Validation gates ───────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
describe('validation gates', () => {
|
|
448
|
+
it('returns status done when all gates pass', async () => {
|
|
449
|
+
const adapter = makeAdapter()
|
|
450
|
+
const spec = makeSpec({ gates: ['echo gate-ok'] }, [{ id: 'task-1' }])
|
|
451
|
+
const engine = createConvoyEngine({
|
|
452
|
+
spec,
|
|
453
|
+
specYaml: 'name: test',
|
|
454
|
+
adapter,
|
|
455
|
+
dbPath,
|
|
456
|
+
_worktreeManager: makeWorktreeManager(),
|
|
457
|
+
_mergeQueue: makeMergeQueue(),
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
const result = await engine.run()
|
|
461
|
+
|
|
462
|
+
expect(result.status).toBe('done')
|
|
463
|
+
expect(result.gateResults).toHaveLength(1)
|
|
464
|
+
expect(result.gateResults![0]).toMatchObject({ command: 'echo gate-ok', exitCode: 0, passed: true })
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('returns status gate-failed when a gate exits non-zero', async () => {
|
|
468
|
+
const adapter = makeAdapter()
|
|
469
|
+
const spec = makeSpec({ gates: ['false'] }, [{ id: 'task-1' }])
|
|
470
|
+
const engine = createConvoyEngine({
|
|
471
|
+
spec,
|
|
472
|
+
specYaml: 'name: test',
|
|
473
|
+
adapter,
|
|
474
|
+
dbPath,
|
|
475
|
+
_worktreeManager: makeWorktreeManager(),
|
|
476
|
+
_mergeQueue: makeMergeQueue(),
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
const result = await engine.run()
|
|
480
|
+
|
|
481
|
+
expect(result.status).toBe('gate-failed')
|
|
482
|
+
expect(result.gateResults).toHaveLength(1)
|
|
483
|
+
expect(result.gateResults![0].passed).toBe(false)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('returns undefined gateResults when spec has no gates', async () => {
|
|
487
|
+
const adapter = makeAdapter()
|
|
488
|
+
const engine = createConvoyEngine({
|
|
489
|
+
spec: makeSpec(),
|
|
490
|
+
specYaml: 'name: test',
|
|
491
|
+
adapter,
|
|
492
|
+
dbPath,
|
|
493
|
+
_worktreeManager: makeWorktreeManager(),
|
|
494
|
+
_mergeQueue: makeMergeQueue(),
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
const result = await engine.run()
|
|
498
|
+
|
|
499
|
+
expect(result.gateResults).toBeUndefined()
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('runs multiple gates and reports each result individually', async () => {
|
|
503
|
+
const adapter = makeAdapter()
|
|
504
|
+
const spec = makeSpec({ gates: ['echo first', 'false', 'echo third'] }, [{ id: 'task-1' }])
|
|
505
|
+
const engine = createConvoyEngine({
|
|
506
|
+
spec,
|
|
507
|
+
specYaml: 'name: test',
|
|
508
|
+
adapter,
|
|
509
|
+
dbPath,
|
|
510
|
+
_worktreeManager: makeWorktreeManager(),
|
|
511
|
+
_mergeQueue: makeMergeQueue(),
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
const result = await engine.run()
|
|
515
|
+
|
|
516
|
+
expect(result.status).toBe('gate-failed')
|
|
517
|
+
expect(result.gateResults).toHaveLength(3)
|
|
518
|
+
expect(result.gateResults![0].passed).toBe(true)
|
|
519
|
+
expect(result.gateResults![1].passed).toBe(false)
|
|
520
|
+
expect(result.gateResults![2].passed).toBe(true)
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// ── 8. Resume (crash recovery) ────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
describe('resume (crash recovery)', () => {
|
|
527
|
+
function seedCrashedConvoy(convoyId: string, taskStatus: 'running' | 'assigned') {
|
|
528
|
+
const seeder = createConvoyStore(dbPath)
|
|
529
|
+
seeder.insertConvoy({
|
|
530
|
+
id: convoyId,
|
|
531
|
+
name: 'Crashed Convoy',
|
|
532
|
+
spec_hash: 'abc123',
|
|
533
|
+
status: 'running',
|
|
534
|
+
branch: 'main',
|
|
535
|
+
created_at: new Date().toISOString(),
|
|
536
|
+
spec_yaml: 'name: test',
|
|
537
|
+
})
|
|
538
|
+
seeder.insertTask({
|
|
539
|
+
id: 'task-1',
|
|
540
|
+
convoy_id: convoyId,
|
|
541
|
+
phase: 0,
|
|
542
|
+
prompt: 'Do something',
|
|
543
|
+
agent: 'developer',
|
|
544
|
+
model: null,
|
|
545
|
+
timeout_ms: 30_000,
|
|
546
|
+
status: taskStatus,
|
|
547
|
+
retries: 0,
|
|
548
|
+
max_retries: 0,
|
|
549
|
+
files: null,
|
|
550
|
+
depends_on: null,
|
|
551
|
+
})
|
|
552
|
+
if (taskStatus === 'running') {
|
|
553
|
+
seeder.insertWorker({
|
|
554
|
+
id: 'worker-orphan',
|
|
555
|
+
task_id: 'task-1',
|
|
556
|
+
adapter: 'test',
|
|
557
|
+
pid: null,
|
|
558
|
+
session_id: null,
|
|
559
|
+
status: 'running',
|
|
560
|
+
worktree: null,
|
|
561
|
+
created_at: new Date().toISOString(),
|
|
562
|
+
})
|
|
563
|
+
seeder.updateTaskStatus('task-1', convoyId, 'running', { worker_id: 'worker-orphan' })
|
|
564
|
+
}
|
|
565
|
+
seeder.close()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
it('resets running tasks to pending, calls removeAll, and re-executes them', async () => {
|
|
569
|
+
const convoyId = 'convoy-crashed-running'
|
|
570
|
+
seedCrashedConvoy(convoyId, 'running')
|
|
571
|
+
|
|
572
|
+
const adapter = makeAdapter()
|
|
573
|
+
const wtManager = makeWorktreeManager()
|
|
574
|
+
const engine = createConvoyEngine({
|
|
575
|
+
spec: makeSpec({}, [{ id: 'task-1' }]),
|
|
576
|
+
specYaml: 'name: test',
|
|
577
|
+
adapter,
|
|
578
|
+
dbPath,
|
|
579
|
+
_worktreeManager: wtManager,
|
|
580
|
+
_mergeQueue: makeMergeQueue(),
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
const result = await engine.resume(convoyId)
|
|
584
|
+
|
|
585
|
+
expect(result.status).toBe('done')
|
|
586
|
+
expect(result.summary.done).toBe(1)
|
|
587
|
+
expect(result.convoyId).toBe(convoyId)
|
|
588
|
+
expect(wtManager.removeAll).toHaveBeenCalledOnce()
|
|
589
|
+
expect(adapter.execute).toHaveBeenCalledOnce()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('resets assigned (not yet running) tasks to pending on resume', async () => {
|
|
593
|
+
const convoyId = 'convoy-crashed-assigned'
|
|
594
|
+
seedCrashedConvoy(convoyId, 'assigned')
|
|
595
|
+
|
|
596
|
+
const adapter = makeAdapter()
|
|
597
|
+
const engine = createConvoyEngine({
|
|
598
|
+
spec: makeSpec({}, [{ id: 'task-1' }]),
|
|
599
|
+
specYaml: 'name: test',
|
|
600
|
+
adapter,
|
|
601
|
+
dbPath,
|
|
602
|
+
_worktreeManager: makeWorktreeManager(),
|
|
603
|
+
_mergeQueue: makeMergeQueue(),
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
const result = await engine.resume(convoyId)
|
|
607
|
+
expect(result.status).toBe('done')
|
|
608
|
+
expect(adapter.execute).toHaveBeenCalledOnce()
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('throws an error when the convoy is not found', async () => {
|
|
612
|
+
const adapter = makeAdapter()
|
|
613
|
+
const engine = createConvoyEngine({
|
|
614
|
+
spec: makeSpec(),
|
|
615
|
+
specYaml: 'name: test',
|
|
616
|
+
adapter,
|
|
617
|
+
dbPath,
|
|
618
|
+
_worktreeManager: makeWorktreeManager(),
|
|
619
|
+
_mergeQueue: makeMergeQueue(),
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
await expect(engine.resume('convoy-does-not-exist')).rejects.toThrow(
|
|
623
|
+
'Convoy "convoy-does-not-exist" not found in store',
|
|
624
|
+
)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('falls back to spec.branch when convoy.branch is null', async () => {
|
|
628
|
+
// Seed a convoy with branch=null to exercise the ?? fallback chain in resume
|
|
629
|
+
const convoyId = 'convoy-null-branch'
|
|
630
|
+
const seeder = createConvoyStore(dbPath)
|
|
631
|
+
seeder.insertConvoy({
|
|
632
|
+
id: convoyId,
|
|
633
|
+
name: 'Null Branch Convoy',
|
|
634
|
+
spec_hash: 'abc123',
|
|
635
|
+
status: 'running',
|
|
636
|
+
branch: null, // convoy has no recorded branch
|
|
637
|
+
created_at: new Date().toISOString(),
|
|
638
|
+
spec_yaml: 'name: test',
|
|
639
|
+
})
|
|
640
|
+
seeder.insertTask({
|
|
641
|
+
id: 'task-1',
|
|
642
|
+
convoy_id: convoyId,
|
|
643
|
+
phase: 0,
|
|
644
|
+
prompt: 'Do something',
|
|
645
|
+
agent: 'developer',
|
|
646
|
+
model: null,
|
|
647
|
+
timeout_ms: 30_000,
|
|
648
|
+
status: 'pending',
|
|
649
|
+
retries: 0,
|
|
650
|
+
max_retries: 0,
|
|
651
|
+
files: null,
|
|
652
|
+
depends_on: null,
|
|
653
|
+
})
|
|
654
|
+
seeder.close()
|
|
655
|
+
|
|
656
|
+
const adapter = makeAdapter()
|
|
657
|
+
const engine = createConvoyEngine({
|
|
658
|
+
spec: makeSpec({ branch: 'feature-branch' }), // spec.branch used as fallback
|
|
659
|
+
specYaml: 'name: test',
|
|
660
|
+
adapter,
|
|
661
|
+
dbPath,
|
|
662
|
+
_worktreeManager: makeWorktreeManager(),
|
|
663
|
+
_mergeQueue: makeMergeQueue(),
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
const result = await engine.resume(convoyId)
|
|
667
|
+
expect(result.status).toBe('done')
|
|
668
|
+
expect(result.convoyId).toBe(convoyId)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('calls getCurrentBranch in resume when convoy.branch and spec.branch are both absent', async () => {
|
|
672
|
+
// Seed a convoy with branch=null; spec also has no branch — triggers getCurrentBranch()
|
|
673
|
+
const convoyId = 'convoy-git-branch-resume'
|
|
674
|
+
const seeder = createConvoyStore(dbPath)
|
|
675
|
+
seeder.insertConvoy({
|
|
676
|
+
id: convoyId,
|
|
677
|
+
name: 'Git Branch Convoy',
|
|
678
|
+
spec_hash: 'abc123',
|
|
679
|
+
status: 'running',
|
|
680
|
+
branch: null,
|
|
681
|
+
created_at: new Date().toISOString(),
|
|
682
|
+
spec_yaml: 'name: test',
|
|
683
|
+
})
|
|
684
|
+
seeder.insertTask({
|
|
685
|
+
id: 'task-1',
|
|
686
|
+
convoy_id: convoyId,
|
|
687
|
+
phase: 0,
|
|
688
|
+
prompt: 'Do something',
|
|
689
|
+
agent: 'developer',
|
|
690
|
+
model: null,
|
|
691
|
+
timeout_ms: 30_000,
|
|
692
|
+
status: 'pending',
|
|
693
|
+
retries: 0,
|
|
694
|
+
max_retries: 0,
|
|
695
|
+
files: null,
|
|
696
|
+
depends_on: null,
|
|
697
|
+
})
|
|
698
|
+
seeder.close()
|
|
699
|
+
|
|
700
|
+
const adapter = makeAdapter()
|
|
701
|
+
const engine = createConvoyEngine({
|
|
702
|
+
spec: {
|
|
703
|
+
name: 'Git Branch Convoy',
|
|
704
|
+
concurrency: 1,
|
|
705
|
+
on_failure: 'continue',
|
|
706
|
+
adapter: 'test',
|
|
707
|
+
// branch not set — getCurrentBranch() will be called
|
|
708
|
+
tasks: [{ id: 'task-1', prompt: 'p', agent: 'dev', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 }],
|
|
709
|
+
},
|
|
710
|
+
specYaml: 'name: git-test',
|
|
711
|
+
adapter,
|
|
712
|
+
dbPath,
|
|
713
|
+
_worktreeManager: makeWorktreeManager(),
|
|
714
|
+
_mergeQueue: makeMergeQueue(),
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
const result = await engine.resume(convoyId)
|
|
718
|
+
expect(result.status).toBe('done')
|
|
719
|
+
})
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
// ── 9. Worktree lifecycle for non-copilot adapters ────────────────────────────
|
|
723
|
+
|
|
724
|
+
describe('worktree lifecycle (non-copilot)', () => {
|
|
725
|
+
it('creates, merges, and removes a worktree on task success', async () => {
|
|
726
|
+
const adapter = makeAdapter('developer')
|
|
727
|
+
const wtManager = makeWorktreeManager()
|
|
728
|
+
const mergeQueue = makeMergeQueue()
|
|
729
|
+
|
|
730
|
+
const engine = createConvoyEngine({
|
|
731
|
+
spec: makeSpec(),
|
|
732
|
+
specYaml: 'name: test',
|
|
733
|
+
adapter,
|
|
734
|
+
dbPath,
|
|
735
|
+
_worktreeManager: wtManager,
|
|
736
|
+
_mergeQueue: mergeQueue,
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
await engine.run()
|
|
740
|
+
|
|
741
|
+
expect(wtManager.create).toHaveBeenCalledOnce()
|
|
742
|
+
expect(mergeQueue.merge).toHaveBeenCalledOnce()
|
|
743
|
+
expect(wtManager.remove).toHaveBeenCalledOnce()
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('removes the worktree but skips merge when task fails', async () => {
|
|
747
|
+
const adapter = makeAdapter('developer')
|
|
748
|
+
adapter.execute.mockResolvedValue({ success: false, output: 'err', exitCode: 1 })
|
|
749
|
+
const wtManager = makeWorktreeManager()
|
|
750
|
+
const mergeQueue = makeMergeQueue()
|
|
751
|
+
|
|
752
|
+
const engine = createConvoyEngine({
|
|
753
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
754
|
+
specYaml: 'name: test',
|
|
755
|
+
adapter,
|
|
756
|
+
dbPath,
|
|
757
|
+
_worktreeManager: wtManager,
|
|
758
|
+
_mergeQueue: mergeQueue,
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
await engine.run()
|
|
762
|
+
|
|
763
|
+
expect(wtManager.create).toHaveBeenCalledOnce()
|
|
764
|
+
expect(mergeQueue.merge).not.toHaveBeenCalled()
|
|
765
|
+
expect(wtManager.remove).toHaveBeenCalledOnce()
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('continues task execution when worktree creation throws', async () => {
|
|
769
|
+
const adapter = makeAdapter('developer')
|
|
770
|
+
const wtManager = makeWorktreeManager()
|
|
771
|
+
wtManager.create.mockRejectedValue(new Error('git worktree unavailable'))
|
|
772
|
+
const mergeQueue = makeMergeQueue()
|
|
773
|
+
|
|
774
|
+
const engine = createConvoyEngine({
|
|
775
|
+
spec: makeSpec(),
|
|
776
|
+
specYaml: 'name: test',
|
|
777
|
+
adapter,
|
|
778
|
+
dbPath,
|
|
779
|
+
_worktreeManager: wtManager,
|
|
780
|
+
_mergeQueue: mergeQueue,
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
// Task should still succeed even without a worktree
|
|
784
|
+
const result = await engine.run()
|
|
785
|
+
expect(result.status).toBe('done')
|
|
786
|
+
expect(adapter.execute).toHaveBeenCalledOnce()
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
it('task still succeeds when merge throws', async () => {
|
|
790
|
+
const adapter = makeAdapter('developer')
|
|
791
|
+
const wtManager = makeWorktreeManager()
|
|
792
|
+
const mergeQueue = makeMergeQueue()
|
|
793
|
+
mergeQueue.merge.mockRejectedValue(new Error('merge conflict'))
|
|
794
|
+
|
|
795
|
+
const engine = createConvoyEngine({
|
|
796
|
+
spec: makeSpec(),
|
|
797
|
+
specYaml: 'name: test',
|
|
798
|
+
adapter,
|
|
799
|
+
dbPath,
|
|
800
|
+
_worktreeManager: wtManager,
|
|
801
|
+
_mergeQueue: mergeQueue,
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
const result = await engine.run()
|
|
805
|
+
// task is still marked done despite the merge warning
|
|
806
|
+
expect(result.status).toBe('done')
|
|
807
|
+
expect(wtManager.remove).toHaveBeenCalledOnce()
|
|
808
|
+
})
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
// ── 10. Copilot adapter skips worktree ────────────────────────────────────────
|
|
812
|
+
|
|
813
|
+
describe('copilot adapter', () => {
|
|
814
|
+
it('skips worktree create, merge, and remove for copilot adapter', async () => {
|
|
815
|
+
const adapter = makeAdapter('copilot')
|
|
816
|
+
const wtManager = makeWorktreeManager()
|
|
817
|
+
const mergeQueue = makeMergeQueue()
|
|
818
|
+
|
|
819
|
+
const engine = createConvoyEngine({
|
|
820
|
+
spec: makeSpec(),
|
|
821
|
+
specYaml: 'name: test',
|
|
822
|
+
adapter,
|
|
823
|
+
dbPath,
|
|
824
|
+
_worktreeManager: wtManager,
|
|
825
|
+
_mergeQueue: mergeQueue,
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
const result = await engine.run()
|
|
829
|
+
|
|
830
|
+
expect(result.status).toBe('done')
|
|
831
|
+
expect(wtManager.create).not.toHaveBeenCalled()
|
|
832
|
+
expect(mergeQueue.merge).not.toHaveBeenCalled()
|
|
833
|
+
expect(wtManager.remove).not.toHaveBeenCalled()
|
|
834
|
+
})
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
// ── 11. Timeout handling ──────────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
describe('timeout handling', () => {
|
|
840
|
+
it('marks a task as timed-out when adapter result carries _timedOut flag', async () => {
|
|
841
|
+
const adapter = makeAdapter()
|
|
842
|
+
// Mirror what makeTimeoutPromise resolves with to exercise the _timedOut branch
|
|
843
|
+
adapter.execute.mockResolvedValue({
|
|
844
|
+
_timedOut: true,
|
|
845
|
+
success: false,
|
|
846
|
+
output: 'Task timed out',
|
|
847
|
+
exitCode: -1,
|
|
848
|
+
} satisfies ExecuteResult)
|
|
849
|
+
|
|
850
|
+
const engine = createConvoyEngine({
|
|
851
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
852
|
+
specYaml: 'name: test',
|
|
853
|
+
adapter,
|
|
854
|
+
dbPath,
|
|
855
|
+
_worktreeManager: makeWorktreeManager(),
|
|
856
|
+
_mergeQueue: makeMergeQueue(),
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
const result = await engine.run()
|
|
860
|
+
|
|
861
|
+
expect(result.status).toBe('failed')
|
|
862
|
+
expect(result.summary.timedOut).toBe(1)
|
|
863
|
+
expect(adapter.kill).toHaveBeenCalledOnce()
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('retries a timed-out task when retries remain', async () => {
|
|
867
|
+
const adapter = makeAdapter()
|
|
868
|
+
adapter.execute
|
|
869
|
+
.mockImplementationOnce(async () => {
|
|
870
|
+
await new Promise(r => setTimeout(r, 5))
|
|
871
|
+
return { _timedOut: true, success: false, output: 'timed out', exitCode: -1 }
|
|
872
|
+
})
|
|
873
|
+
.mockImplementationOnce(async () => {
|
|
874
|
+
await new Promise(r => setTimeout(r, 5))
|
|
875
|
+
return { success: true, output: 'ok', exitCode: 0 }
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
const engine = createConvoyEngine({
|
|
879
|
+
spec: makeSpec({ on_failure: 'continue' }, [{ id: 'task-1', max_retries: 1 }]),
|
|
880
|
+
specYaml: 'name: test',
|
|
881
|
+
adapter,
|
|
882
|
+
dbPath,
|
|
883
|
+
_worktreeManager: makeWorktreeManager(),
|
|
884
|
+
_mergeQueue: makeMergeQueue(),
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
const result = await engine.run()
|
|
888
|
+
|
|
889
|
+
expect(result.status).toBe('done')
|
|
890
|
+
expect(adapter.execute).toHaveBeenCalledTimes(2)
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('does not retry a timed-out task when on_failure is stop', async () => {
|
|
894
|
+
const adapter = makeAdapter()
|
|
895
|
+
adapter.execute.mockResolvedValue({
|
|
896
|
+
_timedOut: true,
|
|
897
|
+
success: false,
|
|
898
|
+
output: 'timed out',
|
|
899
|
+
exitCode: -1,
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
const engine = createConvoyEngine({
|
|
903
|
+
spec: makeSpec({ on_failure: 'stop' }, [{ id: 'task-1', max_retries: 2 }]),
|
|
904
|
+
specYaml: 'name: test',
|
|
905
|
+
adapter,
|
|
906
|
+
dbPath,
|
|
907
|
+
_worktreeManager: makeWorktreeManager(),
|
|
908
|
+
_mergeQueue: makeMergeQueue(),
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
const result = await engine.run()
|
|
912
|
+
|
|
913
|
+
expect(result.summary.timedOut).toBe(1)
|
|
914
|
+
expect(adapter.execute).toHaveBeenCalledOnce()
|
|
915
|
+
})
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
// ── 12. Adapter without kill method ──────────────────────────────────────────
|
|
919
|
+
|
|
920
|
+
describe('adapter without kill method', () => {
|
|
921
|
+
it('handles missing kill gracefully on task failure', async () => {
|
|
922
|
+
const adapter: AgentAdapter = {
|
|
923
|
+
name: 'no-kill-adapter',
|
|
924
|
+
isAvailable: vi.fn().mockResolvedValue(true),
|
|
925
|
+
execute: vi.fn().mockResolvedValue({ success: false, output: 'err', exitCode: 1 }),
|
|
926
|
+
// kill intentionally absent
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const engine = createConvoyEngine({
|
|
930
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
931
|
+
specYaml: 'name: test',
|
|
932
|
+
adapter,
|
|
933
|
+
dbPath,
|
|
934
|
+
_worktreeManager: makeWorktreeManager(),
|
|
935
|
+
_mergeQueue: makeMergeQueue(),
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
const result = await engine.run()
|
|
939
|
+
expect(result.status).toBe('failed')
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('handles missing kill gracefully on timeout', async () => {
|
|
943
|
+
const adapter: AgentAdapter = {
|
|
944
|
+
name: 'no-kill-adapter',
|
|
945
|
+
isAvailable: vi.fn().mockResolvedValue(true),
|
|
946
|
+
execute: vi.fn().mockResolvedValue({
|
|
947
|
+
_timedOut: true,
|
|
948
|
+
success: false,
|
|
949
|
+
output: 'timed out',
|
|
950
|
+
exitCode: -1,
|
|
951
|
+
}),
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const engine = createConvoyEngine({
|
|
955
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
956
|
+
specYaml: 'name: test',
|
|
957
|
+
adapter,
|
|
958
|
+
dbPath,
|
|
959
|
+
_worktreeManager: makeWorktreeManager(),
|
|
960
|
+
_mergeQueue: makeMergeQueue(),
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
const result = await engine.run()
|
|
964
|
+
expect(result.summary.timedOut).toBe(1)
|
|
965
|
+
})
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
// ── 13. Parallel task execution ───────────────────────────────────────────────
|
|
969
|
+
|
|
970
|
+
describe('parallel task execution', () => {
|
|
971
|
+
it('runs independent tasks concurrently when concurrency > 1', async () => {
|
|
972
|
+
let maxActive = 0
|
|
973
|
+
let active = 0
|
|
974
|
+
const adapter = makeAdapter()
|
|
975
|
+
adapter.execute.mockImplementation(async () => {
|
|
976
|
+
active++
|
|
977
|
+
maxActive = Math.max(maxActive, active)
|
|
978
|
+
await new Promise<void>(r => setTimeout(r, 10))
|
|
979
|
+
active--
|
|
980
|
+
return { success: true, output: 'ok', exitCode: 0 }
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
const spec = makeSpec({ concurrency: 3 }, [
|
|
984
|
+
{ id: 'task-1', depends_on: [] },
|
|
985
|
+
{ id: 'task-2', depends_on: [] },
|
|
986
|
+
{ id: 'task-3', depends_on: [] },
|
|
987
|
+
])
|
|
988
|
+
const engine = createConvoyEngine({
|
|
989
|
+
spec,
|
|
990
|
+
specYaml: 'name: test',
|
|
991
|
+
adapter,
|
|
992
|
+
dbPath,
|
|
993
|
+
_worktreeManager: makeWorktreeManager(),
|
|
994
|
+
_mergeQueue: makeMergeQueue(),
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
const result = await engine.run()
|
|
998
|
+
|
|
999
|
+
expect(result.summary.done).toBe(3)
|
|
1000
|
+
expect(maxActive).toBeGreaterThan(1)
|
|
1001
|
+
})
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
// ── 14. Executor error (adapter.execute throws) ───────────────────────────────
|
|
1005
|
+
|
|
1006
|
+
describe('executor error', () => {
|
|
1007
|
+
it('treats a thrown execute error as task failure', async () => {
|
|
1008
|
+
const adapter = makeAdapter()
|
|
1009
|
+
adapter.execute.mockRejectedValue(new Error('adapter crashed'))
|
|
1010
|
+
|
|
1011
|
+
const engine = createConvoyEngine({
|
|
1012
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
1013
|
+
specYaml: 'name: test',
|
|
1014
|
+
adapter,
|
|
1015
|
+
dbPath,
|
|
1016
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1017
|
+
_mergeQueue: makeMergeQueue(),
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
const result = await engine.run()
|
|
1021
|
+
|
|
1022
|
+
expect(result.status).toBe('failed')
|
|
1023
|
+
expect(result.summary.failed).toBe(1)
|
|
1024
|
+
})
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
// ── 15. Verbose mode — covers all if(verbose) branches ───────────────────────
|
|
1028
|
+
|
|
1029
|
+
describe('verbose mode', () => {
|
|
1030
|
+
it('runs a successful task with verbose=true without throwing', async () => {
|
|
1031
|
+
const adapter = makeAdapter('developer')
|
|
1032
|
+
const engine = createConvoyEngine({
|
|
1033
|
+
spec: makeSpec({}, [{ id: 'task-1' }]),
|
|
1034
|
+
specYaml: 'name: test',
|
|
1035
|
+
adapter,
|
|
1036
|
+
verbose: true,
|
|
1037
|
+
dbPath,
|
|
1038
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1039
|
+
_mergeQueue: makeMergeQueue(),
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
const result = await engine.run()
|
|
1043
|
+
expect(result.status).toBe('done')
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
it('runs a failed task with skip cascade with verbose=true without throwing', async () => {
|
|
1047
|
+
const adapter = makeAdapter('developer')
|
|
1048
|
+
adapter.execute.mockImplementation((task: Task) => {
|
|
1049
|
+
if (task.id === 'task-a') return Promise.resolve({ success: false, output: 'fail', exitCode: 1 })
|
|
1050
|
+
return Promise.resolve({ success: true, output: 'ok', exitCode: 0 })
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
const spec = makeSpec({ on_failure: 'continue' }, [
|
|
1054
|
+
{ id: 'task-a', depends_on: [] },
|
|
1055
|
+
{ id: 'task-b', depends_on: ['task-a'] }, // gets skipped — also triggers verbose skip log
|
|
1056
|
+
])
|
|
1057
|
+
const engine = createConvoyEngine({
|
|
1058
|
+
spec,
|
|
1059
|
+
specYaml: 'name: test',
|
|
1060
|
+
adapter,
|
|
1061
|
+
verbose: true,
|
|
1062
|
+
dbPath,
|
|
1063
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1064
|
+
_mergeQueue: makeMergeQueue(),
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
const result = await engine.run()
|
|
1068
|
+
expect(result.summary.failed).toBe(1)
|
|
1069
|
+
expect(result.summary.skipped).toBe(1)
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
it('logs verbose message when retrying a failed task', async () => {
|
|
1073
|
+
const adapter = makeAdapter('developer')
|
|
1074
|
+
adapter.execute
|
|
1075
|
+
.mockImplementationOnce(async () => {
|
|
1076
|
+
await new Promise(r => setTimeout(r, 5))
|
|
1077
|
+
return { success: false, output: 'first fail', exitCode: 1 }
|
|
1078
|
+
})
|
|
1079
|
+
.mockImplementationOnce(async () => {
|
|
1080
|
+
await new Promise(r => setTimeout(r, 5))
|
|
1081
|
+
return { success: true, output: 'ok', exitCode: 0 }
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
const engine = createConvoyEngine({
|
|
1085
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 1 }]),
|
|
1086
|
+
specYaml: 'name: test',
|
|
1087
|
+
adapter,
|
|
1088
|
+
verbose: true,
|
|
1089
|
+
dbPath,
|
|
1090
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1091
|
+
_mergeQueue: makeMergeQueue(),
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
const result = await engine.run()
|
|
1095
|
+
expect(result.status).toBe('done')
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('logs verbose message on permanent timeout', async () => {
|
|
1099
|
+
const adapter = makeAdapter()
|
|
1100
|
+
adapter.execute.mockResolvedValue({
|
|
1101
|
+
_timedOut: true,
|
|
1102
|
+
success: false,
|
|
1103
|
+
output: 'timed out',
|
|
1104
|
+
exitCode: -1,
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
const engine = createConvoyEngine({
|
|
1108
|
+
spec: makeSpec({}, [{ id: 'task-1', max_retries: 0 }]),
|
|
1109
|
+
specYaml: 'name: test',
|
|
1110
|
+
adapter,
|
|
1111
|
+
verbose: true,
|
|
1112
|
+
dbPath,
|
|
1113
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1114
|
+
_mergeQueue: makeMergeQueue(),
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
const result = await engine.run()
|
|
1118
|
+
expect(result.summary.timedOut).toBe(1)
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('logs verbose message when retrying a timed-out task', async () => {
|
|
1122
|
+
const adapter = makeAdapter()
|
|
1123
|
+
adapter.execute
|
|
1124
|
+
.mockImplementationOnce(async () => {
|
|
1125
|
+
await new Promise(r => setTimeout(r, 5))
|
|
1126
|
+
return { _timedOut: true, success: false, output: 'timed out', exitCode: -1 }
|
|
1127
|
+
})
|
|
1128
|
+
.mockImplementationOnce(async () => {
|
|
1129
|
+
await new Promise(r => setTimeout(r, 5))
|
|
1130
|
+
return { success: true, output: 'ok', exitCode: 0 }
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
const engine = createConvoyEngine({
|
|
1134
|
+
spec: makeSpec({ on_failure: 'continue' }, [{ id: 'task-1', max_retries: 1 }]),
|
|
1135
|
+
specYaml: 'name: test',
|
|
1136
|
+
adapter,
|
|
1137
|
+
verbose: true,
|
|
1138
|
+
dbPath,
|
|
1139
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1140
|
+
_mergeQueue: makeMergeQueue(),
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
const result = await engine.run()
|
|
1144
|
+
expect(result.status).toBe('done')
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
it('logs verbose warning when worktree creation fails', async () => {
|
|
1148
|
+
const adapter = makeAdapter('developer')
|
|
1149
|
+
const wtManager = makeWorktreeManager()
|
|
1150
|
+
wtManager.create.mockRejectedValue(new Error('no worktrees'))
|
|
1151
|
+
|
|
1152
|
+
const engine = createConvoyEngine({
|
|
1153
|
+
spec: makeSpec({}, [{ id: 'task-1' }]),
|
|
1154
|
+
specYaml: 'name: test',
|
|
1155
|
+
adapter,
|
|
1156
|
+
verbose: true,
|
|
1157
|
+
dbPath,
|
|
1158
|
+
_worktreeManager: wtManager,
|
|
1159
|
+
_mergeQueue: makeMergeQueue(),
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
const result = await engine.run()
|
|
1163
|
+
expect(result.status).toBe('done')
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
it('logs verbose warning when merge fails', async () => {
|
|
1167
|
+
const adapter = makeAdapter('developer')
|
|
1168
|
+
const mergeQueue = makeMergeQueue()
|
|
1169
|
+
mergeQueue.merge.mockRejectedValue(new Error('merge conflict'))
|
|
1170
|
+
|
|
1171
|
+
const engine = createConvoyEngine({
|
|
1172
|
+
spec: makeSpec({}, [{ id: 'task-1' }]),
|
|
1173
|
+
specYaml: 'name: test',
|
|
1174
|
+
adapter,
|
|
1175
|
+
verbose: true,
|
|
1176
|
+
dbPath,
|
|
1177
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1178
|
+
_mergeQueue: mergeQueue,
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
const result = await engine.run()
|
|
1182
|
+
expect(result.status).toBe('done')
|
|
1183
|
+
})
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
// ── 16. msToTimeout branch coverage ──────────────────────────────────────────
|
|
1187
|
+
|
|
1188
|
+
describe('msToTimeout — timeout string representation', () => {
|
|
1189
|
+
it('runs a task with 1-hour timeout (covers hours branch of msToTimeout)', async () => {
|
|
1190
|
+
const adapter = makeAdapter()
|
|
1191
|
+
// parseTimeout('1h') = 3600000ms; msToTimeout(3600000) = '1h'
|
|
1192
|
+
const spec = makeSpec({}, [{ id: 'task-1', timeout: '1h' }])
|
|
1193
|
+
const engine = createConvoyEngine({
|
|
1194
|
+
spec,
|
|
1195
|
+
specYaml: 'name: test',
|
|
1196
|
+
adapter,
|
|
1197
|
+
dbPath,
|
|
1198
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1199
|
+
_mergeQueue: makeMergeQueue(),
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
const result = await engine.run()
|
|
1203
|
+
expect(result.status).toBe('done')
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
it('runs a task with 1-minute timeout (covers minutes branch of msToTimeout)', async () => {
|
|
1207
|
+
const adapter = makeAdapter()
|
|
1208
|
+
// parseTimeout('1m') = 60000ms; msToTimeout(60000) = '1m'
|
|
1209
|
+
const spec = makeSpec({}, [{ id: 'task-1', timeout: '1m' }])
|
|
1210
|
+
const engine = createConvoyEngine({
|
|
1211
|
+
spec,
|
|
1212
|
+
specYaml: 'name: test',
|
|
1213
|
+
adapter,
|
|
1214
|
+
dbPath,
|
|
1215
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1216
|
+
_mergeQueue: makeMergeQueue(),
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
const result = await engine.run()
|
|
1220
|
+
expect(result.status).toBe('done')
|
|
1221
|
+
})
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
// ── 17. getCurrentBranch fallback ─────────────────────────────────────────────
|
|
1225
|
+
|
|
1226
|
+
describe('getCurrentBranch', () => {
|
|
1227
|
+
it('resolves the base branch from git when spec.branch is not set', async () => {
|
|
1228
|
+
const adapter = makeAdapter()
|
|
1229
|
+
// No spec.branch — forces getCurrentBranch() to call git
|
|
1230
|
+
const spec: TaskSpec = {
|
|
1231
|
+
name: 'Branch Test',
|
|
1232
|
+
concurrency: 1,
|
|
1233
|
+
on_failure: 'continue',
|
|
1234
|
+
adapter: 'test',
|
|
1235
|
+
// branch intentionally omitted
|
|
1236
|
+
tasks: [{ id: 'task-1', prompt: 'p', agent: 'dev', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 }],
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const engine = createConvoyEngine({
|
|
1240
|
+
spec,
|
|
1241
|
+
specYaml: 'name: branch-test',
|
|
1242
|
+
adapter,
|
|
1243
|
+
dbPath,
|
|
1244
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1245
|
+
_mergeQueue: makeMergeQueue(),
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
const result = await engine.run()
|
|
1249
|
+
expect(result.status).toBe('done')
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
it('falls back to "main" when git command fails (non-git basePath)', async () => {
|
|
1253
|
+
const adapter = makeAdapter()
|
|
1254
|
+
const spec: TaskSpec = {
|
|
1255
|
+
name: 'Fallback Branch Test',
|
|
1256
|
+
concurrency: 1,
|
|
1257
|
+
on_failure: 'continue',
|
|
1258
|
+
adapter: 'test',
|
|
1259
|
+
// branch not set — getCurrentBranch will fail because basePath is /tmp
|
|
1260
|
+
tasks: [{ id: 'task-1', prompt: 'p', agent: 'dev', timeout: '30s', depends_on: [], files: [], description: '', max_retries: 0 }],
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const engine = createConvoyEngine({
|
|
1264
|
+
spec,
|
|
1265
|
+
specYaml: 'name: fallback-test',
|
|
1266
|
+
adapter,
|
|
1267
|
+
basePath: tmpdir(), // not a git repo — git command will fail → fallback to 'main'
|
|
1268
|
+
dbPath,
|
|
1269
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1270
|
+
_mergeQueue: makeMergeQueue(),
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
const result = await engine.run()
|
|
1274
|
+
expect(result.status).toBe('done')
|
|
1275
|
+
})
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
// ── 19. Real timer timeout (covers makeTimeoutPromise callback at line 71) ────
|
|
1279
|
+
|
|
1280
|
+
describe('real timer timeout path', () => {
|
|
1281
|
+
it('marks task timed-out when the real internal timer fires via fake timers', async () => {
|
|
1282
|
+
vi.useFakeTimers()
|
|
1283
|
+
|
|
1284
|
+
const adapter = makeAdapter()
|
|
1285
|
+
// adapter.execute returns a promise that never resolves — real timer wins the race
|
|
1286
|
+
adapter.execute.mockImplementation(() => new Promise<ExecuteResult>(() => {}))
|
|
1287
|
+
|
|
1288
|
+
const engine = createConvoyEngine({
|
|
1289
|
+
spec: makeSpec({}, [{ id: 'task-1', timeout: '1s', max_retries: 0 }]),
|
|
1290
|
+
specYaml: 'name: test',
|
|
1291
|
+
adapter,
|
|
1292
|
+
dbPath,
|
|
1293
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1294
|
+
_mergeQueue: makeMergeQueue(),
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
const runPromise = engine.run()
|
|
1298
|
+
// Advance time past the 1s timeout to trigger the internal setTimeout callback
|
|
1299
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
1300
|
+
const result = await runPromise
|
|
1301
|
+
|
|
1302
|
+
vi.useRealTimers()
|
|
1303
|
+
|
|
1304
|
+
expect(result.status).toBe('failed')
|
|
1305
|
+
expect(result.summary.timedOut).toBe(1)
|
|
1306
|
+
})
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
describe('diamond dependency skip', () => {
|
|
1310
|
+
it('handles diamond deps gracefully (task-c skipped via two paths)', async () => {
|
|
1311
|
+
const adapter = makeAdapter()
|
|
1312
|
+
adapter.execute.mockImplementation((task: Task) => {
|
|
1313
|
+
if (task.id === 'task-a') return Promise.resolve({ success: false, output: 'fail', exitCode: 1 })
|
|
1314
|
+
return Promise.resolve({ success: true, output: 'ok', exitCode: 0 })
|
|
1315
|
+
})
|
|
1316
|
+
|
|
1317
|
+
// Diamond: task-a → task-b → task-c AND task-a → task-c directly
|
|
1318
|
+
// When task-a fails, cascadeFailure tries to skip task-b and task-c directly.
|
|
1319
|
+
// skipTask(task-b) recursively skips task-c first.
|
|
1320
|
+
// Then when cascadeFailure tries skipTask(task-c) directly, task-c.status !== 'pending' → early return.
|
|
1321
|
+
const spec = makeSpec({ on_failure: 'continue' }, [
|
|
1322
|
+
{ id: 'task-a', depends_on: [] },
|
|
1323
|
+
{ id: 'task-b', depends_on: ['task-a'] },
|
|
1324
|
+
{ id: 'task-c', depends_on: ['task-a', 'task-b'] }, // diamond
|
|
1325
|
+
])
|
|
1326
|
+
const engine = createConvoyEngine({
|
|
1327
|
+
spec,
|
|
1328
|
+
specYaml: 'name: test',
|
|
1329
|
+
adapter,
|
|
1330
|
+
dbPath,
|
|
1331
|
+
_worktreeManager: makeWorktreeManager(),
|
|
1332
|
+
_mergeQueue: makeMergeQueue(),
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
const result = await engine.run()
|
|
1336
|
+
|
|
1337
|
+
expect(result.summary.failed).toBe(1)
|
|
1338
|
+
expect(result.summary.skipped).toBe(2) // task-b and task-c both skipped
|
|
1339
|
+
expect(result.summary.done).toBe(0)
|
|
1340
|
+
|
|
1341
|
+
const store = createConvoyStore(dbPath)
|
|
1342
|
+
const tasks = store.getTasksByConvoy(result.convoyId)
|
|
1343
|
+
store.close()
|
|
1344
|
+
const byId = Object.fromEntries(tasks.map(t => [t.id, t.status]))
|
|
1345
|
+
expect(byId['task-a']).toBe('failed')
|
|
1346
|
+
expect(byId['task-b']).toBe('skipped')
|
|
1347
|
+
expect(byId['task-c']).toBe('skipped')
|
|
1348
|
+
})
|
|
1349
|
+
})
|