feat(FN-4379): complete Step 2 — orphan-only scope recovery implementation
Fusion-Task-Id: FN-4379 Fusion-Task-Lineage: 88ee0f49-1db2-40e6-8781-7ecbc4dcbdc8
This commit is contained in:
@@ -3672,7 +3672,7 @@ export function extractFileScope(promptContent: string): string[] {
|
||||
* inside a directory that's in scope (e.g., scope has `src/utils/*` and
|
||||
* file is `src/utils/helpers.ts`).
|
||||
*/
|
||||
function matchesScope(filePath: string, scopePatterns: string[]): boolean {
|
||||
export function matchesScope(filePath: string, scopePatterns: string[]): boolean {
|
||||
for (const pattern of scopePatterns) {
|
||||
if (matchGlob(filePath, pattern)) return true;
|
||||
// Directory match: if pattern ends with /* or /**, check prefix
|
||||
@@ -3757,7 +3757,7 @@ export async function assertSquashOverlapsFileScope(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileScopeViolationAgentLog(error: FileScopeViolationError): string {
|
||||
export function formatFileScopeViolationAgentLog(error: FileScopeViolationError): string {
|
||||
const stagedFiles = error.stagedFiles.length > 0 ? error.stagedFiles.join("\n") : "<none>";
|
||||
return [
|
||||
`taskId: ${error.taskId}`,
|
||||
|
||||
@@ -27,6 +27,53 @@ import { classifyError, extractMissingModulePath, isOperatorActionableAgentError
|
||||
const log = createLogger("self-healing");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function matchGlob(path: string, pattern: string): boolean {
|
||||
if (pattern.includes("**")) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, "\\.")
|
||||
.replace(/\*\*/g, "<<<DOUBLESTAR>>>")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/<<<DOUBLESTAR>>>/g, ".*");
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(path);
|
||||
}
|
||||
|
||||
const lastSlash = pattern.lastIndexOf("/");
|
||||
if (lastSlash !== -1) {
|
||||
const patternDir = pattern.slice(0, lastSlash);
|
||||
const patternFile = pattern.slice(lastSlash + 1);
|
||||
const pathDir = path.lastIndexOf("/") !== -1 ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
const pathFile = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/")) : path;
|
||||
|
||||
if (patternDir.includes("*")) {
|
||||
const dirRegex = new RegExp(`^${patternDir.replace(/\./g, "\\.").replace(/\*/g, "[^/]*")}$`);
|
||||
if (!dirRegex.test(pathDir)) return false;
|
||||
} else if (!pathDir.endsWith(patternDir) && patternDir !== pathDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchGlob(pathFile, patternFile);
|
||||
}
|
||||
|
||||
const fileName = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/") + 1) : path;
|
||||
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, "[^/]*");
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(fileName) || regex.test(path);
|
||||
}
|
||||
|
||||
function matchesScope(filePath: string, scopePatterns: string[]): boolean {
|
||||
for (const pattern of scopePatterns) {
|
||||
if (matchGlob(filePath, pattern)) return true;
|
||||
const dirPattern = pattern.replace(/\/\*+$/, "");
|
||||
if (dirPattern !== pattern && filePath.startsWith(dirPattern + "/")) return true;
|
||||
if (pattern.endsWith("/") && filePath.startsWith(pattern)) return true;
|
||||
const patternDir = pattern.lastIndexOf("/") >= 0 ? pattern.slice(0, pattern.lastIndexOf("/")) : "";
|
||||
const fileDir = filePath.lastIndexOf("/") >= 0 ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
|
||||
if (patternDir && fileDir === patternDir) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface SelfHealingOptions {
|
||||
/** Project root directory (parent of .worktrees/) */
|
||||
rootDir: string;
|
||||
@@ -314,6 +361,7 @@ export class SelfHealingManager {
|
||||
{ name: "interrupted-merging", fn: () => this.recoverInterruptedMergingTasks().then(() => undefined) },
|
||||
{ name: "done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata().then(() => undefined) },
|
||||
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks().then(() => undefined) },
|
||||
{ name: "recover-orphan-only-scope-violations", fn: () => this.recoverOrphanOnlyScopeViolations().then(() => undefined) },
|
||||
{ name: "recover-stuck-merge-deadlocks", fn: () => this.recoverStuckMergeDeadlocks().then(() => undefined) },
|
||||
{ name: "misclassified-failures", fn: () => this.recoverMisclassifiedFailures().then(() => undefined) },
|
||||
{ name: "missing-worktree-review-failures", fn: () => this.recoverMissingWorktreeReviewFailures().then(() => undefined) },
|
||||
@@ -1010,6 +1058,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
|
||||
{ name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
|
||||
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() },
|
||||
{ name: "recover-orphan-only-scope-violations", fn: () => this.recoverOrphanOnlyScopeViolations() },
|
||||
{ name: "recover-stuck-merge-deadlocks", fn: () => this.recoverStuckMergeDeadlocks() },
|
||||
{ name: "recover-misclassified-failures", fn: () => this.recoverMisclassifiedFailures() },
|
||||
{ name: "recover-missing-worktree-review-failures", fn: () => this.recoverMissingWorktreeReviewFailures() },
|
||||
@@ -2219,6 +2268,162 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private parseScopeViolationPayload(detail: string): { declaredScope: string[]; stagedFiles: string[] } | null {
|
||||
const lines = detail.split("\n");
|
||||
const declaredScope: string[] = [];
|
||||
const stagedFiles: string[] = [];
|
||||
let section: "declared" | "staged" | null = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "declaredScope:") {
|
||||
section = "declared";
|
||||
continue;
|
||||
}
|
||||
if (line === "stagedFiles:") {
|
||||
section = "staged";
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("- ")) continue;
|
||||
const value = line.slice(2).trim();
|
||||
if (!value || value === "<none>") continue;
|
||||
if (section === "declared") declaredScope.push(value);
|
||||
if (section === "staged") stagedFiles.push(value);
|
||||
}
|
||||
|
||||
if (declaredScope.length === 0 && stagedFiles.length === 0) return null;
|
||||
return { declaredScope, stagedFiles };
|
||||
}
|
||||
|
||||
private parseScopeViolationFromError(errorMessage: string | null | undefined): { declaredScope: string[]; stagedFiles: string[] } | null {
|
||||
if (!errorMessage?.startsWith("File-scope invariant violation for ")) {
|
||||
return null;
|
||||
}
|
||||
const stagedMatch = errorMessage.match(/staged files \[(.*?)\] have zero overlap/s);
|
||||
const scopeMatch = errorMessage.match(/declared File Scope \[(.*?)\]\./s);
|
||||
if (!stagedMatch || !scopeMatch) return null;
|
||||
const stagedFiles = stagedMatch[1]
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "<none outside .changeset/>");
|
||||
const declaredScope = scopeMatch[1]
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return { declaredScope, stagedFiles };
|
||||
}
|
||||
|
||||
async recoverOrphanOnlyScopeViolations(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
task.status === "failed" &&
|
||||
task.scopeOverride !== true &&
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
!executingIds.has(task.id),
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of candidates) {
|
||||
try {
|
||||
const getAgentLogs = (this.store as { getAgentLogs?: (taskId: string, options?: { limit?: number; offset?: number }) => Promise<Array<{ type: string; detail?: string }>> }).getAgentLogs;
|
||||
const recentLogs = typeof getAgentLogs === "function"
|
||||
? await getAgentLogs.call(this.store, task.id, { limit: 50 })
|
||||
: [];
|
||||
const scopeViolationLog = recentLogs.find((entry) =>
|
||||
entry.type === "tool_error" &&
|
||||
entry.detail?.includes("declaredScope:") &&
|
||||
entry.detail?.includes("stagedFiles:"),
|
||||
);
|
||||
|
||||
const parsed = (scopeViolationLog?.detail ? this.parseScopeViolationPayload(scopeViolationLog.detail) : null)
|
||||
?? this.parseScopeViolationFromError(task.error);
|
||||
if (!parsed) continue;
|
||||
|
||||
const { declaredScope, stagedFiles } = parsed;
|
||||
if (declaredScope.length === 0) continue;
|
||||
|
||||
const orphanFiles = stagedFiles.filter((file) => !file.startsWith(".changeset/"));
|
||||
if (orphanFiles.length === 0) continue;
|
||||
const hasDeclaredOverlap = orphanFiles.some((file) => matchesScope(file, declaredScope));
|
||||
if (hasDeclaredOverlap) continue;
|
||||
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
taskId: task.id,
|
||||
lineageId: task.lineageId,
|
||||
repoDir: this.options.rootDir,
|
||||
baseBranch,
|
||||
taskBranch: task.branch,
|
||||
baseCommitSha: task.baseCommitSha,
|
||||
});
|
||||
if (!landed) continue;
|
||||
|
||||
const mergeDetails: MergeDetails = {
|
||||
commitSha: landed.sha,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
resolutionStrategy: "orphan-discard-no-op",
|
||||
};
|
||||
|
||||
const blocker = getTaskMergeBlocker({
|
||||
...task,
|
||||
status: undefined,
|
||||
error: undefined,
|
||||
steps: task.steps ?? [],
|
||||
workflowStepResults: task.workflowStepResults,
|
||||
});
|
||||
if (blocker) {
|
||||
await this.store.updateTask(task.id, {
|
||||
status: "failed",
|
||||
error: `Merge confirmed but finalization blocked: ${blocker}`,
|
||||
mergeDetails,
|
||||
});
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-recovery parked task in in-review: merged content found on ${baseBranch} (${landed.sha.slice(0, 8)}) but finalization blocked — ${blocker}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
status: null,
|
||||
error: null,
|
||||
mergeRetries: 0,
|
||||
mergeDetails,
|
||||
});
|
||||
await this.store.moveTask(task.id, "done");
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-recovered: FileScopeViolationError treated as orphan-only — task work already on main (commit: ${landed.sha.slice(0, 8)} via ${landed.strategy}; rejected orphans: ${orphanFiles.join(", ")})`,
|
||||
);
|
||||
await this.cleanupWorktreeOnly(task);
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`recoverOrphanOnlyScopeViolations: failed for ${task.id}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} orphan-only scope-violation task(s) → done`);
|
||||
}
|
||||
|
||||
return recovered;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Orphan-only scope violation recovery failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover retry-exhausted failed review tasks whose content already landed on
|
||||
* the integration branch via a non-canonical merge lineage.
|
||||
|
||||
Reference in New Issue
Block a user