feat(FN-5065): merge fusion/fn-5065
This commit is contained in:
5
.changeset/fn-5065-reap-orphan-active-session-guard.md
Normal file
5
.changeset/fn-5065-reap-orphan-active-session-guard.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
SelfHealingManager.reapUnregisteredOrphans now defers reaping paths that are bound to a live active session, restoring the FN-4811 guard lost during the auto-archive incident.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Settings, TaskStore } from "@fusion/core";
|
||||||
|
import { activeSessionRegistry } from "../../active-session-registry.js";
|
||||||
|
import { SelfHealingManager } from "../../self-healing.js";
|
||||||
|
|
||||||
|
function sh(command: string, cwd: string): string {
|
||||||
|
return String(execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRepo(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "fn-5065-"));
|
||||||
|
sh("git init", root);
|
||||||
|
sh("git config user.email 'test@example.com'", root);
|
||||||
|
sh("git config user.name 'Test User'", root);
|
||||||
|
writeFileSync(join(root, "README.md"), "base\n", "utf-8");
|
||||||
|
sh("git add README.md", root);
|
||||||
|
sh("git commit -m 'init'", root);
|
||||||
|
sh("git branch -M main", root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStore(): TaskStore & EventEmitter {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const settings = {
|
||||||
|
autoMerge: true,
|
||||||
|
globalPause: false,
|
||||||
|
enginePaused: false,
|
||||||
|
baseBranch: "main",
|
||||||
|
mergeStrategy: "direct",
|
||||||
|
autoRecovery: { mode: "deterministic-only", maxRetries: 3 },
|
||||||
|
} as unknown as Settings;
|
||||||
|
|
||||||
|
return Object.assign(emitter, {
|
||||||
|
getSettings: vi.fn(async () => settings),
|
||||||
|
listTasks: vi.fn(async () => []),
|
||||||
|
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
|
||||||
|
walCheckpoint: vi.fn(() => ({ busy: 0, log: 0, checkpointed: 0 })),
|
||||||
|
}) as unknown as TaskStore & EventEmitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FN-4811 / FN-5065: reapUnregisteredOrphans defers active-session paths", () => {
|
||||||
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
activeSessionRegistry.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
activeSessionRegistry.clear();
|
||||||
|
for (const root of tempRoots.splice(0)) {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-5065: does not remove unregistered orphan while path is active in FN-4811 registry", async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
tempRoots.push(repo);
|
||||||
|
const orphanPath = join(repo, ".worktrees", "fn-5065-active");
|
||||||
|
mkdirSync(orphanPath, { recursive: true });
|
||||||
|
writeFileSync(join(orphanPath, "progress.txt"), "in-flight\n", "utf-8");
|
||||||
|
activeSessionRegistry.registerPath(orphanPath, { taskId: "FN-5065", kind: "executor", ownerKey: "FN-5065" });
|
||||||
|
|
||||||
|
const manager = new SelfHealingManager(makeStore() as any, { rootDir: repo } as any);
|
||||||
|
const cleaned = await (manager as any).reapUnregisteredOrphans();
|
||||||
|
|
||||||
|
expect(cleaned).toBe(0);
|
||||||
|
expect(existsSync(orphanPath)).toBe(true);
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-5065 control: removes unregistered orphan when no FN-4811 active session is registered", async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
tempRoots.push(repo);
|
||||||
|
const orphanPath = join(repo, ".worktrees", "fn-5065-control");
|
||||||
|
mkdirSync(orphanPath, { recursive: true });
|
||||||
|
writeFileSync(join(orphanPath, "stale.txt"), "stale\n", "utf-8");
|
||||||
|
|
||||||
|
const manager = new SelfHealingManager(makeStore() as any, { rootDir: repo } as any);
|
||||||
|
const cleaned = await (manager as any).reapUnregisteredOrphans();
|
||||||
|
|
||||||
|
expect(cleaned).toBe(1);
|
||||||
|
expect(existsSync(orphanPath)).toBe(false);
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6087,6 +6087,13 @@ export class SelfHealingManager {
|
|||||||
log.warn(`Refusing to remove path outside .worktrees: ${path}`);
|
log.warn(`Refusing to remove path outside .worktrees: ${path}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// FN-4811 (restored by FN-5065): never rmSync a directory bound to a live
|
||||||
|
// executor/merger/step session. The worktree may be unregistered in git's
|
||||||
|
// admin file while the owning process is still running.
|
||||||
|
if (activeSessionRegistry.isPathActive(path)) {
|
||||||
|
log.log(`[self-healing] deferring unregistered-orphan reap for ${path}: active session present`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
rmSync(path, { recursive: true, force: true });
|
rmSync(path, { recursive: true, force: true });
|
||||||
log.log(`Cleaned unregistered worktree dir: ${path}`);
|
log.log(`Cleaned unregistered worktree dir: ${path}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user