opencastle 0.33.6 → 0.33.8

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.
Files changed (45) hide show
  1. package/dist/cli/convoy/engine.d.ts +5 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +85 -16
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/engine.test.js +10 -12
  6. package/dist/cli/convoy/engine.test.js.map +1 -1
  7. package/dist/cli/convoy/events.d.ts +10 -5
  8. package/dist/cli/convoy/events.d.ts.map +1 -1
  9. package/dist/cli/convoy/events.js.map +1 -1
  10. package/dist/cli/convoy/events.test.js +9 -7
  11. package/dist/cli/convoy/events.test.js.map +1 -1
  12. package/dist/cli/convoy/pipeline.d.ts +3 -0
  13. package/dist/cli/convoy/pipeline.d.ts.map +1 -1
  14. package/dist/cli/convoy/pipeline.js +88 -18
  15. package/dist/cli/convoy/pipeline.js.map +1 -1
  16. package/dist/cli/convoy/types.d.ts +25 -0
  17. package/dist/cli/convoy/types.d.ts.map +1 -1
  18. package/dist/cli/convoy/types.js +3 -0
  19. package/dist/cli/convoy/types.js.map +1 -1
  20. package/dist/cli/run.d.ts.map +1 -1
  21. package/dist/cli/run.js +1 -123
  22. package/dist/cli/run.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/cli/convoy/engine.test.ts +10 -12
  25. package/src/cli/convoy/engine.ts +84 -16
  26. package/src/cli/convoy/events.test.ts +10 -8
  27. package/src/cli/convoy/events.ts +7 -4
  28. package/src/cli/convoy/pipeline.ts +81 -19
  29. package/src/cli/convoy/types.ts +6 -0
  30. package/src/cli/run.ts +0 -118
  31. package/src/dashboard/dist/data/convoys/demo-api-v2.json +9 -9
  32. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +10 -10
  33. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +6 -6
  34. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +3 -3
  35. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +1 -1
  36. package/src/dashboard/dist/data/convoys/demo-docs-update.json +3 -3
  37. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +4 -4
  38. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  39. package/src/dashboard/public/data/convoys/demo-api-v2.json +9 -9
  40. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +10 -10
  41. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +6 -6
  42. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +3 -3
  43. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +1 -1
  44. package/src/dashboard/public/data/convoys/demo-docs-update.json +3 -3
  45. package/src/dashboard/public/data/convoys/demo-perf-opt.json +4 -4
@@ -82,6 +82,11 @@ export interface ConvoyEngineOptions {
82
82
  _mergeQueue?: MergeQueue
83
83
  /** Override for test injection. Pass `ensureBranch` for real behavior, or a mock. */
84
84
  _ensureBranch?: (branchName: string, basePath: string) => Promise<void>
85
+ /** Pass `null` to skip convoy-level worktree creation (test mode).
86
+ * Pass a string path to inject a specific worktree directory.
87
+ * Omit (undefined) for real worktree creation.
88
+ * Also skipped when `_ensureBranch` is provided (backward-compat for tests). */
89
+ _convoyWorktreeDir?: string | null
85
90
  /** Injectable for test injection of the review pipeline. */
86
91
  _reviewRunner?: (task: TaskRecord, level: ReviewLevel, reviewerModel: string) => Promise<ReviewResult>
87
92
  }
@@ -2981,11 +2986,33 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
2981
2986
  const specHash = createHash('sha256').update(specYaml).digest('hex')
2982
2987
  const baseBranch = spec.branch ?? (await getCurrentBranch())
2983
2988
 
