Skip to content
Merged
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
6 changes: 3 additions & 3 deletions packages/engine/src/orchestrator/phases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,9 +438,9 @@ export async function* executePlans(ctx: PhaseContext): AsyncGenerator<EforgeEve
if (signal?.aborted) {
break;
}

yield event;

// --- eforge:region plan-01-review-cycle-dirty-worktree-safety ---
if (!(event.type === 'plan:status:change' && event.status === 'completed' && state.plans[event.planId]?.status === 'failed')) yield event; // drop stale completed (merge-failure race)
// --- eforge:endregion plan-01-review-cycle-dirty-worktree-safety ---
// Check if any running plans just finished (completed or failed — NOT pending,
// which is the initial state before the async plan runner updates it to running)
const justCompleted: string[] = [];
Expand Down
63 changes: 63 additions & 0 deletions packages/engine/src/pipeline/runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { stat } from 'node:fs/promises';

import type { EforgeEvent, AgentRole } from '../events.js';
import type { TracingContext } from '../tracing.js';
Expand Down Expand Up @@ -310,5 +311,67 @@ export async function* runBuildPipeline(
if (ctx.buildFailed) return;
}

// --- eforge:region plan-01-review-cycle-dirty-worktree-safety ---
// Final guard: fail the pipeline when the plan worktree has uncommitted changes at
// the end of all build stages. A dirty worktree means implementation work was not
// committed, which would produce a silent no-op merge. Non-git contexts (unit tests
// without a real worktree) skip the check gracefully. Real git errors (index lock,
// permission failures, corruption) are treated as hard failures.

// Stat the worktree path first so that ENOENT from exec() is never misread as
// "not a git repository". A missing worktree path means we are in a unit-test
// context without a real worktree — skip gracefully. A path that exists but
// where git fails for other reasons (permissions, corruption, missing binary)
// is treated as a hard failure.
let worktreeExists = false;
try {
await stat(ctx.worktreePath);
worktreeExists = true;
} catch {
// Path does not exist — not a real worktree (unit test). Skip gracefully.
}

let isGitRepo = false;
if (worktreeExists) {
try {
await exec('git', ['rev-parse', '--is-inside-work-tree'], { cwd: ctx.worktreePath });
isGitRepo = true;
} catch (err) {
// Distinguish "not a git repository" (expected in unit tests without a real worktree)
// from real git failures (permissions, corruption, dubious ownership, missing git binary).
// The path exists (confirmed above), so ENOENT here means git itself could not be launched.
const stderr = (err as NodeJS.ErrnoException & { stderr?: string }).stderr ?? '';
const isNotGitRepo =
stderr.includes('not a git repository') ||
stderr.includes('not a git repo');
if (!isNotGitRepo) {
// A real Git error (or missing git binary) — treat as a hard failure.
const errorMsg = `Dirty worktree guard: git failed: ${err instanceof Error ? err.message : String(err)}`;
yield { timestamp: new Date().toISOString(), type: 'plan:build:failed', planId: ctx.planId, error: errorMsg };
ctx.buildFailed = true;
return;
}
// Not a git repository — skip the dirty worktree guard gracefully.
}
}
if (isGitRepo) {
try {
const { stdout: dirtyOut } = await exec('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: ctx.worktreePath });
const dirtyFiles = dirtyOut.trimEnd().split(/\r?\n/).filter(Boolean);
if (dirtyFiles.length > 0) {
const errorMsg = `Plan pipeline completed with ${dirtyFiles.length} uncommitted file(s) in the worktree:\n${dirtyFiles.join('\n')}`;
yield { timestamp: new Date().toISOString(), type: 'plan:build:failed', planId: ctx.planId, error: errorMsg };
ctx.buildFailed = true;
return;
}
} catch (err) {
const errorMsg = `Dirty worktree guard: git status failed: ${err instanceof Error ? err.message : String(err)}`;
yield { timestamp: new Date().toISOString(), type: 'plan:build:failed', planId: ctx.planId, error: errorMsg };
ctx.buildFailed = true;
return;
}
}
// --- eforge:endregion plan-01-review-cycle-dirty-worktree-safety ---

yield { timestamp: new Date().toISOString(), type: 'plan:build:complete', planId: ctx.planId };
}
52 changes: 31 additions & 21 deletions packages/engine/src/pipeline/stages/build-stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,12 @@ function lastBuildEvaluationNotRun(): LastBuildEvaluation {
return { ran: false, accepted: 0, rejected: 0, review: 0, files: [] };
}
// --- eforge:endregion plan-01-adaptive-review-cycle-perspectives ---

