U4 substrate PR1: extract the git-evidence readers (19/19 bodies byte-identical, self-healing.ts -449) (#2489)
Based on `main`. **PR1 of the substrate decomposition** — deliberately small and boring. ## Mechanical proof (the point of this PR) Every moved body diffed against its pre-move text: ``` BLOCK KIND DIFF-vs-ORIGINAL LandedTaskCommit iface EMPTY commitOwnedByTask helper EMPTY escapeRegex helper EMPTY shellQuote helper EMPTY parseShortstat helper EMPTY findLandedTaskCommit method EMPTY findAlreadyMergedTaskCommit method EMPTY refreshRemoteBaseRef method EMPTY readCommitTaskOwnership method EMPTY branchHasNoUniqueDiff method EMPTY baseHasExplicitTaskOwnership method EMPTY foreignTipRejection method EMPTY branchTipForeignOwnership method EMPTY isCommitReachableFromBranch method EMPTY findWorktreePathForBranch method EMPTY repoBranchExists method EMPTY readShortstatForSha method EMPTY readLandedFilesForSha method EMPTY isBranchTipMisboundToTask method EMPTY RESULT: 19/19 BYTE-IDENTICAL modulo the enumerated deviations; 0 differ. ``` No condition reordered, no signature changed, no rename, no inlining, no "while I am here" cleanup. ## The premise needed correcting — the cheap lesson this cut was meant to buy The brief described **13 pure functions**. They are 13 `private async` **methods** closing over `this.options`, so nothing here is byte-identical in the strict sense. Three deviations were structurally unavoidable: 1. **`private` → `protected`** on the 14 methods and the `options` field — a subclass cannot call a `private` base member. One token per declaration. 2. **Two type annotations** rewritten: `SelfHealingManager["readCommitTaskOwnership"]` → the base class, since the original would be a circular import. 3. **Four module-level helpers moved along** and re-exported. Forced by direction — the new module must not import `self-healing.ts`, so everything the bodies call has to live beside them. `execAsync` and `shellQuote` are imported back because call sites remain. ## The set is 14, not 13 `foreignTipRejection` had to come too, and it's what **closes** the cluster — it depends only on `baseHasExplicitTaskOwnership` and `branchHasNoUniqueDiff`, both already in the set. Without it the extraction isn't self-contained and `this.options` isn't the only external dependency. ## Base class, not free functions — deliberately Converting to free functions would change all 14 signatures: a behavior-adjacent edit riding inside a file move, which is exactly the combination that hid the last four safeguard regressions. An abstract base keeps `this` semantics, so every call site stays `this.<method>(...)` and every body is unchanged text. ## Two pre-existing collisions, preserved exactly - The detector module already exports a **free** `findAlreadyMergedTaskCommit` sharing a name with the protected method; inside the class body the bare identifier resolves to the import. - `SelfHealingOptions` is declared in `self-healing.ts`, imported here **type-only** so it's erased at runtime and creates no module cycle. ## Measured line delta — not an estimate | | lines | |---|---:| | `self-healing.ts` | 13,394 → 12,945 = **−449** | | new module | **+527** (454 moved verbatim, 73 scaffold) | | **NET** | **+78** | Same shape as every consolidation in this program: the target file shrinks, the total grows slightly. At −449 for the first and safest cut, the remaining substrate cuts plausibly take `self-healing.ts` under 11k — but that is **file-size reduction, not code reduction**. ## Verification 464 passed across three engine suites with the single known **pre-existing** `archiveStaleDoneTasks` failure; tsc clean; lint clean; merge gate green (299 + 10 + 71). No changeset: `@fusion/engine` is private. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
527
packages/engine/src/self-healing-git-evidence.ts
Normal file
527
packages/engine/src/self-healing-git-evidence.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
FNXC:SelfHealingSubstrate 2026-07-27-05:10 (U4 substrate decomposition, PR1):
|
||||
GIT-EVIDENCE SUBSTRATE — pure evidence readers extracted from self-healing.ts.
|
||||
|
||||
These read git and the task row to answer factual questions: does this commit
|
||||
belong to this task, is this branch tip foreign, did this task already land, what
|
||||
did it change. None of them mutates the store. They are the safest cut in the
|
||||
decomposition and the most valuable to test directly, which is why they go first.
|
||||
|
||||
RELOCATION, NOT REWRITE. Every body below is byte-identical to its original in
|
||||
self-healing.ts. The mechanical proof is in the PR: each moved body diffed against
|
||||
its pre-move text with import lines stripped. Three deviations were structurally
|
||||
unavoidable and are enumerated there rather than hidden here:
|
||||
1. `private` -> `protected` on the 14 methods and the `options` field, because a
|
||||
subclass cannot call a `private` base member;
|
||||
2. two `SelfHealingManager["readCommitTaskOwnership"]` type annotations rewritten
|
||||
to reference this class, since the original would be a circular import;
|
||||
3. four module-level helpers moved with them and re-exported, because
|
||||
`escapeRegex` / `shellQuote` still have call sites left behind in
|
||||
self-healing.ts and the import must stay one-directional.
|
||||
|
||||
WHY A BASE CLASS AND NOT FREE FUNCTIONS: these are `private async` METHODS that
|
||||
close over `this.options`. Converting them to free functions would change every
|
||||
signature, which is a behavior-adjacent edit riding in a file move — exactly the
|
||||
combination that hid the last four safeguard regressions. An abstract base keeps
|
||||
`this` semantics and every body unchanged.
|
||||
*/
|
||||
import { promisify } from "node:util";
|
||||
import { exec } from "node:child_process";
|
||||
/* NOTE: `findAlreadyMergedTaskCommit` here is the FREE function from the
|
||||
detector module — it shares a name with the protected method below, and inside
|
||||
the class body the bare identifier resolves to this import. That collision
|
||||
pre-dates the move and is preserved exactly. */
|
||||
import { findAlreadyMergedTaskCommit, getCommitTaskOwnership } from "./already-merged-detector.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const log = createLogger("self-healing");
|
||||
/* TYPE-ONLY import: erased at runtime, so it creates no module cycle even though
|
||||
self-healing.ts imports this file's class at definition time. */
|
||||
import type { SelfHealingOptions } from "./self-healing.js";
|
||||
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
|
||||
export interface LandedTaskCommit {
|
||||
sha: string;
|
||||
subject?: string;
|
||||
filesChanged?: number;
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
rebaseBaseSha?: string;
|
||||
}
|
||||
|
||||
export function commitOwnedByTask(taskId: string, lineageId: string | undefined, subject: string, body: string): boolean {
|
||||
if (lineageId && new RegExp(`(?:^|\\n)Fusion-Task-Lineage: ${escapeRegex(lineageId)}\\s*(?:\\n|$)`).test(body)) {
|
||||
return true;
|
||||
}
|
||||
if (new RegExp(`(?:^|\\n)Fusion-Task-Id: ${escapeRegex(taskId)}\\s*(?:\\n|$)`).test(body)) {
|
||||
return true;
|
||||
}
|
||||
// Subject anchor: `<type>(<…taskId…>): …` or `<taskId>: …` at start.
|
||||
// The conventional scope group is intentionally NOT optional: a bare
|
||||
// `<type>: …` (e.g. `feat: unrelated change`) carries no task ID and is NOT
|
||||
// ownership evidence, even if the body mentions the task in prose (incident
|
||||
// bug #2 — a prose-mention must never claim a task).
|
||||
const subjectAnchor = new RegExp(
|
||||
`^(?:[A-Za-z]+\\([^)]*\\b${escapeRegex(taskId)}\\b[^)]*\\):|${escapeRegex(taskId)}:)`,
|
||||
);
|
||||
return subjectAnchor.test(subject);
|
||||
}
|
||||
|
||||
export function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function parseShortstat(output: string): Pick<LandedTaskCommit, "filesChanged" | "insertions" | "deletions"> {
|
||||
const normalized = output.trim().replace(/\n/g, " ");
|
||||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||||
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
|
||||
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
|
||||
|
||||
return {
|
||||
filesChanged: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
|
||||
insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0,
|
||||
deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base carrying the git-evidence readers. `SelfHealingManager` extends
|
||||
* it, so every call site stays `this.<method>(...)` and every body below is the
|
||||
* original text.
|
||||
*/
|
||||
export abstract class SelfHealingGitEvidence {
|
||||
protected abstract readonly options: SelfHealingOptions;
|
||||
|
||||
|
||||
protected async findLandedTaskCommit(
|
||||
task: Task,
|
||||
options?: { preferEarliestOwnedCommit?: boolean },
|
||||
): Promise<LandedTaskCommit | null> {
|
||||
// Search strategies, tried in order of reliability:
|
||||
// 1. mergeDetails.commitSha — already stored by the merger; verify it's
|
||||
// reachable from HEAD before trusting it.
|
||||
// 2. Fusion-Task-Lineage trailer — canonical immutable lineage marker.
|
||||
// 3. Fusion-Task-Id trailer — legacy human task-id marker.
|
||||
// 4. Subject grep — legacy/AI commits where the task ID lives in the
|
||||
// subject line (e.g. `feat(FN-123): …`).
|
||||
//
|
||||
// (1) gives us the right sha even if the commit subject is exotic; (2)
|
||||
// covers includeTaskIdInCommit=false setups where (3) would silently
|
||||
// miss; (3) catches commits authored before the trailer was introduced.
|
||||
|
||||
// ── (1) Stored sha ────────────────────────────────────────────────────
|
||||
const rebaseBaseSha = task.mergeDetails?.rebaseBaseSha;
|
||||
const storedSha = task.mergeDetails?.commitSha;
|
||||
if (storedSha) {
|
||||
try {
|
||||
await execAsync(
|
||||
`git merge-base --is-ancestor ${shellQuote(storedSha)} HEAD`,
|
||||
{ cwd: this.options.rootDir },
|
||||
);
|
||||
const { stdout } = await execAsync(
|
||||
`git log -1 --format=%H%x1f%s%x1f%b ${shellQuote(storedSha)}`,
|
||||
{ cwd: this.options.rootDir, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const [sha, subject = "", body = ""] = stdout.trim().split("\x1f");
|
||||
if (sha && commitOwnedByTask(task.id, task.lineageId, subject, body)) {
|
||||
const commit: LandedTaskCommit = { sha, subject, rebaseBaseSha };
|
||||
try {
|
||||
const shortstat = await this.readShortstatForSha(sha, rebaseBaseSha);
|
||||
if (shortstat) {
|
||||
Object.assign(commit, shortstat);
|
||||
}
|
||||
} catch { /* stats are optional */ }
|
||||
return commit;
|
||||
}
|
||||
} catch {
|
||||
// Not reachable (rebased away, branch reset, etc.) — fall through.
|
||||
}
|
||||
}
|
||||
|
||||
const readLog = async (range: string, grepArg: string, fixedStrings: boolean) => {
|
||||
const command = [
|
||||
"git log",
|
||||
"--format=%H%x1f%s",
|
||||
"--max-count=20",
|
||||
...(options?.preferEarliestOwnedCommit ? ["--reverse"] : []),
|
||||
...(fixedStrings ? ["--fixed-strings"] : ["-E"]),
|
||||
`--grep=${grepArg}`,
|
||||
shellQuote(range),
|
||||
].join(" ");
|
||||
|
||||
return execAsync(command, {
|
||||
cwd: this.options.rootDir,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
};
|
||||
|
||||
// Search canonical lineage trailer, then legacy task-id trailer, then
|
||||
// legacy subject fallback. All share bounded/full HEAD range resolution.
|
||||
const search = async (grepArg: string, fixedStrings: boolean): Promise<string> => {
|
||||
let out: string;
|
||||
try {
|
||||
const r = await readLog(
|
||||
task.baseCommitSha ? `${task.baseCommitSha}..HEAD` : "HEAD",
|
||||
grepArg,
|
||||
fixedStrings,
|
||||
);
|
||||
out = r.stdout;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(
|
||||
`Failed to read git log for landed commit lookup (${task.id}): ${errorMessage} — retrying with HEAD range`,
|
||||
);
|
||||
if (!task.baseCommitSha) return "";
|
||||
const r = await readLog("HEAD", grepArg, fixedStrings);
|
||||
out = r.stdout;
|
||||
}
|
||||
// Bounded range may exclude the landed commit when baseCommitSha was
|
||||
// advanced past it; re-scan all of HEAD if empty.
|
||||
if (!out.trim() && task.baseCommitSha) {
|
||||
const r = await readLog("HEAD", grepArg, fixedStrings);
|
||||
out = r.stdout;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// (2) Canonical lineage trailer.
|
||||
let stdout = "";
|
||||
if (task.lineageId) {
|
||||
const lineagePattern = `^Fusion-Task-Lineage: ${task.lineageId}$`;
|
||||
stdout = await search(shellQuote(lineagePattern), false);
|
||||
}
|
||||
|
||||
// (3) Legacy task-id trailer.
|
||||
if (!stdout.trim()) {
|
||||
const trailerPattern = `^Fusion-Task-Id: ${task.id}$`;
|
||||
stdout = await search(shellQuote(trailerPattern), false);
|
||||
}
|
||||
|
||||
// (4) Subject grep fallback (legacy commits).
|
||||
if (!stdout.trim()) {
|
||||
stdout = await search(shellQuote(task.id), true);
|
||||
}
|
||||
|
||||
// FN-5441/FN-5446 regression: `git log --grep=FN-XXXX` matches the entire
|
||||
// commit message, including prose body mentions. The previous code blindly
|
||||
// accepted the first match — which is how FN-5441/5446 got attributed to
|
||||
// an unrelated FN-5483 commit that *mentioned* them by name. Walk the
|
||||
// candidates and accept only the first one that actually owns the task
|
||||
// (anchored lineage/id trailer or subject-anchored conventional commit).
|
||||
const candidateLines = stdout.trim().split("\n").filter(Boolean);
|
||||
let sha = "";
|
||||
let subject = "";
|
||||
for (const line of candidateLines) {
|
||||
const [candidateSha, candidateSubject = ""] = line.split("\x1f");
|
||||
if (!candidateSha) continue;
|
||||
try {
|
||||
const { stdout: bodyOut } = await execAsync(
|
||||
`git log -1 --format=%b ${shellQuote(candidateSha)}`,
|
||||
{ cwd: this.options.rootDir, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
if (commitOwnedByTask(task.id, task.lineageId, candidateSubject, bodyOut)) {
|
||||
sha = candidateSha;
|
||||
subject = candidateSubject;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// If we can't read the body, conservatively skip this candidate.
|
||||
}
|
||||
}
|
||||
if (!sha) return null;
|
||||
|
||||
const commit: LandedTaskCommit = { sha, subject, rebaseBaseSha };
|
||||
try {
|
||||
const shortstat = await this.readShortstatForSha(sha, rebaseBaseSha);
|
||||
if (shortstat) {
|
||||
Object.assign(commit, shortstat);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(
|
||||
`Failed to read shortstat for landed commit ${sha} (${task.id}): ${errorMessage} — continuing without stats`,
|
||||
);
|
||||
// Stats are useful for the task detail view but not required for recovery.
|
||||
}
|
||||
|
||||
return commit;
|
||||
}
|
||||
|
||||
protected async findAlreadyMergedTaskCommit(input: {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
repoDir: string;
|
||||
baseBranch: string;
|
||||
taskBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
}) {
|
||||
return findAlreadyMergedTaskCommit(input);
|
||||
}
|
||||
|
||||
protected async refreshRemoteBaseRef(baseBranch: string): Promise<string | null> {
|
||||
// Already a remote ref — nothing local to refresh.
|
||||
if (baseBranch.startsWith("origin/")) return null;
|
||||
const remoteRef = `origin/${baseBranch}`;
|
||||
try {
|
||||
await execAsync(`git fetch origin ${shellQuote(baseBranch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 60_000,
|
||||
});
|
||||
} catch {
|
||||
// Swallow: fall through to the existing remote-tracking ref if present.
|
||||
}
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(remoteRef)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return remoteRef;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected async readCommitTaskOwnership(sha: string, taskId: string, lineageId?: string) {
|
||||
const { stdout } = await execAsync(`git show -s --format=%s%x1f%b ${shellQuote(sha)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const [subject = "", body = ""] = stdout.split("\x1f");
|
||||
return getCommitTaskOwnership(taskId, lineageId, subject, body);
|
||||
}
|
||||
|
||||
protected async branchHasNoUniqueDiff(branchTip: string, baseBranch: string): Promise<boolean> {
|
||||
const { stdout: mergeBaseStdout } = await execAsync(`git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const mergeBase = mergeBaseStdout.trim();
|
||||
if (!mergeBase) return false;
|
||||
|
||||
await execAsync(`git diff --quiet ${shellQuote(mergeBase)}..${shellQuote(branchTip)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async baseHasExplicitTaskOwnership(taskId: string, lineageId: string | undefined, baseBranch: string): Promise<boolean> {
|
||||
const patterns = lineageId
|
||||
? [`^Fusion-Task-Lineage: ${escapeRegex(lineageId)}$`, `^Fusion-Task-Id: ${escapeRegex(taskId)}$`]
|
||||
: [`^Fusion-Task-Id: ${escapeRegex(taskId)}$`];
|
||||
for (const pattern of patterns) {
|
||||
const { stdout } = await execAsync(`git log --grep=${shellQuote(pattern)} -E --max-count=1 --format=%H ${shellQuote(baseBranch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (stdout.trim()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async foreignTipRejection(input: {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
branchTip: string;
|
||||
baseBranch: string;
|
||||
ownership: Awaited<ReturnType<SelfHealingGitEvidence["readCommitTaskOwnership"]>>;
|
||||
}): Promise<{ reason: "foreign-task-tip" | "foreign-lineage-tip"; owner?: string } | null> {
|
||||
const { taskId, lineageId, branchTip, baseBranch, ownership } = input;
|
||||
if (ownership.rejectionReason !== "foreign-task" && ownership.rejectionReason !== "foreign-lineage") return null;
|
||||
|
||||
const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(branchTip, baseBranch).catch(() => false);
|
||||
const baseAlreadyHasCurrentTask = hasNoUniqueDiff
|
||||
? await this.baseHasExplicitTaskOwnership(taskId, lineageId, baseBranch).catch(() => false)
|
||||
: false;
|
||||
if (hasNoUniqueDiff && !baseAlreadyHasCurrentTask) return null;
|
||||
|
||||
return ownership.rejectionReason === "foreign-task"
|
||||
? { reason: "foreign-task-tip", owner: ownership.ownerTaskId }
|
||||
: { reason: "foreign-lineage-tip", owner: ownership.ownerLineageId };
|
||||
}
|
||||
|
||||
protected async branchTipForeignOwnership(input: {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
}): Promise<{ sha: string; owner?: string; reason: "foreign-task-tip" | "foreign-lineage-tip" | "ownership-unverifiable" } | null> {
|
||||
const { taskId, lineageId, branch, baseBranch } = input;
|
||||
let stdout = "";
|
||||
try {
|
||||
({ stdout } = await execAsync(`git rev-parse ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}));
|
||||
} catch {
|
||||
return { sha: "unverified", reason: "ownership-unverifiable" };
|
||||
}
|
||||
const sha = stdout.trim();
|
||||
if (!sha) return null;
|
||||
let ownership: Awaited<ReturnType<SelfHealingGitEvidence["readCommitTaskOwnership"]>>;
|
||||
try {
|
||||
ownership = await this.readCommitTaskOwnership(sha, taskId, lineageId);
|
||||
} catch {
|
||||
return { sha, reason: "ownership-unverifiable" };
|
||||
}
|
||||
/*
|
||||
FNXC:WorkflowRecovery 2026-07-03-21:35:
|
||||
Already-merged recovery must classify no-diff task branches before enforcing branch-tip trailers. A branch created from main can point at a previous task's landed commit and later sit behind main after unrelated commits; reject foreign trailers only when merge-base-to-tip diff proof shows the branch contains real task-branch content.
|
||||
|
||||
FNXC:WorkflowRecovery 2026-07-25-09:40:
|
||||
That diff-proof classification now lives in `foreignTipRejection` so this path, branch-misbound recovery, and the reclaim sweep cannot drift apart again.
|
||||
*/
|
||||
const rejection = await this.foreignTipRejection({ taskId, lineageId, branchTip: sha, baseBranch, ownership });
|
||||
if (rejection) return { sha, owner: rejection.owner, reason: rejection.reason };
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async isCommitReachableFromBranch(commitSha: string | undefined, branch: string): Promise<boolean> {
|
||||
if (!commitSha) return true;
|
||||
try {
|
||||
await execAsync(`git merge-base --is-ancestor ${shellQuote(commitSha)} ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async findWorktreePathForBranch(branchName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const lines = stdout.split("\n");
|
||||
let currentWorktree: string | undefined;
|
||||
let currentBranch: string | undefined;
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
if (currentWorktree && currentBranch === branchName) return currentWorktree;
|
||||
currentWorktree = undefined;
|
||||
currentBranch = undefined;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("worktree ")) {
|
||||
currentWorktree = line.slice("worktree ".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("branch refs/heads/")) {
|
||||
currentBranch = line.slice("branch refs/heads/".length).trim();
|
||||
}
|
||||
}
|
||||
if (currentWorktree && currentBranch === branchName) return currentWorktree;
|
||||
return undefined;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`[self-healing] reconcileCompletedTask: failed to read worktree list for ${branchName}: ${errorMessage}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected async repoBranchExists(repoRootDir: string, branch: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(`refs/heads/${branch}`)}`, {
|
||||
cwd: repoRootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async readShortstatForSha(
|
||||
sha: string,
|
||||
rebaseBaseSha?: string,
|
||||
): Promise<{ filesChanged: number; insertions: number; deletions: number } | null> {
|
||||
try {
|
||||
const command = rebaseBaseSha
|
||||
? `git diff --shortstat ${shellQuote(`${rebaseBaseSha}..${sha}`)}`
|
||||
: `git show --shortstat --format= ${shellQuote(sha)}`;
|
||||
const stats = await execAsync(command, {
|
||||
cwd: this.options.rootDir,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const parsed = parseShortstat(stats.stdout);
|
||||
return {
|
||||
filesChanged: parsed.filesChanged ?? 0,
|
||||
insertions: parsed.insertions ?? 0,
|
||||
deletions: parsed.deletions ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected async readLandedFilesForSha(sha: string, rebaseBaseSha?: string): Promise<string[] | null> {
|
||||
try {
|
||||
const command = rebaseBaseSha
|
||||
? `git diff --name-only ${shellQuote(`${rebaseBaseSha}..${sha}`)}`
|
||||
: `git show --name-only --format= ${shellQuote(sha)}`;
|
||||
const result = await execAsync(command, {
|
||||
cwd: this.options.rootDir,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const files = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return files.length > 0 ? Array.from(new Set(files)) : [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected async isBranchTipMisboundToTask(input: {
|
||||
branch: string;
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
baseBranch: string;
|
||||
}): Promise<{ misbound: boolean; branchTip: string; landed: Awaited<ReturnType<typeof findAlreadyMergedTaskCommit>>; rejection?: { reason: "foreign-task-tip" | "foreign-lineage-tip"; owner?: string } }> {
|
||||
const { branch, taskId, lineageId, baseBranch } = input;
|
||||
const { stdout: tipOut } = await execAsync(`git rev-parse ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const branchTip = tipOut.trim();
|
||||
const ownership = await this.readCommitTaskOwnership(branchTip, taskId, lineageId);
|
||||
/*
|
||||
FNXC:WorkflowRecovery 2026-07-03-21:39:
|
||||
Branch-misbound recovery shares the no-op inheritance edge case with already-merged recovery. Check merge-base-to-tip diff state first so a branch with no unique task content is not mislabeled misbound solely because its inherited tip belongs to the previously landed task, even after base advances.
|
||||
|
||||
FNXC:WorkflowRecovery 2026-07-25-09:40:
|
||||
Shared with already-merged recovery and the reclaim sweep via `foreignTipRejection`.
|
||||
*/
|
||||
const rejection = await this.foreignTipRejection({ taskId, lineageId, branchTip, baseBranch, ownership });
|
||||
if (rejection) {
|
||||
return { misbound: false, branchTip, landed: null, rejection };
|
||||
}
|
||||
const hasTaskId = ownership.ownerTaskId === taskId;
|
||||
const hasLineage = lineageId ? ownership.ownerLineageId === lineageId : false;
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
taskId,
|
||||
lineageId,
|
||||
repoDir: this.options.rootDir,
|
||||
baseBranch,
|
||||
taskBranch: branch,
|
||||
});
|
||||
return { misbound: !hasTaskId && !hasLineage, branchTip, landed };
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,7 @@
|
||||
* - `reclaimStaleActiveBranches`: remains native (branch-level)
|
||||
*/
|
||||
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { execSync } from "node:child_process";
|
||||
import { setImmediate as setImmediateCb } from "node:timers";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
@@ -74,7 +73,7 @@ imported it from merger-ai while merger-ai imports `MIN_TEMP_WORKTREE_REAP_AGE_M
|
||||
self-healing — a real import cycle. Importing from the predicate module breaks the cycle.
|
||||
*/
|
||||
import { isRepoLanded } from "./workspace-land-predicate.js";
|
||||
import { findAlreadyMergedTaskCommit, getCommitTaskOwnership } from "./already-merged-detector.js";
|
||||
import { getCommitTaskOwnership } from "./already-merged-detector.js";
|
||||
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
|
||||
import { shouldReclaimWedgedMerge } from "./merge-reclaim-policy.js";
|
||||
|
||||
@@ -98,6 +97,9 @@ import {
|
||||
import type { GhostBugDecision } from "./triage-preflight.js";
|
||||
import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap, shouldHoldActiveFileScopeLease } from "./scheduler.js";
|
||||
import { runSurfacingSweep, hours, type SurfacingCycle } from "./surfacing-sweeps.js";
|
||||
/* U4 substrate PR1: the git-evidence readers and their helpers now live in
|
||||
self-healing-git-evidence.ts. Imported back here because call sites remain. */
|
||||
import { SelfHealingGitEvidence, execAsync, shellQuote } from "./self-healing-git-evidence.js";
|
||||
import { evaluateParkedAgentTaskLink, PARKED_AGENT_LINK_FRESH_RUN_MS } from "./task-agent-sync.js";
|
||||
import { describeSelfHealingNoActionWedge } from "./notification/task-wedge-notification.js";
|
||||
|
||||
@@ -172,7 +174,6 @@ const worktreeMetadataReconcileLog = createLogger("worktree-metadata-reconcile")
|
||||
FNXC:EngineDiagnostics 2026-07-26-10:25:
|
||||
Self-healing no-action/skip/defer/worktrunk-skip/maintenance lifecycle lines fire every sweep and drowned recoveries in the TUI. Those are debug (FUSION_DEBUG=self-healing). Keep log/warn/error for real recoveries (Recovered/Reclaimed/Cleaned/Revived/…), stuck kills, and failures.
|
||||
*/
|
||||
const execAsync = promisify(exec);
|
||||
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
|
||||
const DONE_TASK_INTEGRITY_SWEEP_LIMIT = 50;
|
||||
const BOARD_STALL_NOTIFICATION_COOLDOWN_MS = 60 * 60_000;
|
||||
@@ -776,14 +777,6 @@ type AutoRebindSafetyResult =
|
||||
|
||||
export type RebindResult = { repaired: number; outcomes: RebindOutcome[] };
|
||||
|
||||
interface LandedTaskCommit {
|
||||
sha: string;
|
||||
subject?: string;
|
||||
filesChanged?: number;
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
rebaseBaseSha?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a git commit belongs to a given task.
|
||||
@@ -800,52 +793,17 @@ interface LandedTaskCommit {
|
||||
* - Subject anchored on the task ID in conventional-commit form:
|
||||
* `<type>(<taskId>): …` or `<taskId>: …` or `<type>(<taskId>/...): …`
|
||||
*/
|
||||
function commitOwnedByTask(taskId: string, lineageId: string | undefined, subject: string, body: string): boolean {
|
||||
if (lineageId && new RegExp(`(?:^|\\n)Fusion-Task-Lineage: ${escapeRegex(lineageId)}\\s*(?:\\n|$)`).test(body)) {
|
||||
return true;
|
||||
}
|
||||
if (new RegExp(`(?:^|\\n)Fusion-Task-Id: ${escapeRegex(taskId)}\\s*(?:\\n|$)`).test(body)) {
|
||||
return true;
|
||||
}
|
||||
// Subject anchor: `<type>(<…taskId…>): …` or `<taskId>: …` at start.
|
||||
// The conventional scope group is intentionally NOT optional: a bare
|
||||
// `<type>: …` (e.g. `feat: unrelated change`) carries no task ID and is NOT
|
||||
// ownership evidence, even if the body mentions the task in prose (incident
|
||||
// bug #2 — a prose-mention must never claim a task).
|
||||
const subjectAnchor = new RegExp(
|
||||
`^(?:[A-Za-z]+\\([^)]*\\b${escapeRegex(taskId)}\\b[^)]*\\):|${escapeRegex(taskId)}:)`,
|
||||
);
|
||||
return subjectAnchor.test(subject);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
|
||||
function parseShortstat(output: string): Pick<LandedTaskCommit, "filesChanged" | "insertions" | "deletions"> {
|
||||
const normalized = output.trim().replace(/\n/g, " ");
|
||||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||||
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
|
||||
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
|
||||
|
||||
return {
|
||||
filesChanged: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
|
||||
insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0,
|
||||
deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function hasTerminalInvalidDoneTransition(task: Pick<Task, "error">): boolean {
|
||||
const error = task.error ?? "";
|
||||
return error.includes("Invalid transition:") && error.includes("→ 'done'");
|
||||
}
|
||||
|
||||
export class SelfHealingManager {
|
||||
export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
// ── Auto-unpause state ──────────────────────────────────────────────
|
||||
private unpauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private unpauseAttempt = 0;
|
||||
@@ -923,8 +881,12 @@ export class SelfHealingManager {
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private options: SelfHealingOptions,
|
||||
) {}
|
||||
protected readonly options: SelfHealingOptions,
|
||||
) {
|
||||
/* U4 substrate PR1: the git-evidence readers moved to a base class, so this
|
||||
derived constructor needs an explicit super(). No other change. */
|
||||
super();
|
||||
}
|
||||
|
||||
private classifyPausedAbortWorkflowRecovery(
|
||||
task: Task,
|
||||
@@ -2087,170 +2049,7 @@ export class SelfHealingManager {
|
||||
return this.isPastInterruptedMergeGrace(task, timeoutMs);
|
||||
}
|
||||
|
||||
private async findLandedTaskCommit(
|
||||
task: Task,
|
||||
options?: { preferEarliestOwnedCommit?: boolean },
|
||||
): Promise<LandedTaskCommit | null> {
|
||||
// Search strategies, tried in order of reliability:
|
||||
// 1. mergeDetails.commitSha — already stored by the merger; verify it's
|
||||
// reachable from HEAD before trusting it.
|
||||
// 2. Fusion-Task-Lineage trailer — canonical immutable lineage marker.
|
||||
// 3. Fusion-Task-Id trailer — legacy human task-id marker.
|
||||
// 4. Subject grep — legacy/AI commits where the task ID lives in the
|
||||
// subject line (e.g. `feat(FN-123): …`).
|
||||
//
|
||||
// (1) gives us the right sha even if the commit subject is exotic; (2)
|
||||
// covers includeTaskIdInCommit=false setups where (3) would silently
|
||||
// miss; (3) catches commits authored before the trailer was introduced.
|
||||
|
||||
// ── (1) Stored sha ────────────────────────────────────────────────────
|
||||
const rebaseBaseSha = task.mergeDetails?.rebaseBaseSha;
|
||||
const storedSha = task.mergeDetails?.commitSha;
|
||||
if (storedSha) {
|
||||
try {
|
||||
await execAsync(
|
||||
`git merge-base --is-ancestor ${shellQuote(storedSha)} HEAD`,
|
||||
{ cwd: this.options.rootDir },
|
||||
);
|
||||
const { stdout } = await execAsync(
|
||||
`git log -1 --format=%H%x1f%s%x1f%b ${shellQuote(storedSha)}`,
|
||||
{ cwd: this.options.rootDir, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const [sha, subject = "", body = ""] = stdout.trim().split("\x1f");
|
||||
if (sha && commitOwnedByTask(task.id, task.lineageId, subject, body)) {
|
||||
const commit: LandedTaskCommit = { sha, subject, rebaseBaseSha };
|
||||
try {
|
||||
const shortstat = await this.readShortstatForSha(sha, rebaseBaseSha);
|
||||
if (shortstat) {
|
||||
Object.assign(commit, shortstat);
|
||||
}
|
||||
} catch { /* stats are optional */ }
|
||||
return commit;
|
||||
}
|
||||
} catch {
|
||||
// Not reachable (rebased away, branch reset, etc.) — fall through.
|
||||
}
|
||||
}
|
||||
|
||||
const readLog = async (range: string, grepArg: string, fixedStrings: boolean) => {
|
||||
const command = [
|
||||
"git log",
|
||||
"--format=%H%x1f%s",
|
||||
"--max-count=20",
|
||||
...(options?.preferEarliestOwnedCommit ? ["--reverse"] : []),
|
||||
...(fixedStrings ? ["--fixed-strings"] : ["-E"]),
|
||||
`--grep=${grepArg}`,
|
||||
shellQuote(range),
|
||||
].join(" ");
|
||||
|
||||
return execAsync(command, {
|
||||
cwd: this.options.rootDir,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
};
|
||||
|
||||
// Search canonical lineage trailer, then legacy task-id trailer, then
|
||||
// legacy subject fallback. All share bounded/full HEAD range resolution.
|
||||
const search = async (grepArg: string, fixedStrings: boolean): Promise<string> => {
|
||||
let out: string;
|
||||
try {
|
||||
const r = await readLog(
|
||||
task.baseCommitSha ? `${task.baseCommitSha}..HEAD` : "HEAD",
|
||||
grepArg,
|
||||
fixedStrings,
|
||||
);
|
||||
out = r.stdout;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(
|
||||
`Failed to read git log for landed commit lookup (${task.id}): ${errorMessage} — retrying with HEAD range`,
|
||||
);
|
||||
if (!task.baseCommitSha) return "";
|
||||
const r = await readLog("HEAD", grepArg, fixedStrings);
|
||||
out = r.stdout;
|
||||
}
|
||||
// Bounded range may exclude the landed commit when baseCommitSha was
|
||||
// advanced past it; re-scan all of HEAD if empty.
|
||||
if (!out.trim() && task.baseCommitSha) {
|
||||
const r = await readLog("HEAD", grepArg, fixedStrings);
|
||||
out = r.stdout;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// (2) Canonical lineage trailer.
|
||||
let stdout = "";
|
||||
if (task.lineageId) {
|
||||
const lineagePattern = `^Fusion-Task-Lineage: ${task.lineageId}$`;
|
||||
stdout = await search(shellQuote(lineagePattern), false);
|
||||
}
|
||||
|
||||
// (3) Legacy task-id trailer.
|
||||
if (!stdout.trim()) {
|
||||
const trailerPattern = `^Fusion-Task-Id: ${task.id}$`;
|
||||
stdout = await search(shellQuote(trailerPattern), false);
|
||||
}
|
||||
|
||||
// (4) Subject grep fallback (legacy commits).
|
||||
if (!stdout.trim()) {
|
||||
stdout = await search(shellQuote(task.id), true);
|
||||
}
|
||||
|
||||
// FN-5441/FN-5446 regression: `git log --grep=FN-XXXX` matches the entire
|
||||
// commit message, including prose body mentions. The previous code blindly
|
||||
// accepted the first match — which is how FN-5441/5446 got attributed to
|
||||
// an unrelated FN-5483 commit that *mentioned* them by name. Walk the
|
||||
// candidates and accept only the first one that actually owns the task
|
||||
// (anchored lineage/id trailer or subject-anchored conventional commit).
|
||||
const candidateLines = stdout.trim().split("\n").filter(Boolean);
|
||||
let sha = "";
|
||||
let subject = "";
|
||||
for (const line of candidateLines) {
|
||||
const [candidateSha, candidateSubject = ""] = line.split("\x1f");
|
||||
if (!candidateSha) continue;
|
||||
try {
|
||||
const { stdout: bodyOut } = await execAsync(
|
||||
`git log -1 --format=%b ${shellQuote(candidateSha)}`,
|
||||
{ cwd: this.options.rootDir, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
if (commitOwnedByTask(task.id, task.lineageId, candidateSubject, bodyOut)) {
|
||||
sha = candidateSha;
|
||||
subject = candidateSubject;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// If we can't read the body, conservatively skip this candidate.
|
||||
}
|
||||
}
|
||||
if (!sha) return null;
|
||||
|
||||
const commit: LandedTaskCommit = { sha, subject, rebaseBaseSha };
|
||||
try {
|
||||
const shortstat = await this.readShortstatForSha(sha, rebaseBaseSha);
|
||||
if (shortstat) {
|
||||
Object.assign(commit, shortstat);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(
|
||||
`Failed to read shortstat for landed commit ${sha} (${task.id}): ${errorMessage} — continuing without stats`,
|
||||
);
|
||||
// Stats are useful for the task detail view but not required for recovery.
|
||||
}
|
||||
|
||||
return commit;
|
||||
}
|
||||
|
||||
private async findAlreadyMergedTaskCommit(input: {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
repoDir: string;
|
||||
baseBranch: string;
|
||||
taskBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
}) {
|
||||
return findAlreadyMergedTaskCommit(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort refresh of the remote-tracking base ref so the already-merged
|
||||
@@ -2262,70 +2061,9 @@ export class SelfHealingManager {
|
||||
* still attempt to resolve the (possibly stale) remote-tracking ref; if even
|
||||
* that is absent we return null and the caller leaves the card untouched.
|
||||
*/
|
||||
private async refreshRemoteBaseRef(baseBranch: string): Promise<string | null> {
|
||||
// Already a remote ref — nothing local to refresh.
|
||||
if (baseBranch.startsWith("origin/")) return null;
|
||||
const remoteRef = `origin/${baseBranch}`;
|
||||
try {
|
||||
await execAsync(`git fetch origin ${shellQuote(baseBranch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 60_000,
|
||||
});
|
||||
} catch {
|
||||
// Swallow: fall through to the existing remote-tracking ref if present.
|
||||
}
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(remoteRef)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return remoteRef;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readCommitTaskOwnership(sha: string, taskId: string, lineageId?: string) {
|
||||
const { stdout } = await execAsync(`git show -s --format=%s%x1f%b ${shellQuote(sha)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const [subject = "", body = ""] = stdout.split("\x1f");
|
||||
return getCommitTaskOwnership(taskId, lineageId, subject, body);
|
||||
}
|
||||
|
||||
private async branchHasNoUniqueDiff(branchTip: string, baseBranch: string): Promise<boolean> {
|
||||
const { stdout: mergeBaseStdout } = await execAsync(`git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const mergeBase = mergeBaseStdout.trim();
|
||||
if (!mergeBase) return false;
|
||||
|
||||
await execAsync(`git diff --quiet ${shellQuote(mergeBase)}..${shellQuote(branchTip)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async baseHasExplicitTaskOwnership(taskId: string, lineageId: string | undefined, baseBranch: string): Promise<boolean> {
|
||||
const patterns = lineageId
|
||||
? [`^Fusion-Task-Lineage: ${escapeRegex(lineageId)}$`, `^Fusion-Task-Id: ${escapeRegex(taskId)}$`]
|
||||
: [`^Fusion-Task-Id: ${escapeRegex(taskId)}$`];
|
||||
for (const pattern of patterns) {
|
||||
const { stdout } = await execAsync(`git log --grep=${shellQuote(pattern)} -E --max-count=1 --format=%H ${shellQuote(baseBranch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (stdout.trim()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowRecovery 2026-07-25-09:40:
|
||||
@@ -2348,26 +2086,6 @@ export class SelfHealingManager {
|
||||
* (`isBranchTipMisboundToTask`), and the self-owned-branch reclaim sweep's `tip-already-merged` arm. Keeping the
|
||||
* three on one helper is the point — the reclaim arm drifted without the diff proof and that was the bug.
|
||||
*/
|
||||
private async foreignTipRejection(input: {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
branchTip: string;
|
||||
baseBranch: string;
|
||||
ownership: Awaited<ReturnType<SelfHealingManager["readCommitTaskOwnership"]>>;
|
||||
}): Promise<{ reason: "foreign-task-tip" | "foreign-lineage-tip"; owner?: string } | null> {
|
||||
const { taskId, lineageId, branchTip, baseBranch, ownership } = input;
|
||||
if (ownership.rejectionReason !== "foreign-task" && ownership.rejectionReason !== "foreign-lineage") return null;
|
||||
|
||||
const hasNoUniqueDiff = await this.branchHasNoUniqueDiff(branchTip, baseBranch).catch(() => false);
|
||||
const baseAlreadyHasCurrentTask = hasNoUniqueDiff
|
||||
? await this.baseHasExplicitTaskOwnership(taskId, lineageId, baseBranch).catch(() => false)
|
||||
: false;
|
||||
if (hasNoUniqueDiff && !baseAlreadyHasCurrentTask) return null;
|
||||
|
||||
return ownership.rejectionReason === "foreign-task"
|
||||
? { reason: "foreign-task-tip", owner: ownership.ownerTaskId }
|
||||
: { reason: "foreign-lineage-tip", owner: ownership.ownerLineageId };
|
||||
}
|
||||
|
||||
private async rejectForeignAlreadyMergedCandidate(input: {
|
||||
task: Pick<Task, "id" | "lineageId">;
|
||||
@@ -2412,42 +2130,6 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async branchTipForeignOwnership(input: {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
}): Promise<{ sha: string; owner?: string; reason: "foreign-task-tip" | "foreign-lineage-tip" | "ownership-unverifiable" } | null> {
|
||||
const { taskId, lineageId, branch, baseBranch } = input;
|
||||
let stdout = "";
|
||||
try {
|
||||
({ stdout } = await execAsync(`git rev-parse ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}));
|
||||
} catch {
|
||||
return { sha: "unverified", reason: "ownership-unverifiable" };
|
||||
}
|
||||
const sha = stdout.trim();
|
||||
if (!sha) return null;
|
||||
let ownership: Awaited<ReturnType<SelfHealingManager["readCommitTaskOwnership"]>>;
|
||||
try {
|
||||
ownership = await this.readCommitTaskOwnership(sha, taskId, lineageId);
|
||||
} catch {
|
||||
return { sha, reason: "ownership-unverifiable" };
|
||||
}
|
||||
/*
|
||||
FNXC:WorkflowRecovery 2026-07-03-21:35:
|
||||
Already-merged recovery must classify no-diff task branches before enforcing branch-tip trailers. A branch created from main can point at a previous task's landed commit and later sit behind main after unrelated commits; reject foreign trailers only when merge-base-to-tip diff proof shows the branch contains real task-branch content.
|
||||
|
||||
FNXC:WorkflowRecovery 2026-07-25-09:40:
|
||||
That diff-proof classification now lives in `foreignTipRejection` so this path, branch-misbound recovery, and the reclaim sweep cannot drift apart again.
|
||||
*/
|
||||
const rejection = await this.foreignTipRejection({ taskId, lineageId, branchTip: sha, baseBranch, ownership });
|
||||
if (rejection) return { sha, owner: rejection.owner, reason: rejection.reason };
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveSelfHealingMergeTarget(
|
||||
task: Task,
|
||||
@@ -2511,17 +2193,6 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async isCommitReachableFromBranch(commitSha: string | undefined, branch: string): Promise<boolean> {
|
||||
if (!commitSha) return true;
|
||||
try {
|
||||
await execAsync(`git merge-base --is-ancestor ${shellQuote(commitSha)} ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async recordSelfHealingBranchGroupMemberLanding(
|
||||
task: Task,
|
||||
@@ -4658,39 +4329,6 @@ export class SelfHealingManager {
|
||||
*
|
||||
* @returns Number of tasks unblocked
|
||||
*/
|
||||
private async findWorktreePathForBranch(branchName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const lines = stdout.split("\n");
|
||||
let currentWorktree: string | undefined;
|
||||
let currentBranch: string | undefined;
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
if (currentWorktree && currentBranch === branchName) return currentWorktree;
|
||||
currentWorktree = undefined;
|
||||
currentBranch = undefined;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("worktree ")) {
|
||||
currentWorktree = line.slice("worktree ".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("branch refs/heads/")) {
|
||||
currentBranch = line.slice("branch refs/heads/".length).trim();
|
||||
}
|
||||
}
|
||||
if (currentWorktree && currentBranch === branchName) return currentWorktree;
|
||||
return undefined;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`[self-healing] reconcileCompletedTask: failed to read worktree list for ${branchName}: ${errorMessage}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async clearCompletionBranchIfSubsumed(task: Task, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -8901,17 +8539,6 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
/** True iff `branch` exists as a local ref in the sub-repo at `repoRootDir`. */
|
||||
private async repoBranchExists(repoRootDir: string, branch: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${shellQuote(`refs/heads/${branch}`)}`, {
|
||||
cwd: repoRootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD3 — phantom workspace-repo-land lease reclaim):
|
||||
@@ -9144,47 +8771,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async readShortstatForSha(
|
||||
sha: string,
|
||||
rebaseBaseSha?: string,
|
||||
): Promise<{ filesChanged: number; insertions: number; deletions: number } | null> {
|
||||
try {
|
||||
const command = rebaseBaseSha
|
||||
? `git diff --shortstat ${shellQuote(`${rebaseBaseSha}..${sha}`)}`
|
||||
: `git show --shortstat --format= ${shellQuote(sha)}`;
|
||||
const stats = await execAsync(command, {
|
||||
cwd: this.options.rootDir,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const parsed = parseShortstat(stats.stdout);
|
||||
return {
|
||||
filesChanged: parsed.filesChanged ?? 0,
|
||||
insertions: parsed.insertions ?? 0,
|
||||
deletions: parsed.deletions ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readLandedFilesForSha(sha: string, rebaseBaseSha?: string): Promise<string[] | null> {
|
||||
try {
|
||||
const command = rebaseBaseSha
|
||||
? `git diff --name-only ${shellQuote(`${rebaseBaseSha}..${sha}`)}`
|
||||
: `git show --name-only --format= ${shellQuote(sha)}`;
|
||||
const result = await execAsync(command, {
|
||||
cwd: this.options.rootDir,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const files = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return files.length > 0 ? Array.from(new Set(files)) : [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async recoverDoneTaskMergeMetadata(): Promise<number> {
|
||||
try {
|
||||
@@ -10497,42 +10084,6 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async isBranchTipMisboundToTask(input: {
|
||||
branch: string;
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
baseBranch: string;
|
||||
}): Promise<{ misbound: boolean; branchTip: string; landed: Awaited<ReturnType<typeof findAlreadyMergedTaskCommit>>; rejection?: { reason: "foreign-task-tip" | "foreign-lineage-tip"; owner?: string } }> {
|
||||
const { branch, taskId, lineageId, baseBranch } = input;
|
||||
const { stdout: tipOut } = await execAsync(`git rev-parse ${shellQuote(branch)}`, {
|
||||
cwd: this.options.rootDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const branchTip = tipOut.trim();
|
||||
const ownership = await this.readCommitTaskOwnership(branchTip, taskId, lineageId);
|
||||
/*
|
||||
FNXC:WorkflowRecovery 2026-07-03-21:39:
|
||||
Branch-misbound recovery shares the no-op inheritance edge case with already-merged recovery. Check merge-base-to-tip diff state first so a branch with no unique task content is not mislabeled misbound solely because its inherited tip belongs to the previously landed task, even after base advances.
|
||||
|
||||
FNXC:WorkflowRecovery 2026-07-25-09:40:
|
||||
Shared with already-merged recovery and the reclaim sweep via `foreignTipRejection`.
|
||||
*/
|
||||
const rejection = await this.foreignTipRejection({ taskId, lineageId, branchTip, baseBranch, ownership });
|
||||
if (rejection) {
|
||||
return { misbound: false, branchTip, landed: null, rejection };
|
||||
}
|
||||
const hasTaskId = ownership.ownerTaskId === taskId;
|
||||
const hasLineage = lineageId ? ownership.ownerLineageId === lineageId : false;
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
taskId,
|
||||
lineageId,
|
||||
repoDir: this.options.rootDir,
|
||||
baseBranch,
|
||||
taskBranch: branch,
|
||||
});
|
||||
return { misbound: !hasTaskId && !hasLineage, branchTip, landed };
|
||||
}
|
||||
|
||||
async recoverBranchMisboundInReviewTasks(): Promise<number> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user