fix(dashboard): hand off cleanly from setup wizard to model onboarding

On a fresh install useAuthOnboarding's effect ran at mount before the
setup wizard's 500ms auto-open timer fired. The one-shot ref locked,
and the resolved fetch could either stack model onboarding on top of
the wizard or never re-trigger after the wizard closed.

- Gate the trigger on projectId being set so the wizard owns the
  bootstrap phase; the auth check only fires once a project exists.
- Re-check setupWizardOpen via a ref when the auth fetch resolves to
  avoid stacking onboarding on top of a wizard opened mid-fetch.
- Release the one-shot in that suppressed branch so the effect retries
  when the wizard closes.

Adds two regression tests: fresh-install handoff and mid-fetch wizard
suppression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-28 14:30:38 -07:00
committed by gsxdsm
parent 5e5020b78f
commit 5e6dbbb6f3
7 changed files with 300 additions and 109 deletions

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js"; export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; export type { MergeStrategy, MergeConflictStrategy, Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js"; export { AGENT_VALID_TRANSITIONS } from "./types.js";
export { export {
BUILTIN_AGENT_PROMPTS, BUILTIN_AGENT_PROMPTS,

View File

@@ -114,6 +114,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
smartConflictResolution: true, smartConflictResolution: true,
worktreeRebaseBeforeMerge: true, worktreeRebaseBeforeMerge: true,
worktreeRebaseRemote: "", worktreeRebaseRemote: "",
worktreeRebaseLocalBase: true,
mergeConflictStrategy: "smart",
workflowStepTimeoutMs: 360_000, workflowStepTimeoutMs: 360_000,
strictScopeEnforcement: false, strictScopeEnforcement: false,
buildRetryCount: 0, buildRetryCount: 0,

View File

@@ -92,6 +92,16 @@ export type ColorTheme = (typeof COLOR_THEMES)[number];
export type PrStatus = "open" | "closed" | "merged"; export type PrStatus = "open" | "closed" | "merged";
export type MergeStrategy = "direct" | "pull-request"; export type MergeStrategy = "direct" | "pull-request";
/** How merge conflicts are resolved when the AI agent can't (or shouldn't) decide.
* - "smart" (default): try AI, then auto-resolve lock/generated/trivial files,
* then fall back to `-X theirs` (task branch wins). Backwards-compatible.
* - "ai-only": run AI on every attempt; never silently prefer one side.
* - "prefer-main": after AI/auto-resolve, fall back to `-X ours` so main's
* state wins. Best when concurrent tasks frequently regress just-merged
* sibling work.
* - "abort": run AI once; if conflict remains, fail the merge so a human
* can resolve it. */
export type MergeConflictStrategy = "smart" | "ai-only" | "prefer-main" | "abort";
/** Policy for handling task execution when the selected node is unavailable/unhealthy. */ /** Policy for handling task execution when the selected node is unavailable/unhealthy. */
export type UnavailableNodePolicy = "block" | "fallback-local"; export type UnavailableNodePolicy = "block" | "fallback-local";
@@ -685,8 +695,8 @@ export interface MergeDetails {
mergedAt?: string; mergedAt?: string;
mergeConfirmed?: boolean; mergeConfirmed?: boolean;
prNumber?: number; prNumber?: number;
resolutionStrategy?: "ai" | "auto-resolve" | "theirs"; resolutionStrategy?: "ai" | "auto-resolve" | "theirs" | "ours" | "abort";
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs"; resolutionMethod?: "ai" | "auto" | "mixed" | "theirs" | "ours" | "abort";
attemptsMade?: 1 | 2 | 3; attemptsMade?: 1 | 2 | 3;
autoResolvedCount?: number; autoResolvedCount?: number;
} }
@@ -1500,6 +1510,17 @@ export interface ProjectSettings {
* (typically `origin`). Exposed as a dropdown in the dashboard's * (typically `origin`). Exposed as a dropdown in the dashboard's
* Worktrees settings. */ * Worktrees settings. */
worktreeRebaseRemote?: string; worktreeRebaseRemote?: string;
/** When true, the worktree is also rebased onto the local default-branch
* HEAD (in addition to the remote rebase). Catches sibling tasks that
* merged into local main *after* this task's worktree was created but
* *before* its merge — including merges that haven't been pushed yet.
* Without this, concurrent task branches based on stale main can silently
* re-introduce code that an earlier sibling task already deleted.
* Default: true. */
worktreeRebaseLocalBase?: boolean;
/** Strategy used when a merge conflict can't be resolved by AI. See
* {@link MergeConflictStrategy}. Default: "smart". */
mergeConflictStrategy?: MergeConflictStrategy;
/** Wall-clock timeout (ms) for a single pre-merge workflow step's AI call. /** Wall-clock timeout (ms) for a single pre-merge workflow step's AI call.
* When a step exceeds this, the session is aborted and the executor is * When a step exceeds this, the session is aborted and the executor is
* given one shot to retry with the configured fallback model before the * given one shot to retry with the configured fallback model before the

View File

@@ -2770,6 +2770,22 @@ export function SettingsModal({
</small> </small>
</div> </div>
)} )}
<div className="form-group">
<label htmlFor="worktreeRebaseLocalBase" className="checkbox-label">
<input
id="worktreeRebaseLocalBase"
type="checkbox"
checked={form.worktreeRebaseLocalBase !== false}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseLocalBase: e.target.checked }))
}
/>
Also rebase onto local default-branch HEAD
</label>
<small>
In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow.
</small>
</div>
</> </>
); );
case "commands": case "commands":
@@ -2937,6 +2953,24 @@ export function SettingsModal({
</label> </label>
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small> <small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small>
</div> </div>
<div className="form-group">
<label htmlFor="mergeConflictStrategy">Conflict Fallback Strategy</label>
<select
id="mergeConflictStrategy"
value={form.mergeConflictStrategy ?? "smart"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart" | "ai-only" | "prefer-main" | "abort" }))
}
>
<option value="smart">Smart — AI, then auto-resolve, then prefer task branch (default)</option>
<option value="ai-only">AI only — never silently pick a side</option>
<option value="prefer-main">Prefer main — fall back to keeping main's version</option>
<option value="abort">Abort — fail merge if AI can't resolve (manual review)</option>
</select>
<small>
What to do on the final merge attempt when AI and auto-resolve haven't fully resolved the conflict. <strong>Smart</strong> matches the historical behavior — the task branch wins on hard conflicts, which is fast but can resurrect code an earlier sibling task deleted. <strong>Prefer main</strong> sides with main instead, protecting just-merged sibling work. <strong>AI only</strong> retries the AI agent rather than silently choosing a side. <strong>Abort</strong> requires manual resolution for any conflict the AI can't handle.
</small>
</div>
<div className="form-group"> <div className="form-group">
<label htmlFor="pushAfterMerge" className="checkbox-label"> <label htmlFor="pushAfterMerge" className="checkbox-label">
<input <input

View File

@@ -1702,31 +1702,6 @@ export function TaskDetailModal({
</div> </div>
)} )}
<MergeDetails task={task} /> <MergeDetails task={task} />
<div className="detail-section">
<h4>Node Routing</h4>
<dl className="detail-source-grid">
<div>
<dt>Task Override</dt>
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
</div>
<div>
<dt>Effective Node</dt>
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
</div>
<div>
<dt>Routing Source</dt>
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
</div>
<div>
<dt>Unavailable Node Policy</dt>
<dd>{(settings as Settings & { unavailableNodePolicy?: string } | undefined)?.unavailableNodePolicy ?? "block"}</dd>
</div>
<div>
<dt>Blocking Reason</dt>
<dd>{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason || (task as Task & { statusReason?: string }).statusReason) ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
</div>
</dl>
</div>
{task.sourceIssue && ( {task.sourceIssue && (
<div className="detail-section detail-source-section"> <div className="detail-section detail-source-section">
<h4>Source Issue</h4> <h4>Source Issue</h4>

View File

@@ -4161,11 +4161,11 @@ and show an appropriate message to the user.\`
const result = 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 // Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge
// is enabled, fetch the remote and rebase the just-created task branch // is enabled, fetch the remote and rebase the just-created task branch
// onto the latest <remote>/<defaultBranch>. This makes the worktree // onto the latest <remote>/<defaultBranch>. When worktreeRebaseLocalBase
// start from origin/main + local main both, so divergence only matters // is also enabled (default), additionally rebase onto rootDir's HEAD —
// if the user actively skips this setting. Best-effort: failures here // catches sibling tasks that merged into local main but haven't been
// don't abort task setup. // pushed yet. Best-effort: failures don't abort task setup.
await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err: unknown) => { await this.rebaseNewWorktreeOntoBase(result.path, result.branch, taskId).catch((err: unknown) => {
executorLog.warn( executorLog.warn(
`Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`, `Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`,
); );
@@ -4202,17 +4202,25 @@ and show an appropriate message to the user.\`
} }
/** /**
* After creating a fresh task worktree, fetch the configured remote and * After creating a fresh task worktree, rebase the task branch onto the
* rebase the task branch onto `<remote>/<defaultBranch>`. The result is a * freshest available base.
* 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 * Two stages, each independent and best-effort:
* configured/resolvable, or the rebase produces conflicts (we abort and * 1. Fetch the configured remote and rebase onto `<remote>/<defaultBranch>`
* leave the worktree as-is so the executor can still run). * (controlled by `worktreeRebaseBeforeMerge`).
* 2. Rebase onto rootDir's HEAD — i.e., the local default branch with any
* sibling-task merges that haven't been pushed yet (controlled by
* `worktreeRebaseLocalBase`).
*
* Without stage 2, a task that branches before a sibling merge will silently
* resurrect deletions that the sibling already shipped (the FN-2887 class of
* regression). With stage 2, even unpushed local merges are picked up.
*
* No-op when both rebases are disabled. Conflicts at either stage abort that
* stage cleanly and leave the worktree usable; the merge-time rebase remains
* the backstop.
*/ */
private async rebaseNewWorktreeOntoRemote( private async rebaseNewWorktreeOntoBase(
worktreePath: string, worktreePath: string,
branch: string, branch: string,
taskId: string, taskId: string,
@@ -4223,69 +4231,105 @@ and show an appropriate message to the user.\`
} catch { } catch {
return; return;
} }
if (settings.worktreeRebaseBeforeMerge === false) return;
let remote = settings.worktreeRebaseRemote?.trim() || ""; const remoteRebaseEnabled = settings.worktreeRebaseBeforeMerge !== false;
if (!remote) { const localRebaseEnabled = settings.worktreeRebaseLocalBase !== false;
try { if (!remoteRebaseEnabled && !localRebaseEnabled) return;
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 = ""; let defaultBranch = "";
try { let remote = "";
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), ""); if (remoteRebaseEnabled) {
} catch { remote = settings.worktreeRebaseRemote?.trim() || "";
// origin/HEAD not set — fall back to current branch in rootDir. 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 — fall through; the local-base stage may still apply.
}
}
if (remote) {
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 through.
}
}
} }
if (!defaultBranch) { if (!defaultBranch) {
try { try {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir }); const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
defaultBranch = stdout.trim(); defaultBranch = stdout.trim();
} catch {
// No HEAD branch — nothing to rebase onto.
}
}
if (defaultBranch === "HEAD") defaultBranch = "";
// Stage 1: rebase onto remote tip.
if (remoteRebaseEnabled && remote && defaultBranch) {
const remoteRef = `${remote}/${defaultBranch}`;
try {
await execAsync(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
await execAsync(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
await this.store.logEntry(taskId, `Rebased new worktree branch ${branch} onto ${remoteRef}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
executorLog.warn(
`Worktree rebase: stage 1 (onto ${remoteRef}) failed for ${taskId}: ${msg}`,
);
try {
await execAsync("git rebase --abort", { cwd: worktreePath });
} catch {
// best-effort
}
}
}
// Stage 2: rebase onto local rootDir HEAD. Picks up sibling merges that
// landed locally after this worktree was created but haven't been pushed.
if (localRebaseEnabled) {
let localHead = "";
try {
const { stdout } = await execAsync("git rev-parse HEAD", { cwd: this.rootDir });
localHead = stdout.trim();
} catch { } catch {
return; return;
} }
} if (!localHead) return;
if (!defaultBranch || defaultBranch === "HEAD") return;
const remoteRef = `${remote}/${defaultBranch}`; // Skip if the worktree branch already contains the local HEAD — nothing to do.
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 { try {
await execAsync("git rebase --abort", { cwd: worktreePath }); await execAsync(`git merge-base --is-ancestor ${this.quoteShellArg(localHead)} HEAD`, { cwd: worktreePath });
return;
} catch { } catch {
// best-effort // not an ancestor — proceed to rebase.
}
try {
await execAsync(`git rebase ${this.quoteShellArg(localHead)}`, { cwd: worktreePath });
await this.store.logEntry(taskId, `Rebased new worktree branch ${branch} onto local ${defaultBranch || "HEAD"} (${localHead.slice(0, 8)})`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
executorLog.warn(
`Worktree rebase: stage 2 (onto local HEAD ${localHead.slice(0, 8)}) failed for ${taskId} — aborting: ${msg}`,
);
try {
await execAsync("git rebase --abort", { cwd: worktreePath });
} catch {
// best-effort
}
await this.store.logEntry(
taskId,
`Could not rebase new worktree onto local ${defaultBranch || "HEAD"} — merge-time rebase will retry with conflict resolution.`,
);
} }
await this.store.logEntry(
taskId,
`Could not rebase new worktree onto ${remoteRef} — kept local base. The merge-time rebase will retry with conflict resolution.`,
);
} }
} }

