From 9cad8ff716570869b64a634914e6f7f20313923f Mon Sep 17 00:00:00 2001 From: semih Date: Fri, 15 May 2026 12:00:14 +0300 Subject: [PATCH] fix(executor): contamination detector reads origin/, not stale local main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.29.0's classifyForeignCommits() defaults mainRef="main" — the LOCAL main branch. For Fusion setups where main is never auto-updated (e.g. Fusion writes only to origin/dev with humans merging dev→main on a separate branch), local main drifts behind quickly. Each task created after the drift fails with: Branch fusion/fn-XXX contains N foreign task-attributed commits since base SHA [recovery] contamination classification: already-upstream=[none] unique=[14 SHAs] All "unique" SHAs are legitimate merged dev commits that ARE on origin/main and origin/dev — the detector just can't see that because it's comparing against stale local main. Resolution order at the call site: 1. origin/ (settings: baseBranch + worktreeRebaseRemote remote name) 2. origin/main (matches the audit script's default) 3. "main" (legacy default — preserves behavior for projects without those settings) Logs the resolved mainRef so future drift is observable. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/src/executor.ts | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index d82cdbfbe..b3bedf3e3 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -4347,11 +4347,45 @@ export class TaskExecutor { return; } + // Sase setup: Fusion only writes to origin/dev (main is human-merged from dev). + // Local `main` is never auto-updated, so the default `mainRef = "main"` makes the + // detector compare against a stale ref and mis-flag legit merged commits as foreign. + // Settings expose `worktreeRebaseRemote` (= "origin") and `baseBranch` (= "dev"); + // prefer the project's deploy branch (origin/) and fall back to origin/main + // then "main" so non-sase projects keep current behavior. + const settingsForContamination = await this.store.getSettings(); + const baseBranchSetting = (settingsForContamination as { baseBranch?: string }).baseBranch; + const remoteSetting = (settingsForContamination as { worktreeRebaseRemote?: string }).worktreeRebaseRemote; + const remoteName = typeof remoteSetting === "string" && remoteSetting.trim().length > 0 + ? remoteSetting.split(/\s+/)[0] + : "origin"; + const resolveContaminationRef = async (ref: string): Promise => { + try { + const { stdout } = await execAsync(`git -C "${this.rootDir}" rev-parse --verify ${ref}`, { timeout: 5000 }); + return stdout.trim() ? ref : null; + } catch { + return null; + } + }; + const contaminationMainRef = + (typeof baseBranchSetting === "string" && baseBranchSetting.trim().length > 0 + ? await resolveContaminationRef(`${remoteName}/${baseBranchSetting.trim()}`) + : null) + ?? (await resolveContaminationRef(`${remoteName}/main`)) + ?? "main"; + await this.store.logEntry( + task.id, + `[recovery] using contamination mainRef=${contaminationMainRef}`, + undefined, + this.currentRunContext, + ); + const classified = await classifyForeignCommits({ repoDir: this.rootDir, branchName: err.branchName, baseSha: err.baseSha, foreignCommits: err.foreignCommits, + mainRef: contaminationMainRef, }); const alreadyShas = classified.alreadyUpstream.map((commit) => commit.sha.slice(0, 12)).join(", ") || "none";