@astroanywhere/agent 0.4.4 → 0.5.1

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.
@@ -13,11 +13,11 @@ import { createProviderAdapter } from '../providers/index.js';
13
13
  import { ClaudeSdkAdapter } from '../providers/claude-sdk-adapter.js';
14
14
  import { OpenClawAdapter } from '../providers/openclaw-adapter.js';
15
15
  import { SlurmJobMonitor } from './slurm-job-monitor.js';
16
- import { createWorktree, syncProjectWorktree } from './worktree.js';
16
+ import { createWorktree, syncDeliveryWorktree, getGitRoot } from './worktree.js';
17
17
  import { ensureProjectWorkspace } from './workspace-root.js';
18
18
  import { BranchLockManager } from './branch-lock.js';
19
19
  import { pushAndCreatePR, mergePullRequest, getRemoteBranchSha, isGhAvailable, getRepoSlug } from './git-pr.js';
20
- import { localMergeIntoProjectBranch } from './local-merge.js';
20
+ import { localMergeIntoDeliveryBranch } from './local-merge.js';
21
21
  import { checkWorkdirSafety, isGitAvailable, isGitRepo, isUntrackedInParentRepo, createSandbox, WorkdirSafetyTier, } from './workdir-safety.js';
22
22
  import { initializeGit } from './git-bootstrap.js';
23
23
  const execFileAsync = promisify(execFileCb);
