@astroanywhere/agent 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.d.ts +0 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +0 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/branch-lock.d.ts +9 -7
  6. package/dist/lib/branch-lock.d.ts.map +1 -1
  7. package/dist/lib/branch-lock.js +9 -7
  8. package/dist/lib/branch-lock.js.map +1 -1
  9. package/dist/lib/display.d.ts +0 -1
  10. package/dist/lib/display.d.ts.map +1 -1
  11. package/dist/lib/display.js +0 -13
  12. package/dist/lib/display.js.map +1 -1
  13. package/dist/lib/ssh-installer.d.ts +0 -5
  14. package/dist/lib/ssh-installer.d.ts.map +1 -1
  15. package/dist/lib/ssh-installer.js +0 -13
  16. package/dist/lib/ssh-installer.js.map +1 -1
  17. package/dist/lib/task-executor.d.ts +0 -5
  18. package/dist/lib/task-executor.d.ts.map +1 -1
  19. package/dist/lib/task-executor.js +253 -81
  20. package/dist/lib/task-executor.js.map +1 -1
  21. package/dist/lib/worktree.d.ts +39 -0
  22. package/dist/lib/worktree.d.ts.map +1 -1
  23. package/dist/lib/worktree.js +136 -7
  24. package/dist/lib/worktree.js.map +1 -1
  25. package/dist/providers/index.d.ts +0 -4
  26. package/dist/providers/index.d.ts.map +1 -1
  27. package/dist/providers/index.js +0 -6
  28. package/dist/providers/index.js.map +1 -1
  29. package/package.json +1 -1
  30. package/dist/lib/prompt-templates.d.ts +0 -195
  31. package/dist/lib/prompt-templates.d.ts.map +0 -1
  32. package/dist/lib/prompt-templates.js +0 -354
  33. package/dist/lib/prompt-templates.js.map +0 -1
  34. package/dist/lib/repo-context.d.ts +0 -18
  35. package/dist/lib/repo-context.d.ts.map +0 -1
  36. package/dist/lib/repo-context.js +0 -128
  37. package/dist/lib/repo-context.js.map +0 -1
  38. package/dist/lib/streaming-prompt.d.ts +0 -48
  39. package/dist/lib/streaming-prompt.d.ts.map +0 -1
  40. package/dist/lib/streaming-prompt.js +0 -91
  41. package/dist/lib/streaming-prompt.js.map +0 -1
  42. package/dist/providers/slurm-adapter.d.ts +0 -26
  43. package/dist/providers/slurm-adapter.d.ts.map +0 -1
  44. package/dist/providers/slurm-adapter.js +0 -146
  45. package/dist/providers/slurm-adapter.js.map +0 -1
@@ -13,12 +13,106 @@ import { CodexAdapter } from '../providers/codex-adapter.js';
13
13
  import { OpenClawAdapter } from '../providers/openclaw-adapter.js';
14
14
  import { OpenCodeAdapter } from '../providers/opencode-adapter.js';
15
15
  import { SlurmJobMonitor } from './slurm-job-monitor.js';
16
- import { createWorktree } from './worktree.js';
16
+ import { createWorktree, syncProjectWorktree } from './worktree.js';
17
17
  import { BranchLockManager } from './branch-lock.js';
18
- import { pushAndCreatePR } from './git-pr.js';
18
+ import { pushAndCreatePR, mergePullRequest, getRemoteBranchSha } from './git-pr.js';
19
19
  import { localMergeIntoProjectBranch } from './local-merge.js';
20
20
  import { checkWorkdirSafety, isGitAvailable, isGitRepo, isUntrackedInParentRepo, createSandbox, WorkdirSafetyTier, } from './workdir-safety.js';
21
21
  const execFileAsync = promisify(execFileCb);
