fix(merge): re-enqueue stale merges + rebase new worktrees onto remote
Stale-merge recovery now calls back into ProjectEngine's auto-merge queue directly instead of waiting on the 15s polling sweep — wired via a new InProcessRuntime.setMergeEnqueuer hook so SelfHealingManager can re-enqueue without leaking engine internals. createWorktree mirrors the merge-time rebase: when worktreeRebaseBeforeMerge is enabled, the new task branch is rebased onto <remote>/<defaultBranch> right after creation, so executors start from origin's tip with local main replayed on top. Best-effort — fetch/rebase failures abort cleanly and leave the merge-time rebase as the backstop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4092,7 +4092,19 @@ and show an appropriate message to the user.\`
|
||||
|
||||
for (let attempt = 0; attempt < this.MAX_WORKTREE_RETRIES; attempt++) {
|
||||
try {
|
||||
return await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
|
||||
const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
|
||||
// Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge
|
||||
// is enabled, fetch the remote and rebase the just-created task branch
|
||||
// onto the latest <remote>/<defaultBranch>. This makes the worktree
|
||||
// start from origin/main + local main both, so divergence only matters
|
||||
// if the user actively skips this setting. Best-effort: failures here
|
||||
// don't abort task setup.
|
||||
await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err: unknown) => {
|
||||
executorLog.warn(
|
||||
`Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isLastAttempt = attempt === this.MAX_WORKTREE_RETRIES - 1;
|
||||
@@ -4119,6 +4131,98 @@ and show an appropriate message to the user.\`
|
||||
throw new Error("Unexpected exit from worktree creation retry loop");
|
||||
}
|
||||
|
||||
private quoteShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* After creating a fresh task worktree, fetch the configured remote and
|
||||
* rebase the task branch onto `<remote>/<defaultBranch>`. The result is a
|
||||
* branch that contains origin's tip plus any local main commits, so the
|
||||
* eventual merge has fewer surprises and the executor sees the freshest
|
||||
* code its peers/CI may have published.
|
||||
*
|
||||
* No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is
|
||||
* configured/resolvable, or the rebase produces conflicts (we abort and
|
||||
* leave the worktree as-is so the executor can still run).
|
||||
*/
|
||||
private async rebaseNewWorktreeOntoRemote(
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
let settings;
|
||||
try {
|
||||
settings = await this.store.getSettings();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (settings.worktreeRebaseBeforeMerge === false) return;
|
||||
|
||||
let remote = settings.worktreeRebaseRemote?.trim() || "";
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git remote", { cwd: this.rootDir });
|
||||
const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (remotes.includes("origin")) remote = "origin";
|
||||
else if (remotes.length === 1) remote = remotes[0];
|
||||
} catch {
|
||||
// No remote resolvable — nothing to rebase against.
|
||||
}
|
||||
}
|
||||
if (!remote) return;
|
||||
|
||||
let defaultBranch = "";
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
|
||||
} catch {
|
||||
// origin/HEAD not set — fall back to current branch in rootDir.
|
||||
}
|
||||
if (!defaultBranch) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
|
||||
defaultBranch = stdout.trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!defaultBranch || defaultBranch === "HEAD") return;
|
||||
|
||||
const remoteRef = `${remote}/${defaultBranch}`;
|
||||
|
||||
try {
|
||||
await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
|
||||
} catch (err) {
|
||||
executorLog.warn(
|
||||
`Worktree rebase: fetch ${remote} ${defaultBranch} failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Rebased new worktree branch ${branch} onto ${remoteRef}`,
|
||||
);
|
||||
} catch (rebaseErr) {
|
||||
const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
|
||||
executorLog.warn(
|
||||
`Worktree rebase: rebase onto ${remoteRef} failed for ${taskId} — aborting and leaving local base intact: ${msg}`,
|
||||
);
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Could not rebase new worktree onto ${remoteRef} — kept local base. The merge-time rebase will retry with conflict resolution.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored baseBranch to a concrete commit SHA.
|
||||
*
|
||||
|
||||
@@ -192,6 +192,11 @@ export class ProjectEngine {
|
||||
? { ...config, externalTaskStore: options.externalTaskStore }
|
||||
: config;
|
||||
this.runtime = new InProcessRuntime(runtimeConfig, centralCore);
|
||||
// Let the runtime's SelfHealingManager re-enqueue tasks directly into our
|
||||
// auto-merge queue when it clears a stale `merging` status, instead of
|
||||
// relying on the 15s polling sweep to eventually catch them.
|
||||
// Tests substitute a minimal runtime mock that may not implement this hook.
|
||||
this.runtime.setMergeEnqueuer?.((taskId) => this.internalEnqueueMerge(taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,12 @@ export class InProcessRuntime
|
||||
private ephemeralCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Listener for agent:stateChanged events to clean up terminated ephemeral agents */
|
||||
private ephemeralTerminationListener?: (agentId: string, from: import("@fusion/core").AgentState, to: import("@fusion/core").AgentState) => void;
|
||||
/**
|
||||
* Optional callback the runtime forwards to SelfHealingManager so that
|
||||
* stale-merge recovery can re-enqueue tasks immediately. Set by ProjectEngine
|
||||
* before `start()` via `setMergeEnqueuer`.
|
||||
*/
|
||||
private mergeEnqueuer?: (taskId: string) => void;
|
||||
|
||||
/**
|
||||
* @param config - Runtime configuration
|
||||
@@ -675,6 +681,7 @@ export class InProcessRuntime
|
||||
recoverApprovedTriageTask: (task) => this.triageProcessor?.recoverApprovedTask(task) ?? Promise.resolve(false),
|
||||
getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? new Set<string>(),
|
||||
evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? new Set<string>(),
|
||||
enqueueMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) : undefined,
|
||||
});
|
||||
this.selfHealingManager.start();
|
||||
this.stuckTaskDetector.start();
|
||||
@@ -921,6 +928,15 @@ export class InProcessRuntime
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback used by SelfHealingManager to re-enqueue tasks for
|
||||
* auto-merge after clearing a stale `merging` status. Must be called before
|
||||
* `start()` because SelfHealingManager is constructed during startup.
|
||||
*/
|
||||
setMergeEnqueuer(enqueueMerge: (taskId: string) => void): void {
|
||||
this.mergeEnqueuer = enqueueMerge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project's TaskStore instance.
|
||||
* @throws Error if runtime has not been started
|
||||
|
||||
@@ -66,6 +66,15 @@ export interface SelfHealingOptions {
|
||||
* Should return true if the task was successfully sent back, false otherwise.
|
||||
*/
|
||||
recoverFailedPreMergeStep?: (task: Task) => Promise<boolean>;
|
||||
/**
|
||||
* Re-enqueue a task into the auto-merge queue. Used by
|
||||
* `recoverInterruptedMergingTasks` so that a stale `merging` status that was
|
||||
* just cleared is retried immediately instead of waiting on the next
|
||||
* 15s polling sweep — and so the engine's in-memory `mergeActive` set is
|
||||
* refreshed (otherwise a leftover entry from a SIGKILL'd merge would cause
|
||||
* the polling sweep's enqueue to silently no-op).
|
||||
*/
|
||||
enqueueMerge?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
|
||||
@@ -966,6 +975,13 @@ export class SelfHealingManager {
|
||||
"Auto-recovered: stale merge status cleared; merge will be retried",
|
||||
);
|
||||
log.log(`Recovered interrupted merge ${task.id}: cleared stale status for retry`);
|
||||
try {
|
||||
this.options.enqueueMerge?.(task.id);
|
||||
} catch (enqueueErr: unknown) {
|
||||
log.warn(
|
||||
`Failed to re-enqueue ${task.id} after stale-merge recovery (will rely on polling sweep): ${enqueueErr instanceof Error ? enqueueErr.message : String(enqueueErr)}`,
|
||||
);
|
||||
}
|
||||
recovered++;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Failed to recover interrupted merge ${task.id}: ${errorMessage}`);
|
||||
|
||||
Reference in New Issue
Block a user