@@ -32,24 +32,24 @@ function sanitizeGitRef(ref) {
32
32
  /**
33
33
  * Build a prompt instructing the agent to resolve merge conflicts.
34
34
  * The agent's worktree has the task branch checked out; it needs to
35
- * rebase onto the project branch to resolve conflicts, then commit.
35
+ * rebase onto the delivery branch to resolve conflicts, then commit.
36
36
  */
37
- function buildConflictResolutionPrompt(conflictFiles, projectBranch, attempt, maxAttempts) {
38
- const safeBranch = sanitizeGitRef(projectBranch);
37
+ function buildConflictResolutionPrompt(conflictFiles, deliveryBranch, attempt, maxAttempts) {
38
+ const safeBranch = sanitizeGitRef(deliveryBranch);
39
39
  const fileList = conflictFiles.map(f => `- ${f}`).join('\n');
40
40
  return `MERGE CONFLICT DETECTED (attempt ${attempt}/${maxAttempts})
41
41
 
42
- Your task branch cannot be cleanly merged into the project branch because
42
+ Your task branch cannot be cleanly merged into the delivery branch because
43
43
  parallel tasks have modified overlapping files since you branched.
44
44
 
45
45
  Conflicting files:
46
46
  ${fileList}
47
47
 
48
- The project branch is: ${safeBranch}
48
+ The delivery branch is: ${safeBranch}
49
49
 
50
50
  Please resolve this:
51
- 1. Fetch the latest project branch: git fetch origin 2>/dev/null; git fetch . ${safeBranch}:${safeBranch} 2>/dev/null || true
52
- 2. Rebase onto the project branch: git rebase ${safeBranch}
51
+ 1. Fetch the latest delivery branch: git fetch origin 2>/dev/null; git fetch . ${safeBranch}:${safeBranch} 2>/dev/null || true
52
+ 2. Rebase onto the delivery branch: git rebase ${safeBranch}
53
53
  3. For each conflict, open the file, resolve the conflict markers (<<<<<<< / ======= / >>>>>>>), keeping the correct combination of both changes
54
54
  4. Stage resolved files: git add <resolved-files>
55
55
  5. Continue the rebase: git rebase --continue
@@ -63,12 +63,12 @@ After you finish resolving, I will automatically retry the merge.`;
63
63
  * Similar to local mode, but the agent must also force-push after rebasing
64
64
  * because the merge happens via GitHub API (gh pr merge), not locally.
65
65
  */
66
- function buildPRConflictResolutionPrompt(projectBranch, branchName, attempt, maxAttempts) {
67
- const safeBranch = sanitizeGitRef(projectBranch);
66
+ function buildPRConflictResolutionPrompt(deliveryBranch, branchName, attempt, maxAttempts) {
67
+ const safeBranch = sanitizeGitRef(deliveryBranch);
68
68
  const safeTaskBranch = sanitizeGitRef(branchName);
69
69
  return `MERGE CONFLICT DETECTED ON GITHUB (attempt ${attempt}/${maxAttempts})
70
70
 
71
- Your pull request cannot be automatically merged into the project branch because
71
+ Your pull request cannot be automatically merged into the delivery branch because
72
72
  parallel tasks have modified overlapping files.
73
73
 
74
74
  Your task branch is: ${safeTaskBranch}
@@ -87,7 +87,7 @@ IMPORTANT: Do NOT create a merge commit. Use rebase so the history is clean.
87
87
  After you force-push, I will automatically retry the GitHub merge.`;
88
88
  }
89
89
  /**
90
- * Best-effort pre-merge rebase: if the project branch has moved forward
90
+ * Best-effort pre-merge rebase: if the delivery branch has moved forward
91
91
  * (another task merged), rebase the task branch onto the latest tip before
92
92
  * attempting the squash merge. This avoids conflicts in the common case
93
93
  * where changes don't overlap. On any failure, silently aborts — the
@@ -180,6 +180,8 @@ export class TaskExecutor {
180
180
  /** Tasks claimed by submitTask() but not yet in runningTasks (closes TOCTOU dedup gap). */
181
181
  claimedTasks = new Set();
182
182
  openclawBridge = null;
183
+ /** Branch names that are singleton delivery branches — must not be deleted by cleanupTask. */
184
+ singletonBranches = new Set();
183
185
  // Safety tracking
184
186
  tasksByDirectory = new Map(); // workdir -> taskIds
185
187
  pendingSafetyChecks = new Map(); // taskId -> pending check
@@ -439,21 +441,29 @@ export class TaskExecutor {
439
441
  }
440
442
  console.log(`[executor] Task ${taskId}: worktree removed`);
441
443
  }
442
- // Delete local branch
443
- try {
444
- execSync(`git branch -D "${branchName}"`, { encoding: 'utf-8', timeout: 10_000 });
445
- console.log(`[executor] Task ${taskId}: local branch ${branchName} deleted`);
446
- }
447
- catch {
448
- console.log(`[executor] Task ${taskId}: local branch ${branchName} not found or already deleted`);
444
+ // Skip branch deletion for singleton delivery branches — these are shared
445
+ // accumulation branches that outlive individual tasks and will be used for
446
+ // the component's final PR to the base branch.
447
+ if (this.singletonBranches.has(branchName)) {
448
+ console.log(`[executor] Task ${taskId}: skipping branch deletion — ${branchName} is a singleton delivery branch`);
449
449
  }
450
- // Delete remote branch
451
- try {
452
- execSync(`git push origin --delete "${branchName}"`, { encoding: 'utf-8', timeout: 30_000 });
453
- console.log(`[executor] Task ${taskId}: remote branch ${branchName} deleted`);
454
- }
455
- catch {
456
- console.log(`[executor] Task ${taskId}: remote branch ${branchName} not found or already deleted`);
450
+ else {
451
+ // Delete local branch
452
+ try {
453
+ execSync(`git branch -D "${branchName}"`, { encoding: 'utf-8', timeout: 10_000 });
454
+ console.log(`[executor] Task ${taskId}: local branch ${branchName} deleted`);
455
+ }
456
+ catch {
457
+ console.log(`[executor] Task ${taskId}: local branch ${branchName} not found or already deleted`);
458
+ }
459
+ // Delete remote branch
460
+ try {
461
+ execSync(`git push origin --delete "${branchName}"`, { encoding: 'utf-8', timeout: 30_000 });
462
+ console.log(`[executor] Task ${taskId}: remote branch ${branchName} deleted`);
463
+ }
464
+ catch {
465
+ console.log(`[executor] Task ${taskId}: remote branch ${branchName} not found or already deleted`);
466
+ }
457
467
  }
458
468
  }
459
469
  catch (error) {
@@ -501,6 +511,8 @@ export class TaskExecutor {
501
511
  // Release all locks to unblock any waiting tasks
502
512
  this.branchLockManager.releaseAll();
503
513
  this.directoryLockManager.releaseAll();
514
+ // Clear singleton tracking — all tasks cancelled, no branches to protect
515
+ this.singletonBranches.clear();
504
516
  // Stop job monitor
505
517
  this.jobMonitor.stop();
506
518
  }
@@ -622,9 +634,9 @@ export class TaskExecutor {
622
634
  if (resumeDir && !existsSync(resumeDir)) {
623
635
  const meta = this.completedTaskMeta.get(taskId);
624
636
  if (meta?.originalWorkingDirectory && existsSync(meta.originalWorkingDirectory)) {
625
- // Try to recreate a worktree from the project branch (like a new task)
637
+ // Try to recreate a worktree from the delivery branch (like a new task)
626
638
  try {
627
- console.log(`[executor] Task ${taskId}: worktree gone, recreating from project branch at ${meta.originalWorkingDirectory}`);
639
+ console.log(`[executor] Task ${taskId}: worktree gone, recreating from delivery branch at ${meta.originalWorkingDirectory}`);
628
640
  const worktree = await createWorktree({
629
641
  workingDirectory: meta.originalWorkingDirectory,
630
642
  taskId: `resume-${taskId.slice(0, 12)}-${Date.now()}`,
@@ -633,7 +645,8 @@ export class TaskExecutor {
633
645
  shortNodeId: meta.shortNodeId,
634
646
  agentDir: meta.agentDir,
635
647
  baseBranch: meta.baseBranch,
636
- projectBranch: meta.projectBranch,
648
+ deliveryBranch: meta.deliveryBranch,
649
+ deliveryBranchIsSingleton: meta.deliveryBranchIsSingleton,
637
650
  stdout: stream.stdout,
638
651
  stderr: stream.stderr,
639
652
  operational: stream.operational,
@@ -1077,12 +1090,13 @@ export class TaskExecutor {
1077
1090
  this.cleanupExpiredTaskMeta();
1078
1091
  this.completedTaskMeta.set(task.id, {
1079
1092
  originalWorkingDirectory: normalizedTask.workingDirectory,
1080
- projectBranch: normalizedTask.projectBranch,
1093
+ deliveryBranch: normalizedTask.deliveryBranch ?? normalizedTask.projectBranch,
1081
1094
  baseBranch: normalizedTask.baseBranch,
1082
1095
  agentDir: normalizedTask.agentDir,
1083
1096
  shortProjectId: normalizedTask.shortProjectId,
1084
1097
  shortNodeId: normalizedTask.shortNodeId,
1085
1098
  projectId: normalizedTask.projectId,
1099
+ deliveryBranchIsSingleton: normalizedTask.deliveryBranchIsSingleton,
1086
1100
  storedAt: Date.now(),
1087
1101
  });
1088
1102
  }
@@ -1092,7 +1106,7 @@ export class TaskExecutor {
1092
1106
  }
1093
1107
  // Delivery-mode-aware result handling
1094
1108
  // If pr mode is requested but gh is unavailable, fall back to branch mode
1095
- // so the work is still merged locally into the project branch.
1109
+ // so the work is still merged locally into the delivery branch.
1096
1110
  let deliveryMode = task.deliveryMode ?? 'pr';
1097
1111
  if (deliveryMode === 'pr' && !(await isGhAvailable())) {
1098
1112
  console.log(`[executor] Task ${task.id}: gh CLI not available, falling back from 'pr' to 'branch' mode`);
@@ -1160,26 +1174,26 @@ export class TaskExecutor {
1160
1174
  console.log(`[executor] Task ${task.id}: copy mode, worktree preserved at ${prepared.workingDirectory}`);
1161
1175
  }
1162
1176
  else if (deliveryMode === 'branch') {
1163
- stream.operational?.(`Merging into project branch ${prepared.projectBranch ?? 'local'}...`, 'git');
1164
- // Branch mode: commit locally, merge into project branch if available.
1177
+ stream.operational?.(`Merging into delivery branch ${prepared.deliveryBranch ?? 'local'}...`, 'git');
1178
+ // Branch mode: commit locally, merge into delivery branch if available.
1165
1179
  // The merge lock is held only during the squash-merge (seconds, not minutes),
1166
1180
  // allowing tasks to execute in parallel. The squash merge naturally handles
1167
- // the case where the project branch moved forward (another task merged first)
1181
+ // the case where the delivery branch moved forward (another task merged first)
1168
1182
  // because it computes the diff from the merge-base and applies it on the
1169
- // current project branch tip.
1183
+ // current delivery branch tip.
1170
1184
  //
1171
1185
  // On conflict: if the provider supports session resume (Claude SDK), we
1172
1186
  // resume the agent session to let it resolve the conflict, then retry.
1173
1187
  result.branchName = prepared.branchName;
1174
1188
  keepBranch = true;
1175
- if (prepared.gitRoot && prepared.projectBranch && prepared.branchName) {
1176
- // Pre-merge rebase: if the project branch moved forward (another task
1189
+ if (prepared.gitRoot && prepared.deliveryBranch && prepared.branchName) {
1190
+ // Pre-merge rebase: if the delivery branch moved forward (another task
1177
1191
  // merged), rebase our task branch first. Avoids conflicts when changes
1178
1192
  // don't overlap, and saves one retry cycle when they do.
1179
- const preRebase = await tryPreMergeRebase(prepared.workingDirectory, prepared.projectBranch, false);
1193
+ const preRebase = await tryPreMergeRebase(prepared.workingDirectory, prepared.deliveryBranch, false);
1180
1194
  if (preRebase.rebased) {
1181
- stream.operational?.(`Rebased onto ${prepared.projectBranch}`, 'git');
1182
- console.log(`[executor] Task ${task.id}: pre-merge rebase onto ${prepared.projectBranch} succeeded`);
1195
+ stream.operational?.(`Rebased onto ${prepared.deliveryBranch}`, 'git');
1196
+ console.log(`[executor] Task ${task.id}: pre-merge rebase onto ${prepared.deliveryBranch} succeeded`);
1183
1197
  }
1184
1198
  else if (!preRebase.skipped) {
1185
1199
  stream.operational?.('Rebase skipped (conflicts), retrying via merge', 'git');
@@ -1194,8 +1208,8 @@ export class TaskExecutor {
1194
1208
  const mergeLock = await this.branchLockManager.acquire(mergeLockKey, task.id);
1195
1209
  let mergeResult;
1196
1210
  try {
1197
- this.wsClient.sendTaskStatus(task.id, 'running', 96, 'Merging into project branch...');
1198
- mergeResult = await localMergeIntoProjectBranch(prepared.gitRoot, prepared.branchName, prepared.projectBranch, commitMessage, (msg) => stream.operational?.(msg, 'git'));
1211
+ this.wsClient.sendTaskStatus(task.id, 'running', 96, 'Merging into delivery branch...');
1212
+ mergeResult = await localMergeIntoDeliveryBranch(prepared.gitRoot, prepared.branchName, prepared.deliveryBranch, commitMessage, (msg) => stream.operational?.(msg, 'git'));
1199
1213
  }
1200
1214
  finally {
1201
1215
  mergeLock.release();
@@ -1204,11 +1218,11 @@ export class TaskExecutor {
1204
1218
  if (mergeResult.merged) {
1205
1219
  result.deliveryStatus = 'success';
1206
1220
  result.commitAfterSha = mergeResult.commitSha;
1207
- stream.operational?.(`Merged into ${prepared.projectBranch} (${mergeResult.commitSha?.slice(0, 7)})`, 'git');
1208
- console.log(`[executor] Task ${task.id}: merged into ${prepared.projectBranch} (${mergeResult.commitSha})`);
1209
- // Sync project worktree to reflect the merged changes on disk
1210
- if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
1211
- await syncProjectWorktree(prepared.projectWorktreePath, prepared.projectBranch, prepared.gitRoot);
1221
+ stream.operational?.(`Merged into ${prepared.deliveryBranch} (${mergeResult.commitSha?.slice(0, 7)})`, 'git');
1222
+ console.log(`[executor] Task ${task.id}: merged into ${prepared.deliveryBranch} (${mergeResult.commitSha})`);
1223
+ // Sync delivery worktree to reflect the merged changes on disk
1224
+ if (prepared.deliveryWorktreePath && prepared.deliveryBranch && prepared.gitRoot) {
1225
+ await syncDeliveryWorktree(prepared.deliveryWorktreePath, prepared.deliveryBranch, prepared.gitRoot);
1212
1226
  }
1213
1227
  break;
1214
1228
  }
@@ -1226,7 +1240,7 @@ export class TaskExecutor {
1226
1240
  // Resume agent session with conflict resolution instructions.
1227
1241
  // No merge lock held during this — agent may take minutes.
1228
1242
  try {
1229
- await adapter.resumeTask(task.id, buildConflictResolutionPrompt(mergeResult.conflictFiles ?? [], prepared.projectBranch, attempt, MAX_MERGE_ATTEMPTS), prepared.workingDirectory, taskContext.sessionId, stream, abortController.signal);
1243
+ await adapter.resumeTask(task.id, buildConflictResolutionPrompt(mergeResult.conflictFiles ?? [], prepared.deliveryBranch, attempt, MAX_MERGE_ATTEMPTS), prepared.workingDirectory, taskContext.sessionId, stream, abortController.signal);
1230
1244
  console.log(`[executor] Task ${task.id}: agent conflict resolution session completed (attempt ${attempt})`);
1231
1245
  }
1232
1246
  catch (resumeErr) {
@@ -1260,11 +1274,11 @@ export class TaskExecutor {
1260
1274
  }
1261
1275
  }
1262
1276
  }
1263
- else if (prepared.projectBranch) {
1264
- console.warn(`[executor] Task ${task.id}: projectBranch=${prepared.projectBranch} but gitRoot=${prepared.gitRoot}, branchName=${prepared.branchName} — skipping local merge`);
1277
+ else if (prepared.deliveryBranch) {
1278
+ console.warn(`[executor] Task ${task.id}: deliveryBranch=${prepared.deliveryBranch} but gitRoot=${prepared.gitRoot}, branchName=${prepared.branchName} — skipping local merge`);
1265
1279
  }
1266
1280
  else {
1267
- console.log(`[executor] Task ${task.id}: branch mode, committing locally (no project branch)`);
1281
+ console.log(`[executor] Task ${task.id}: branch mode, committing locally (no delivery branch)`);
1268
1282
  }
1269
1283
  }
1270
1284
  else if (deliveryMode === 'push') {
@@ -1301,7 +1315,7 @@ export class TaskExecutor {
1301
1315
  }
1302
1316
  }
1303
1317
  else {
1304
- // 'pr' — push + create PR, auto-merge into project branch if applicable
1318
+ // 'pr' — push + create PR, auto-merge into delivery branch if applicable
1305
1319
  stream.operational?.(`Creating PR: ${prepared.branchName} → ${prepared.baseBranch ?? 'main'}...`, 'git');
1306
1320
  this.wsClient.sendTaskStatus(task.id, 'running', 94, 'Rebasing before push...');
1307
1321
  // Pre-push rebase: if the target branch moved forward, rebase our task
@@ -1320,14 +1334,17 @@ export class TaskExecutor {
1320
1334
  }
1321
1335
  this.wsClient.sendTaskStatus(task.id, 'running', 95, 'Creating pull request...');
1322
1336
  console.log(`[executor] Task ${task.id}: pr mode, attempting PR creation for branch ${prepared.branchName}`);
1323
- const hasProjectBranch = !!task.projectBranch;
1337
+ // Singleton delivery branches create PRs directly to the base branch
1338
+ // (no auto-merge into an accumulation branch). Multi-task components
1339
+ // auto-merge per-task PRs into the delivery branch.
1340
+ const hasDeliveryBranch = !!(task.deliveryBranch ?? task.projectBranch) && !task.deliveryBranchIsSingleton;
1324
1341
  const prResult = await pushAndCreatePR(prepared.workingDirectory, {
1325
1342
  branchName: prepared.branchName,
1326
1343
  taskTitle: prTitle,
1327
1344
  taskDescription: task.description || task.prompt.slice(0, 500),
1328
1345
  body: prBody,
1329
1346
  baseBranch: prepared.baseBranch,
1330
- autoMerge: hasProjectBranch,
1347
+ autoMerge: hasDeliveryBranch,
1331
1348
  commitBeforeSha: prepared.commitBeforeSha,
1332
1349
  gitRoot: prepared.gitRoot,
1333
1350
  });
@@ -1348,7 +1365,7 @@ export class TaskExecutor {
1348
1365
  const prTaskContext = this.isResumableAdapter(adapter)
1349
1366
  ? adapter.getTaskContext(task.id)
1350
1367
  : null;
1351
- if (prTaskContext?.sessionId && this.isResumableAdapter(adapter) && hasProjectBranch && prepared.branchName && prepared.baseBranch && prResult.prNumber && prepared.gitRoot) {
1368
+ if (prTaskContext?.sessionId && this.isResumableAdapter(adapter) && hasDeliveryBranch && prepared.branchName && prepared.baseBranch && prResult.prNumber && prepared.gitRoot) {
1352
1369
  for (let attempt = 1; attempt <= MAX_PR_MERGE_ATTEMPTS; attempt++) {
1353
1370
  stream.operational?.(`Auto-merge failed — agent resolving conflict (attempt ${attempt})...`, 'git');
1354
1371
  console.log(`[executor] Task ${task.id}: PR auto-merge failed (attempt ${attempt}), resuming ${adapter.name} to resolve`);
@@ -1378,8 +1395,8 @@ export class TaskExecutor {
1378
1395
  result.deliveryStatus = 'success';
1379
1396
  prMergeResolved = true;
1380
1397
  console.log(`[executor] Task ${task.id}: PR merged on retry (attempt ${attempt}), commitAfterSha=${result.commitAfterSha}`);
1381
- if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
1382
- await syncProjectWorktree(prepared.projectWorktreePath, prepared.projectBranch, prepared.gitRoot);
1398
+ if (prepared.deliveryWorktreePath && prepared.deliveryBranch && prepared.gitRoot) {
1399
+ await syncDeliveryWorktree(prepared.deliveryWorktreePath, prepared.deliveryBranch, prepared.gitRoot);
1383
1400
  }
1384
1401
  break;
1385
1402
  }
@@ -1394,15 +1411,15 @@ export class TaskExecutor {
1394
1411
  if (!prMergeResolved && !result.deliveryError) {
1395
1412
  // No resume capability or not applicable — original failure
1396
1413
  result.deliveryStatus = 'failed';
1397
- result.deliveryError = 'PR created but auto-merge into project branch failed';
1414
+ result.deliveryError = 'PR created but auto-merge into delivery branch failed';
1398
1415
  console.error(`[executor] Task ${task.id}: PR created at ${prResult.prUrl} but auto-merge failed (no resume capability)`);
1399
1416
  }
1400
1417
  }
1401
1418
  else {
1402
1419
  result.deliveryStatus = 'success';
1403
1420
  console.log(`[executor] Task ${task.id}: PR created at ${prResult.prUrl}`);
1404
- if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
1405
- await syncProjectWorktree(prepared.projectWorktreePath, prepared.projectBranch, prepared.gitRoot);
1421
+ if (prepared.deliveryWorktreePath && prepared.deliveryBranch && prepared.gitRoot) {
1422
+ await syncDeliveryWorktree(prepared.deliveryWorktreePath, prepared.deliveryBranch, prepared.gitRoot);
1406
1423
  }
1407
1424
  }
1408
1425
  }
@@ -1629,7 +1646,26 @@ export class TaskExecutor {
1629
1646
  // Git worktree path — worktree creation must succeed or fail the task.
1630
1647
  // Running in the raw workdir without isolation risks cross-task commit
1631
1648
  // contamination and breaks PR creation.
1649
+ //
1650
+ // Singleton delivery branches: acquire a branch-level lock during worktree
1651
+ // creation to prevent concurrent dispatches (if server-side invariant fails)
1652
+ // from fighting over the same branch. The lock is held only during worktree
1653
+ // setup (seconds), not the entire task execution.
1654
+ let singletonLock;
1632
1655
  try {
1656
+ if (task.deliveryBranchIsSingleton && (task.deliveryBranch ?? task.projectBranch)) {
1657
+ // Use git root (not workingDirectory) for the lock key so that tasks
1658
+ // dispatched to different subdirectories of the same repo (e.g., repo/
1659
+ // and repo/subdir/) share the same lock for the same branch.
1660
+ const gitRoot = await getGitRoot(task.workingDirectory);
1661
+ if (!gitRoot) {
1662
+ throw new Error(`Cannot resolve git root for singleton branch lock (workdir: ${task.workingDirectory}). ` +
1663
+ `Singleton worktrees require a valid git repository.`);
1664
+ }
1665
+ const lockKey = BranchLockManager.computeLockKey(gitRoot, undefined, undefined, `singleton::${task.deliveryBranch ?? task.projectBranch}`);
1666
+ console.log(`[executor] Task ${task.id}: acquiring singleton branch lock: ${lockKey}`);
1667
+ singletonLock = await this.branchLockManager.acquire(lockKey, task.id);
1668
+ }
1633
1669
  console.log(`[executor] Task ${task.id}: creating worktree for workdir=${task.workingDirectory}`);
1634
1670
  const worktree = await createWorktree({
1635
1671
  workingDirectory: task.workingDirectory,
@@ -1641,7 +1677,8 @@ export class TaskExecutor {
1641
1677
  shortNodeId: task.shortNodeId,
1642
1678
  agentDir: task.agentDir,
1643
1679
  baseBranch: task.baseBranch,
1644
- projectBranch: task.projectBranch,
1680
+ deliveryBranch: task.deliveryBranch ?? task.projectBranch,
1681
+ deliveryBranchIsSingleton: task.deliveryBranchIsSingleton,
1645
1682
  stdout: stream.stdout,
1646
1683
  stderr: stream.stderr,
1647
1684
  operational: stream.operational,
@@ -1651,6 +1688,12 @@ export class TaskExecutor {
1651
1688
  throw new Error(`Worktree creation returned null for ${task.workingDirectory}. Cannot proceed without isolation.`);
1652
1689
  }
1653
1690
  console.log(`[executor] Task ${task.id}: worktree created at ${worktree.workingDirectory} (branch: ${worktree.branchName})`);
1691
+ // Track singleton delivery branches so cleanupTask won't destroy them.
1692
+ // Singleton branches are shared across the delivery lifecycle — deleting
1693
+ // them would orphan the delivery branch and its eventual PR.
1694
+ if (task.deliveryBranchIsSingleton && worktree.branchName) {
1695
+ this.singletonBranches.add(worktree.branchName);
1696
+ }
1654
1697
  return worktree;
1655
1698
  }
1656
1699
  catch (error) {
@@ -1659,6 +1702,9 @@ export class TaskExecutor {
1659
1702
  this.wsClient.sendTaskStatus(task.id, 'failed', 0, `Worktree setup failed: ${errorMsg}`);
1660
1703
  throw error;
1661
1704
  }
1705
+ finally {
1706
+ singletonLock?.release();
1707
+ }
1662
1708
  }
1663
1709
  /**
1664
1710
  * Run `git diff --numstat` after task execution to emit accurate file_change