@astroanywhere/agent 0.3.1 → 0.3.2

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":"AAAA;;;;;GAKG;AAOH,OAAO,KAAK,EAAE,IAAI,EAA4B,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAI7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAsJ3D,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,eAAe,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;CACtC;AAuBD,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,YAAY,CAAuC;IAC3D,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAiD;IACjE,OAAO,CAAC,WAAW,CAAU;IAC7B,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,iBAAiB,CAAU;IACnC,OAAO,CAAC,UAAU,CAAkB;IACpC,OAAO,CAAC,WAAW,CAAU;IAC7B,OAAO,CAAC,UAAU,CAAU;IAC5B,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,YAAY,CAAkB;IACtC,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,iBAAiB,CAA2B;IACpD,+FAA+F;IAC/F,OAAO,CAAC,oBAAoB,CAA2B;IACvD,OAAO,CAAC,cAAc,CAA+B;IAGrD,OAAO,CAAC,gBAAgB,CAAuC;IAC/D,OAAO,CAAC,mBAAmB,CAA8C;IAEzE,gFAAgF;IAChF,OAAO,CAAC,iBAAiB,CASV;IAEf,gFAAgF;IAChF,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,0BAA0B,CAAkB;gBAExD,OAAO,EAAE,mBAAmB;IAyBxC;;;OAGG;IACH,iBAAiB,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAW/C;;OAEG;IACG,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAsH3C;;OAEG;IACG,oBAAoB,CACxB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,GACtD,OAAO,CAAC,IAAI,CAAC;IAWhB;;OAEG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAgCnC;;;OAGG;IACG,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiErE;;OAEG;IACH,aAAa,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE;IAOpD;;OAEG;IACH,SAAS,IAAI,IAAI;IAmCjB;;OAEG;IACH,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAMxC;;;;;;OAMG;IACG,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,UAAQ,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAgCxI,kEAAkE;IAClE,OAAO,CAAC,kBAAkB;IAI1B,sDAAsD;IACtD,OAAO,CAAC,sBAAsB;IAS9B;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAU9B;;;;OAIG;YACW,sBAAsB;IA6HpC;;OAEG;YACW,kBAAkB;IAKhC;;OAEG;YACW,qBAAqB;IAkGnC;;OAEG;YACW,cAAc;IAY5B,+EAA+E;IAC/E,OAAO,CAAC,eAAe;IAIvB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAO1B;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAY5B;;OAEG;IACH,OAAO,CAAC,yBAAyB;YAMnB,WAAW;YAqpBX,oBAAoB;IAsIlC;;;;OAIG;YACW,sBAAsB;YAmFtB,UAAU;IA6BxB,OAAO,CAAC,YAAY;CAuBrB"}
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
  /**
@@ -920,15 +937,35 @@ 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 taskHeartbeatPhase = 'preparing';
951
+ let heartbeatSeq = 0;
952
+ const taskHeartbeatTimer = setInterval(() => {
953
+ // Send directly via wsClient to keep the server's activity timer alive
954
+ // WITHOUT resetting the agent-side idle timeout. stream.text() calls
955
+ // resetIdleTimeout(), which would make a hung agent run until hard cap
956
+ // instead of idle-timing out after 15 minutes of no real activity.
957
+ this.wsClient.sendTaskText(normalizedTask.id, `[Astro] Heartbeat — ${taskHeartbeatPhase}...\n`, -(++heartbeatSeq));
958
+ }, TASK_HEARTBEAT_INTERVAL_MS);
923
959
  // Text-only tasks (plan/chat/summarize) without a working directory skip workspace prep
924
960
  const isTextOnly = normalizedTask.type === 'summarize' || normalizedTask.type === 'chat' || normalizedTask.type === 'plan';
925
961
  let prepared;
926
962
  try {
927
963
  prepared = isTextOnly && !normalizedTask.workingDirectory
928
964
  ? { workingDirectory: '', cleanup: async () => { } }
929
- : await this.prepareTaskWorkspace(normalizedTask, stream);
965
+ : await this.prepareTaskWorkspace(normalizedTask, stream, abortController.signal);
930
966
  }
931
967
  catch (prepErr) {
968
+ clearInterval(taskHeartbeatTimer);
932
969
  this.runningTasks.delete(normalizedTask.id);
933
970
  this.untrackTaskDirectory(task);
934
971
  // Do NOT removeActiveTask here — the caller's catch will send
@@ -938,6 +975,7 @@ export class TaskExecutor {
938
975
  }
939
976
  const taskWithWorkspace = { ...normalizedTask, workingDirectory: prepared.workingDirectory };
940
977
  runningTask.task = taskWithWorkspace;
978
+ taskHeartbeatPhase = 'executing';
941
979
  console.log(`[executor] Task ${task.id}: workspace prepared, cwd=${prepared.workingDirectory}`);
942
980
  // Execute with idle timeout + hard cap.
943
981
  // Idle timeout resets on every stream activity (text, tool, file, etc.).
@@ -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,8 @@ export class TaskExecutor {
1035
1078
  ? `[${task.shortProjectId}/${task.shortNodeId}] ${rawTitle}`
1036
1079
  : rawTitle;
1037
1080
  if (prepared.branchName && result.status === 'completed') {
1081
+ taskHeartbeatPhase = 'delivering';
1082
+ stream.text?.(`\n[Astro] Delivering changes (mode: ${deliveryMode})...\n`);
1038
1083
  this.wsClient.sendTaskStatus(task.id, 'running', 90, 'Delivering changes...');
1039
1084
  // Build PR body: enrich with summary data when available
1040
1085
  const prBodyParts = [];
@@ -1075,6 +1120,7 @@ export class TaskExecutor {
1075
1120
  console.log(`[executor] Task ${task.id}: copy mode, worktree preserved at ${prepared.workingDirectory}`);
1076
1121
  }
1077
1122
  else if (deliveryMode === 'branch') {
1123
+ stream.text?.(`[Astro] Merging into project branch ${prepared.projectBranch ?? 'local'}...\n`);
1078
1124
  // Branch mode: commit locally, merge into project branch if available.
1079
1125
  // The merge lock is held only during the squash-merge (seconds, not minutes),
1080
1126
  // allowing tasks to execute in parallel. The squash merge naturally handles
@@ -1116,6 +1162,7 @@ export class TaskExecutor {
1116
1162
  if (mergeResult.merged) {
1117
1163
  result.deliveryStatus = 'success';
1118
1164
  result.commitAfterSha = mergeResult.commitSha;
1165
+ stream.text?.(`[Astro] Merged into ${prepared.projectBranch} (${mergeResult.commitSha?.slice(0, 7)})\n`);
1119
1166
  console.log(`[executor] Task ${task.id}: merged into ${prepared.projectBranch} (${mergeResult.commitSha})`);
1120
1167
  // Sync project worktree to reflect the merged changes on disk
1121
1168
  if (prepared.projectWorktreePath && prepared.projectBranch && prepared.gitRoot) {
@@ -1131,6 +1178,7 @@ export class TaskExecutor {
1131
1178
  : null;
1132
1179
  if (taskContext?.sessionId && this.isResumableAdapter(adapter) && attempt < MAX_MERGE_ATTEMPTS) {
1133
1180
  const conflictFiles = mergeResult.conflictFiles?.join(', ') ?? 'unknown files';
1181
+ stream.text?.(`[Astro] Merge conflict in: ${conflictFiles} — agent resolving (attempt ${attempt})...\n`);
1134
1182
  console.log(`[executor] Task ${task.id}: merge conflict (attempt ${attempt}), resuming ${adapter.name} to resolve: ${conflictFiles}`);
1135
1183
  this.wsClient.sendTaskStatus(task.id, 'running', 97, `Merge conflict — agent resolving (attempt ${attempt})...`);
1136
1184
  // Resume agent session with conflict resolution instructions.
@@ -1179,6 +1227,7 @@ export class TaskExecutor {
1179
1227
  }
1180
1228
  else if (deliveryMode === 'push') {
1181
1229
  // Push branch to remote, but don't create a PR — user creates PR manually
1230
+ stream.text?.(`[Astro] Pushing branch ${prepared.branchName} to origin...\n`);
1182
1231
  this.wsClient.sendTaskStatus(task.id, 'running', 95, 'Pushing branch...');
1183
1232
  console.log(`[executor] Task ${task.id}: push mode, pushing branch ${prepared.branchName}`);
1184
1233
  const prResult = await pushAndCreatePR(prepared.workingDirectory, {
@@ -1193,20 +1242,24 @@ export class TaskExecutor {
1193
1242
  // Delivery failure — don't override execution status
1194
1243
  result.deliveryStatus = 'failed';
1195
1244
  result.deliveryError = `Push delivery failed: ${prResult.error}`;
1245
+ stream.text?.(`[Astro] Push failed: ${prResult.error}\n`);
1196
1246
  console.error(`[executor] Task ${task.id}: push delivery failed: ${prResult.error}`);
1197
1247
  }
1198
1248
  else if (prResult.pushed) {
1199
1249
  result.deliveryStatus = 'success';
1200
1250
  keepBranch = true;
1251
+ stream.text?.(`[Astro] Branch pushed to origin: ${prepared.branchName}\n`);
1201
1252
  console.log(`[executor] Task ${task.id}: branch pushed (${prepared.branchName})`);
1202
1253
  }
1203
1254
  else {
1204
1255
  result.deliveryStatus = 'skipped';
1256
+ stream.text?.(`[Astro] No changes to push\n`);
1205
1257
  console.log(`[executor] Task ${task.id}: no changes to push`);
1206
1258
  }
1207
1259
  }
1208
1260
  else {
1209
1261
  // 'pr' — push + create PR, auto-merge into project branch if applicable
1262
+ stream.text?.(`[Astro] Creating pull request for branch ${prepared.branchName}...\n`);
1210
1263
  this.wsClient.sendTaskStatus(task.id, 'running', 94, 'Rebasing before push...');
1211
1264
  // Pre-push rebase: if the target branch moved forward, rebase our task
1212
1265
  // branch so the PR will be cleanly mergeable. The branch hasn't been
@@ -1239,6 +1292,7 @@ export class TaskExecutor {
1239
1292
  result.commitBeforeSha = prResult.commitBeforeSha;
1240
1293
  result.commitAfterSha = prResult.commitAfterSha;
1241
1294
  keepBranch = true;
1295
+ stream.text?.(`[Astro] Pull request created: ${prResult.prUrl}\n`);
1242
1296
  if (prResult.autoMergeFailed) {
1243
1297
  // PR was created but auto-merge failed (likely conflict).
1244
1298
  // If the adapter supports session resume, ask the agent to
@@ -1250,6 +1304,7 @@ export class TaskExecutor {
1250
1304
  : null;
1251
1305
  if (prTaskContext?.sessionId && this.isResumableAdapter(adapter) && hasProjectBranch && prepared.branchName && prepared.baseBranch && prResult.prNumber && prepared.gitRoot) {
1252
1306
  for (let attempt = 1; attempt <= MAX_PR_MERGE_ATTEMPTS; attempt++) {
1307
+ stream.text?.(`[Astro] PR auto-merge failed — agent resolving conflict (attempt ${attempt})...\n`);
1253
1308
  console.log(`[executor] Task ${task.id}: PR auto-merge failed (attempt ${attempt}), resuming ${adapter.name} to resolve`);
1254
1309
  this.wsClient.sendTaskStatus(task.id, 'running', 97, `PR merge conflict — agent resolving (attempt ${attempt})...`);
1255
1310
  // Resume agent session — agent rebases and force-pushes.
@@ -1307,15 +1362,18 @@ export class TaskExecutor {
1307
1362
  result.deliveryStatus = 'failed';
1308
1363
  result.deliveryError = `PR delivery failed: ${prResult.error}`;
1309
1364
  keepBranch = prResult.pushed ?? false; // Keep branch if it was pushed
1365
+ stream.text?.(`[Astro] PR delivery failed: ${prResult.error}\n`);
1310
1366
  console.error(`[executor] Task ${task.id}: PR delivery failed: ${prResult.error}`);
1311
1367
  }
1312
1368
  else {
1369
+ stream.text?.(`[Astro] No changes to deliver\n`);
1313
1370
  console.log(`[executor] Task ${task.id}: no changes to push`);
1314
1371
  }
1315
1372
  }
1316
1373
  }
1317
1374
  catch (prError) {
1318
1375
  const prMsg = prError instanceof Error ? prError.message : String(prError);
1376
+ stream.text?.(`[Astro] Delivery failed: ${prMsg}\n`);
1319
1377
  console.error(`[executor] Task ${task.id}: delivery (${deliveryMode}) failed: ${prMsg}`);
1320
1378
  // Delivery failure — don't override execution status
1321
1379
  result.deliveryStatus = 'failed';
@@ -1353,31 +1411,34 @@ export class TaskExecutor {
1353
1411
  // Don't send final result — the SlurmJobMonitor will send it when jobs finish
1354
1412
  }
1355
1413
  else {
1356
- // Send final status + result
1357
- this.wsClient.sendTaskStatus(task.id, 'completed', 100, 'Task complete');
1358
- this.wsClient.sendTaskResult(result);
1414
+ // Defer result to after cleanup (sent in the finally block).
1415
+ pendingResult = result;
1359
1416
  }
1360
1417
  }
1361
1418
  catch (error) {
1362
1419
  // Unexpected error during execution
1363
1420
  const errorMsg = error instanceof Error ? error.message : String(error);
1364
1421
  console.error(`[executor] Task ${task.id}: execution error: ${errorMsg}`);
1365
- this.wsClient.sendTaskResult({
1422
+ pendingResult = {
1366
1423
  taskId: task.id,
1367
1424
  status: 'failed',
1368
1425
  error: errorMsg,
1369
1426
  completedAt: new Date().toISOString(),
1370
- });
1427
+ };
1371
1428
  }
1372
1429
  finally {
1373
1430
  clearTimeout(hardCapTimeoutId);
1374
1431
  if (idleTimerId !== undefined)
1375
1432
  clearTimeout(idleTimerId);
1433
+ taskHeartbeatPhase = 'cleaning up';
1376
1434
  // Always cleanup the local worktree directory to reclaim disk space
1377
1435
  // (node_modules alone is ~680MB per worktree). When keepBranch is true
1378
1436
  // (PR created or branch pushed), we preserve the git branch but still
1379
1437
  // remove the working copy — the branch lives on remote/local refs,
1380
1438
  // and re-execution will create a fresh worktree if needed.
1439
+ if (prepared.branchName) {
1440
+ stream.text?.(`\n[Astro] Cleaning up worktree (branch: ${prepared.branchName})...\n`);
1441
+ }
1381
1442
  if (this.preserveWorktrees) {
1382
1443
  console.log(`[executor] Task ${task.id}: worktree preserved (debug mode)`);
1383
1444
  // Still release directory locks even in debug mode to avoid deadlocks.
@@ -1404,6 +1465,18 @@ export class TaskExecutor {
1404
1465
  console.log(`[executor] Task ${task.id}: cleaning up sandbox`);
1405
1466
  await sandbox.cleanup();
1406
1467
  }
1468
+ // Stop heartbeat — cleanup is done, we're about to send the result.
1469
+ clearInterval(taskHeartbeatTimer);
1470
+ // Send deferred result AFTER cleanup completes.
1471
+ // This ensures the server's auto-dispatch doesn't start the next task
1472
+ // while this task's worktree cleanup is still running on the same git repo.
1473
+ // Without this, the new task's createWorktree() fights with cleanupWorktree()
1474
+ // over the same .git directory (concurrent git worktree prune, branch -D, etc.).
1475
+ if (pendingResult) {
1476
+ const isSuccess = pendingResult.status !== 'failed';
1477
+ this.wsClient.sendTaskStatus(task.id, isSuccess ? 'completed' : 'failed', 100, isSuccess ? 'Task complete' : 'Task failed');
1478
+ this.wsClient.sendTaskResult(pendingResult);
1479
+ }
1407
1480
  // Untrack task from directory
1408
1481
  this.untrackTaskDirectory(task);
1409
1482
  this.runningTasks.delete(task.id);
@@ -1420,7 +1493,7 @@ export class TaskExecutor {
1420
1493
  this.processQueue();
1421
1494
  }
1422
1495
  }
1423
- async prepareTaskWorkspace(task, stream) {
1496
+ async prepareTaskWorkspace(task, stream, signal) {
1424
1497
  // Per-task explicit opt-out: user consciously chose to skip worktree
1425
1498
  if (task.useWorktree === false) {
1426
1499
  console.log(`[executor] Task ${task.id}: worktree explicitly disabled by user, using raw workdir: ${task.workingDirectory}`);
@@ -1523,6 +1596,8 @@ export class TaskExecutor {
1523
1596
  projectBranch: task.projectBranch,
1524
1597
  stdout: stream.stdout,
1525
1598
  stderr: stream.stderr,
1599
+ text: stream.text,
1600
+ signal,
1526
1601
  });
1527
1602
  if (!worktree) {
1528
1603
  throw new Error(`Worktree creation returned null for ${task.workingDirectory}. Cannot proceed without isolation.`);