2984
- // Ensure target branch exists before acquiring any locks.
2985
- // Uses _ensureBranch injection so callers/tests can override.
2989
+ // Create convoy-level worktree for branch isolation.
2990
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
2991
+ let effectiveBasePath = basePath
2992
+ let convoyWorktreeDir: string | undefined
2986
2993
  if (spec.branch !== undefined) {
2987
- const branchFn = options._ensureBranch ?? ensureBranch
2988
- await branchFn(spec.branch, basePath)
2994
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
2995
+ if (!skipWorktree) {
2996
+ if (typeof options._convoyWorktreeDir === 'string') {
2997
+ effectiveBasePath = options._convoyWorktreeDir
2998
+ convoyWorktreeDir = options._convoyWorktreeDir
2999
+ } else {
3000
+ const worktreeId = `convoy-root-${Date.now()}`
3001
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
3002
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
3003
+ let branchExists = false
3004
+ try {
3005
+ await execFile('git', ['rev-parse', '--verify', spec.branch], { cwd: basePath })
3006
+ branchExists = true
3007
+ } catch { /* branch doesn't exist */ }
3008
+ if (branchExists) {
3009
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, spec.branch], { cwd: basePath })
3010
+ } else {
3011
+ await execFile('git', ['worktree', 'add', '-b', spec.branch, convoyWorktreeDir], { cwd: basePath })
3012
+ }
3013
+ effectiveBasePath = convoyWorktreeDir
3014
+ }
3015
+ }
2989
3016
  }
2990
3017
 
2991
3018
  mkdirSync(dirname(dbPath), { recursive: true })
@@ -3022,10 +3049,10 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3022
3049
  const store = createConvoyStore(dbPath)
3023
3050
  const ndjsonPath = options.logsDir
3024
3051
  ? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
3025
- : ndjsonPathForConvoy(convoyId, basePath)
3052
+ : ndjsonPathForConvoy(convoyId, effectiveBasePath)
3026
3053
  const events = createEventEmitter(store, { ndjsonPath })
3027
- const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
3028
- const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
3054
+ const wtManager = options._worktreeManager ?? createWorktreeManager(effectiveBasePath)
3055
+ const mergeQueue = options._mergeQueue ?? createMergeQueue(effectiveBasePath)
3029
3056
 
3030
3057
  let result: ConvoyResult
3031
3058
  try {
@@ -3088,7 +3115,7 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3088
3115
 
3089
3116
  result = await runConvoy(
3090
3117
  convoyId, spec, adapter, store, events,
3091
- wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath,
3118
+ wtManager, mergeQueue, effectiveBasePath, baseBranch, verbose, startTime, ndjsonPath,
3092
3119
  options._reviewRunner,
3093
3120
  )
3094
3121
  } finally {
@@ -3096,6 +3123,11 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3096
3123
  store.close()
3097
3124
  lock.release()
3098
3125
  lockDb.close()
3126
+ if (convoyWorktreeDir) {
3127
+ try {
3128
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
3129
+ } catch { /* ignore cleanup errors */ }
3130
+ }
3099
3131
  }
3100
3132
  return result
3101
3133
  }
@@ -3135,12 +3167,9 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3135
3167
  lock.startHeartbeat()
3136
3168
 
3137
3169
  const store = createConvoyStore(dbPath)
3138
- const ndjsonPath = options.logsDir
3139
- ? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
3140
- : ndjsonPathForConvoy(convoyId, basePath)
3141
- const events = createEventEmitter(store, { ndjsonPath })
3142
- const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
3143
- const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
3170
+ let effectiveBasePath = basePath
3171
+ let convoyWorktreeDir: string | undefined
3172
+ let events: ConvoyEventEmitter | undefined
3144
3173
 
3145
3174
  let result: ConvoyResult
3146
3175
  try {
@@ -3150,6 +3179,40 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3150
3179
  }
3151
3180
 
3152
3181
  const baseBranch = convoy.branch ?? spec.branch ?? (await getCurrentBranch())
3182
+ const convoyBranch = convoy.branch ?? spec.branch
3183
+
3184
+ // Create convoy-level worktree for branch isolation.
3185
+ if (convoyBranch !== undefined) {
3186
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
3187
+ if (!skipWorktree) {
3188
+ if (typeof options._convoyWorktreeDir === 'string') {
3189
+ effectiveBasePath = options._convoyWorktreeDir
3190
+ convoyWorktreeDir = options._convoyWorktreeDir
3191
+ } else {
3192
+ const worktreeId = `convoy-root-${Date.now()}`
3193
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
3194
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
3195
+ let branchExists = false
3196
+ try {
3197
+ await execFile('git', ['rev-parse', '--verify', convoyBranch], { cwd: basePath })
3198
+ branchExists = true
3199
+ } catch { /* branch doesn't exist */ }
3200
+ if (branchExists) {
3201
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, convoyBranch], { cwd: basePath })
3202
+ } else {
3203
+ await execFile('git', ['worktree', 'add', '-b', convoyBranch, convoyWorktreeDir], { cwd: basePath })
3204
+ }
3205
+ effectiveBasePath = convoyWorktreeDir
3206
+ }
3207
+ }
3208
+ }
3209
+
3210
+ const ndjsonPath = options.logsDir
3211
+ ? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
3212
+ : ndjsonPathForConvoy(convoyId, effectiveBasePath)
3213
+ events = createEventEmitter(store, { ndjsonPath })
3214
+ const wtManager = options._worktreeManager ?? createWorktreeManager(effectiveBasePath)
3215
+ const mergeQueue = options._mergeQueue ?? createMergeQueue(effectiveBasePath)
3153
3216
 
