@astroanywhere/agent 0.4.3 → 0.5.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.
@@ -10,7 +10,7 @@ import { repoHasRemote } from './workdir-safety.js';
10
10
  import { pushBranchToRemote } from './git-pr.js';
11
11
  const execFileAsync = promisify(execFile);
12
12
  export async function createWorktree(options) {
13
- const { workingDirectory, taskId, rootOverride, projectId, nodeId, shortProjectId, shortNodeId, agentDir, baseBranch: dispatchBaseBranch, projectBranch: dispatchProjectBranch, stdout, stderr, signal, operational, } = options;
13
+ const { workingDirectory, taskId, rootOverride, projectId, nodeId, shortProjectId, shortNodeId, agentDir, baseBranch: dispatchBaseBranch, deliveryBranch: dispatchDeliveryBranch, deliveryBranchIsSingleton, stdout, stderr, signal, operational, } = options;
14
14
  // Validate taskId format to prevent command injection
15
15
  validateTaskId(taskId);
16
16
  const resolvedWorkingDirectory = resolve(workingDirectory);
@@ -27,15 +27,29 @@ export async function createWorktree(options) {
27
27
  const agentDirName = agentDir ?? '.astro';
28
28
  const baseRoot = rootOverride ?? await resolveWorktreeRoot(gitRoot, agentDirName);
29
29
  const branchPrefix = await readBranchPrefix(gitRoot, agentDirName);
30
- // Separate project branch (accumulative) from task branch (per-task).
31
- // Project branch: `astro/{shortProjectId}` accumulates all task work via auto-merged PRs
32
- // Task branch: `astro/{shortProjectId}-{shortNodeId}` per-task worktree branch
33
- const projectBranchName = dispatchProjectBranch
30
+ // Delivery branch: per-connected-component accumulation branch (e.g., 'astro/7b19a9-e4f1a2').
31
+ // Task branch: per-task worktree branch (e.g., 'astro/7b19a9-a1b2c3').
32
+ // Singleton mode: delivery branch IS the working branch (no task sub-branch).
33
+ //
34
+ // Validate dispatch-provided branch name: while execFileAsync prevents shell
35
+ // injection, we still reject names with unexpected characters to prevent
36
+ // path traversal or git ref manipulation.
37
+ if (dispatchDeliveryBranch) {
38
+ validateBranchName(dispatchDeliveryBranch);
39
+ }
40
+ const deliveryBranchName = dispatchDeliveryBranch
34
41
  ?? (shortProjectId ? `${branchPrefix}${sanitize(shortProjectId)}` : undefined);
42
+ // Validate singleton prerequisite before computing isSingleton
43
+ if (deliveryBranchIsSingleton && !deliveryBranchName) {
44
+ throw new Error('Singleton delivery branch requires deliveryBranchName');
45
+ }
46
+ const isSingleton = deliveryBranchIsSingleton && !!deliveryBranchName;
35
47
  const branchSuffix = shortProjectId && shortNodeId
36
48
  ? `${sanitize(shortProjectId)}-${sanitize(shortNodeId)}`
37
49
  : sanitize(taskId);
38
- const taskBranchName = `${branchPrefix}${branchSuffix}`;
50
+ const taskBranchName = isSingleton
51
+ ? deliveryBranchName // Singleton: work directly on delivery branch
52
+ : `${branchPrefix}${branchSuffix}`;
39
53
  const worktreePath = join(baseRoot, branchSuffix);
40
54
  // Abort-signal gate: check between every long git operation so cancellation
41
55
  // actually halts workspace prep instead of letting it run to completion.
@@ -47,15 +61,19 @@ export async function createWorktree(options) {
47
61
  checkAborted();
48
62
  await rm(worktreePath, { recursive: true, force: true });
49
63
  await pruneWorktrees(gitRoot);
50
- // Clean up lingering worktrees and branches for the TASK branch only.
51
- // Never delete the project branch it accumulates work across tasks.
64
+ // Clean up lingering worktrees for the working branch.
65
+ // For singletons, only clean up stale worktree checkouts never delete
66
+ // the delivery branch itself (it's managed by the server).
67
+ // For multi-task, also delete the stale task branch and its remote.
52
68
  checkAborted();
53
69
  await removeLingeringWorktrees(gitRoot, taskBranchName);
54
- await ensureBranchAvailable(gitRoot, taskBranchName);
55
- // Delete remote task branch if it exists — prevents non-fast-forward push
56
- // failures when re-executing a task whose previous branch was already pushed
57
- checkAborted();
58
- await deleteRemoteBranch(gitRoot, taskBranchName);
70
+ if (!isSingleton) {
71
+ await ensureBranchAvailable(gitRoot, taskBranchName);
72
+ // Delete remote task branch if it exists prevents non-fast-forward push
73
+ // failures when re-executing a task whose previous branch was already pushed
74
+ checkAborted();
75
+ await deleteRemoteBranch(gitRoot, taskBranchName);
76
+ }
59
77
  // Fetch latest so we branch from up-to-date origin (skip for local-only repos)
60
78
  checkAborted();
61
79
  const hasRemote = await repoHasRemote(gitRoot);
@@ -81,23 +99,24 @@ export async function createWorktree(options) {
81
99
  const defaultBranch = (dispatchBranchValid ? dispatchBaseBranch : null)
82
100
  ?? await readBaseBranch(gitRoot, agentDirName)
83
101
  ?? await getDefaultBranch(gitRoot);
84
- // Ensure the project branch exists on origin. If this is the first task,
102
+ // Ensure the delivery branch exists on origin. If this is the first task,
85
103
  // create it from origin/{defaultBranch}. Idempotent.
86
104
  checkAborted();
87
- if (projectBranchName) {
88
- await ensureProjectBranch(gitRoot, projectBranchName, defaultBranch, operational);
105
+ if (deliveryBranchName) {
106
+ await ensureDeliveryBranch(gitRoot, deliveryBranchName, defaultBranch, operational);
89
107
  }
90
- // Create persistent project worktree (detached HEAD) — idempotent.
108
+ // Create persistent delivery worktree (detached HEAD) — idempotent.
91
109
  // This enables file browsing at .astro/worktrees/{shortProjectId}/ after
92
110
  // task worktrees are cleaned up.
93
- let projectWorktreePath;
94
- if (projectBranchName && shortProjectId) {
95
- projectWorktreePath = await createProjectWorktree(gitRoot, projectBranchName, baseRoot, sanitize(shortProjectId), operational) ?? undefined;
111
+ // Skip for singletons — the task worktree IS the working worktree.
112
+ let deliveryWorktreePath;
113
+ if (!isSingleton && deliveryBranchName && shortProjectId) {
114
+ deliveryWorktreePath = await createDeliveryWorktree(gitRoot, deliveryBranchName, baseRoot, sanitize(shortProjectId), operational) ?? undefined;
96
115
  }
97
- // Start point: prefer project branch tip (accumulates prior task work),
98
- // fall back to default branch for non-project worktrees.
116
+ // Start point: prefer delivery branch tip (accumulates prior task work),
117
+ // fall back to default branch for non-delivery worktrees.
99
118
  // Using origin/<branch> avoids stale local refs.
100
- const effectiveBase = projectBranchName ?? defaultBranch;
119
+ const effectiveBase = deliveryBranchName ?? defaultBranch;
101
120
  const remoteRef = `origin/${effectiveBase}`;
102
121
  const hasRemoteRef = await refExists(gitRoot, remoteRef);
103
122
  const startPoint = hasRemoteRef ? remoteRef : effectiveBase;
@@ -118,28 +137,66 @@ export async function createWorktree(options) {
118
137
  console.warn('[worktree] Failed to capture commitBeforeSha for audit trail');
119
138
  }
120
139
  checkAborted();
121
- operational?.(`Creating worktree from ${startPoint}...`, 'astro');
122
- try {
123
- await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '-b', taskBranchName, worktreePath, startPoint], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
124
- }
125
- catch (err) {
126
- const msg = err instanceof Error ? err.message : String(err);
127
- if (msg.includes('already exists')) {
128
- // Branch exists from a previous failed/retried execution — delete it and retry.
129
- console.log(`[worktree] Branch ${taskBranchName} already exists, deleting stale branch and retrying`);
130
- try {
131
- await execFileAsync('git', ['-C', gitRoot, 'branch', '-D', taskBranchName], { env: withGitEnv(), timeout: 5_000 });
140
+ if (isSingleton) {
141
+ // Singleton: checkout the existing delivery branch in the worktree.
142
+ // No new branch is created the agent works directly on the delivery branch.
143
+ // INVARIANT: The server guarantees at most one task dispatched per singleton
144
+ // delivery branch. Concurrent dispatches to the same branch are prevented
145
+ // by the dispatch engine's node-level locking.
146
+ operational?.(`Creating singleton worktree on delivery branch ${taskBranchName}...`, 'astro');
147
+ try {
148
+ await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', worktreePath, taskBranchName], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
149
+ }
150
+ catch (err) {
151
+ const msg = err instanceof Error ? err.message : String(err);
152
+ if (msg.includes('already checked out') || msg.includes('already registered')) {
153
+ // Stale worktree — prune and retry. If the branch is still locked after
154
+ // pruning, another task is genuinely using it (server invariant violated).
155
+ console.warn(`[worktree] Delivery branch ${taskBranchName} locked by existing worktree, pruning stale entries`);
156
+ await pruneWorktrees(gitRoot);
157
+ try {
158
+ await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', worktreePath, taskBranchName], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
159
+ }
160
+ catch (retryErr) {
161
+ const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
162
+ if (retryMsg.includes('already checked out') || retryMsg.includes('already registered')) {
163
+ throw new Error(`Singleton delivery branch ${taskBranchName} is still checked out after pruning. ` +
164
+ `Another task may be actively using this branch — server-side mutual exclusion may be broken. ` +
165
+ `Original error: ${retryMsg}`);
166
+ }
167
+ throw retryErr;
168
+ }
132
169
  }
133
- catch { /* branch might be checked out in a stale worktree — prune first */ }
134
- try {
135
- await execFileAsync('git', ['-C', gitRoot, 'worktree', 'prune'], { env: withGitEnv(), timeout: 5_000 });
136
- await execFileAsync('git', ['-C', gitRoot, 'branch', '-D', taskBranchName], { env: withGitEnv(), timeout: 5_000 });
170
+ else {
171
+ throw err;
137
172
  }
138
- catch { /* best effort — may already be deleted */ }
173
+ }
174
+ }
175
+ else {
176
+ // Multi-task: create a new task branch from the delivery branch (or default branch).
177
+ operational?.(`Creating worktree from ${startPoint}...`, 'astro');
178
+ try {
139
179
  await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '-b', taskBranchName, worktreePath, startPoint], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
140
180
  }
141
- else {
142
- throw err;
181
+ catch (err) {
182
+ const msg = err instanceof Error ? err.message : String(err);
183
+ if (msg.includes('already exists')) {
184
+ // Branch exists from a previous failed/retried execution — delete it and retry.
185
+ console.log(`[worktree] Branch ${taskBranchName} already exists, deleting stale branch and retrying`);
186
+ try {
187
+ await execFileAsync('git', ['-C', gitRoot, 'branch', '-D', taskBranchName], { env: withGitEnv(), timeout: 5_000 });
188
+ }
189
+ catch { /* branch might be checked out in a stale worktree — prune first */ }
190
+ try {
191
+ await execFileAsync('git', ['-C', gitRoot, 'worktree', 'prune'], { env: withGitEnv(), timeout: 5_000 });
192
+ await execFileAsync('git', ['-C', gitRoot, 'branch', '-D', taskBranchName], { env: withGitEnv(), timeout: 5_000 });
193
+ }
194
+ catch { /* best effort — may already be deleted */ }
195
+ await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '-b', taskBranchName, worktreePath, startPoint], { env: withGitEnv(), timeout: 30_000, signal: signal ?? undefined });
196
+ }
197
+ else {
198
+ throw err;
199
+ }
143
200
  }
144
201
  }
145
202
  // Initialize submodules if the repo uses them (non-fatal)
@@ -210,13 +267,17 @@ export async function createWorktree(options) {
210
267
  return {
211
268
  workingDirectory: worktreeWorkingDirectory,
212
269
  branchName: taskBranchName,
213
- baseBranch: effectiveBase,
270
+ // Singleton: PR targets the base branch (main) directly.
271
+ // Multi-task: PR targets the delivery branch (effectiveBase).
272
+ baseBranch: isSingleton ? defaultBranch : effectiveBase,
214
273
  commitBeforeSha,
215
274
  gitRoot,
216
- projectBranch: projectBranchName,
217
- projectWorktreePath,
275
+ // Singleton: no accumulative merge step — PR goes directly to base branch.
276
+ deliveryBranch: isSingleton ? undefined : deliveryBranchName,
277
+ deliveryWorktreePath: isSingleton ? undefined : deliveryWorktreePath,
218
278
  cleanup: async (options) => {
219
- await cleanupWorktree(gitRoot, worktreePath, taskBranchName, options?.keepBranch);
279
+ // Singleton: always keep the delivery branch (server manages its lifecycle).
280
+ await cleanupWorktree(gitRoot, worktreePath, taskBranchName, isSingleton || options?.keepBranch);
220
281
  },
221
282
  };
222
283
  }
@@ -269,7 +330,7 @@ export async function removeLingeringWorktrees(gitRoot, branchName) {
269
330
  }
270
331
  }
271
332
  /**
272
- * Ensure the project-level accumulation branch exists.
333
+ * Ensure the delivery branch exists.
273
334
  *
274
335
  * For repos WITH a remote: create on origin and push.
275
336
  * For repos WITHOUT a remote: create locally only (no push).
@@ -281,185 +342,185 @@ export async function removeLingeringWorktrees(gitRoot, branchName) {
281
342
  * It is internally idempotent: if a parallel task already created the branch,
282
343
  * the "already exists" error is caught and handled gracefully.
283
344
  */
284
- async function ensureProjectBranch(gitRoot, projectBranch, defaultBranch, operational) {
345
+ async function ensureDeliveryBranch(gitRoot, deliveryBranch, defaultBranch, operational) {
285
346
  const hasRemote = await repoHasRemote(gitRoot);
286
347
  if (hasRemote) {
287
348
  // --- Remote mode: check origin, push if needed ---
288
- const remoteRef = `origin/${projectBranch}`;
349
+ const remoteRef = `origin/${deliveryBranch}`;
289
350
  if (await refExists(gitRoot, remoteRef)) {
290
- operational?.(`Project branch ${projectBranch} exists on origin`, 'git');
291
- console.log(`[worktree] Project branch ${projectBranch} exists on origin`);
351
+ operational?.(`Delivery branch ${deliveryBranch} exists on origin`, 'git');
352
+ console.log(`[worktree] Delivery branch ${deliveryBranch} exists on origin`);
292
353
  return;
293
354
  }
294
355
  // Branch not on origin — either create it or push an existing local branch.
295
- const localExists = await refExists(gitRoot, `refs/heads/${projectBranch}`);
356
+ const localExists = await refExists(gitRoot, `refs/heads/${deliveryBranch}`);
296
357
  if (!localExists) {
297
- // First-ever task for this project — create the branch locally.
358
+ // First-ever task for this component — create the branch locally.
298
359
  const defaultRemoteRef = `origin/${defaultBranch}`;
299
360
  const hasDefaultRemote = await refExists(gitRoot, defaultRemoteRef);
300
361
  const startPoint = hasDefaultRemote ? defaultRemoteRef : defaultBranch;
301
- operational?.(`Creating project branch ${projectBranch} from ${startPoint}...`, 'git');
302
- console.log(`[worktree] Creating project branch ${projectBranch} from ${startPoint}`);
362
+ operational?.(`Creating delivery branch ${deliveryBranch} from ${startPoint}...`, 'git');
363
+ console.log(`[worktree] Creating delivery branch ${deliveryBranch} from ${startPoint}`);
303
364
  try {
304
- await execFileAsync('git', ['-C', gitRoot, 'branch', projectBranch, startPoint], { env: withGitEnv(), timeout: 10_000 });
365
+ await execFileAsync('git', ['-C', gitRoot, 'branch', deliveryBranch, startPoint], { env: withGitEnv(), timeout: 10_000 });
305
366
  }
306
367
  catch (err) {
307
368
  const msg = err instanceof Error ? err.message : String(err);
308
369
  // Handle race condition: parallel task already created the branch locally.
309
370
  if (msg.includes('already exists')) {
310
- operational?.(`Project branch ${projectBranch} already created (race OK)`, 'git');
311
- console.log(`[worktree] Project branch ${projectBranch} created by another task (race OK)`);
371
+ operational?.(`Delivery branch ${deliveryBranch} already created (race OK)`, 'git');
372
+ console.log(`[worktree] Delivery branch ${deliveryBranch} created by another task (race OK)`);
312
373
  }
313
374
  else {
314
- throw new Error(`Failed to create project branch ${projectBranch}: ${msg}`);
375
+ throw new Error(`Failed to create delivery branch ${deliveryBranch}: ${msg}`);
315
376
  }
316
377
  }
317
378
  }
318
379
  else {
319
- operational?.(`Project branch ${projectBranch} exists locally but not on origin`, 'git');
320
- console.log(`[worktree] Project branch ${projectBranch} exists locally, not on origin — pushing`);
380
+ operational?.(`Delivery branch ${deliveryBranch} exists locally but not on origin`, 'git');
381
+ console.log(`[worktree] Delivery branch ${deliveryBranch} exists locally, not on origin — pushing`);
321
382
  }
322
- // Push the project branch to origin — required for PR mode to work.
383
+ // Push the delivery branch to origin — required for PR mode to work.
323
384
  // Uses shared helper: 2-attempt retry with 2s delay + post-push verification.
324
- const pushResult = await pushBranchToRemote(gitRoot, projectBranch, {
385
+ const pushResult = await pushBranchToRemote(gitRoot, deliveryBranch, {
325
386
  operational,
326
- label: 'ensureProjectBranch',
387
+ label: 'ensureDeliveryBranch',
327
388
  });
328
389
  if (!pushResult.ok) {
329
390
  operational?.('PR delivery will fail — the base branch must exist on GitHub for PRs.', 'git');
330
- throw new Error(`${pushResult.error}. PR delivery requires the project branch to exist on the remote. `
391
+ throw new Error(`${pushResult.error}. PR delivery requires the delivery branch to exist on the remote. `
331
392
  + `Check: git remote permissions, SSH keys, network connectivity.`);
332
393
  }
333
394
  }
334
395
  else {
335
396
  // --- Local mode (no remote): create branch locally only ---
336
- if (await refExists(gitRoot, `refs/heads/${projectBranch}`)) {
337
- operational?.(`Project branch ${projectBranch} exists locally (no remote)`, 'git');
338
- console.log(`[worktree] Project branch ${projectBranch} exists locally (no remote)`);
397
+ if (await refExists(gitRoot, `refs/heads/${deliveryBranch}`)) {
398
+ operational?.(`Delivery branch ${deliveryBranch} exists locally (no remote)`, 'git');
399
+ console.log(`[worktree] Delivery branch ${deliveryBranch} exists locally (no remote)`);
339
400
  return;
340
401
  }
341
- operational?.(`Creating local project branch ${projectBranch} from ${defaultBranch}...`, 'git');
342
- console.log(`[worktree] Creating local project branch ${projectBranch} from ${defaultBranch}`);
402
+ operational?.(`Creating local delivery branch ${deliveryBranch} from ${defaultBranch}...`, 'git');
403
+ console.log(`[worktree] Creating local delivery branch ${deliveryBranch} from ${defaultBranch}`);
343
404
  try {
344
- await execFileAsync('git', ['-C', gitRoot, 'branch', projectBranch, defaultBranch], { env: withGitEnv(), timeout: 10_000 });
345
- operational?.(`Created local project branch ${projectBranch}`, 'git');
346
- console.log(`[worktree] Created local project branch ${projectBranch}`);
405
+ await execFileAsync('git', ['-C', gitRoot, 'branch', deliveryBranch, defaultBranch], { env: withGitEnv(), timeout: 10_000 });
406
+ operational?.(`Created local delivery branch ${deliveryBranch}`, 'git');
407
+ console.log(`[worktree] Created local delivery branch ${deliveryBranch}`);
347
408
  }
348
409
  catch (err) {
349
410
  const msg = err instanceof Error ? err.message : String(err);
350
411
  if (msg.includes('already exists')) {
351
- operational?.(`Project branch ${projectBranch} already created (race OK)`, 'git');
352
- console.log(`[worktree] Project branch ${projectBranch} created by another task (race OK)`);
412
+ operational?.(`Delivery branch ${deliveryBranch} already created (race OK)`, 'git');
413
+ console.log(`[worktree] Delivery branch ${deliveryBranch} created by another task (race OK)`);
353
414
  return;
354
415
  }
355
- throw new Error(`Failed to create local project branch ${projectBranch}: ${msg}`);
416
+ throw new Error(`Failed to create local delivery branch ${deliveryBranch}: ${msg}`);
356
417
  }
357
418
  }
358
419
  }
359
420
  /**
360
- * Create a persistent project-level worktree using detached HEAD.
421
+ * Create a persistent delivery worktree using detached HEAD.
361
422
  *
362
- * The project worktree lives at {baseRoot}/{shortProjectId}/ and mirrors
363
- * the project branch on disk. It uses `--detach` so the project branch
364
- * ref remains free for temporary merge worktrees (localMergeIntoProjectBranch
365
- * checks out the project branch — git prevents the same branch in two worktrees).
423
+ * The delivery worktree lives at {baseRoot}/{shortProjectId}/ and mirrors
424
+ * the delivery branch on disk. It uses `--detach` so the delivery branch
425
+ * ref remains free for temporary merge worktrees (localMergeIntoDeliveryBranch
426
+ * checks out the delivery branch — git prevents the same branch in two worktrees).
366
427
  *
367
428
  * Idempotent — safe to call on every task dispatch. If the worktree
368
429
  * already exists, returns the existing path.
369
430
  */
370
- export async function createProjectWorktree(gitRoot, projectBranch, baseRoot, shortProjectId, operational) {
371
- const projectWorktreePath = join(baseRoot, shortProjectId);
431
+ export async function createDeliveryWorktree(gitRoot, deliveryBranch, baseRoot, shortProjectId, operational) {
432
+ const deliveryWorktreePath = join(baseRoot, shortProjectId);
372
433
  // Already exists — no-op
373
- if (existsSync(projectWorktreePath)) {
374
- console.log(`[worktree] Project worktree already exists at ${projectWorktreePath}`);
375
- return projectWorktreePath;
434
+ if (existsSync(deliveryWorktreePath)) {
435
+ console.log(`[worktree] Delivery worktree already exists at ${deliveryWorktreePath}`);
436
+ return deliveryWorktreePath;
376
437
  }
377
438
  // Determine start point: prefer local ref, fall back to remote
378
- const localRef = `refs/heads/${projectBranch}`;
379
- const remoteRef = `origin/${projectBranch}`;
439
+ const localRef = `refs/heads/${deliveryBranch}`;
440
+ const remoteRef = `origin/${deliveryBranch}`;
380
441
  const hasLocal = await refExists(gitRoot, localRef);
381
442
  const startPoint = hasLocal ? localRef : remoteRef;
382
443
  try {
383
- await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '--detach', projectWorktreePath, startPoint], { env: withGitEnv(), timeout: 30_000 });
384
- console.log(`[worktree] Created persistent project worktree at ${projectWorktreePath} (detached HEAD at ${projectBranch})`);
385
- return projectWorktreePath;
444
+ await execFileAsync('git', ['-C', gitRoot, 'worktree', 'add', '--detach', deliveryWorktreePath, startPoint], { env: withGitEnv(), timeout: 30_000 });
445
+ console.log(`[worktree] Created persistent delivery worktree at ${deliveryWorktreePath} (detached HEAD at ${deliveryBranch})`);
446
+ return deliveryWorktreePath;
386
447
  }
387
448
  catch (err) {
388
449
  const msg = err instanceof Error ? err.message : String(err);
389
450
  // Handle race: another parallel task created it between our check and git worktree add
390
451
  if (msg.includes('already registered') || msg.includes('already exists')) {
391
- console.log(`[worktree] Project worktree created by another task (race OK): ${projectWorktreePath}`);
392
- return projectWorktreePath;
452
+ console.log(`[worktree] Delivery worktree created by another task (race OK): ${deliveryWorktreePath}`);
453
+ return deliveryWorktreePath;
393
454
  }
394
- console.warn(`[worktree] Failed to create project worktree: ${msg}`);
395
- operational?.(`WARNING: Could not create project worktree (file browsing between tasks may be unavailable): ${msg}`, 'git');
455
+ console.warn(`[worktree] Failed to create delivery worktree: ${msg}`);
456
+ operational?.(`WARNING: Could not create delivery worktree (file browsing between tasks may be unavailable): ${msg}`, 'git');
396
457
  return null;
397
458
  }
398
459
  }
399
460
  /**
400
- * Sync the persistent project worktree to the latest project branch tip.
461
+ * Sync the persistent delivery worktree to the latest delivery branch tip.
401
462
  *
402
- * After each successful merge (branch or PR mode), the project branch moves
403
- * forward. This updates the detached HEAD in the project worktree so the
463
+ * After each successful merge (branch or PR mode), the delivery branch moves
464
+ * forward. This updates the detached HEAD in the delivery worktree so the
404
465
  * files on disk reflect the latest state.
405
466
  *
406
467
  * Ref selection:
407
- * - Branch mode (no remote): localMergeIntoProjectBranch() advances
408
- * refs/heads/{projectBranch} directly → use the local ref.
409
- * - PR mode (has remote): GitHub merge advances origin/{projectBranch},
468
+ * - Branch mode (no remote): localMergeIntoDeliveryBranch() advances
469
+ * refs/heads/{deliveryBranch} directly → use the local ref.
470
+ * - PR mode (has remote): GitHub merge advances origin/{deliveryBranch},
410
471
  * but refs/heads/ is stale (no local commit) → fetch then use remote ref.
411
472
  *
412
473
  * Non-fatal — sync failure doesn't affect task completion.
413
474
  */
414
- export async function syncProjectWorktree(projectWorktreePath, projectBranch, gitRoot) {
415
- if (!existsSync(projectWorktreePath)) {
475
+ export async function syncDeliveryWorktree(deliveryWorktreePath, deliveryBranch, gitRoot) {
476
+ if (!existsSync(deliveryWorktreePath)) {
416
477
  return;
417
478
  }
418
479
  // Determine the correct ref to checkout:
419
480
  // - Remote repos (PR mode): fetch, then use origin/ (remote has the merged state)
420
481
  // - Local repos (branch mode): use refs/heads/ (local merge updated it directly)
421
482
  const hasRemote = await repoHasRemote(gitRoot);
422
- let checkoutRef = `refs/heads/${projectBranch}`;
483
+ let checkoutRef = `refs/heads/${deliveryBranch}`;
423
484
  if (hasRemote) {
424
485
  try {
425
- await execFileAsync('git', ['-C', gitRoot, 'fetch', 'origin', projectBranch], { env: withGitEnv(), timeout: 15_000 });
426
- checkoutRef = `origin/${projectBranch}`;
486
+ await execFileAsync('git', ['-C', gitRoot, 'fetch', 'origin', deliveryBranch], { env: withGitEnv(), timeout: 15_000 });
487
+ checkoutRef = `origin/${deliveryBranch}`;
427
488
  }
428
489
  catch (fetchErr) {
429
490
  const fetchMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
430
- console.warn(`[worktree] syncProjectWorktree: fetch origin/${projectBranch} failed: ${fetchMsg}`);
491
+ console.warn(`[worktree] syncDeliveryWorktree: fetch origin/${deliveryBranch} failed: ${fetchMsg}`);
431
492
  // Fall back to local ref (best effort)
432
493
  }
433
494
  }
434
495
  try {
435
- await execFileAsync('git', ['-C', projectWorktreePath, 'checkout', '--detach', checkoutRef], { env: withGitEnv(), timeout: 10_000 });
436
- console.log(`[worktree] Synced project worktree at ${projectWorktreePath} to ${checkoutRef}`);
496
+ await execFileAsync('git', ['-C', deliveryWorktreePath, 'checkout', '--detach', checkoutRef], { env: withGitEnv(), timeout: 10_000 });
497
+ console.log(`[worktree] Synced delivery worktree at ${deliveryWorktreePath} to ${checkoutRef}`);
437
498
  }
438
499
  catch (err) {
439
500
  const msg = err instanceof Error ? err.message : String(err);
440
- console.warn(`[worktree] Failed to sync project worktree: ${msg}`);
501
+ console.warn(`[worktree] Failed to sync delivery worktree: ${msg}`);
441
502
  }
442
503
  }
443
504
  /**
444
- * Remove the persistent project worktree.
505
+ * Remove the persistent delivery worktree.
445
506
  *
446
507
  * This is an exported utility for the astro platform to call when a project
447
508
  * is deleted. The agent-runner does not manage project lifecycles — it only
448
509
  * provides the building blocks. The integration point (calling this on
449
510
  * project deletion) lives in the astro server, not here.
450
511
  */
451
- export async function cleanupProjectWorktree(gitRoot, projectWorktreePath) {
452
- if (!existsSync(projectWorktreePath)) {
512
+ export async function cleanupDeliveryWorktree(gitRoot, deliveryWorktreePath) {
513
+ if (!existsSync(deliveryWorktreePath)) {
453
514
  return;
454
515
  }
455
516
  try {
456
- await execFileAsync('git', ['-C', gitRoot, 'worktree', 'remove', '--force', projectWorktreePath], { env: withGitEnv(), timeout: 30_000 });
517
+ await execFileAsync('git', ['-C', gitRoot, 'worktree', 'remove', '--force', deliveryWorktreePath], { env: withGitEnv(), timeout: 30_000 });
457
518
  }
458
519
  catch {
459
- await rm(projectWorktreePath, { recursive: true, force: true });
520
+ await rm(deliveryWorktreePath, { recursive: true, force: true });
460
521
  }
461
522
  await pruneWorktrees(gitRoot);
462
- console.log(`[worktree] Cleaned up project worktree at ${projectWorktreePath}`);
523
+ console.log(`[worktree] Cleaned up delivery worktree at ${deliveryWorktreePath}`);
463
524
  }
464
525
  /**
465
526
  * Delete remote branch if it exists.
@@ -709,7 +770,7 @@ async function initSubmodules(worktreePath, stderr) {
709
770
  stderr?.(`[worktree] Submodule init failed: ${msg}`);
710
771
  }
711
772
  }
712
- async function getGitRoot(workingDirectory) {
773
+ export async function getGitRoot(workingDirectory) {
713
774
  try {
714
775
  const { stdout } = await execFileAsync('git', ['-C', workingDirectory, 'rev-parse', '--show-toplevel'], { env: withGitEnv(), timeout: 5_000 });
715
776
  const root = stdout.trim();
@@ -817,6 +878,29 @@ function validateTaskId(taskId) {
817
878
  throw new Error(`Invalid taskId format: ${taskId}. Must be alphanumeric with hyphens/underscores/dots, max 200 chars.`);
818
879
  }
819
880
  }
881
+ /**
882
+ * Validate that a dispatch-provided branch name is safe for git operations.
883
+ * Enforces git check-ref-format rules:
884
+ * - Only alphanumeric, hyphens, underscores, dots, forward slashes
885
+ * - No ".." (path traversal), no "//", no leading/trailing "/"
886
+ * - No ".lock" suffix, no dot-prefixed path components (e.g., ".hidden", "foo/.bar")
887
+ * - Max 200 chars
888
+ */
889
+ function validateBranchName(name) {
890
+ const safePattern = /^[a-zA-Z0-9/_.-]+$/;
891
+ if (!safePattern.test(name) ||
892
+ name.length > 200 ||
893
+ name.includes('..') ||
894
+ name.includes('//') ||
895
+ name.startsWith('/') ||
896
+ name.endsWith('/') ||
897
+ name.endsWith('.lock') ||
898
+ name === '.' ||
899
+ /(?:^|\/)\./.test(name) // dot-prefixed path components
900
+ ) {
901
+ throw new Error(`Invalid branch name from dispatch: ${name.slice(0, 100)}. Must be a valid git ref name, max 200 chars.`);
902
+ }
903
+ }
820
904
  function sanitize(value) {
821
905
  return value.replace(/[^a-zA-Z0-9._-]/g, '_');
822
906
  }