22
+ /**
23
+ * Sanitize a git ref name for embedding in prompt shell commands.
24
+ * Strips characters that could enable command injection (;, $, `, |, &, etc.)
25
+ * while preserving valid git ref characters (alphanumeric, /, -, _, .).
26
+ */
27
+ function sanitizeGitRef(ref) {
28
+ return ref.replace(/[^a-zA-Z0-9/_.\-]/g, '');
29
+ }
30
+ /**
31
+ * Build a prompt instructing the agent to resolve merge conflicts.
32
+ * The agent's worktree has the task branch checked out; it needs to
33
+ * rebase onto the project branch to resolve conflicts, then commit.
34
+ */
35
+ function buildConflictResolutionPrompt(conflictFiles, projectBranch, attempt, maxAttempts) {
36
+ const safeBranch = sanitizeGitRef(projectBranch);
37
+ const fileList = conflictFiles.map(f => `- ${f}`).join('\n');
38
+ return `MERGE CONFLICT DETECTED (attempt ${attempt}/${maxAttempts})
39
+
40
+ Your task branch cannot be cleanly merged into the project branch because
41
+ parallel tasks have modified overlapping files since you branched.
42
+
43
+ Conflicting files:
44
+ ${fileList}
45
+
46
+ The project branch is: ${safeBranch}
47
+
48
+ Please resolve this:
49
+ 1. Fetch the latest project branch: git fetch origin 2>/dev/null; git fetch . ${safeBranch}:${safeBranch} 2>/dev/null || true
50
+ 2. Rebase onto the project branch: git rebase ${safeBranch}
51
+ 3. For each conflict, open the file, resolve the conflict markers (<<<<<<< / ======= / >>>>>>>), keeping the correct combination of both changes
52
+ 4. Stage resolved files: git add <resolved-files>
53
+ 5. Continue the rebase: git rebase --continue
54
+ 6. Verify your changes still work (run a quick build/test if applicable)
55
+
56
+ IMPORTANT: Do NOT create a merge commit. Use rebase so the merge will be clean.
57
+ After you finish resolving, I will automatically retry the merge.`;
58
+ }
59
+ /**
60
+ * Build a prompt for PR mode conflict resolution.
61
+ * Similar to local mode, but the agent must also force-push after rebasing
62
+ * because the merge happens via GitHub API (gh pr merge), not locally.
63
+ */
64
+ function buildPRConflictResolutionPrompt(projectBranch, branchName, attempt, maxAttempts) {
65
+ const safeBranch = sanitizeGitRef(projectBranch);
66
+ const safeTaskBranch = sanitizeGitRef(branchName);
67
+ return `MERGE CONFLICT DETECTED ON GITHUB (attempt ${attempt}/${maxAttempts})
68
+
69
+ Your pull request cannot be automatically merged into the project branch because
70
+ parallel tasks have modified overlapping files.
71
+
72
+ Your task branch is: ${safeTaskBranch}
73
+ The target branch is: ${safeBranch}
74
+
75
+ Please resolve this:
76
+ 1. Fetch the latest target branch: git fetch origin ${safeBranch}
77
+ 2. Rebase onto the target branch: git rebase origin/${safeBranch}
78
+ 3. For each conflict, open the file, resolve the conflict markers (<<<<<<< / ======= / >>>>>>>), keeping the correct combination of both changes
79
+ 4. Stage resolved files: git add <resolved-files>
80
+ 5. Continue the rebase: git rebase --continue
81
+ 6. Verify your changes still work (run a quick build/test if applicable)
82
+ 7. Force-push the rebased branch: git push --force-with-lease origin ${safeTaskBranch}
83
+
84
+ IMPORTANT: Do NOT create a merge commit. Use rebase so the history is clean.
85
+ After you force-push, I will automatically retry the GitHub merge.`;
86
+ }
87
+ /**
88
+ * Best-effort pre-merge rebase: if the project branch has moved forward
89
+ * (another task merged), rebase the task branch onto the latest tip before
90
+ * attempting the squash merge. This avoids conflicts in the common case
91
+ * where changes don't overlap. On any failure, silently aborts — the
92
+ * existing merge retry loop handles real conflicts.
93
+ */
94
+ async function tryPreMergeRebase(workdir, targetBranch, isRemote) {
95
+ try {
96
+ const rebaseTarget = isRemote ? `origin/${targetBranch}` : targetBranch;
97
+ if (isRemote) {
98
+ await execFileAsync('git', ['fetch', 'origin', targetBranch], { cwd: workdir, timeout: 30_000 });
99
+ }
100
+ // Check if rebase is needed (target branch moved since we branched)
101
+ const { stdout: mergeBase } = await execFileAsync('git', ['merge-base', 'HEAD', rebaseTarget], { cwd: workdir });
102
+ const { stdout: targetTip } = await execFileAsync('git', ['rev-parse', rebaseTarget], { cwd: workdir });
103
+ if (mergeBase.trim() === targetTip.trim()) {
104
+ return { rebased: false, skipped: true }; // Already up to date
105
+ }
106
+ // Try automatic rebase — timeout after 60s (should be fast for non-conflicting changes)
107
+ await execFileAsync('git', ['rebase', rebaseTarget], { cwd: workdir, timeout: 60_000 });
108
+ return { rebased: true };
109
+ }
110
+ catch {
111
+ // Abort on failure — existing retry loop will handle conflicts
112
+ await execFileAsync('git', ['rebase', '--abort'], { cwd: workdir }).catch(() => { });
113
+ return { rebased: false };
114
+ }
115
+ }
22
116
  export class TaskExecutor {
23
117
  wsClient;
24
118
  runningTasks = new Map();
@@ -691,24 +785,6 @@ export class TaskExecutor {
691
785
  const tasks = this.tasksByDirectory.get(workdir);
692
786
  return tasks ? tasks.size : 0;
693
787
  }
694
- /**
695
- * Determine whether a task will create a git worktree.
696
- * Used to decide if the branch lock should be acquired.
697
- */
698
- taskWillCreateWorktree(task) {
699
- const isTextOnly = task.type === 'summarize' || task.type === 'chat' || task.type === 'plan';
700
- if (isTextOnly)
701
- return false;
702
- if (task.useWorktree === false)
703
- return false;
704
- if (!this.useWorktree)
705
- return false;
706
- if (task.deliveryMode === 'direct')
707
- return false;
708
- if (task.deliveryMode === 'copy')
709
- return false;
710
- return true;
711
- }
712
788
  async executeTask(task, useSandbox = false) {
713
789
  console.log(`[executor] Task ${task.id}: workingDirectory=${task.workingDirectory} sandbox=${useSandbox}`);
714
790
  // Setup sandbox if requested
@@ -802,27 +878,6 @@ export class TaskExecutor {
802
878
  };
803
879
  this.runningTasks.set(normalizedTask.id, runningTask);
804
880
  this.wsClient.addActiveTask(normalizedTask.id);
805
- // Acquire per-branch lock to prevent concurrent worktree creation on the same branch.
806
- // Tasks sharing a branch (same project) serialize; different branches run in parallel.
807
- let branchLock;
808
- if (this.taskWillCreateWorktree(normalizedTask)) {
809
- const lockKey = BranchLockManager.computeLockKey(normalizedTask.workingDirectory, normalizedTask.shortProjectId, normalizedTask.shortNodeId, normalizedTask.id);
810
- const queueLength = this.branchLockManager.getQueueLength(lockKey);
811
- if (queueLength > 0 || this.branchLockManager.isLocked(lockKey)) {
812
- console.log(`[executor] Task ${task.id}: waiting for branch lock (${queueLength} ahead)`);
813
- this.wsClient.sendTaskStatus(normalizedTask.id, 'running', 0, 'Waiting for branch lock...');
814
- }
815
- branchLock = await this.branchLockManager.acquire(lockKey, normalizedTask.id);
816
- // If task was cancelled while waiting for the lock, bail out early
817
- if (abortController.signal.aborted) {
818
- branchLock.release();
819
- this.runningTasks.delete(normalizedTask.id);
820
- this.wsClient.removeActiveTask(normalizedTask.id);
821
- this.untrackTaskDirectory(task);
822
- this.processQueue();
823
- return;
824
- }
825
- }
826
881
  // Text-only tasks (plan/chat/summarize) without a working directory skip workspace prep
827
882
  const isTextOnly = normalizedTask.type === 'summarize' || normalizedTask.type === 'chat' || normalizedTask.type === 'plan';
828
883
  let prepared;
@@ -832,12 +887,6 @@ export class TaskExecutor {
832
887
  : await this.prepareTaskWorkspace(normalizedTask, stream);
833
888
  }
834
889
  catch (prepErr) {
835
- // Release branch lock on workspace preparation failure to avoid deadlocking
836
- // subsequent tasks in the same project (fix from PR #26).
837
- // Note: processQueue() is NOT called here — the finally block in the outer
838
- // try-catch handles queue draining for all exit paths, avoiding double-dequeue.
839
- if (branchLock)
840
- branchLock.release();
841
890
  this.runningTasks.delete(normalizedTask.id);
842
891
  this.wsClient.removeActiveTask(normalizedTask.id);
843
892
  this.untrackTaskDirectory(task);
@@ -937,33 +986,99 @@ export class TaskExecutor {
937
986
  console.log(`[executor] Task ${task.id}: copy mode, worktree preserved at ${prepared.workingDirectory}`);
938
987
  }
939
988
  else if (deliveryMode === 'branch') {
940
- // Branch mode: commit locally, merge into project branch if available
989
+ // Branch mode: commit locally, merge into project branch if available.
990
+ // The merge lock is held only during the squash-merge (seconds, not minutes),
991
+ // allowing tasks to execute in parallel. The squash merge naturally handles
992
+ // the case where the project branch moved forward (another task merged first)
993
+ // because it computes the diff from the merge-base and applies it on the
994
+ // current project branch tip.
995
+ //
996
+ // On conflict: if the provider supports session resume (Claude SDK), we
997
+ // resume the agent session to let it resolve the conflict, then retry.
941
998
  result.branchName = prepared.branchName;
942
999
  keepBranch = true;
943
1000
  if (prepared.gitRoot && prepared.projectBranch && prepared.branchName) {
944
- // Local merge: squash-merge task branch into project accumulation branch
945
- this.wsClient.sendTaskStatus(task.id, 'running', 95, 'Merging into project branch...');
946
- console.log(`[executor] Task ${task.id}: branch mode, merging ${prepared.branchName} ${prepared.projectBranch}`);
947
- const mergeResult = await localMergeIntoProjectBranch(prepared.gitRoot, prepared.branchName, prepared.projectBranch, `[${task.shortProjectId ?? 'astro'}/${task.shortNodeId ?? task.id.slice(0, 6)}] ${rawTitle}`);
948
- if (mergeResult.merged) {
949
- result.deliveryStatus = 'success';
950
- result.commitAfterSha = mergeResult.commitSha;
951
- console.log(`[executor] Task ${task.id}: merged into ${prepared.projectBranch} (${mergeResult.commitSha})`);
1001
+ // Pre-merge rebase: if the project branch moved forward (another task
1002
+ // merged), rebase our task branch first. Avoids conflicts when changes
1003
+ // don't overlap, and saves one retry cycle when they do.
1004
+ const preRebase = await tryPreMergeRebase(prepared.workingDirectory, prepared.projectBranch, false);
1005
+ if (preRebase.rebased) {
1006
+ console.log(`[executor] Task ${task.id}: pre-merge rebase onto ${prepared.projectBranch} succeeded`);
952
1007
  }
953
- else if (mergeResult.conflict) {
954
- result.deliveryStatus = 'failed';
955
- result.deliveryError = `Merge conflict in: ${mergeResult.conflictFiles?.join(', ')}`;
956
- console.error(`[executor] Task ${task.id}: merge conflict — ${mergeResult.conflictFiles?.join(', ')}`);
1008
+ else if (!preRebase.skipped) {
1009
+ console.log(`[executor] Task ${task.id}: pre-merge rebase had conflicts, falling back to merge retry loop`);
957
1010
  }
958
- else if (mergeResult.error) {
959
- result.deliveryStatus = 'failed';
960
- result.deliveryError = mergeResult.error;
961
- console.error(`[executor] Task ${task.id}: merge failed ${mergeResult.error}`);
962
- }
963
- else {
964
- // No changes to merge
965
- result.deliveryStatus = 'skipped';
966
- console.log(`[executor] Task ${task.id}: no changes to merge`);
1011
+ const mergeLockKey = BranchLockManager.computeLockKey(prepared.gitRoot, task.shortProjectId, task.shortNodeId, task.id);
1012
+ const commitMessage = `[${task.shortProjectId ?? 'astro'}/${task.shortNodeId ?? task.id.slice(0, 6)}] ${rawTitle}`;
1013
+ const MAX_MERGE_ATTEMPTS = 3;
1014
+ for (let attempt = 1; attempt <= MAX_MERGE_ATTEMPTS; attempt++) {
1015
+ // Acquire merge lock — held only during the squash-merge (seconds).
1016
+ this.wsClient.sendTaskStatus(task.id, 'running', 95, attempt === 1 ? 'Waiting for merge lock...' : `Retrying merge (attempt ${attempt}/${MAX_MERGE_ATTEMPTS})...`);
1017
+ const mergeLock = await this.branchLockManager.acquire(mergeLockKey, task.id);
1018
+ let mergeResult;
1019
+ try {
1020
+ this.wsClient.sendTaskStatus(task.id, 'running', 96, 'Merging into project branch...');
1021
+ mergeResult = await localMergeIntoProjectBranch(prepared.gitRoot, prepared.branchName, prepared.projectBranch, commitMessage);
1022
+ }
1023
+ finally {
1024
+ mergeLock.release();
1025
+ console.log(`[executor] Task ${task.id}: merge lock released (attempt ${attempt})`);
1026
+ }
1027
+ if (mergeResult.merged) {
1028
+ result.deliveryStatus = 'success';
1029
+ result.commitAfterSha = mergeResult.commitSha;
1030
+ console.log(`[executor] Task ${task.id}: merged into ${prepared.projectBranch} (${mergeResult.commitSha})`);
1031
+ // Sync project worktree to reflect the merged changes on disk
1032
+ if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
1033
+ await syncProjectWorktree(prepared.projectWorktreePath, prepared.projectBranch, prepared.gitRoot);
1034
+ }
1035
+ break;
1036
+ }
1037
+ else if (mergeResult.conflict) {
1038
+ // Can the agent resolve this? Check if adapter supports session resume.
1039
+ // Get context once; isResumableAdapter narrows adapter type for resumeTask().
1040
+ const taskContext = this.isResumableAdapter(adapter)
1041
+ ? adapter.getTaskContext(task.id)
1042
+ : null;
1043
+ if (taskContext?.sessionId && this.isResumableAdapter(adapter) && attempt < MAX_MERGE_ATTEMPTS) {
1044
+ const conflictFiles = mergeResult.conflictFiles?.join(', ') ?? 'unknown files';
1045
+ console.log(`[executor] Task ${task.id}: merge conflict (attempt ${attempt}), resuming ${adapter.name} to resolve: ${conflictFiles}`);
1046
+ this.wsClient.sendTaskStatus(task.id, 'running', 97, `Merge conflict — agent resolving (attempt ${attempt})...`);
1047
+ // Resume agent session with conflict resolution instructions.
1048
+ // No merge lock held during this — agent may take minutes.
1049
+ try {
1050
+ await adapter.resumeTask(task.id, buildConflictResolutionPrompt(mergeResult.conflictFiles ?? [], prepared.projectBranch, attempt, MAX_MERGE_ATTEMPTS), prepared.workingDirectory, taskContext.sessionId, stream, abortController.signal);
1051
+ console.log(`[executor] Task ${task.id}: agent conflict resolution session completed (attempt ${attempt})`);
1052
+ }
1053
+ catch (resumeErr) {
1054
+ console.error(`[executor] Task ${task.id}: agent conflict resolution failed: ${resumeErr instanceof Error ? resumeErr.message : resumeErr}`);
1055
+ result.deliveryStatus = 'failed';
1056
+ result.deliveryError = `Merge conflict in: ${mergeResult.conflictFiles?.join(', ') ?? 'unknown files'}. Agent resolution failed: ${resumeErr instanceof Error ? resumeErr.message : resumeErr}`;
1057
+ break;
1058
+ }
1059
+ // Loop continues — will retry merge
1060
+ continue;
1061
+ }
1062
+ // No resume capability or final attempt — fail
1063
+ result.deliveryStatus = 'failed';
1064
+ result.deliveryError = attempt > 1
1065
+ ? `Merge conflict unresolved after ${attempt} attempts: ${mergeResult.conflictFiles?.join(', ')}`
1066
+ : `Merge conflict in: ${mergeResult.conflictFiles?.join(', ')}`;
1067
+ console.error(`[executor] Task ${task.id}: merge conflict — ${result.deliveryError}`);
1068
+ break;
1069
+ }
1070
+ else if (mergeResult.error) {
1071
+ result.deliveryStatus = 'failed';
1072
+ result.deliveryError = mergeResult.error;
1073
+ console.error(`[executor] Task ${task.id}: merge failed — ${mergeResult.error}`);
1074
+ break;
1075
+ }
1076
+ else {
1077
+ // No changes to merge
1078
+ result.deliveryStatus = 'skipped';
1079
+ console.log(`[executor] Task ${task.id}: no changes to merge`);
1080
+ break;
1081
+ }
967
1082
  }
968
1083
  }
969
1084
  else if (prepared.projectBranch) {
@@ -1003,6 +1118,19 @@ export class TaskExecutor {
1003
1118
  }
1004
1119
  else {
1005
1120
  // 'pr' — push + create PR, auto-merge into project branch if applicable
1121
+ this.wsClient.sendTaskStatus(task.id, 'running', 94, 'Rebasing before push...');
1122
+ // Pre-push rebase: if the target branch moved forward, rebase our task
1123
+ // branch so the PR will be cleanly mergeable. The branch hasn't been
1124
+ // pushed yet, so no force-push is needed.
1125
+ if (prepared.baseBranch) {
1126
+ const preRebase = await tryPreMergeRebase(prepared.workingDirectory, prepared.baseBranch, true);
1127
+ if (preRebase.rebased) {
1128
+ console.log(`[executor] Task ${task.id}: pre-push rebase onto origin/${prepared.baseBranch} succeeded`);
1129
+ }
1130
+ else if (!preRebase.skipped) {
1131
+ console.log(`[executor] Task ${task.id}: pre-push rebase had conflicts, proceeding without rebase`);
1132
+ }
1133
+ }
1006
1134
  this.wsClient.sendTaskStatus(task.id, 'running', 95, 'Creating pull request...');
1007
1135
  console.log(`[executor] Task ${task.id}: pr mode, attempting PR creation for branch ${prepared.branchName}`);
1008
1136
  const hasProjectBranch = !!task.projectBranch;
@@ -1023,15 +1151,66 @@ export class TaskExecutor {
1023
1151
  result.commitAfterSha = prResult.commitAfterSha;
1024
1152
  keepBranch = true;
1025
1153
  if (prResult.autoMergeFailed) {
1026
- // PR was created but auto-merge into project branch failed —
1027
- // subsequent tasks won't see this task's changes
1028
- result.deliveryStatus = 'failed';
1029
- result.deliveryError = 'PR created but auto-merge into project branch failed';
1030
- console.error(`[executor] Task ${task.id}: PR created at ${prResult.prUrl} but auto-merge failed`);
1154
+ // PR was created but auto-merge failed (likely conflict).
1155
+ // If the adapter supports session resume, ask the agent to
1156
+ // rebase and force-push, then retry the GitHub merge.
1157
+ const MAX_PR_MERGE_ATTEMPTS = 3;
1158
+ let prMergeResolved = false;
1159
+ const prTaskContext = this.isResumableAdapter(adapter)
1160
+ ? adapter.getTaskContext(task.id)
1161
+ : null;
1162
+ if (prTaskContext?.sessionId && this.isResumableAdapter(adapter) && hasProjectBranch && prepared.branchName && prepared.baseBranch && prResult.prNumber && prepared.gitRoot) {
1163
+ for (let attempt = 1; attempt <= MAX_PR_MERGE_ATTEMPTS; attempt++) {
1164
+ console.log(`[executor] Task ${task.id}: PR auto-merge failed (attempt ${attempt}), resuming ${adapter.name} to resolve`);
1165
+ this.wsClient.sendTaskStatus(task.id, 'running', 97, `PR merge conflict — agent resolving (attempt ${attempt})...`);
1166
+ // Resume agent session — agent rebases and force-pushes.
1167
+ try {
1168
+ await adapter.resumeTask(task.id, buildPRConflictResolutionPrompt(prepared.baseBranch, prepared.branchName, attempt, MAX_PR_MERGE_ATTEMPTS), prepared.workingDirectory, prTaskContext.sessionId, stream, abortController.signal);
1169
+ console.log(`[executor] Task ${task.id}: agent PR conflict resolution completed (attempt ${attempt})`);
1170
+ }
1171
+ catch (resumeErr) {
1172
+ console.error(`[executor] Task ${task.id}: agent PR conflict resolution failed: ${resumeErr instanceof Error ? resumeErr.message : resumeErr}`);
1173
+ result.deliveryStatus = 'failed';
1174
+ result.deliveryError = `PR created but auto-merge failed. Agent resolution failed: ${resumeErr instanceof Error ? resumeErr.message : resumeErr}`;
1175
+ break;
1176
+ }
1177
+ // Retry the GitHub merge
1178
+ this.wsClient.sendTaskStatus(task.id, 'running', 98, `Retrying PR merge (attempt ${attempt})...`);
1179
+ const retryMerge = await mergePullRequest(prepared.workingDirectory, prResult.prNumber, {
1180
+ method: 'squash',
1181
+ deleteBranch: true,
1182
+ });
1183
+ if (retryMerge.ok) {
1184
+ result.commitAfterSha = await getRemoteBranchSha(prepared.gitRoot, prepared.baseBranch) ?? undefined;
1185
+ result.deliveryStatus = 'success';
1186
+ prMergeResolved = true;
1187
+ console.log(`[executor] Task ${task.id}: PR merged on retry (attempt ${attempt}), commitAfterSha=${result.commitAfterSha}`);
1188
+ if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
1189
+ await syncProjectWorktree(prepared.projectWorktreePath, prepared.projectBranch, prepared.gitRoot);
1190
+ }
1191
+ break;
1192
+ }
1193
+ if (attempt === MAX_PR_MERGE_ATTEMPTS) {
1194
+ result.deliveryStatus = 'failed';
1195
+ result.deliveryError = `PR created but auto-merge failed after ${attempt} attempts: ${retryMerge.error ?? 'merge conflict'}`;
1196
+ console.error(`[executor] Task ${task.id}: PR merge failed after ${attempt} attempts`);
1197
+ }
1198
+ // Loop continues — agent will try again
1199
+ }
1200
+ }
1201
+ if (!prMergeResolved && !result.deliveryError) {
1202
+ // No resume capability or not applicable — original failure
1203
+ result.deliveryStatus = 'failed';
1204
+ result.deliveryError = 'PR created but auto-merge into project branch failed';
1205
+ console.error(`[executor] Task ${task.id}: PR created at ${prResult.prUrl} but auto-merge failed (no resume capability)`);
1206
+ }
1031
1207
  }
1032
1208
  else {
1033
1209
  result.deliveryStatus = 'success';
1034
1210
  console.log(`[executor] Task ${task.id}: PR created at ${prResult.prUrl}`);
1211
+ if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
1212
+ await syncProjectWorktree(prepared.projectWorktreePath, prepared.projectBranch, prepared.gitRoot);
1213
+ }
1035
1214
  }
1036
1215
  }
1037
1216
  else if (prResult.error) {
@@ -1127,13 +1306,6 @@ export class TaskExecutor {
1127
1306
  console.log(`[executor] Task ${task.id}: cleaning up sandbox`);
1128
1307
  await sandbox.cleanup();
1129
1308
  }
1130
- // Release branch lock after execution + delivery (auto-merge) completes.
1131
- // The accumulative model requires the lock to be held through auto-merge
1132
- // so the next task branches from the updated project branch tip.
1133
- if (branchLock) {
1134
- branchLock.release();
1135
- console.log(`[executor] Task ${task.id}: branch lock released after delivery`);
1136
- }
1137
1309
  // Untrack task from directory
1138
1310
  this.untrackTaskDirectory(task);
1139
1311
  this.runningTasks.delete(task.id);