feat(FN-4546): complete Step 2 — reclaim stale active branches
Fusion-Task-Id: FN-4546 Fusion-Task-Lineage: f10f8fcb-b26d-4ee8-b83e-b741de01983b
This commit is contained in:
@@ -438,6 +438,7 @@ export class SelfHealingManager {
|
|||||||
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks().then(() => undefined) },
|
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks().then(() => undefined) },
|
||||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
|
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
|
||||||
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
|
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
|
||||||
|
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches().then(() => undefined) },
|
||||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
|
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
|
||||||
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews().then(() => undefined) },
|
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews().then(() => undefined) },
|
||||||
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates().then(() => undefined) },
|
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates().then(() => undefined) },
|
||||||
@@ -1152,6 +1153,7 @@ export class SelfHealingManager {
|
|||||||
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks() },
|
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks() },
|
||||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
|
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
|
||||||
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
|
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
|
||||||
|
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches() },
|
||||||
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
|
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
|
||||||
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews() },
|
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews() },
|
||||||
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates() },
|
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates() },
|
||||||
@@ -1786,6 +1788,122 @@ export class SelfHealingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reclaimStaleActiveBranches(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const settings = await this.store.getSettings();
|
||||||
|
if (settings.globalPause || settings.enginePaused) return 0;
|
||||||
|
|
||||||
|
const activeTaskIds = new Set<string>();
|
||||||
|
if (this.options.agentStore) {
|
||||||
|
try {
|
||||||
|
const activeRuns = await this.options.agentStore.listActiveHeartbeatRuns();
|
||||||
|
const activeWindowMs = RUNNING_ON_INACTIVE_TASK_STALE_RUN_MS;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const run of activeRuns) {
|
||||||
|
const startedAtMs = Date.parse(run.startedAt ?? "");
|
||||||
|
if (!Number.isFinite(startedAtMs) || now - startedAtMs > activeWindowMs) continue;
|
||||||
|
const taskId = run.contextSnapshot && typeof run.contextSnapshot.taskId === "string"
|
||||||
|
? run.contextSnapshot.taskId.toUpperCase()
|
||||||
|
: null;
|
||||||
|
if (taskId) activeTaskIds.add(taskId);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
log.warn(`Unable to enumerate active heartbeat runs for stale-active branch reclaim sweep: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchesRaw = String(execSync("git branch --list 'fusion/*'", {
|
||||||
|
cwd: this.options.rootDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}) || "");
|
||||||
|
const branches = branchesRaw
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/^\*\s*/, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (branches.length === 0) return 0;
|
||||||
|
|
||||||
|
const tasks = await this.store.listTasks({ slim: true, includeArchived: true });
|
||||||
|
const taskById = new Map(tasks.map((task) => [task.id.toUpperCase(), task]));
|
||||||
|
|
||||||
|
let reclaimed = 0;
|
||||||
|
for (const branch of branches) {
|
||||||
|
const derivedTaskId = this.deriveTaskIdFromFusionBranch(branch);
|
||||||
|
if (!derivedTaskId) continue;
|
||||||
|
|
||||||
|
const task = taskById.get(derivedTaskId.toUpperCase());
|
||||||
|
if (!task || task.column === "archived" || task.checkedOutBy || task.userPaused) continue;
|
||||||
|
if (activeTaskIds.has(task.id.toUpperCase())) continue;
|
||||||
|
|
||||||
|
if (task.worktree && await isUsableTaskWorktree(this.options.rootDir, task.worktree)) continue;
|
||||||
|
|
||||||
|
const inspection = await this.inspectOrphanedBranch(branch);
|
||||||
|
if (!inspection) continue;
|
||||||
|
|
||||||
|
if (inspection.uniqueCommitCount > 0) {
|
||||||
|
log.warn(`[recovery] stale-active-branch-rescue-needed ${task.id} branch=${branch} unique=${inspection.uniqueCommitCount} tip=${inspection.tipSha.slice(0, 12)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(`git branch -D ${JSON.stringify(branch)}`, {
|
||||||
|
cwd: this.options.rootDir,
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
await execAsync("git worktree prune", {
|
||||||
|
cwd: this.options.rootDir,
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.store.updateTask(task.id, {
|
||||||
|
worktree: null,
|
||||||
|
branch: null,
|
||||||
|
baseCommitSha: null,
|
||||||
|
});
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`[recovery] stale-active-branch-reclaim ${task.id} branch=${branch} reason=zero-unique-commits-no-worktree`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auditor = createRunAuditor(this.store, {
|
||||||
|
runId: generateSyntheticRunId("self-heal", task.id),
|
||||||
|
agentId: "self-healing",
|
||||||
|
taskId: task.id,
|
||||||
|
taskLineageId: task.lineageId,
|
||||||
|
phase: "reclaim-stale-active-branches",
|
||||||
|
});
|
||||||
|
await auditor.git({
|
||||||
|
type: "branch:stale-active-reclaim",
|
||||||
|
target: branch,
|
||||||
|
metadata: {
|
||||||
|
taskId: task.id,
|
||||||
|
branch,
|
||||||
|
tipSha: inspection.tipSha,
|
||||||
|
uniqueCommitCount: inspection.uniqueCommitCount,
|
||||||
|
reason: "zero-unique-commits-no-worktree",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (auditErr: unknown) {
|
||||||
|
log.warn(`Failed to write branch:stale-active-reclaim run-audit event for ${task.id}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reclaimed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reclaimed > 0) {
|
||||||
|
log.log(`Reclaimed ${reclaimed} stale active fusion branch(es) with no usable worktree`);
|
||||||
|
}
|
||||||
|
return reclaimed;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
log.error(`Stale active branch reclaim sweep failed: ${errorMessage}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear `blockedBy` on todo tasks whose blocker has reached a terminal or
|
* Clear `blockedBy` on todo tasks whose blocker has reached a terminal or
|
||||||
* stuck state.
|
* stuck state.
|
||||||
|
|||||||
Reference in New Issue
Block a user