Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions packages/engine/src/__tests__/mission-execution-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TEST_MODE_RESOLVED } from "@fusion/core";
import type {
Mission,
Expand Down Expand Up @@ -472,6 +476,25 @@ function expectNoValidationBoardTaskMutation(taskStore: ReturnType<typeof create

// ── Tests ───────────────────────────────────────────────────────────────────

// ── real-git fixtures (for the stale-workspace guard) ────────────────────────
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;

function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}

/** A throwaway git repo with one initial commit; HEAD on `main`. */
function initGitRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "fn-stale-ws-"));
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git config commit.gpgsign false");
writeFileSync(join(repo, "foo.ts"), "line1\n");
git(repo, "git add foo.ts && git commit -m init");
return repo;
}
Comment on lines +486 to +496

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

git init --initial-branch -b flag introduced version

💡 Result:

The --initial-branch and -b flags for the git init command were introduced in Git version 2.28.0 [1][2][3]. These options allow users to specify the name of the initial branch in a newly created repository [4][5]. If these flags are not used, Git defaults to the name defined by the init.defaultBranch configuration variable, or to master if that variable is not set [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the test file around the reported lines.
sed -n '440,530p' packages/engine/src/__tests__/mission-execution-loop.test.ts

# Find the helper used to gate git-dependent tests.
rg -n "hasGit|git init -b main|initial-branch|branch -M main" packages/engine/src/__tests__ packages/engine/src -S

# Locate the hasGit implementation.
fd -a "hasGit" packages/engine/src packages/engine/src/__tests__

Repository: Runfusion/Fusion

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any explicit Git version checks in the test suite or helpers.
rg -n "git version|--version|2\.28|initialBranch|init\.defaultBranch" packages/engine/src -S

# If there's a shared test utility, inspect it for Git capability checks.
rg -n "function hasGit|const hasGit|export .*hasGit" . -S

Repository: Runfusion/Fusion

Length of output: 13456


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find every call site of initGitRepo in the file.
rg -n "initGitRepo\(" packages/engine/src/__tests__/mission-execution-loop.test.ts -n -S

# Read the surrounding test blocks for those call sites.
python3 - <<'PY'
from pathlib import Path
p = Path("packages/engine/src/__tests__/mission-execution-loop.test.ts")
lines = p.read_text().splitlines()
targets = []
for i,l in enumerate(lines, start=1):
    if "initGitRepo(" in l:
        targets.append(i)
for t in targets:
    start = max(1, t-25)
    end = min(len(lines), t+35)
    print(f"\n--- lines {start}-{end} around {t} ---")
    for n in range(start, end+1):
        print(f"{n:4d}: {lines[n-1]}")
PY

Repository: Runfusion/Fusion

Length of output: 5149


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the skip gating around the real-git tests that use initGitRepo.
sed -n '2030,2185p' packages/engine/src/__tests__/mission-execution-loop.test.ts

Repository: Runfusion/Fusion

Length of output: 6239


Avoid git init -b main without a Git-version gate
hasGit only checks that git is installed, so these fixtures still run on Git < 2.28 and git init -b main will fail. Gate on the git version or initialize normally and rename the branch afterward.

♻️ Version-agnostic branch setup
-  git(repo, "git init -b main");
+  git(repo, "git init");
   git(repo, 'git config user.email "test@example.com"');
   git(repo, 'git config user.name "Test User"');
   git(repo, "git config commit.gpgsign false");
   writeFileSync(join(repo, "foo.ts"), "line1\n");
   git(repo, "git add foo.ts && git commit -m init");
+  git(repo, "git branch -M main");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** A throwaway git repo with one initial commit; HEAD on `main`. */
function initGitRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "fn-stale-ws-"));
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git config commit.gpgsign false");
writeFileSync(join(repo, "foo.ts"), "line1\n");
git(repo, "git add foo.ts && git commit -m init");
return repo;
}
/** A throwaway git repo with one initial commit; HEAD on `main`. */
function initGitRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "fn-stale-ws-"));
git(repo, "git init");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git config commit.gpgsign false");
writeFileSync(join(repo, "foo.ts"), "line1\n");
git(repo, "git add foo.ts && git commit -m init");
git(repo, "git branch -M main");
return repo;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/__tests__/mission-execution-loop.test.ts` around lines
486 - 496, The test fixture setup in initGitRepo uses git init -b main without
checking the installed Git version, so it can fail on older Git releases. Update
the repo initialization in initGitRepo to be version-agnostic by either gating
the main-branch initialization on a Git version check or initializing the repo
normally and renaming the branch afterward, while keeping the rest of the
fixture setup unchanged.


describe("MissionExecutionLoop", () => {
let loop: MissionExecutionLoop;
let missionStore: ReturnType<typeof createMockMissionStore>;
Expand Down Expand Up @@ -1881,6 +1904,16 @@ describe("MissionExecutionLoop", () => {
// ── premerge guard (fail verdict while the linked task is unmerged) ──────

describe("premerge guard", () => {
const gitRepos: string[] = [];
afterEach(() => {
for (const dir of gitRepos.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function makeGitRepo(): string {
const repo = initGitRepo();
gitRepos.push(repo);
return repo;
}

function primeFailVerdict() {
const failResponse = JSON.stringify({
status: "fail",
Expand Down Expand Up @@ -2012,6 +2045,144 @@ describe("MissionExecutionLoop", () => {
expect.objectContaining({ featureId: "F-001" }),
);
});

// ── stale-workspace guard (task done, but rootDir predates the merge) ────

(hasGit ? it : it.skip)(
"should defer a fail when the judged checkout predates the merged commit",
async () => {
primeFeature();
primeFailVerdict();

// rootDir HEAD is `main` @ commit1. The merged commit lives on a side
// branch and is NOT reachable from HEAD — exactly the stale-checkout
// case (merge landed on remote / another worktree, rootDir never reset).
const repo = makeGitRepo();
git(repo, "git checkout -q -b feature");
writeFileSync(join(repo, "foo.ts"), "line1\nmerged\n");
git(repo, "git add foo.ts && git commit -m merged");
const mergedSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -q main");

// Column is `done`, so the premerge column guard passes; only the
// ancestry check can catch the stale workspace.
taskStore._setTask({ id: "FN-001", title: "Test", description: "d", log: [], column: "done", integrationSha: mergedSha } as any);

loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: repo,
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();

await loop.processTaskOutcome("FN-001");

expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"blocked",
expect.stringContaining("predates the merged code"),
);
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({
featureId: "F-001",
reason: expect.stringContaining("predates the merged code"),
}),
);
expect(emitSpy).not.toHaveBeenCalledWith("validation:failed", expect.anything());
},
);

(hasGit ? it : it.skip)(
"should run the normal fail path when the merged commit is an ancestor of HEAD",
async () => {
primeFeature();
primeFailVerdict();

// rootDir HEAD advanced PAST the merged commit — the workspace is fresh,
// so the fail is real and must mint a Fix Feature.
const repo = makeGitRepo();
const baseSha = git(repo, "git rev-parse HEAD");
writeFileSync(join(repo, "foo.ts"), "line1\nadvanced\n");
git(repo, "git add foo.ts && git commit -m advance");

taskStore._setTask({ id: "FN-001", title: "Test", description: "d", log: [], column: "done", integrationSha: baseSha } as any);

loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: repo,
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();

await loop.processTaskOutcome("FN-001");

expect(missionStore.createGeneratedFixFeature).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:failed",
expect.objectContaining({ featureId: "F-001" }),
);
expect(emitSpy).not.toHaveBeenCalledWith("validation:inconclusive", expect.anything());
},
);

it("should fail open (normal fail) when the task carries no integration SHA", async () => {
primeFeature();
primeFailVerdict();

// Done, but no integrationSha/baseCommit → no evidence of staleness.
taskStore._setTask({ id: "FN-001", title: "Test", description: "d", log: [], column: "done" });

loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();

await loop.processTaskOutcome("FN-001");

expect(missionStore.createGeneratedFixFeature).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:failed",
expect.objectContaining({ featureId: "F-001" }),
);
expect(emitSpy).not.toHaveBeenCalledWith("validation:inconclusive", expect.anything());
});

(hasGit ? it : it.skip)(
"should fail open (normal fail) when the integration SHA is an unknown object",
async () => {
primeFeature();
primeFailVerdict();

// A bogus SHA makes `git merge-base --is-ancestor` exit 128 (bad object),
// which is unknown → must NOT suppress the fail.
const repo = makeGitRepo();
taskStore._setTask({ id: "FN-001", title: "Test", description: "d", log: [], column: "done", integrationSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" } as any);

loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: repo,
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();

await loop.processTaskOutcome("FN-001");

expect(missionStore.createGeneratedFixFeature).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:failed",
expect.objectContaining({ featureId: "F-001" }),
);
expect(emitSpy).not.toHaveBeenCalledWith("validation:inconclusive", expect.anything());
},
);
});

// ── handleValidationBlocked ───────────────────────────────────────────────
Expand Down
47 changes: 47 additions & 0 deletions packages/engine/src/mission-execution-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ import { createLogger } from "./logger.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { resolveMcpServersForStore } from "./mcp-resolution.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { exec } from "node:child_process";
import { promisify } from "node:util";

const execAsync = promisify(exec);

/** Shell-quote a single argument for a `git` invocation (mirror of the local
* helper in branch-conflicts.ts — kept local rather than shared per repo
* convention). */
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

/** Logger for the mission execution loop subsystem. */
export const loopLog = createLogger("mission-loop");
Expand Down Expand Up @@ -512,6 +523,18 @@ export class MissionExecutionLoop extends EventEmitter {
run.id,
`linked task ${feature.taskId} is still "${premergeColumn}" (code not merged yet) — validation deferred`,
);
} else if (await this.isValidationWorkspaceStale(feature)) {
// Even when the task column shows "done", the judge session ran in
// this.rootDir — if that working copy never fetched/reset to the
// merged commit (merge landed on remote or another worktree), the
// validator read PRE-merge files → a spurious fail. Defer instead of
// minting a bogus Fix Feature; a later validation judges the merged
// code. Only affirmative staleness evidence defers (fail-open).
await this.handleValidationInconclusive(
feature.id,
run.id,
`validation workspace predates the merged code for ${feature.taskId} — validation deferred`,
);
} else {
await this.handleValidationFail(feature.id, run.id, result);
}
Expand Down Expand Up @@ -547,6 +570,30 @@ export class MissionExecutionLoop extends EventEmitter {
return column;
}

/**
* Affirmative-evidence check that the judged checkout (this.rootDir HEAD)
* predates the linked task's merged code. True ONLY when the integration SHA
* is resolvable AND is NOT an ancestor of rootDir HEAD. Fails open (returns
* false = trust the fail) on unresolvable SHA / unknown object / any git
* error — the guard may only ever defer a fail, never suppress one.
*/
private async isValidationWorkspaceStale(feature: MissionFeature): Promise<boolean> {
const integrationSha = await this.resolveIntegrationSha(feature);
if (!integrationSha) return false; // no evidence → trust the fail
Comment on lines +581 to +582

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Production SHA Field Is Missed

When a done task only carries the modeled task SHA field, resolveIntegrationSha returns no SHA because it looks for duck-typed integrationSha or baseCommit fields. That makes this new guard return false, so the stale-workspace fail still enters handleValidationFail and mints the bogus Fix Feature this change is meant to defer.

try {
await execAsync(`git merge-base --is-ancestor ${quoteShellArg(integrationSha)} HEAD`, {
cwd: this.rootDir,
timeout: 30_000,
});
return false; // exit 0 → ancestor → workspace is fresh
} catch (err) {
// `--is-ancestor` exits 1 = NOT an ancestor (affirmatively stale); exit
// 128 = bad object / not a repo (unknown → fail-open). execAsync's thrown
// error carries `.code` = process exit code. ONLY code === 1 defers.
return (err as { code?: number })?.code === 1;
}
}

/**
* Run the validation AI session for a feature.
*
Expand Down
Loading