opencastle 0.11.0 → 0.13.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.
Files changed (75) hide show
  1. package/dist/cli/convoy/events.d.ts +10 -0
  2. package/dist/cli/convoy/events.d.ts.map +1 -0
  3. package/dist/cli/convoy/events.js +27 -0
  4. package/dist/cli/convoy/events.js.map +1 -0
  5. package/dist/cli/convoy/events.test.d.ts +2 -0
  6. package/dist/cli/convoy/events.test.d.ts.map +1 -0
  7. package/dist/cli/convoy/events.test.js +94 -0
  8. package/dist/cli/convoy/events.test.js.map +1 -0
  9. package/dist/cli/convoy/merge.d.ts +15 -0
  10. package/dist/cli/convoy/merge.d.ts.map +1 -0
  11. package/dist/cli/convoy/merge.js +62 -0
  12. package/dist/cli/convoy/merge.js.map +1 -0
  13. package/dist/cli/convoy/merge.test.d.ts +2 -0
  14. package/dist/cli/convoy/merge.test.d.ts.map +1 -0
  15. package/dist/cli/convoy/merge.test.js +134 -0
  16. package/dist/cli/convoy/merge.test.js.map +1 -0
  17. package/dist/cli/convoy/store.d.ts +23 -0
  18. package/dist/cli/convoy/store.d.ts.map +1 -0
  19. package/dist/cli/convoy/store.js +210 -0
  20. package/dist/cli/convoy/store.js.map +1 -0
  21. package/dist/cli/convoy/store.test.d.ts +2 -0
  22. package/dist/cli/convoy/store.test.d.ts.map +1 -0
  23. package/dist/cli/convoy/store.test.js +387 -0
  24. package/dist/cli/convoy/store.test.js.map +1 -0
  25. package/dist/cli/convoy/types.d.ts +56 -0
  26. package/dist/cli/convoy/types.d.ts.map +1 -0
  27. package/dist/cli/convoy/types.js +2 -0
  28. package/dist/cli/convoy/types.js.map +1 -0
  29. package/dist/cli/convoy/worktree.d.ts +13 -0
  30. package/dist/cli/convoy/worktree.d.ts.map +1 -0
  31. package/dist/cli/convoy/worktree.js +90 -0
  32. package/dist/cli/convoy/worktree.js.map +1 -0
  33. package/dist/cli/convoy/worktree.test.d.ts +2 -0
  34. package/dist/cli/convoy/worktree.test.d.ts.map +1 -0
  35. package/dist/cli/convoy/worktree.test.js +146 -0
  36. package/dist/cli/convoy/worktree.test.js.map +1 -0
  37. package/dist/cli/run/adapters/claude-code.js +1 -1
  38. package/dist/cli/run/adapters/claude-code.js.map +1 -1
  39. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  40. package/dist/cli/run/adapters/copilot.js +5 -0
  41. package/dist/cli/run/adapters/copilot.js.map +1 -1
  42. package/dist/cli/run/adapters/cursor.js +1 -1
  43. package/dist/cli/run/adapters/cursor.js.map +1 -1
  44. package/dist/cli/run/executor.test.js +1 -0
  45. package/dist/cli/run/executor.test.js.map +1 -1
  46. package/dist/cli/run/loop-executor.d.ts.map +1 -1
  47. package/dist/cli/run/loop-executor.js +1 -0
  48. package/dist/cli/run/loop-executor.js.map +1 -1
  49. package/dist/cli/run/schema.d.ts +4 -0
  50. package/dist/cli/run/schema.d.ts.map +1 -1
  51. package/dist/cli/run/schema.js +78 -2
  52. package/dist/cli/run/schema.js.map +1 -1
  53. package/dist/cli/run/schema.test.js +384 -1
  54. package/dist/cli/run/schema.test.js.map +1 -1
  55. package/dist/cli/types.d.ts +21 -0
  56. package/dist/cli/types.d.ts.map +1 -1
  57. package/package.json +3 -2
  58. package/src/cli/convoy/events.test.ts +118 -0
  59. package/src/cli/convoy/events.ts +41 -0
  60. package/src/cli/convoy/merge.test.ts +184 -0
  61. package/src/cli/convoy/merge.ts +89 -0
  62. package/src/cli/convoy/store.test.ts +446 -0
  63. package/src/cli/convoy/store.ts +308 -0
  64. package/src/cli/convoy/types.ts +68 -0
  65. package/src/cli/convoy/worktree.test.ts +177 -0
  66. package/src/cli/convoy/worktree.ts +116 -0
  67. package/src/cli/run/adapters/claude-code.ts +1 -1
  68. package/src/cli/run/adapters/copilot.ts +5 -0
  69. package/src/cli/run/adapters/cursor.ts +1 -1
  70. package/src/cli/run/executor.test.ts +1 -0
  71. package/src/cli/run/loop-executor.ts +1 -0
  72. package/src/cli/run/schema.test.ts +462 -1
  73. package/src/cli/run/schema.ts +96 -2
  74. package/src/cli/types.ts +22 -0
  75. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -0,0 +1,308 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+ import type {
3
+ ConvoyRecord,
4
+ ConvoyStatus,
5
+ TaskRecord,
6
+ ConvoyTaskStatus,
7
+ WorkerRecord,
8
+ WorkerStatus,
9
+ EventRecord,
10
+ } from './types.js'
11
+
12
+ const SCHEMA_VERSION = 1
13
+
14
+ export interface ConvoyStore {
15
+ insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void
16
+ getConvoy(id: string): ConvoyRecord | undefined
17
+ updateConvoyStatus(
18
+ id: string,
19
+ status: ConvoyStatus,
20
+ extra?: { started_at?: string; finished_at?: string },
21
+ ): void
22
+ insertTask(
23
+ record: Omit<
24
+ TaskRecord,
25
+ 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
26
+ >,
27
+ ): void
28
+ getTask(id: string, convoyId: string): TaskRecord | undefined
29
+ getTasksByConvoy(convoyId: string): TaskRecord[]
30
+ updateTaskStatus(
31
+ id: string,
32
+ convoyId: string,
33
+ status: ConvoyTaskStatus,
34
+ extra?: Partial<
35
+ Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
36
+ >,
37
+ ): void
38
+ getReadyTasks(convoyId: string): TaskRecord[]
39
+ insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void
40
+ getWorker(id: string): WorkerRecord | undefined
41
+ updateWorkerStatus(
42
+ id: string,
43
+ status: WorkerStatus,
44
+ extra?: Partial<Pick<WorkerRecord, 'finished_at' | 'last_heartbeat' | 'pid'>>,
45
+ ): void
46
+ insertEvent(record: Omit<EventRecord, 'id'>): void
47
+ getEvents(convoyId: string): EventRecord[]
48
+ withTransaction<T>(fn: () => T): T
49
+ close(): void
50
+ }
51
+
52
+ class ConvoyStoreImpl implements ConvoyStore {
53
+ private db: DatabaseSync
54
+
55
+ constructor(dbPath: string) {
56
+ this.db = new DatabaseSync(dbPath)
57
+ this.db.exec('PRAGMA journal_mode = WAL')
58
+ this.db.exec('PRAGMA synchronous = NORMAL')
59
+ this.initSchema()
60
+ }
61
+
62
+ private initSchema(): void {
63
+ const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number }
64
+ if (row.user_version === 0) {
65
+ this.db.exec(`
66
+ CREATE TABLE IF NOT EXISTS convoy (
67
+ id TEXT PRIMARY KEY,
68
+ name TEXT NOT NULL,
69
+ spec_hash TEXT NOT NULL,
70
+ status TEXT NOT NULL DEFAULT 'pending',
71
+ branch TEXT,
72
+ created_at TEXT NOT NULL,
73
+ started_at TEXT,
74
+ finished_at TEXT,
75
+ spec_yaml TEXT NOT NULL
76
+ );
77
+
78
+ CREATE TABLE IF NOT EXISTS task (
79
+ id TEXT PRIMARY KEY,
80
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
81
+ phase INTEGER NOT NULL,
82
+ prompt TEXT NOT NULL,
83
+ agent TEXT NOT NULL DEFAULT 'developer',
84
+ model TEXT,
85
+ timeout_ms INTEGER NOT NULL DEFAULT 1800000,
86
+ status TEXT NOT NULL DEFAULT 'pending',
87
+ worker_id TEXT,
88
+ worktree TEXT,
89
+ output TEXT,
90
+ exit_code INTEGER,
91
+ started_at TEXT,
92
+ finished_at TEXT,
93
+ retries INTEGER NOT NULL DEFAULT 0,
94
+ max_retries INTEGER NOT NULL DEFAULT 1,
95
+ files TEXT,
96
+ depends_on TEXT
97
+ );
98
+
99
+ CREATE TABLE IF NOT EXISTS worker (
100
+ id TEXT PRIMARY KEY,
101
+ task_id TEXT REFERENCES task(id),
102
+ adapter TEXT NOT NULL,
103
+ pid INTEGER,
104
+ session_id TEXT,
105
+ status TEXT NOT NULL DEFAULT 'spawned',
106
+ worktree TEXT,
107
+ created_at TEXT NOT NULL,
108
+ finished_at TEXT,
109
+ last_heartbeat TEXT
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS event (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ convoy_id TEXT REFERENCES convoy(id),
115
+ task_id TEXT,
116
+ worker_id TEXT,
117
+ type TEXT NOT NULL,
118
+ data TEXT,
119
+ created_at TEXT NOT NULL
120
+ );
121
+ `)
122
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
123
+ }
124
+ }
125
+
126
+ insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at'>): void {
127
+ this.db
128
+ .prepare(
129
+ `INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml)
130
+ VALUES (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL, :spec_yaml)`,
131
+ )
132
+ .run(record)
133
+ }
134
+
135
+ getConvoy(id: string): ConvoyRecord | undefined {
136
+ return this.db
137
+ .prepare('SELECT * FROM convoy WHERE id = :id')
138
+ .get({ id }) as ConvoyRecord | undefined
139
+ }
140
+
141
+ updateConvoyStatus(
142
+ id: string,
143
+ status: ConvoyStatus,
144
+ extra?: { started_at?: string; finished_at?: string },
145
+ ): void {
146
+ const sets = ['status = :status']
147
+ const params: Record<string, string | null> = { id, status }
148
+
149
+ if (extra?.started_at !== undefined) {
150
+ sets.push('started_at = :started_at')
151
+ params.started_at = extra.started_at
152
+ }
153
+ if (extra?.finished_at !== undefined) {
154
+ sets.push('finished_at = :finished_at')
155
+ params.finished_at = extra.finished_at
156
+ }
157
+
158
+ this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params)
159
+ }
160
+
161
+ insertTask(
162
+ record: Omit<
163
+ TaskRecord,
164
+ 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
165
+ >,
166
+ ): void {
167
+ this.db
168
+ .prepare(
169
+ `INSERT INTO task
170
+ (id, convoy_id, phase, prompt, agent, model, timeout_ms, status,
171
+ worker_id, worktree, output, exit_code, started_at, finished_at,
172
+ retries, max_retries, files, depends_on)
173
+ VALUES
174
+ (:id, :convoy_id, :phase, :prompt, :agent, :model, :timeout_ms, :status,
175
+ NULL, NULL, NULL, NULL, NULL, NULL,
176
+ :retries, :max_retries, :files, :depends_on)`,
177
+ )
178
+ .run(record)
179
+ }
180
+
181
+ getTask(id: string, convoyId: string): TaskRecord | undefined {
182
+ return this.db
183
+ .prepare('SELECT * FROM task WHERE id = :id AND convoy_id = :convoy_id')
184
+ .get({ id, convoy_id: convoyId }) as TaskRecord | undefined
185
+ }
186
+
187
+ getTasksByConvoy(convoyId: string): TaskRecord[] {
188
+ return this.db
189
+ .prepare('SELECT * FROM task WHERE convoy_id = :convoy_id ORDER BY phase, id')
190
+ .all({ convoy_id: convoyId }) as unknown as TaskRecord[]
191
+ }
192
+
193
+ updateTaskStatus(
194
+ id: string,
195
+ convoyId: string,
196
+ status: ConvoyTaskStatus,
197
+ extra?: Partial<
198
+ Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries'>
199
+ >,
200
+ ): void {
201
+ const sets = ['status = :status']
202
+ const params: Record<string, string | number | null> = { id, convoy_id: convoyId, status }
203
+ const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries'] as const
204
+
205
+ if (extra) {
206
+ for (const field of extraFields) {
207
+ if (field in extra && extra[field] !== undefined) {
208
+ sets.push(`${field} = :${field}`)
209
+ params[field] = extra[field] as string | number | null
210
+ }
211
+ }
212
+ }
213
+
214
+ this.db
215
+ .prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`)
216
+ .run(params)
217
+ }
218
+
219
+ getReadyTasks(convoyId: string): TaskRecord[] {
220
+ const allTasks = this.getTasksByConvoy(convoyId)
221
+ const doneTaskIds = new Set(allTasks.filter(t => t.status === 'done').map(t => t.id))
222
+
223
+ return allTasks.filter(task => {
224
+ if (task.status !== 'pending') return false
225
+ if (!task.depends_on) return true
226
+ const deps = JSON.parse(task.depends_on) as string[]
227
+ return deps.length === 0 || deps.every(depId => doneTaskIds.has(depId))
228
+ })
229
+ }
230
+
231
+ insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void {
232
+ this.db
233
+ .prepare(
234
+ `INSERT INTO worker
235
+ (id, task_id, adapter, pid, session_id, status, worktree, created_at,
236
+ finished_at, last_heartbeat)
237
+ VALUES
238
+ (:id, :task_id, :adapter, :pid, :session_id, :status, :worktree, :created_at,
239
+ NULL, NULL)`,
240
+ )
241
+ .run(record)
242
+ }
243
+
244
+ getWorker(id: string): WorkerRecord | undefined {
245
+ return this.db
246
+ .prepare('SELECT * FROM worker WHERE id = :id')
247
+ .get({ id }) as WorkerRecord | undefined
248
+ }
249
+
250
+ updateWorkerStatus(
251
+ id: string,
252
+ status: WorkerStatus,
253
+ extra?: Partial<Pick<WorkerRecord, 'finished_at' | 'last_heartbeat' | 'pid'>>,
254
+ ): void {
255
+ const sets = ['status = :status']
256
+ const params: Record<string, string | number | null> = { id, status }
257
+
258
+ if (extra?.finished_at !== undefined) {
259
+ sets.push('finished_at = :finished_at')
260
+ params.finished_at = extra.finished_at
261
+ }
262
+ if (extra?.last_heartbeat !== undefined) {
263
+ sets.push('last_heartbeat = :last_heartbeat')
264
+ params.last_heartbeat = extra.last_heartbeat
265
+ }
266
+ if (extra?.pid !== undefined) {
267
+ sets.push('pid = :pid')
268
+ params.pid = extra.pid
269
+ }
270
+
271
+ this.db.prepare(`UPDATE worker SET ${sets.join(', ')} WHERE id = :id`).run(params)
272
+ }
273
+
274
+ insertEvent(record: Omit<EventRecord, 'id'>): void {
275
+ this.db
276
+ .prepare(
277
+ `INSERT INTO event (convoy_id, task_id, worker_id, type, data, created_at)
278
+ VALUES (:convoy_id, :task_id, :worker_id, :type, :data, :created_at)`,
279
+ )
280
+ .run(record)
281
+ }
282
+
283
+ getEvents(convoyId: string): EventRecord[] {
284
+ return this.db
285
+ .prepare('SELECT * FROM event WHERE convoy_id = :convoy_id ORDER BY id')
286
+ .all({ convoy_id: convoyId }) as unknown as EventRecord[]
287
+ }
288
+
289
+ withTransaction<T>(fn: () => T): T {
290
+ this.db.exec('BEGIN')
291
+ try {
292
+ const result = fn()
293
+ this.db.exec('COMMIT')
294
+ return result
295
+ } catch (err) {
296
+ this.db.exec('ROLLBACK')
297
+ throw err
298
+ }
299
+ }
300
+
301
+ close(): void {
302
+ this.db.close()
303
+ }
304
+ }
305
+
306
+ export function createConvoyStore(dbPath: string): ConvoyStore {
307
+ return new ConvoyStoreImpl(dbPath)
308
+ }
@@ -0,0 +1,68 @@
1
+ export type ConvoyStatus = 'pending' | 'running' | 'done' | 'failed' | 'gate-failed'
2
+
3
+ export type ConvoyTaskStatus =
4
+ | 'pending'
5
+ | 'assigned'
6
+ | 'running'
7
+ | 'done'
8
+ | 'failed'
9
+ | 'timed-out'
10
+ | 'skipped'
11
+
12
+ export type WorkerStatus = 'spawned' | 'running' | 'done' | 'failed' | 'killed'
13
+
14
+ export interface ConvoyRecord {
15
+ id: string
16
+ name: string
17
+ spec_hash: string
18
+ status: ConvoyStatus
19
+ branch: string | null
20
+ created_at: string
21
+ started_at: string | null
22
+ finished_at: string | null
23
+ spec_yaml: string
24
+ }
25
+
26
+ export interface TaskRecord {
27
+ id: string
28
+ convoy_id: string
29
+ phase: number
30
+ prompt: string
31
+ agent: string
32
+ model: string | null
33
+ timeout_ms: number
34
+ status: ConvoyTaskStatus
35
+ worker_id: string | null
36
+ worktree: string | null
37
+ output: string | null
38
+ exit_code: number | null
39
+ started_at: string | null
40
+ finished_at: string | null
41
+ retries: number
42
+ max_retries: number
43
+ files: string | null
44
+ depends_on: string | null
45
+ }
46
+
47
+ export interface WorkerRecord {
48
+ id: string
49
+ task_id: string | null
50
+ adapter: string
51
+ pid: number | null
52
+ session_id: string | null
53
+ status: WorkerStatus
54
+ worktree: string | null
55
+ created_at: string
56
+ finished_at: string | null
57
+ last_heartbeat: string | null
58
+ }
59
+
60
+ export interface EventRecord {
61
+ id?: number
62
+ convoy_id: string | null
63
+ task_id: string | null
64
+ worker_id: string | null
65
+ type: string
66
+ data: string | null
67
+ created_at: string
68
+ }
@@ -0,0 +1,177 @@
1
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { execFile as execFileCb } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
+ import { createWorktreeManager } from './worktree.js'
8
+ import type { WorktreeManager } from './worktree.js'
9
+
10
+ const execFile = promisify(execFileCb)
11
+
12
+ // ── helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ let tmpDir: string
15
+ let manager: WorktreeManager
16
+
17
+ async function initGitRepo(dir: string): Promise<void> {
18
+ await execFile('git', ['init'], { cwd: dir })
19
+ await execFile('git', ['config', 'user.email', 'test@test.com'], { cwd: dir })
20
+ await execFile('git', ['config', 'user.name', 'Test User'], { cwd: dir })
21
+ await execFile('git', ['commit', '--allow-empty', '-m', 'Initial commit'], { cwd: dir })
22
+ }
23
+
24
+ beforeEach(async () => {
25
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'convoy-worktree-test-')))
26
+ await initGitRepo(tmpDir)
27
+ manager = createWorktreeManager(tmpDir)
28
+ })
29
+
30
+ afterEach(() => {
31
+ rmSync(tmpDir, { recursive: true, force: true })
32
+ })
33
+
34
+ // ── create ────────────────────────────────────────────────────────────────────
35
+
36
+ describe('create', () => {
37
+ it('creates a worktree and returns its absolute path', async () => {
38
+ const worktreePath = await manager.create('worker1', 'HEAD')
39
+ expect(worktreePath).toBe(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
40
+ })
41
+
42
+ it('creates the .opencastle/worktrees directory when it does not exist', async () => {
43
+ const { existsSync } = await import('node:fs')
44
+ const worktreePath = await manager.create('worker1', 'HEAD')
45
+ expect(existsSync(worktreePath)).toBe(true)
46
+ })
47
+
48
+ it('creates the new branch in the worktree', async () => {
49
+ await manager.create('worker1', 'HEAD')
50
+ const { stdout } = await execFile('git', ['branch', '--list', 'convoy-worker1'], { cwd: tmpDir })
51
+ // git prefixes branches checked out in a worktree with '+ '
52
+ expect(stdout.trim().replace(/^[*+]\s+/, '')).toBe('convoy-worker1')
53
+ })
54
+
55
+ it('throws when featureBranch does not exist', async () => {
56
+ await expect(manager.create('worker1', 'nonexistent-branch')).rejects.toThrow()
57
+ })
58
+
59
+ it('throws when workerId is already in use', async () => {
60
+ await manager.create('worker1', 'HEAD')
61
+ await expect(manager.create('worker1', 'HEAD')).rejects.toThrow()
62
+ })
63
+
64
+ it('throws for workerId with path traversal characters', async () => {
65
+ await expect(manager.create('../escape', 'HEAD')).rejects.toThrow(/Invalid workerId/)
66
+ })
67
+
68
+ it('throws for workerId with slashes', async () => {
69
+ await expect(manager.create('a/b', 'HEAD')).rejects.toThrow(/Invalid workerId/)
70
+ })
71
+ })
72
+
73
+ // ── remove ────────────────────────────────────────────────────────────────────
74
+
75
+ describe('remove', () => {
76
+ it('removes the worktree so it no longer appears in list()', async () => {
77
+ const path = await manager.create('worker1', 'HEAD')
78
+ await manager.remove(path)
79
+ const worktrees = await manager.list()
80
+ expect(worktrees).toHaveLength(0)
81
+ })
82
+
83
+ it('deletes the convoy branch after removing the worktree', async () => {
84
+ const path = await manager.create('worker1', 'HEAD')
85
+ await manager.remove(path)
86
+ const { stdout } = await execFile('git', ['branch', '--list', 'convoy-worker1'], { cwd: tmpDir })
87
+ expect(stdout.trim()).toBe('')
88
+ })
89
+
90
+ it('is idempotent — does not throw for a non-existent worktree path', async () => {
91
+ const nonExistent = join(tmpDir, '.opencastle', 'worktrees', 'ghost')
92
+ await expect(manager.remove(nonExistent)).resolves.toBeUndefined()
93
+ })
94
+
95
+ it('re-throws git errors that are not "not a working tree"', async () => {
96
+ const path = await manager.create('worker1', 'HEAD')
97
+ // Lock the worktree so that single --force removal fails
98
+ await execFile('git', ['worktree', 'lock', path], { cwd: tmpDir })
99
+ await expect(manager.remove(path)).rejects.toThrow()
100
+ // Cleanup: unlock and remove manually
101
+ await execFile('git', ['worktree', 'unlock', path], { cwd: tmpDir })
102
+ })
103
+
104
+ it('throws when path is outside the managed worktrees directory', async () => {
105
+ await expect(manager.remove('/some/arbitrary/path')).rejects.toThrow(
106
+ /outside the managed worktrees directory/,
107
+ )
108
+ })
109
+ })
110
+
111
+ // ── list ──────────────────────────────────────────────────────────────────────
112
+
113
+ describe('list', () => {
114
+ it('returns an empty array when no convoy worktrees exist', async () => {
115
+ const worktrees = await manager.list()
116
+ expect(worktrees).toHaveLength(0)
117
+ })
118
+
119
+ it('does not include the main worktree', async () => {
120
+ const worktrees = await manager.list()
121
+ for (const wt of worktrees) {
122
+ expect(wt.path).toContain('.opencastle/worktrees')
123
+ }
124
+ })
125
+
126
+ it('returns the correct WorktreeInfo for a created worktree', async () => {
127
+ await manager.create('worker1', 'HEAD')
128
+ const worktrees = await manager.list()
129
+ expect(worktrees).toHaveLength(1)
130
+ expect(worktrees[0].path).toBe(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
131
+ expect(worktrees[0].branch).toBe('refs/heads/convoy-worker1')
132
+ expect(worktrees[0].head).toMatch(/^[0-9a-f]{40}$/)
133
+ })
134
+
135
+ it('returns multiple worktrees when several have been created', async () => {
136
+ await manager.create('worker1', 'HEAD')
137
+ await manager.create('worker2', 'HEAD')
138
+ const worktrees = await manager.list()
139
+ expect(worktrees).toHaveLength(2)
140
+ const paths = worktrees.map(w => w.path)
141
+ expect(paths).toContain(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
142
+ expect(paths).toContain(join(tmpDir, '.opencastle', 'worktrees', 'worker2'))
143
+ })
144
+
145
+ it('handles detached HEAD worktrees by returning empty branch string', async () => {
146
+ // git worktree list --porcelain outputs 'detached' (not 'branch refs/...') for
147
+ // detached-HEAD worktrees — this exercises the else-if fallthrough in parseWorktreeList
148
+ const { existsSync, mkdirSync } = await import('node:fs')
149
+ const detachedPath = join(tmpDir, '.opencastle', 'worktrees', 'detached-test')
150
+ if (!existsSync(join(tmpDir, '.opencastle', 'worktrees'))) {
151
+ mkdirSync(join(tmpDir, '.opencastle', 'worktrees'), { recursive: true })
152
+ }
153
+ const { stdout: sha } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: tmpDir })
154
+ await execFile('git', ['worktree', 'add', '--detach', detachedPath, sha.trim()], { cwd: tmpDir })
155
+ const worktrees = await manager.list()
156
+ const detached = worktrees.find(w => w.path === detachedPath)
157
+ expect(detached).toBeDefined()
158
+ expect(detached!.branch).toBe('')
159
+ expect(detached!.head).toMatch(/^[0-9a-f]{40}$/)
160
+ })
161
+ })
162
+
163
+ // ── removeAll ─────────────────────────────────────────────────────────────────
164
+
165
+ describe('removeAll', () => {
166
+ it('removes all convoy worktrees', async () => {
167
+ await manager.create('worker1', 'HEAD')
168
+ await manager.create('worker2', 'HEAD')
169
+ await manager.removeAll()
170
+ const worktrees = await manager.list()
171
+ expect(worktrees).toHaveLength(0)
172
+ })
173
+
174
+ it('is a no-op when no convoy worktrees exist', async () => {
175
+ await expect(manager.removeAll()).resolves.toBeUndefined()
176
+ })
177
+ })
@@ -0,0 +1,116 @@
1
+ import { execFile as execFileCb } from 'node:child_process'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { realpathSync } from 'node:fs'
4
+ import { join, basename, resolve, sep } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ const execFile = promisify(execFileCb)
8
+
9
+ export interface WorktreeInfo {
10
+ path: string
11
+ branch: string
12
+ head: string
13
+ }
14
+
15
+ export interface WorktreeManager {
16
+ create(workerId: string, featureBranch: string): Promise<string>
17
+ remove(worktreePath: string): Promise<void>
18
+ list(): Promise<WorktreeInfo[]>
19
+ removeAll(): Promise<void>
20
+ }
21
+
22
+ export function createWorktreeManager(basePath: string): WorktreeManager {
23
+ const resolvedBase = realpathSync(resolve(basePath))
24
+ const worktreesDir = join(resolvedBase, '.opencastle', 'worktrees')
25
+
26
+ async function create(workerId: string, featureBranch: string): Promise<string> {
27
+ if (!/^[a-zA-Z0-9_-]+$/.test(workerId)) {
28
+ throw new Error(
29
+ `Invalid workerId "${workerId}": must only contain alphanumeric characters, hyphens, and underscores`,
30
+ )
31
+ }
32
+ const worktreePath = join(worktreesDir, workerId)
33
+ await mkdir(worktreesDir, { recursive: true })
34
+ await execFile(
35
+ 'git',
36
+ ['worktree', 'add', worktreePath, '-b', `convoy-${workerId}`, featureBranch],
37
+ { cwd: resolvedBase },
38
+ )
39
+ return worktreePath
40
+ }
41
+
42
+ async function remove(worktreePath: string): Promise<void> {
43
+ let resolved: string
44
+ try {
45
+ resolved = realpathSync(worktreePath)
46
+ } catch {
47
+ resolved = resolve(worktreePath)
48
+ }
49
+ if (!resolved.startsWith(worktreesDir + sep)) {
50
+ throw new Error(`Path "${worktreePath}" is outside the managed worktrees directory`)
51
+ }
52
+ const workerId = basename(resolved)
53
+ try {
54
+ await execFile('git', ['worktree', 'remove', resolved, '--force'], {
55
+ cwd: resolvedBase,
56
+ })
57
+ } catch (err) {
58
+ const stderr = (err as { stderr?: string }).stderr ?? ''
59
+ if (stderr.includes('is not a working tree')) {
60
+ return
61
+ }
62
+ throw err
63
+ }
64
+ try {
65
+ await execFile('git', ['branch', '-D', `convoy-${workerId}`], { cwd: resolvedBase })
66
+ } catch {
67
+ // Branch may already be deleted — ignore
68
+ }
69
+ }
70
+
71
+
72
+ async function list(): Promise<WorktreeInfo[]> {
73
+ const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], {
74
+ cwd: resolvedBase,
75
+ })
76
+ return parseWorktreeList(stdout, worktreesDir)
77
+ }
78
+
79
+ async function removeAll(): Promise<void> {
80
+ const worktrees = await list()
81
+ for (const wt of worktrees) {
82
+ await remove(wt.path)
83
+ }
84
+ }
85
+
86
+ return { create, remove, list, removeAll }
87
+ }
88
+
89
+ function parseWorktreeList(output: string, worktreesDir: string): WorktreeInfo[] {
90
+ const results: WorktreeInfo[] = []
91
+ const blocks = output.trim().split(/\n\n+/).filter(Boolean)
92
+
93
+ for (const block of blocks) {
94
+ const lines = block.split('\n')
95
+ let path = ''
96
+ let head = ''
97
+ let branch = ''
98
+
99
+ for (const line of lines) {
100
+ if (line.startsWith('worktree ')) {
101
+ path = line.slice('worktree '.length)
102
+ } else if (line.startsWith('HEAD ')) {
103
+ head = line.slice('HEAD '.length)
104
+ } else if (line.startsWith('branch ')) {
105
+ branch = line.slice('branch '.length)
106
+ }
107
+ }
108
+
109
+ // Only include worktrees that live under .opencastle/worktrees/
110
+ if (path.startsWith(worktreesDir + sep)) {
111
+ results.push({ path, branch, head })
112
+ }
113
+ }
114
+
115
+ return results
116
+ }
@@ -38,7 +38,7 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
38
38
  const proc = spawn('claude', args, {
39
39
  stdio: ['ignore', 'pipe', 'pipe'],
40
40
  env: { ...process.env },
41
- cwd: process.cwd(),
41
+ cwd: options?.cwd ?? process.cwd(),
42
42
  })
43
43
 
44
44
  let stdout = ''
@@ -61,6 +61,11 @@ async function getClient(): Promise<CopilotClientType> {
61
61
  * - Streaming enabled in verbose mode for live output
62
62
  */
63
63
  export async function execute(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
64
+ // NOTE: The Copilot SDK CopilotClient is a shared singleton. Per-task cwd
65
+ // isolation requires SDK support for per-session workingDirectory, which is
66
+ // not yet available. When running in convoy mode with worktrees, prefer
67
+ // subprocess-based adapters (claude-code, cursor) that support options.cwd
68
+ // natively. Copilot SDK per-session cwd support is tracked for Phase 3.
64
69
  let prompt = `You are a ${task.agent}. ${task.prompt}`
65
70
 
66
71
  if (task.files && task.files.length > 0) {