3154
3217
  // Reset interrupted tasks and mark their workers as killed
3155
3218
  const allTasks = store.getTasksByConvoy(convoyId)
@@ -3187,14 +3250,19 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3187
3250
 
3188
3251
  result = await runConvoy(
3189
3252
  convoyId, spec, adapter, store, events,
3190
- wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath,
3253
+ wtManager, mergeQueue, effectiveBasePath, baseBranch, verbose, startTime, ndjsonPath,
3191
3254
  options._reviewRunner,
3192
3255
  )
3193
3256
  } finally {
3194
- events.close()
3257
+ events?.close()
3195
3258
  store.close()
3196
3259
  lock.release()
3197
3260
  lockDb.close()
3261
+ if (convoyWorktreeDir) {
3262
+ try {
3263
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
3264
+ } catch { /* ignore cleanup errors */ }
3265
+ }
3198
3266
  }
3199
3267
  return result
3200
3268
  }
@@ -63,7 +63,7 @@ describe('createEventEmitter', () => {
63
63
 
64
64
  it('stores null data when no data object is provided', () => {
65
65
  const emitter = createEventEmitter(store)
66
- emitter.emit('heartbeat', undefined, { convoy_id: 'c1' })
66
+ emitter.emit('convoy_guard', undefined, { convoy_id: 'c1' })
67
67
  emitter.close()
68
68
  const events = store.getEvents('c1')
69
69
  expect(events[0].data).toBeNull()
@@ -92,7 +92,7 @@ describe('createEventEmitter', () => {
92
92
 
93
93
  it('defaults all ids to null when ids are not provided', () => {
94
94
  const emitter = createEventEmitter(store, { ndjsonPath })
95
- emitter.emit('generic_event')
95
+ emitter.emit('convoy_guard')
96
96
  emitter.close()
97
97
  const events = store.getEvents('c1')
98
98
  expect(events).toHaveLength(0)
@@ -106,7 +106,7 @@ describe('createEventEmitter', () => {
106
106
 
107
107
  it('includes all provided ids in the NDJSON record', () => {
108
108
  const emitter = createEventEmitter(store, { ndjsonPath })
109
- emitter.emit('worker_spawned', {}, { convoy_id: 'c1', task_id: 't1', worker_id: 'w1' })
109
+ emitter.emit('task_started', {}, { convoy_id: 'c1', task_id: 't1', worker_id: 'w1' })
110
110
  emitter.close()
111
111
  const line = JSON.parse(readFileSync(ndjsonPath, 'utf8').trim())
112
112
  expect(line.convoy_id).toBe('c1')
@@ -116,7 +116,7 @@ describe('createEventEmitter', () => {
116
116
 
117
117
  it('SQLite event stores correct ids', () => {
118
118
  const emitter = createEventEmitter(store)
119
- emitter.emit('worker_done', {}, { convoy_id: 'c1', task_id: 'task-x', worker_id: 'wkr-y' })
119
+ emitter.emit('task_done', {}, { convoy_id: 'c1', task_id: 'task-x', worker_id: 'wkr-y' })
120
120
  emitter.close()
121
121
  const events = store.getEvents('c1')
122
122
  expect(events[0].task_id).toBe('task-x')
@@ -125,7 +125,7 @@ describe('createEventEmitter', () => {
125
125
 
126
126
  it('does not throw if NDJSON path is not provided', () => {
127
127
  const emitter = createEventEmitter(store)
128
- expect(() => emitter.emit('test', {}, { convoy_id: 'c1' })).not.toThrow()
128
+ expect(() => emitter.emit('convoy_guard', {}, { convoy_id: 'c1' })).not.toThrow()
129
129
  emitter.close()
130
130
  })
131
131
 
@@ -266,7 +266,7 @@ describe('crash resilience', () => {
266
266
  const count = 1000
267
267
  const emitter = createEventEmitter(store, { ndjsonPath })
268
268
  for (let i = 0; i < count; i++) {
269
- emitter.emit('bench_event', { index: i }, { convoy_id: 'c1', task_id: `t${i}` })
269
+ emitter.emit('task_retried', { previous_status: String(i) }, { convoy_id: 'c1', task_id: `t${i}` })
270
270
  }
271
271
  emitter.close()
272
272
 
@@ -280,7 +280,7 @@ describe('crash resilience', () => {
280
280
  // Each line is valid JSON with the right type
281
281
  for (const line of lines) {
282
282
  const parsed = JSON.parse(line)
283
- expect(parsed.type).toBe('bench_event')
283
+ expect(parsed.type).toBe('task_retried')
284
284
  expect(parsed.convoy_id).toBe('c1')
285
285
  }
286
286
 
@@ -453,7 +453,9 @@ describe('emit-time data validation', () => {
453
453
 
454
454
  it('unknown event type bypasses data validation (only one warning)', () => {
455
455
  const emitter = createEventEmitter(store)
456
- emitter.emit('unknown_type_xyz', { any: 'data' }, { convoy_id: 'c1' })
456
+ // Cast needed: TypeScript normally catches unknown types at compile time.
457
+ // This tests the runtime guard (e.g. JS callers / dynamic values / `as any` paths).
458
+ emitter.emit('unknown_type_xyz' as Parameters<typeof emitter.emit>[0], { any: 'data' }, { convoy_id: 'c1' })
457
459
  emitter.close()
458
460
  expect(warnSpy).toHaveBeenCalledTimes(1)
459
461
  expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown event type: "unknown_type_xyz"'))
@@ -2,6 +2,7 @@ import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync
2
2
  import { dirname, join } from 'node:path'
3
3
  import type { ConvoyStore } from './store.js'
4
4
  import { KNOWN_EVENT_TYPES } from './types.js'
5
+ import type { ConvoyEventType } from './types.js'
5
6
  import { validateEventData } from './event-schemas.js'
6
7
 
7
8
  const RESERVED_KEYS = new Set(['_event_id', 'convoy_id', 'task_id', 'worker_id', 'timestamp', 'type'])
@@ -16,11 +17,13 @@ export function ndjsonPathForConvoy(convoyId: string, basePath?: string): string
16
17
  return join(base, '.opencastle', 'logs', 'convoys', `${convoyId}.ndjson`)
17
18
  }
18
19
 
20
+ type ConvoyEmitIds = { convoy_id?: string; task_id?: string; worker_id?: string }
21
+
19
22
  export interface ConvoyEventEmitter {
20
- emit(
21
- type: string,
22
- data?: Record<string, unknown>,
23
- ids?: { convoy_id?: string; task_id?: string; worker_id?: string },
23
+ emit<T extends ConvoyEventType>(
24
+ type: T['type'],
25
+ data?: T extends { data?: infer D } ? D : never,
26
+ ids?: ConvoyEmitIds,
24
27
  ): void
25
28
  close(): void
26
29
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { mkdirSync } from 'node:fs'
3
- import { resolve, dirname, relative, isAbsolute, sep } from 'node:path'
3
+ import { resolve, join, dirname, relative, isAbsolute, sep } from 'node:path'
4
4
  import { execFile as execFileCb } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import type { TaskSpec, AgentAdapter } from '../types.js'
@@ -8,7 +8,6 @@ import { parseTaskSpecText } from '../run/schema.js'
8
8
  import { createConvoyStore } from './store.js'
9
9
  import {
10
10
  createConvoyEngine,
11
- ensureBranch,
12
11
  type ConvoyEngine,
13
12
  type ConvoyResult,
14
13
  type ConvoyEngineOptions,
@@ -51,6 +50,9 @@ export interface PipelineOrchestratorOptions {
51
50
  _createConvoyEngine?: (opts: ConvoyEngineOptions) => ConvoyEngine
52
51
  /** Injectable branch handler (used in tests). */
53
52
  _ensureBranch?: (branchName: string, basePath: string) => Promise<void>
53
+ /** Pass `null` to skip pipeline-level worktree creation (test mode).
54
+ * Also skipped when `_ensureBranch` is provided (backward-compat for tests). */
55
+ _convoyWorktreeDir?: string | null
54
56
  }
55
57
 
56
58
  // ── Internal helpers ──────────────────────────────────────────────────────────
@@ -87,7 +89,6 @@ export function createPipelineOrchestrator(
87
89
  const basePath = resolve(options.basePath ?? process.cwd())
88
90
  const dbPath = options.dbPath ?? resolve(basePath, '.opencastle', 'convoy.db')
89
91
  const engineFactory = options._createConvoyEngine ?? createConvoyEngine
90
- const branchFn = options._ensureBranch ?? ensureBranch
91
92
 
92
93
  async function getCurrentBranch(): Promise<string> {
93
94
  try {
@@ -118,7 +119,7 @@ export function createPipelineOrchestrator(
118
119
  specPath: string,
119
120
  pipelineId: string,
120
121
  branch: string,
121
- skipDirtyCheck = false,
122
+ effectiveBase: string = basePath,
122
123
  ): Promise<ConvoyResult> {
123
124
  const absPath = resolveSpecPath(specPath)
124
125
  const convoyYaml = await readFile(absPath, 'utf8')
@@ -129,16 +130,13 @@ export function createPipelineOrchestrator(
129
130
  spec: overriddenSpec,
130
131
  specYaml: convoyYaml,
131
132
  adapter,
132
- basePath,
133
+ basePath: effectiveBase,
133
134
  dbPath,
134
135
  logsDir: options.logsDir,
135
136
  verbose,
136
137
  pipelineId,
138
+ _convoyWorktreeDir: null,
137
139
  }
138
- if (skipDirtyCheck) {
139
- engineOpts._ensureBranch = (b, base) => ensureBranch(b, base, true)
140
- }
141
-
142
140
  const engine = engineFactory(engineOpts)
143
141
  return engine.run()
144
142
  }
@@ -149,10 +147,33 @@ export function createPipelineOrchestrator(
149
147
  const branch = spec.branch ?? (await getCurrentBranch())
150
148
  const convoySpecs = spec.depends_on_convoy ?? []
151
149
 
152
- // Switch branch BEFORE any DB writes — otherwise the convoy.db modification
153
- // from insertPipeline() causes ensureBranch's dirty check to fail.
150
+ // Create pipeline-level worktree for branch isolation.
151
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
152
+ let effectiveBasePath = basePath
153
+ let convoyWorktreeDir: string | undefined
154
154
  if (spec.branch !== undefined) {
155
- await branchFn(spec.branch, basePath)
155
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
156
+ if (!skipWorktree) {
157
+ if (typeof options._convoyWorktreeDir === 'string') {
158
+ effectiveBasePath = options._convoyWorktreeDir
159
+ convoyWorktreeDir = options._convoyWorktreeDir
160
+ } else {
161
+ const worktreeId = `convoy-root-${Date.now()}`
162
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
163
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
164
+ let branchExists = false
165
+ try {
166
+ await execFile('git', ['rev-parse', '--verify', spec.branch], { cwd: basePath })
167
+ branchExists = true
168
+ } catch { /* branch doesn't exist */ }
169
+ if (branchExists) {
170
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, spec.branch], { cwd: basePath })
171
+ } else {
172
+ await execFile('git', ['worktree', 'add', '-b', spec.branch, convoyWorktreeDir], { cwd: basePath })
173
+ }
174
+ effectiveBasePath = convoyWorktreeDir
175
+ }
176
+ }
156
177
  }
157
178
 
158
179
  mkdirSync(dirname(dbPath), { recursive: true })
@@ -187,10 +208,7 @@ export function createPipelineOrchestrator(
187
208
 
188
209
  let convoyResult: ConvoyResult
189
210
  try {
190
- // Always skip dirty check inside pipeline — run.ts pre-flight already
191
- // handled the stash prompt, and insertPipeline() writes to convoy.db
192
- // before the first convoy runs (which would cause a false dirty-check failure).
193
- convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, true)
211
+ convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, effectiveBasePath)
194
212
  } catch (err) {
195
213
  process.stderr.write(
196
214
  ` ✗ Convoy spec "${specPath}" failed to load: ${(err as Error).message}\n`,
@@ -218,11 +236,12 @@ export function createPipelineOrchestrator(
218
236
  spec: { ...spec, branch },
219
237
  specYaml,
220
238
  adapter,
221
- basePath,
239
+ basePath: effectiveBasePath,
222
240
  dbPath,
223
241
  logsDir: options.logsDir,
224
242
  verbose,
225
243
  pipelineId,
244
+ _convoyWorktreeDir: null,
226
245
  })
227
246
  const hybridResult = await hybridEngine.run()
228
247
  convoyResults.push(hybridResult)
@@ -238,6 +257,12 @@ export function createPipelineOrchestrator(
238
257
  failStore.close()
239
258
  }
240
259
  throw err
260
+ } finally {
261
+ if (convoyWorktreeDir) {
262
+ try {
263
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
264
+ } catch { /* ignore cleanup errors */ }
265
+ }
241
266
  }
242
267
 
243
268
  const totalTokens = aggregateTokens(convoyResults)
@@ -283,6 +308,36 @@ export function createPipelineOrchestrator(
283
308
  const convoySpecs: string[] = JSON.parse(pipeline.convoy_specs) as string[]
284
309
  const branch = pipeline.branch ?? spec.branch ?? (await getCurrentBranch())
285
310
 
311
+ // Create pipeline-level worktree for branch isolation.
312
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
313
+ let effectiveBasePath = basePath
314
+ let convoyWorktreeDir: string | undefined
315
+ const pipelineBranch = pipeline.branch ?? spec.branch
316
+ if (pipelineBranch !== undefined) {
317
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
318
+ if (!skipWorktree) {
319
+ if (typeof options._convoyWorktreeDir === 'string') {
320
+ effectiveBasePath = options._convoyWorktreeDir
321
+ convoyWorktreeDir = options._convoyWorktreeDir
322
+ } else {
323
+ const worktreeId = `convoy-root-${Date.now()}`
324
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
325
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
326
+ let branchExists = false
327
+ try {
328
+ await execFile('git', ['rev-parse', '--verify', pipelineBranch], { cwd: basePath })
329
+ branchExists = true
330
+ } catch { /* branch doesn't exist */ }
331
+ if (branchExists) {
332
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, pipelineBranch], { cwd: basePath })
333
+ } else {
334
+ await execFile('git', ['worktree', 'add', '-b', pipelineBranch, convoyWorktreeDir], { cwd: basePath })
335
+ }
336
+ effectiveBasePath = convoyWorktreeDir
337
+ }
338
+ }
339
+ }
340
+
286
341
  // Load all convoys linked to this pipeline, sorted by creation time
287
342
  const convoyStore = createConvoyStore(dbPath)
288
343
  let existingConvoys
@@ -350,11 +405,12 @@ export function createPipelineOrchestrator(
350
405
  spec: overriddenSpec,
351
406
  specYaml: convoyYaml,
352
407
  adapter,
353
- basePath,
408
+ basePath: effectiveBasePath,
354
409
  dbPath,
355
410
  logsDir: options.logsDir,
356
411
  verbose,
357
412
  pipelineId,
413
+ _convoyWorktreeDir: null,
358
414
  })
359
415
 
360
416
  if (existing.status === 'failed') {
@@ -366,7 +422,7 @@ export function createPipelineOrchestrator(
366
422
  } else {
367
423
  // Run fresh
368
424
  try {
369
- convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, true)
425
+ convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, effectiveBasePath)
370
426
  } catch (err) {
371
427
  process.stderr.write(
372
428
  ` ✗ Convoy spec "${specPath}" failed to load: ${(err as Error).message}\n`,
@@ -399,6 +455,12 @@ export function createPipelineOrchestrator(
399
455
  failStore.close()
400
456
  }
401
457
  throw err
458
+ } finally {
459
+ if (convoyWorktreeDir) {
460
+ try {
461
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
462
+ } catch { /* ignore cleanup errors */ }
463
+ }
402
464
  }
403
465
 
404
466
  const totalTokens = aggregateTokens(convoyResults)
@@ -364,6 +364,9 @@ export type ConvoyEventType =
364
364
  | { type: 'tdd_check_passed'; data?: { task_id?: string; new_source_files?: number; existing_test_files?: number } }
365
365
  | { type: 'tdd_check_failed'; data?: { task_id?: string; missing_test_files?: string[]; new_source_files?: number } }
366
366
  | { type: 'tdd_check_skipped'; data?: { task_id?: string; reason?: string; agent?: string } }
367
+ | { type: 'convoy_resumed'; data?: { original_created_at?: string } }
368
+ | { type: 'artifacts_extracted'; data?: { task_id?: string; count?: number; artifacts?: Array<{ filename: string; summary?: string }> } }
369
+ | { type: 'file_partition_conflict'; data?: { conflicts?: Array<{ phase: number; taskA: string; taskB: string; overlapping: string[] }> } }
367
370
 
368
371
  /** All canonical convoy event type strings. Used for runtime validation. */
369
372
  export const KNOWN_EVENT_TYPES: Set<string> = new Set<ConvoyEventType['type']>([
@@ -414,4 +417,7 @@ export const KNOWN_EVENT_TYPES: Set<string> = new Set<ConvoyEventType['type']>([
414
417
  'tdd_check_passed',
415
418
  'tdd_check_failed',
416
419
  'tdd_check_skipped',
420
+ 'convoy_resumed',
421
+ 'artifacts_extracted',
422
+ 'file_partition_conflict',
417
423
  ])
package/src/cli/run.ts CHANGED
@@ -871,54 +871,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
871
871
  if (spec.branch) console.log(` Branch: ${spec.branch}`)
872
872
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
873
873
 
874
- // ── Pre-flight: handle uncommitted changes before branch switch ──
875
- let pipelineDidStash = false
876
- if (spec.branch) {
877
- const { execFile: execFileCb } = await import('node:child_process')
878
- const { promisify } = await import('node:util')
879
- const execFile = promisify(execFileCb)
880
- let statusOut: string
881
- try {
882
- const result = await execFile('git', ['status', '--porcelain'], {
883
- cwd: process.cwd(),
884
- })
885
- statusOut = result.stdout
886
- } catch {
887
- console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
888
- console.error(` Run \`git init\` to initialize a repository.`)
889
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
890
- process.exit(1)
891
- }
892
- // Untracked files (??) don't block branch checkout — ignore them
893
- const trackedChanges = statusOut
894
- .split('\n')
895
- .filter(line => line.trim() && !line.startsWith('??'))
896
- .join('\n')
897
- if (trackedChanges) {
898
- console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
899
- const shouldStash = await confirm('Stash changes and continue?', true)
900
- if (!shouldStash) {
901
- console.log(' Aborted. Commit or stash your changes manually, then retry.')
902
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
903
- closePrompts()
904
- process.exit(1)
905
- }
906
- try {
907
- await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before pipeline'], {
908
- cwd: process.cwd(),
909
- })
910
- pipelineDidStash = true
911
- console.log(` ${c.green('✓')} Changes stashed.`)
912
- } catch {
913
- console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
914
- console.log(` Commit or stash your changes manually, then resume:\n`)
915
- console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
916
- closePrompts()
917
- process.exit(1)
918
- }
919
- }
920
- }
921
-
922
874
  const { startDashboardServer } = await import('./dashboard.js')
923
875
  let pipelineDashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
924
876
  try {
@@ -953,17 +905,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
953
905
  throw err
954
906
  }
955
907
  printPipelineResult(pipelineResult)
956
- if (pipelineDidStash) {
957
- const { execFile: execFileCb } = await import('node:child_process')
958
- const { promisify } = await import('node:util')
959
- const execFile = promisify(execFileCb)
960
- try {
961
- await execFile('git', ['stash', 'pop'], { cwd: process.cwd() })
962
- console.log(` ${c.green('✓')} Stashed changes restored.`)
963
- } catch {
964
- console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
965
- }
966
- }
967
908
  if (pipelineDashboardResult) {
968
909
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
969
910
  console.log(` ${c.dim('Dashboard:')} ${pipelineDashboardResult.url}`)
@@ -996,54 +937,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
996
937
  if (spec.branch) console.log(` Branch: ${spec.branch}`)
997
938
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
998
939
 
999
- // ── Pre-flight: handle uncommitted changes before branch switch ──
1000
- let didStash = false
1001
- if (spec.branch) {
1002
- const { execFile: execFileCb } = await import('node:child_process')
1003
- const { promisify } = await import('node:util')
1004
- const execFile = promisify(execFileCb)
1005
- let statusOut: string
1006
- try {
1007
- const result = await execFile('git', ['status', '--porcelain'], {
1008
- cwd: process.cwd(),
1009
- })
1010
- statusOut = result.stdout
1011
- } catch {
1012
- console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
1013
- console.error(` Run \`git init\` to initialize a repository.`)
1014
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1015
- process.exit(1)
1016
- }
1017
- // Untracked files (??) don't block branch checkout — ignore them
1018
- const trackedChanges = statusOut
1019
- .split('\n')
1020
- .filter(line => line.trim() && !line.startsWith('??'))
1021
- .join('\n')
1022
- if (trackedChanges) {
1023
- console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
1024
- const shouldStash = await confirm('Stash changes and continue?', true)
1025
- if (!shouldStash) {
1026
- console.log(' Aborted. Commit or stash your changes manually, then retry.')
1027
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1028
- closePrompts()
1029
- process.exit(1)
1030
- }
1031
- try {
1032
- await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before convoy'], {
1033
- cwd: process.cwd(),
1034
- })
1035
- didStash = true
1036
- console.log(` ${c.green('✓')} Changes stashed.`)
1037
- } catch {
1038
- console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
1039
- console.log(` Commit or stash your changes manually, then resume:\n`)
1040
- console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1041
- closePrompts()
1042
- process.exit(1)
1043
- }
1044
- }
1045
- }
1046
-
1047
940
  const { startDashboardServer } = await import('./dashboard.js')
1048
941
  let dashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
1049
942
  try {
@@ -1095,17 +988,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
1095
988
  throw err
1096
989
  }
1097
990
  printConvoyResult(result)
1098
- if (didStash) {
1099
- const { execFile: execFileCb } = await import('node:child_process')
1100
- const { promisify } = await import('node:util')
1101
- const execFile = promisify(execFileCb)
1102
- try {
1103
- await execFile('git', ['stash', 'pop'], { cwd: process.cwd() })
1104
- console.log(` ${c.green('✓')} Stashed changes restored.`)
1105
- } catch {
1106
- console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
1107
- }
1108
- }
1109
991
  if (dashboardResult) {
1110
992
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
1111
993
  console.log(` ${c.dim('Dashboard:')} ${dashboardResult.url}`)
@@ -51,21 +51,21 @@
51
51
  "name": "docs/api-v2-contract.json",
52
52
  "type": "json",
53
53
  "task_id": "api-t1",
54
- "created_at": "2026-04-06T12:18:29.969Z"
55
- },
56
- {
57
- "id": "artifact-demo-api-v2-reports-security-gate-failure-md",
58
- "name": "reports/security-gate-failure.md",
59
- "type": "summary",
60
- "task_id": "api-t3",
61
- "created_at": "2026-04-06T12:18:29.969Z"
54
+ "created_at": "2026-04-07T13:06:16.545Z"
62
55
  },
63
56
  {
64
57
  "id": "artifact-demo-api-v2-src-api-rate-limiter-ts",
65
58
  "name": "src/api/rate-limiter.ts",
66
59
  "type": "file",
67
60
  "task_id": "api-t2",
68
- "created_at": "2026-04-06T12:18:29.969Z"
61
+ "created_at": "2026-04-07T13:06:16.545Z"
62
+ },
63
+ {
64
+ "id": "artifact-demo-api-v2-reports-security-gate-failure-md",
65
+ "name": "reports/security-gate-failure.md",
66
+ "type": "summary",
67
+ "task_id": "api-t3",
68
+ "created_at": "2026-04-07T13:06:16.546Z"
69
69
  }
70
70
  ],
71
71
  "has_more_events": false,