// --- eforge:region plan-01-review-cycle-dirty-worktree-safety ---
function formatNoVerdictsFailureMessage(s: EvaluationSnapshot, r: string): string {
const p = s.files.map((f) => f.path).join(', ');
return p ? `${r} Candidate files with uncommitted changes: ${p}` : r;
}
// --- eforge:endregion plan-01-review-cycle-dirty-worktree-safety ---
function summarizeEvaluationVerdicts(verdicts: EvaluationVerdict[]) {
return verdicts.map(v => ({
file: v.file,
Expand Down Expand Up @@ -536,16 +541,10 @@ async function* evaluateStageInner(
yield driftFailure;
return;
}
yield {
timestamp: new Date().toISOString(),
type: 'agent:warning',
planId: ctx.planId,
agentId: 'unknown-evaluator',
agent: 'evaluator',
code: 'evaluation-judgment-failed',
message: err instanceof Error ? err.message : String(err),
};
setLastBuildEvaluation(ctx, lastBuildEvaluationNotRun());
// --- eforge:region plan-01-review-cycle-dirty-worktree-safety ---
yield { timestamp: new Date().toISOString(), type: 'plan:build:failed', planId: ctx.planId, error: formatNoVerdictsFailureMessage(snapshot, err instanceof Error ? err.message : String(err)) } as EforgeEvent;
ctx.buildFailed = true;
// --- eforge:endregion plan-01-review-cycle-dirty-worktree-safety ---
return;
}

Expand All @@ -555,16 +554,10 @@ async function* evaluateStageInner(
yield driftFailure;
return;
}
yield {
timestamp: new Date().toISOString(),
type: 'agent:warning',
planId: ctx.planId,
agentId: result?.agentId ?? 'unknown-evaluator',
agent: 'evaluator',
code: (suppressedTerminalFailure || result?.failed) ? 'evaluation-judgment-failed' : 'evaluation-verdicts-missing',
message: suppressedTerminalFailure?.error ?? result?.error ?? 'Evaluator produced no verdicts; no review-fixer changes were committed.',
};
setLastBuildEvaluation(ctx, lastBuildEvaluationNotRun());
// --- eforge:region plan-01-review-cycle-dirty-worktree-safety ---
yield { timestamp: new Date().toISOString(), type: 'plan:build:failed', planId: ctx.planId, error: formatNoVerdictsFailureMessage(snapshot, suppressedTerminalFailure?.error ?? result?.error ?? 'Evaluator produced no verdicts; review-fixer changes remain uncommitted.') } as EforgeEvent;
ctx.buildFailed = true;
// --- eforge:endregion plan-01-review-cycle-dirty-worktree-safety ---
return;
}

Expand Down Expand Up @@ -1269,6 +1262,22 @@ registerBuildStage({
}),
} as unknown as Parameters<typeof emitBuildDecision>[1]);
// --- eforge:endregion plan-02-build-evaluator-enforcement ---
// --- eforge:region plan-01-review-cycle-dirty-worktree-safety ---
// Use lastReviewIssueCount rather than ctx.reviewIssues.length because
// evaluateStageInner clears ctx.reviewIssues after verdict application.
// Fail when the last review found issues AND the final evaluation either did not
// run or did not accept all fixes (rejected or marked for review remain non-zero).
const evalNotAcceptedAll =
!finalEvaluation?.ran ||
(finalEvaluation.rejected + finalEvaluation.review) > 0;
if (lastReviewIssueCount > 0 && evalNotAcceptedAll) {
const errorMsg = !finalEvaluation?.ran
? `Review cycle exhausted ${maxRounds} round(s) without a final evaluation verdict.`
: `${lastReviewIssueCount} unresolved issue(s) remain after ${maxRounds} review round(s) (${finalEvaluation.rejected} rejected, ${finalEvaluation.review} under review).`;
yield { timestamp: new Date().toISOString(), type: 'plan:build:failed', planId: ctx.planId, error: errorMsg } as EforgeEvent;
ctx.buildFailed = true;
}
// --- eforge:endregion plan-01-review-cycle-dirty-worktree-safety ---
}
});

Expand Down Expand Up @@ -1504,5 +1513,6 @@ registerBuildStage({
yield* testStageInner(ctx);
if (ctx.reviewIssues.length === 0) break;
yield* evaluateStageInner(ctx, { strictness });
if (ctx.buildFailed) return;
}
});
2 changes: 1 addition & 1 deletion scripts/agent-maintainability-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
},
{
"path": "packages/engine/src/pipeline/stages/build-stages.ts",
"noGrowthCeiling": 1508,
"noGrowthCeiling": 1518,
"category": "implementation"
},
{
Expand Down
Loading
Loading