@astroanywhere/agent 0.3.1 → 0.3.3

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.
@@ -40,6 +40,8 @@ export declare class TaskExecutor {
40
40
  private branchLockManager;
41
41
  /** Per-directory lock for serializing tasks on non-git directories (no worktree isolation). */
42
42
  private directoryLockManager;
43
+ /** Tasks claimed by submitTask() but not yet in runningTasks (closes TOCTOU dedup gap). */
44
+ private claimedTasks;
43
45
  private openclawBridge;
44
46
  private tasksByDirectory;
45
47
  private pendingSafetyChecks;
@@ -1 +1 @@
1
- {"version":3,"file":"task-executor.d.ts","sourceRoot":"","sources":["../../src/lib/task-executor.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"task-executor.d.ts","sourceRoot":"","sources":["../../src/lib/task-executor.ts"],"names":[],"mappings":"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"}
@@ -154,6 +154,8 @@ export class TaskExecutor {
154
154
  branchLockManager = new BranchLockManager();
155
155
  /** Per-directory lock for serializing tasks on non-git directories (no worktree isolation). */
156
156
  directoryLockManager = new BranchLockManager();
157
+ /** Tasks claimed by submitTask() but not yet in runningTasks (closes TOCTOU dedup gap). */
158
+ claimedTasks = new Set();
157
159
  openclawBridge = null;
158
160
  // Safety tracking
159
161
  tasksByDirectory = new Map(); // workdir -> taskIds
@@ -201,111 +203,126 @@ export class TaskExecutor {
201
203
  * Submit a task for execution (with safety checks)
202
204
  */
203
205
  async submitTask(task) {
204
- // Skip workingDirectory resolution for lightweight text-only tasks (no file system access)
205
- const isTextOnlyTask = task.type === 'summarize' || task.type === 'chat' || task.type === 'plan';
206
- // Text-only tasks (plan/chat/summarize) can run without a working directory.
207
- // For all others, resolve the directory or auto-provision one.
208
- let resolvedWorkDir;
209
- if (isTextOnlyTask && !task.workingDirectory) {
210
- resolvedWorkDir = undefined;
206
+ // Dedup: reject if this task is already running OR claimed by a concurrent submitTask().
207
+ // runningTasks is only populated inside executeTask() (line ~1079), but submitTask()
208
+ // does async work (safety checks, git detection) before reaching executeTask(). Without
209
+ // claimedTasks, two rapid dispatches for the same taskId could both pass the runningTasks
210
+ // check and execute concurrently (TOCTOU race).
211
+ if (this.runningTasks.has(task.id) || this.claimedTasks.has(task.id)) {
212
+ console.warn(`[executor] Task ${task.id}: already running or claimed, skipping duplicate dispatch`);
213
+ return;
211
214
  }
212
- else {
213
- try {
214
- resolvedWorkDir = resolveWorkingDirectory(task.workingDirectory, task.projectId);
215
+ this.claimedTasks.add(task.id);
216
+ try {
217
+ // Skip workingDirectory resolution for lightweight text-only tasks (no file system access)
218
+ const isTextOnlyTask = task.type === 'summarize' || task.type === 'chat' || task.type === 'plan';
219
+ // Text-only tasks (plan/chat/summarize) can run without a working directory.
220
+ // For all others, resolve the directory or auto-provision one.
221
+ let resolvedWorkDir;
222
+ if (isTextOnlyTask && !task.workingDirectory) {
223
+ resolvedWorkDir = undefined;
215
224
  }
216
- catch (err) {
217
- // Fail fast with a clear error instead of entering the execution pipeline.
218
- // Without this, tasks with non-existent directories become dead jobs.
219
- const errorMsg = err instanceof Error ? err.message : String(err);
220
- console.error(`[executor] Task ${task.id}: ${errorMsg}`);
221
- this.wsClient.sendTaskResult({
222
- taskId: task.id,
223
- status: 'failed',
224
- error: errorMsg,
225
- completedAt: new Date().toISOString(),
226
- });
227
- this.wsClient.removeActiveTask(task.id);
225
+ else {
226
+ try {
227
+ resolvedWorkDir = resolveWorkingDirectory(task.workingDirectory, task.projectId);
228
+ }
229
+ catch (err) {
230
+ // Fail fast with a clear error instead of entering the execution pipeline.
231
+ // Without this, tasks with non-existent directories become dead jobs.
232
+ const errorMsg = err instanceof Error ? err.message : String(err);
233
+ console.error(`[executor] Task ${task.id}: ${errorMsg}`);
234
+ this.wsClient.sendTaskResult({
235
+ taskId: task.id,
236
+ status: 'failed',
237
+ error: errorMsg,
238
+ completedAt: new Date().toISOString(),
239
+ });
240
+ this.wsClient.removeActiveTask(task.id);
241
+ return;
242
+ }
243
+ }
244
+ const normalizedTask = {
245
+ ...task,
246
+ workingDirectory: resolvedWorkDir ?? '',
247
+ };
248
+ // Determine if worktree isolation will be used for this task.
249
+ // Check git availability early: non-git directories cannot use git worktrees,
250
+ // so willUseWorktree must be false for them. Without this, the safety check
251
+ // thinks worktree isolation is active and allows parallel execution, but
252
+ // prepareTaskWorkspace() later falls back to direct in-place execution —
253
+ // causing file conflicts when multiple tasks run on the same non-git directory.
254
+ const isGitDir = !isTextOnlyTask && normalizedTask.workingDirectory && this.gitAvailable
255
+ ? await isGitRepo(normalizedTask.workingDirectory)
256
+ : false;
257
+ const willUseWorktree = this.useWorktree
258
+ && normalizedTask.useWorktree !== false
259
+ && normalizedTask.deliveryMode !== 'direct'
260
+ && (isGitDir || normalizedTask.deliveryMode === 'copy');
261
+ if (!isTextOnlyTask && task.skipSafetyCheck) {
262
+ // Server already approved safety for this directory — skip the prompt.
263
+ // Only init git when the original safety decision was 'init-git'.
264
+ // When safetyDecision is 'proceed' (user chose non-git direct execution)
265
+ // or undefined (builtin template, text-only, etc.), skip git init entirely.
266
+ if (normalizedTask.safetyDecision === 'init-git') {
267
+ const needsGitInit = this.gitAvailable && !(await isGitRepo(normalizedTask.workingDirectory));
268
+ if (needsGitInit) {
269
+ await initializeGit(normalizedTask.workingDirectory);
270
+ }
271
+ }
272
+ this.trackTaskDirectory(normalizedTask);
273
+ if (this.runningTasks.size < this.maxConcurrentTasks) {
274
+ await this.executeTask(normalizedTask, false);
275
+ }
276
+ else {
277
+ this.taskQueue.push(normalizedTask);
278
+ this.wsClient.sendTaskStatus(normalizedTask.id, 'queued', 0, 'Waiting for available slot');
279
+ }
228
280
  return;
229
281
  }
230
- }
231
- const normalizedTask = {
232
- ...task,
233
- workingDirectory: resolvedWorkDir ?? '',
234
- };
235
- // Determine if worktree isolation will be used for this task.
236
- // Check git availability early: non-git directories cannot use git worktrees,
237
- // so willUseWorktree must be false for them. Without this, the safety check
238
- // thinks worktree isolation is active and allows parallel execution, but
239
- // prepareTaskWorkspace() later falls back to direct in-place execution —
240
- // causing file conflicts when multiple tasks run on the same non-git directory.
241
- const isGitDir = !isTextOnlyTask && normalizedTask.workingDirectory && this.gitAvailable
242
- ? await isGitRepo(normalizedTask.workingDirectory)
243
- : false;
244
- const willUseWorktree = this.useWorktree
245
- && normalizedTask.useWorktree !== false
246
- && normalizedTask.deliveryMode !== 'direct'
247
- && (isGitDir || normalizedTask.deliveryMode === 'copy');
248
- if (!isTextOnlyTask && task.skipSafetyCheck) {
249
- // Server already approved safety for this directory — skip the prompt.
250
- // Only init git when the original safety decision was 'init-git'.
251
- // When safetyDecision is 'proceed' (user chose non-git direct execution)
252
- // or undefined (builtin template, text-only, etc.), skip git init entirely.
253
- if (normalizedTask.safetyDecision === 'init-git') {
254
- const needsGitInit = this.gitAvailable && !(await isGitRepo(normalizedTask.workingDirectory));
255
- if (needsGitInit) {
256
- await initializeGit(normalizedTask.workingDirectory);
282
+ if (!isTextOnlyTask) {
283
+ // Perform safety check (worktree flag affects tier assignment)
284
+ const safetyCheck = await this.performSafetyCheck(normalizedTask, willUseWorktree);
285
+ // Handle safety tiers
286
+ if (safetyCheck.tier === WorkdirSafetyTier.UNSAFE) {
287
+ // QUEUE: serial execution required (non-git parallel, or git + uncommitted + no worktree).
288
+ // Instead of failing, queue the task and execute it once the current task
289
+ // in this directory completes. Track it so further tasks also queue behind it.
290
+ console.log(`[executor] Task ${normalizedTask.id}: queued for serial execution (${safetyCheck.parallelTaskCount} active in dir)`);
291
+ this.trackTaskDirectory(normalizedTask);
292
+ this.taskQueue.push(normalizedTask);
293
+ const reason = safetyCheck.isGitRepo
294
+ ? 'uncommitted changes without worktree isolation'
295
+ : 'non-git directory';
296
+ this.wsClient.sendTaskStatus(normalizedTask.id, 'queued', 0, `Waiting for ${safetyCheck.parallelTaskCount} task(s) in this directory to complete (serial execution: ${reason})`);
297
+ return;
298
+ }
299
+ if (safetyCheck.tier === WorkdirSafetyTier.RISKY && !this.allowNonGit) {
300
+ // PROMPT: risky conditions require user decision
301
+ await this.requestSafetyDecision(normalizedTask, safetyCheck);
302
+ // Execution will continue when decision is received
303
+ return;
304
+ }
305
+ if (safetyCheck.tier === WorkdirSafetyTier.GUARDED) {
306
+ // WARN: inform user but continue
307
+ this.wsClient.sendTaskStatus(normalizedTask.id, 'queued', 0, safetyCheck.warning);
257
308
  }
258
309
  }
259
- this.trackTaskDirectory(normalizedTask);
310
+ // Track task by directory (skip for text-only tasks without a working directory)
311
+ if (normalizedTask.workingDirectory) {
312
+ this.trackTaskDirectory(normalizedTask);
313
+ }
314
+ // Check if we can run immediately
260
315
  if (this.runningTasks.size < this.maxConcurrentTasks) {
261
- await this.executeTask(normalizedTask, false);
316
+ await this.executeTask(normalizedTask, this.useSandbox);
262
317
  }
263
318
  else {
319
+ // Queue the task
264
320
  this.taskQueue.push(normalizedTask);
265
321
  this.wsClient.sendTaskStatus(normalizedTask.id, 'queued', 0, 'Waiting for available slot');
266
322
  }
267
- return;
268
- }
269
- if (!isTextOnlyTask) {
270
- // Perform safety check (worktree flag affects tier assignment)
271
- const safetyCheck = await this.performSafetyCheck(normalizedTask, willUseWorktree);
272
- // Handle safety tiers
273
- if (safetyCheck.tier === WorkdirSafetyTier.UNSAFE) {
274
- // QUEUE: serial execution required (non-git parallel, or git + uncommitted + no worktree).
275
- // Instead of failing, queue the task and execute it once the current task
276
- // in this directory completes. Track it so further tasks also queue behind it.
277
- console.log(`[executor] Task ${normalizedTask.id}: queued for serial execution (${safetyCheck.parallelTaskCount} active in dir)`);
278
- this.trackTaskDirectory(normalizedTask);
279
- this.taskQueue.push(normalizedTask);
280
- const reason = safetyCheck.isGitRepo
281
- ? 'uncommitted changes without worktree isolation'
282
- : 'non-git directory';
283
- this.wsClient.sendTaskStatus(normalizedTask.id, 'queued', 0, `Waiting for ${safetyCheck.parallelTaskCount} task(s) in this directory to complete (serial execution: ${reason})`);
284
- return;
285
- }
286
- if (safetyCheck.tier === WorkdirSafetyTier.RISKY && !this.allowNonGit) {
287
- // PROMPT: risky conditions require user decision
288
- await this.requestSafetyDecision(normalizedTask, safetyCheck);
289
- // Execution will continue when decision is received
290
- return;
291
- }
292
- if (safetyCheck.tier === WorkdirSafetyTier.GUARDED) {
293
- // WARN: inform user but continue
294
- this.wsClient.sendTaskStatus(normalizedTask.id, 'queued', 0, safetyCheck.warning);
295
- }
296
- }
297
- // Track task by directory (skip for text-only tasks without a working directory)
298
- if (normalizedTask.workingDirectory) {
299
- this.trackTaskDirectory(normalizedTask);
300
- }
301
- // Check if we can run immediately
302
- if (this.runningTasks.size < this.maxConcurrentTasks) {
303
- await this.executeTask(normalizedTask, this.useSandbox);
304
323
  }
305
- else {
306
- // Queue the task
307
- this.taskQueue.push(normalizedTask);
308
- this.wsClient.sendTaskStatus(normalizedTask.id, 'queued', 0, 'Waiting for available slot');
324
+ finally {
325
+ this.claimedTasks.delete(task.id);
309
326
  }
310
327
  }
311
328
  /**
@@ -554,11 +571,11 @@ export class TaskExecutor {
554
571
  text: (data) => {
555
572
  this.wsClient.sendTaskText(taskId, data, textSequence++);
556
573
  },
557
- toolUse: (toolName, toolInput) => {
558
- this.wsClient.sendTaskToolUse(taskId, toolName, toolInput);
574
+ toolUse: (toolName, toolInput, toolUseId) => {
575
+ this.wsClient.sendTaskToolUse(taskId, toolName, toolInput, toolUseId);
559
576
  },
560
- toolResult: (toolName, result, success) => {
561
- this.wsClient.sendTaskToolResult(taskId, toolName, result, success);
577
+ toolResult: (toolName, result, success, toolUseId) => {
578
+ this.wsClient.sendTaskToolResult(taskId, toolName, result, success, toolUseId);
562
579
  },
563
580
  fileChange: (path, action, linesAdded, linesRemoved, diff) => {
564
581
  this.wsClient.sendTaskFileChange(taskId, path, action, linesAdded, linesRemoved, diff);
@@ -885,13 +902,13 @@ export class TaskExecutor {
885
902
  resetIdleTimeout();
886
903
  this.wsClient.sendTaskText(normalizedTask.id, data, textSequence++);
887
904
  },
888
- toolUse: (toolName, toolInput) => {
905
+ toolUse: (toolName, toolInput, toolUseId) => {
889
906
  resetIdleTimeout();
890
- this.wsClient.sendTaskToolUse(normalizedTask.id, toolName, toolInput);
907
+ this.wsClient.sendTaskToolUse(normalizedTask.id, toolName, toolInput, toolUseId);
891
908
  },
892
- toolResult: (toolName, result, success) => {
909
+ toolResult: (toolName, result, success, toolUseId) => {
893
910
  resetIdleTimeout();
894
- this.wsClient.sendTaskToolResult(normalizedTask.id, toolName, result, success);
911
+ this.wsClient.sendTaskToolResult(normalizedTask.id, toolName, result, success, toolUseId);
895
912
  },
896
913
  fileChange: (path, action, linesAdded, linesRemoved, diff) => {
897
914
  resetIdleTimeout();
@@ -920,15 +937,36 @@ export class TaskExecutor {
920
937
  };
921
938
  this.runningTasks.set(normalizedTask.id, runningTask);
922
939
  this.wsClient.addActiveTask(normalizedTask.id);
940
+ // Task-level heartbeat: send a lightweight text event every 30s to keep the
941
+ // server's activity timer alive. This prevents the server's startup timeout
942
+ // from resetting the node to "planned" while we're doing workspace prep,
943
+ // delivery, cleanup, or any other long-running phase. The server only resets
944
+ // hasReceivedEvent from execution events (text, tool_use, etc.) — without
945
+ // this heartbeat, phases that don't produce those events (git operations,
946
+ // worktree creation, PR delivery) leave the server blind.
947
+ // 30s aligns with the server's heartbeat check interval and is well under
948
+ // the 3-minute startup timeout (STARTUP_TIMEOUT_MS in dispatch.ts).
949
+ const TASK_HEARTBEAT_INTERVAL_MS = 30_000;
950
+ let heartbeatSeq = 0;
951
+ const taskHeartbeatTimer = setInterval(() => {
952
+ // Send directly via wsClient to keep the server's activity timer alive
953
+ // WITHOUT resetting the agent-side idle timeout. stream.text() calls
954
+ // resetIdleTimeout(), which would make a hung agent run until hard cap
955
+ // instead of idle-timing out after 15 minutes of no real activity.
956
+ // Heartbeat text is intentionally empty — it exists only to keep the
957
+ // server's activity timer alive, not to display anything to the user.
958
+ this.wsClient.sendTaskText(normalizedTask.id, '', -(++heartbeatSeq));
959
+ }, TASK_HEARTBEAT_INTERVAL_MS);
923
960
  // Text-only tasks (plan/chat/summarize) without a working directory skip workspace prep
924
961
  const isTextOnly = normalizedTask.type === 'summarize' || normalizedTask.type === 'chat' || normalizedTask.type === 'plan';
925
962
  let prepared;
926
963
  try {
927
964
  prepared = isTextOnly && !normalizedTask.workingDirectory
928
965
  ? { workingDirectory: '', cleanup: async () => { } }
929
- : await this.prepareTaskWorkspace(normalizedTask, stream);
966
+ : await this.prepareTaskWorkspace(normalizedTask, stream, abortController.signal);
930
967
  }
931
968
  catch (prepErr) {
969
+ clearInterval(taskHeartbeatTimer);
932
970
  this.runningTasks.delete(normalizedTask.id);
933
971
  this.untrackTaskDirectory(task);
934
972
  // Do NOT removeActiveTask here — the caller's catch will send
@@ -950,6 +988,11 @@ export class TaskExecutor {
950
988
  // Start the idle timer
951
989
  resetIdleTimeout();
952
990
  let keepBranch = false;
991
+ // Deferred result — sent AFTER cleanup to prevent auto-dispatch race.
992
+ // The server dispatches downstream tasks immediately on receiving the result,
993
+ // and the new task's createWorktree() would fight with this task's cleanupWorktree()
994
+ // over the same .git directory (concurrent git worktree prune, branch -D, etc.).
995
+ let pendingResult;
953
996
  try {
954
997
  // Notify task started
955
998
  this.wsClient.sendTaskStatus(task.id, 'running', 0, 'Starting');
@@ -1035,6 +1078,7 @@ export class TaskExecutor {
1035
1078
  ? `[${task.shortProjectId}/${task.shortNodeId}] ${rawTitle}`
1036
1079
  : rawTitle;
1037
1080
  if (prepared.branchName && result.status === 'completed') {
1081
+ stream.text?.(`\n── [Astro] Delivering changes (mode: ${deliveryMode})...\n`);
1038
1082
  this.wsClient.sendTaskStatus(task.id, 'running', 90, 'Delivering changes...');
1039
1083
  // Build PR body: enrich with summary data when available
1040
1084
  const prBodyParts = [];
@@ -1075,6 +1119,7 @@ export class TaskExecutor {
1075
1119
  console.log(`[executor] Task ${task.id}: copy mode, worktree preserved at ${prepared.workingDirectory}`);
1076
1120
  }
1077
1121
  else if (deliveryMode === 'branch') {
1122
+ stream.text?.(`── [Astro] Merging into project branch ${prepared.projectBranch ?? 'local'}...\n`);
1078
1123
  // Branch mode: commit locally, merge into project branch if available.
1079
1124
  // The merge lock is held only during the squash-merge (seconds, not minutes),
1080
1125
  // allowing tasks to execute in parallel. The squash merge naturally handles
@@ -1116,6 +1161,7 @@ export class TaskExecutor {
1116
1161
  if (mergeResult.merged) {
1117
1162
  result.deliveryStatus = 'success';
1118
1163
  result.commitAfterSha = mergeResult.commitSha;
1164
+ stream.text?.(`── [Astro] Merged into ${prepared.projectBranch} (${mergeResult.commitSha?.slice(0, 7)})\n`);
1119
1165
  console.log(`[executor] Task ${task.id}: merged into ${prepared.projectBranch} (${mergeResult.commitSha})`);
1120
1166
  // Sync project worktree to reflect the merged changes on disk
1121
1167
  if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
@@ -1131,6 +1177,7 @@ export class TaskExecutor {
1131
1177
  : null;
1132
1178
  if (taskContext?.sessionId && this.isResumableAdapter(adapter) && attempt < MAX_MERGE_ATTEMPTS) {
1133
1179
  const conflictFiles = mergeResult.conflictFiles?.join(', ') ?? 'unknown files';
1180
+ stream.text?.(`── [Astro] Merge conflict in: ${conflictFiles} — agent resolving (attempt ${attempt})...\n`);
1134
1181
  console.log(`[executor] Task ${task.id}: merge conflict (attempt ${attempt}), resuming ${adapter.name} to resolve: ${conflictFiles}`);
1135
1182
  this.wsClient.sendTaskStatus(task.id, 'running', 97, `Merge conflict — agent resolving (attempt ${attempt})...`);
1136
1183
  // Resume agent session with conflict resolution instructions.
@@ -1179,6 +1226,7 @@ export class TaskExecutor {
1179
1226
  }
1180
1227
  else if (deliveryMode === 'push') {
1181
1228
  // Push branch to remote, but don't create a PR — user creates PR manually
1229
+ stream.text?.(`── [Astro] Pushing branch ${prepared.branchName} to origin...\n`);
1182
1230
  this.wsClient.sendTaskStatus(task.id, 'running', 95, 'Pushing branch...');
1183
1231
  console.log(`[executor] Task ${task.id}: push mode, pushing branch ${prepared.branchName}`);
1184
1232
  const prResult = await pushAndCreatePR(prepared.workingDirectory, {
@@ -1193,20 +1241,24 @@ export class TaskExecutor {
1193
1241
  // Delivery failure — don't override execution status
1194
1242
  result.deliveryStatus = 'failed';
1195
1243
  result.deliveryError = `Push delivery failed: ${prResult.error}`;
1244
+ stream.text?.(`── [Astro] Push failed: ${prResult.error}\n`);
1196
1245
  console.error(`[executor] Task ${task.id}: push delivery failed: ${prResult.error}`);
1197
1246
  }
1198
1247
  else if (prResult.pushed) {
1199
1248
  result.deliveryStatus = 'success';
1200
1249
  keepBranch = true;
1250
+ stream.text?.(`── [Astro] Branch pushed to origin: ${prepared.branchName}\n`);
1201
1251
  console.log(`[executor] Task ${task.id}: branch pushed (${prepared.branchName})`);
1202
1252
  }
1203
1253
  else {
1204
1254
  result.deliveryStatus = 'skipped';
1255
+ stream.text?.(`── [Astro] No changes to push\n`);
1205
1256
  console.log(`[executor] Task ${task.id}: no changes to push`);
1206
1257
  }
1207
1258
  }
1208
1259
  else {
1209
1260
  // 'pr' — push + create PR, auto-merge into project branch if applicable
1261
+ stream.text?.(`── [Astro] Creating pull request for branch ${prepared.branchName}...\n`);
1210
1262
  this.wsClient.sendTaskStatus(task.id, 'running', 94, 'Rebasing before push...');
1211
1263
  // Pre-push rebase: if the target branch moved forward, rebase our task
1212
1264
  // branch so the PR will be cleanly mergeable. The branch hasn't been
@@ -1239,6 +1291,7 @@ export class TaskExecutor {
1239
1291
  result.commitBeforeSha = prResult.commitBeforeSha;
1240
1292
  result.commitAfterSha = prResult.commitAfterSha;
1241
1293
  keepBranch = true;
1294
+ stream.text?.(`── [Astro] Pull request created: ${prResult.prUrl}\n`);
1242
1295
  if (prResult.autoMergeFailed) {
1243
1296
  // PR was created but auto-merge failed (likely conflict).
1244
1297
  // If the adapter supports session resume, ask the agent to
@@ -1250,6 +1303,7 @@ export class TaskExecutor {
1250
1303
  : null;
1251
1304
  if (prTaskContext?.sessionId && this.isResumableAdapter(adapter) && hasProjectBranch && prepared.branchName && prepared.baseBranch && prResult.prNumber && prepared.gitRoot) {
1252
1305
  for (let attempt = 1; attempt <= MAX_PR_MERGE_ATTEMPTS; attempt++) {
1306
+ stream.text?.(`── [Astro] PR auto-merge failed — agent resolving conflict (attempt ${attempt})...\n`);
1253
1307
  console.log(`[executor] Task ${task.id}: PR auto-merge failed (attempt ${attempt}), resuming ${adapter.name} to resolve`);
1254
1308
  this.wsClient.sendTaskStatus(task.id, 'running', 97, `PR merge conflict — agent resolving (attempt ${attempt})...`);
1255
1309
  // Resume agent session — agent rebases and force-pushes.
@@ -1307,15 +1361,18 @@ export class TaskExecutor {
1307
1361
  result.deliveryStatus = 'failed';
1308
1362
  result.deliveryError = `PR delivery failed: ${prResult.error}`;
1309
1363
  keepBranch = prResult.pushed ?? false; // Keep branch if it was pushed
1364
+ stream.text?.(`── [Astro] PR delivery failed: ${prResult.error}\n`);
1310
1365
  console.error(`[executor] Task ${task.id}: PR delivery failed: ${prResult.error}`);
1311
1366
  }
1312
1367
  else {
1368
+ stream.text?.(`── [Astro] No changes to deliver\n`);
1313
1369
  console.log(`[executor] Task ${task.id}: no changes to push`);
1314
1370
  }
1315
1371
  }
1316
1372
  }
1317
1373
  catch (prError) {
1318
1374
  const prMsg = prError instanceof Error ? prError.message : String(prError);
1375
+ stream.text?.(`── [Astro] Delivery failed: ${prMsg}\n`);
1319
1376
  console.error(`[executor] Task ${task.id}: delivery (${deliveryMode}) failed: ${prMsg}`);
1320
1377
  // Delivery failure — don't override execution status
1321
1378
  result.deliveryStatus = 'failed';
@@ -1353,21 +1410,20 @@ export class TaskExecutor {
1353
1410
  // Don't send final result — the SlurmJobMonitor will send it when jobs finish
1354
1411
  }
1355
1412
  else {
1356
- // Send final status + result
1357
- this.wsClient.sendTaskStatus(task.id, 'completed', 100, 'Task complete');
1358
- this.wsClient.sendTaskResult(result);
1413
+ // Defer result to after cleanup (sent in the finally block).
1414
+ pendingResult = result;
1359
1415
  }
1360
1416
  }
1361
1417
  catch (error) {
1362
1418
  // Unexpected error during execution
1363
1419
  const errorMsg = error instanceof Error ? error.message : String(error);
1364
1420
  console.error(`[executor] Task ${task.id}: execution error: ${errorMsg}`);
1365
- this.wsClient.sendTaskResult({
1421
+ pendingResult = {
1366
1422
  taskId: task.id,
1367
1423
  status: 'failed',
1368
1424
  error: errorMsg,
1369
1425
  completedAt: new Date().toISOString(),
1370
- });
1426
+ };
1371
1427
  }
1372
1428
  finally {
1373
1429
  clearTimeout(hardCapTimeoutId);
@@ -1378,6 +1434,9 @@ export class TaskExecutor {
1378
1434
  // (PR created or branch pushed), we preserve the git branch but still
1379
1435
  // remove the working copy — the branch lives on remote/local refs,
1380
1436
  // and re-execution will create a fresh worktree if needed.
1437
+ if (prepared.branchName) {
1438
+ stream.text?.(`\n── [Astro] Cleaning up worktree (branch: ${prepared.branchName})...\n`);
1439
+ }
1381
1440
  if (this.preserveWorktrees) {
1382
1441
  console.log(`[executor] Task ${task.id}: worktree preserved (debug mode)`);
1383
1442
  // Still release directory locks even in debug mode to avoid deadlocks.
@@ -1404,6 +1463,18 @@ export class TaskExecutor {
1404
1463
  console.log(`[executor] Task ${task.id}: cleaning up sandbox`);
1405
1464
  await sandbox.cleanup();
1406
1465
  }
1466
+ // Stop heartbeat — cleanup is done, we're about to send the result.
1467
+ clearInterval(taskHeartbeatTimer);
1468
+ // Send deferred result AFTER cleanup completes.
1469
+ // This ensures the server's auto-dispatch doesn't start the next task
1470
+ // while this task's worktree cleanup is still running on the same git repo.
1471
+ // Without this, the new task's createWorktree() fights with cleanupWorktree()
1472
+ // over the same .git directory (concurrent git worktree prune, branch -D, etc.).
1473
+ if (pendingResult) {
1474
+ const isSuccess = pendingResult.status !== 'failed';
1475
+ this.wsClient.sendTaskStatus(task.id, isSuccess ? 'completed' : 'failed', 100, isSuccess ? 'Task complete' : 'Task failed');
1476
+ this.wsClient.sendTaskResult(pendingResult);
1477
+ }
1407
1478
  // Untrack task from directory
1408
1479
  this.untrackTaskDirectory(task);
1409
1480
  this.runningTasks.delete(task.id);
@@ -1420,7 +1491,7 @@ export class TaskExecutor {
1420
1491
  this.processQueue();
1421
1492
  }
1422
1493
  }
1423
- async prepareTaskWorkspace(task, stream) {
1494
+ async prepareTaskWorkspace(task, stream, signal) {
1424
1495
  // Per-task explicit opt-out: user consciously chose to skip worktree
1425
1496
  if (task.useWorktree === false) {
1426
1497
  console.log(`[executor] Task ${task.id}: worktree explicitly disabled by user, using raw workdir: ${task.workingDirectory}`);
@@ -1523,6 +1594,8 @@ export class TaskExecutor {
1523
1594
  projectBranch: task.projectBranch,
1524
1595
  stdout: stream.stdout,
1525
1596
  stderr: stream.stderr,
1597
+ text: stream.text,
1598
+ signal,
1526
1599
  });
1527
1600
  if (!worktree) {
1528
1601
  throw new Error(`Worktree creation returned null for ${task.workingDirectory}. Cannot proceed without isolation.`);