@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.
- 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 +180 -107
- package/dist/lib/task-executor.js.map +1 -1
- package/dist/lib/websocket-client.d.ts +9 -3
- package/dist/lib/websocket-client.d.ts.map +1 -1
- package/dist/lib/websocket-client.js +11 -5
- package/dist/lib/websocket-client.js.map +1 -1
- package/dist/lib/worktree.d.ts +4 -0
- package/dist/lib/worktree.d.ts.map +1 -1
- package/dist/lib/worktree.js +22 -3
- package/dist/lib/worktree.js.map +1 -1
- package/dist/providers/base-adapter.d.ts +2 -2
- package/dist/providers/base-adapter.d.ts.map +1 -1
- package/dist/providers/claude-sdk-adapter.d.ts.map +1 -1
- package/dist/providers/claude-sdk-adapter.js +13 -5
- package/dist/providers/claude-sdk-adapter.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
//
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
error: errorMsg
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
//
|
|
1357
|
-
|
|
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
|
-
|
|
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.`);
|