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.
@@ -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
+ })