@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.
- 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 +174 -99
- package/dist/lib/task-executor.js.map +1 -1
- package/dist/lib/websocket-client.d.ts +7 -1
- package/dist/lib/websocket-client.d.ts.map +1 -1
- package/dist/lib/websocket-client.js +7 -1
- 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/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
|
/**
|
|
@@ -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
|
-
//
|
|
1357
|
-
|
|
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
|
-
|
|
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.`);
|