@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.
- package/dist/lib/branch-lock.d.ts +3 -3
- package/dist/lib/branch-lock.js +3 -3
- package/dist/lib/git-pr.d.ts +11 -11
- package/dist/lib/git-pr.d.ts.map +1 -1
- package/dist/lib/git-pr.js +17 -17
- package/dist/lib/git-pr.js.map +1 -1
- package/dist/lib/local-merge.d.ts +3 -3
- package/dist/lib/local-merge.d.ts.map +1 -1
- package/dist/lib/local-merge.js +9 -9
- package/dist/lib/local-merge.js.map +1 -1
- package/dist/lib/task-executor.d.ts +2 -0
- package/dist/lib/task-executor.d.ts.map +1 -1
- package/dist/lib/task-executor.js +108 -62
- package/dist/lib/task-executor.js.map +1 -1
- package/dist/lib/worktree.d.ts +27 -22
- package/dist/lib/worktree.d.ts.map +1 -1
- package/dist/lib/worktree.js +200 -116
- package/dist/lib/worktree.js.map +1 -1
- package/dist/types.d.ts +11 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -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,
|
|
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 {
|
|
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
|
|
35
|
+
* rebase onto the delivery branch to resolve conflicts, then commit.
|
|
36
36
|
*/
|
|
37
|
-
function buildConflictResolutionPrompt(conflictFiles,
|
|
38
|
-
const safeBranch = sanitizeGitRef(
|
|
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
|
|
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
|
|
48
|
+
The delivery branch is: ${safeBranch}
|
|
49
49
|
|
|
50
50
|
Please resolve this:
|
|
51
|
-
1. Fetch the latest
|
|
52
|
-
2. Rebase onto the
|
|
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(
|
|
67
|
-
const safeBranch = sanitizeGitRef(
|
|
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
|
|
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
|
|
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
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1164
|
-
// Branch mode: commit locally, merge into
|
|
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
|
|
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
|
|
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.
|
|
1176
|
-
// Pre-merge rebase: if the
|
|
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.
|
|
1193
|
+
const preRebase = await tryPreMergeRebase(prepared.workingDirectory, prepared.deliveryBranch, false);
|
|
1180
1194
|
if (preRebase.rebased) {
|
|
1181
|
-
stream.operational?.(`Rebased onto ${prepared.
|
|
1182
|
-
console.log(`[executor] Task ${task.id}: pre-merge rebase onto ${prepared.
|
|
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
|
|
1198
|
-
mergeResult = await
|
|
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.
|
|
1208
|
-
console.log(`[executor] Task ${task.id}: merged into ${prepared.
|
|
1209
|
-
// Sync
|
|
1210
|
-
if (prepared.
|
|
1211
|
-
await
|
|
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.
|
|
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.
|
|
1264
|
-
console.warn(`[executor] Task ${task.id}:
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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) &&
|
|
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.
|
|
1382
|
-
await
|
|
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
|
|
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.
|
|
1405
|
-
await
|
|
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
|
-
|
|
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
|