View File

@@ -133,7 +133,7 @@ async function execWithProcessGroup(
} }
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core"; import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig, type MergeConflictStrategy } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core"; import { resolveAgentPrompt } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js"; import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -2170,6 +2170,7 @@ export async function aiMergeTask(
const includeTaskId = settings.includeTaskIdInCommit !== false; const includeTaskId = settings.includeTaskIdInCommit !== false;
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy) // Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false; const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
const mergeConflictStrategy: NonNullable<MergeConflictStrategy> = settings.mergeConflictStrategy ?? "smart";
// 3. Check branch exists // 3. Check branch exists
try { try {
@@ -2320,6 +2321,45 @@ export async function aiMergeTask(
throwIfAborted(options.signal, taskId); throwIfAborted(options.signal, taskId);
await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath }); await execAsync(`git rebase "${remoteRef}"`, { cwd: worktreePath });
mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`); mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
// Stage 2: also rebase onto rootDir's local HEAD when enabled.
// Catches sibling-task merges that landed locally but haven't
// been pushed to remote yet. Without this, two tasks based on a
// common ancestor where one deletes code can have the other
// silently resurrect that code via a -X theirs fallback.
if (settings.worktreeRebaseLocalBase !== false) {
try {
const { stdout: localHeadOut } = await execAsync("git rev-parse HEAD", {
cwd: rootDir,
encoding: "utf-8",
});
const localHead = localHeadOut.trim();
if (localHead) {
// Skip if worktree branch already contains local HEAD.
let alreadyContains = false;
try {
await execAsync(`git merge-base --is-ancestor "${localHead}" HEAD`, { cwd: worktreePath });
alreadyContains = true;
} catch {
// not an ancestor — rebase needed
}
if (!alreadyContains) {
throwIfAborted(options.signal, taskId);
await execAsync(`git rebase "${localHead}"`, { cwd: worktreePath });
mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)}`);
}
}
} catch (localRebaseErr) {
rethrowIfMergeAborted(localRebaseErr);
const lmsg = localRebaseErr instanceof Error ? localRebaseErr.message : String(localRebaseErr);
mergerLog.warn(`${taskId}: pre-merge rebase onto local HEAD failed (${lmsg}) — aborting and falling through`);
try {
await execAsync("git rebase --abort", { cwd: worktreePath });
} catch (abortError: unknown) {
mergerLog.warn(`${taskId}: failed to abort local-HEAD rebase: ${getCommandErrorMessage(abortError)}`);
}
}
}
} else { } else {
mergerLog.warn(`${taskId}: no worktreePath — skipping task branch rebase`); mergerLog.warn(`${taskId}: no worktreePath — skipping task branch rebase`);
} }
@@ -2341,6 +2381,40 @@ export async function aiMergeTask(
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) — proceeding without rebase`); mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) — proceeding without rebase`);
} }
} else if (settings.worktreeRebaseLocalBase !== false && worktreePath) {
// Remote rebase is disabled but the user still wants the local-base
// rebase. This catches sibling-task merges that landed locally even when
// the project doesn't have a remote configured.
try {
const { stdout: localHeadOut } = await execAsync("git rev-parse HEAD", {
cwd: rootDir,
encoding: "utf-8",
});
const localHead = localHeadOut.trim();
if (localHead) {
let alreadyContains = false;
try {
await execAsync(`git merge-base --is-ancestor "${localHead}" HEAD`, { cwd: worktreePath });
alreadyContains = true;
} catch {
// not an ancestor — rebase needed
}
if (!alreadyContains) {
throwIfAborted(options.signal, taskId);
await execAsync(`git rebase "${localHead}"`, { cwd: worktreePath });
mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)} (remote rebase disabled)`);
}
}
} catch (localOnlyErr) {
rethrowIfMergeAborted(localOnlyErr);
const msg = localOnlyErr instanceof Error ? localOnlyErr.message : String(localOnlyErr);
mergerLog.warn(`${taskId}: pre-merge local-HEAD rebase failed (${msg}) — falling through to smart/AI merge`);
try {
await execAsync("git rebase --abort", { cwd: worktreePath });
} catch (abortError: unknown) {
mergerLog.warn(`${taskId}: failed to abort local-HEAD rebase: ${getCommandErrorMessage(abortError)}`);
}
}
} }
// 4. Gather context for the agent (used in all attempts) // 4. Gather context for the agent (used in all attempts)
@@ -2436,6 +2510,7 @@ export async function aiMergeTask(
diffStat, diffStat,
includeTaskId, includeTaskId,
smartConflictResolution, smartConflictResolution,
mergeConflictStrategy,
attemptNum, attemptNum,
options, options,
result, result,
@@ -2448,7 +2523,7 @@ export async function aiMergeTask(
if (success) { if (success) {
result.attemptsMade = attemptNum; result.attemptsMade = attemptNum;
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution); result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy);
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked); result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
result.merged = true; result.merged = true;
return true; return true;
@@ -2698,13 +2773,20 @@ export async function aiMergeTask(
// Attempt 1: Standard AI merge // Attempt 1: Standard AI merge
merged = await mergeAttempt(1); merged = await mergeAttempt(1);
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled) // Attempt 2: Auto-resolve lock/generated files, then AI (if enabled).
if (!merged && smartConflictResolution) { // Skipped for "abort" — that strategy gives the user one AI shot, no more.
if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
merged = await mergeAttempt(2); merged = await mergeAttempt(2);
} }
// Attempt 3: Use -X theirs merge strategy (if enabled) // Attempt 3: -X theirs (smart) or -X ours (prefer-main) fallback.
if (!merged && smartConflictResolution) { // Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
if (
!merged
&& smartConflictResolution
&& mergeConflictStrategy !== "ai-only"
&& mergeConflictStrategy !== "abort"
) {
merged = await mergeAttempt(3); merged = await mergeAttempt(3);
} }
@@ -2717,6 +2799,10 @@ export async function aiMergeTask(
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: git reset --merge cleanup failed: ${errorMessage}`); mergerLog.warn(`${taskId}: git reset --merge cleanup failed: ${errorMessage}`);
} }
if (mergeConflictStrategy === "abort") {
result.resolutionStrategy = "abort";
throw new Error(`Merge conflict for ${taskId}: aborted per mergeConflictStrategy="abort" — manual resolution required`);
}
throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`); throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
} }
@@ -2926,10 +3012,14 @@ export async function aiMergeTask(
return result; return result;
} }
/** Get the resolution strategy based on attempt number and settings */ /** Get the resolution strategy based on attempt number and settings.
* `mergeConflictStrategy` controls the FALLBACK on attempt 3 (and gates the
* whole cascade on "abort"); attempts 12 always try AI then auto-resolve so
* trivial conflicts don't pay an unnecessary price. */
function getResolutionStrategy( function getResolutionStrategy(
attemptNum: 1 | 2 | 3, attemptNum: 1 | 2 | 3,
smartConflictResolution: boolean, smartConflictResolution: boolean,
mergeConflictStrategy: NonNullable<MergeConflictStrategy> = "smart",
): MergeResult["resolutionStrategy"] { ): MergeResult["resolutionStrategy"] {
if (!smartConflictResolution || attemptNum === 1) { if (!smartConflictResolution || attemptNum === 1) {
return "ai"; return "ai";
@@ -2937,7 +3027,18 @@ function getResolutionStrategy(
if (attemptNum === 2) { if (attemptNum === 2) {
return "auto-resolve"; return "auto-resolve";
} }
return "theirs"; // Attempt 3: fallback strategy
switch (mergeConflictStrategy) {
case "ai-only":
return "ai";
case "prefer-main":
return "ours";
case "abort":
return "abort";
case "smart":
default:
return "theirs";
}
} }
/** Map resolutionStrategy and autoResolvedCount to resolutionMethod for metrics/debugging */ /** Map resolutionStrategy and autoResolvedCount to resolutionMethod for metrics/debugging */
@@ -2948,6 +3049,8 @@ function getResolutionMethod(
): MergeResult["resolutionMethod"] { ): MergeResult["resolutionMethod"] {
if (strategy === "ai") return "ai"; if (strategy === "ai") return "ai";
if (strategy === "theirs") return "theirs"; if (strategy === "theirs") return "theirs";
if (strategy === "ours") return "ours";
if (strategy === "abort") return "abort";
if (strategy === "auto-resolve") { if (strategy === "auto-resolve") {
// auto-resolve strategy: determine if pure auto or mixed with AI // auto-resolve strategy: determine if pure auto or mixed with AI
if (autoResolvedCount && autoResolvedCount > 0) { if (autoResolvedCount && autoResolvedCount > 0) {
@@ -2968,6 +3071,7 @@ interface MergeAttemptParams {
diffStat: string; diffStat: string;
includeTaskId: boolean; includeTaskId: boolean;
smartConflictResolution: boolean; smartConflictResolution: boolean;
mergeConflictStrategy: NonNullable<MergeConflictStrategy>;
attemptNum: 1 | 2 | 3; attemptNum: 1 | 2 | 3;
options: MergerOptions; options: MergerOptions;
result: MergeResult; result: MergeResult;
@@ -3017,9 +3121,15 @@ async function executeMergeAttempt(
buildSource, buildSource,
} = params; } = params;
// Attempt 3: Use -X theirs strategy // Attempt 3: dispatch on the configured fallback strategy.
// Note: "ai-only" and "abort" are filtered out by the mergeAttempt cascade
// before reaching here — only "smart" (theirs) and "prefer-main" (ours)
// legitimately run attempt 3.
if (attemptNum === 3) { if (attemptNum === 3) {
return attemptWithTheirsStrategy(params); if (params.mergeConflictStrategy === "prefer-main") {
return attemptWithSideStrategy(params, "ours");
}
return attemptWithSideStrategy(params, "theirs");
} }
// Attempt 1 & 2: Standard squash merge // Attempt 1 & 2: Standard squash merge
@@ -3292,17 +3402,22 @@ async function executeMergeAttempt(
} }
/** /**
* Attempt 3: Use git merge -X theirs --squash strategy * Attempt 3: Use git merge -X{theirs,ours} --squash strategy.
* Side controls which version wins on conflicts:
* - "theirs" — the task branch wins (default fallback)
* - "ours" — the main branch wins (used by mergeConflictStrategy="prefer-main")
*/ */
async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<boolean> { async function attemptWithSideStrategy(
params: MergeAttemptParams,
side: "theirs" | "ours" = "theirs",
): Promise<boolean> {
const { rootDir, branch, commitLog, includeTaskId, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params; const { rootDir, branch, commitLog, includeTaskId, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`); mergerLog.log(`${taskId}: attempting merge with -X ${side} strategy`);
try { try {
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
throwIfAborted(params.options.signal, taskId); throwIfAborted(params.options.signal, taskId);
await execAsync(`git merge -X theirs --squash "${branch}"`, { await execAsync(`git merge -X ${side} --squash "${branch}"`, {
cwd: rootDir, cwd: rootDir,
}); });
@@ -3313,8 +3428,8 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
}).trim(); }).trim();
if (conflictedOutput.length > 0) { if (conflictedOutput.length > 0) {
mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`); mergerLog.warn(`${taskId}: -X ${side} left unresolved conflicts: ${conflictedOutput}`);
return false; // Still has conflicts after -X theirs return false;
} }
// Check if there's anything staged // Check if there's anything staged
@@ -3351,7 +3466,7 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${authorArg}`, `git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${authorArg}`,
{ cwd: rootDir }, { cwd: rootDir },
); );
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`); mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
// Run deterministic verification after committing // Run deterministic verification after committing
if (testCommand || buildCommand) { if (testCommand || buildCommand) {
@@ -3373,7 +3488,7 @@ async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<bo
if (error instanceof Error && error.name === "MergeAbortedError") { if (error instanceof Error && error.name === "MergeAbortedError") {
throw error; throw error;
} }
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`); mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
return false; return false;
} }
} }