feat(FN-4830): complete Steps 4-7 stale lock recovery delivery

Fusion-Task-Id: FN-4830
Fusion-Task-Lineage: d9b8ad72-669f-488e-8e85-be2dce9b8341
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 20:40:27 -07:00
committed by gsxdsm
parent 161cb565e6
commit 27dd927213
5 changed files with 107 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Auto-recover native worktree-create failures caused by stale git `index.lock` files. Fusion now classifies stale vs active lock contention, retries creation once after safe stale-lock removal, and emits dedicated `worktree:stale-lock-*` run-audit events for detection and outcome visibility.

View File

@@ -1473,6 +1473,13 @@ The GitHub tracking state listener now attaches to every registered project stor
- Self-healing is worktrunk-aware for failure recovery: tasks paused with `pausedReason: "worktrunk_operation_failed"` are explicitly skipped in reclaim sweeps (`self-healing.ts`) until operator intervention.
- Failure contract: delegated worktrunk errors preserve stderr context (`WorktrunkOperationError`) and are handled by `worktrunk.onFailure` — `"fail"` pauses the task, while `"fallback-native"` retries on the native backend and emits one-shot fallback telemetry.
#### Stale `index.lock` recovery on worktree create
- Native worktree create paths now classify `git worktree add` failures containing `.../index.lock: File exists` before falling back to generic branch-conflict handling.
- Classifier gates are deterministic: the lock must exist, be older than the stale threshold (default 30s), not be owned by a live `activeSessionRegistry` session, and resolve to a normalized lock/worktree path.
- If classified `stale`, Fusion removes the lock and retries create exactly once.
- If staleness cannot be proven, lock removal is refused and the flow raises `StaleWorktreeIndexLockError` so task failure messaging can escalate with manual remediation guidance.
- Run-audit events emitted by the create path: `worktree:stale-lock-detected`, `worktree:stale-lock-recovered`, `worktree:stale-lock-recovery-failed`, `worktree:stale-lock-refused`.
#### Branch-conflict inspection and auto-reclaim
- `inspectBranchConflict` classifies branch collisions as `stale`, `stale-resolved`, `reclaimable`, or `live-foreign`.
- Dispatch preflight (`acquireTaskWorktree`/executor) now auto-reclaims `reclaimable` self-owned conflicts and emits `branch:auto-reclaim` run-audit events with task/branch/worktree/tip/stranded-commit metadata.

View File

@@ -0,0 +1,75 @@
import { mkdtemp, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { activeSessionRegistry } from "../../active-session-registry.js";
import { classifyStaleLock, tryRemoveStaleLock } from "../../worktree-stale-lock.js";
import { git, hasGit } from "./_helpers.js";
describe.skipIf(!hasGit)("reliability interactions: worktree stale lock recovery", () => {
const roots: string[] = [];
afterEach(async () => {
activeSessionRegistry.clear();
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});
async function setupRepo() {
const root = await mkdtemp(join(tmpdir(), "fusion-stale-lock-"));
roots.push(root);
git(root, "git init -b main");
git(root, 'git config user.email "test@example.com"');
git(root, 'git config user.name "Test User"');
await writeFile(join(root, "README.md"), "# repo\n", "utf-8");
git(root, "git add README.md");
git(root, 'git commit -m "init"');
return root;
}
it("classifies stale lock and removes it", async () => {
const root = await setupRepo();
const lockPath = join(root, ".git", "index.lock");
await writeFile(lockPath, "lock", "utf-8");
const old = new Date(Date.now() - 120_000);
await utimes(lockPath, old, old);
const classification = await classifyStaleLock({ rootDir: root, lockPath, activeSessionRegistry });
expect(classification.kind).toBe("stale");
const removed = await tryRemoveStaleLock({ lockPath });
expect(removed.removed).toBe(true);
await expect(stat(lockPath)).rejects.toBeTruthy();
});
it("classifies active-session and preserves lock", async () => {
const root = await setupRepo();
const lockPath = join(root, ".git", "index.lock");
await writeFile(lockPath, "lock", "utf-8");
const old = new Date(Date.now() - 120_000);
await utimes(lockPath, old, old);
activeSessionRegistry.registerPath(root, { taskId: "FN-OWNER", kind: "executor", ownerKey: "owner" });
const classification = await classifyStaleLock({ rootDir: root, lockPath, activeSessionRegistry });
expect(classification.kind).toBe("active-session");
await expect(stat(lockPath)).resolves.toBeTruthy();
});
it("returns one terminal outcome per classification branch", async () => {
const root = await setupRepo();
const staleLockPath = join(root, ".git", "index.lock");
await writeFile(staleLockPath, "lock", "utf-8");
const old = new Date(Date.now() - 120_000);
await utimes(staleLockPath, old, old);
const stale = await classifyStaleLock({ rootDir: root, lockPath: staleLockPath, activeSessionRegistry });
const staleTerminal = stale.kind === "stale" ? "worktree:stale-lock-recovered" : "worktree:stale-lock-refused";
expect(["worktree:stale-lock-recovered", "worktree:stale-lock-refused"]).toContain(staleTerminal);
await writeFile(staleLockPath, "lock", "utf-8");
await utimes(staleLockPath, old, old);
activeSessionRegistry.registerPath(root, { taskId: "FN-OWNER-2", kind: "executor", ownerKey: "owner-2" });
const active = await classifyStaleLock({ rootDir: root, lockPath: staleLockPath, activeSessionRegistry });
const activeTerminal = active.kind === "stale" ? "worktree:stale-lock-recovered" : "worktree:stale-lock-refused";
expect(activeTerminal).toBe("worktree:stale-lock-refused");
});
});

View File

@@ -65,7 +65,9 @@ describe("BubblewrapBackend", () => {
expect(nativeStub.run).toHaveBeenCalled();
});
it("attempts bwrap execution when available", async () => {
it(
"attempts bwrap execution when available",
async () => {
detectMock.mockResolvedValue({ available: true, path: "bwrap" });
const backend = new BubblewrapBackend();
await backend.prepare({ allowNetwork: true });
@@ -77,9 +79,11 @@ describe("BubblewrapBackend", () => {
encoding: "utf-8",
});
expect(result).toHaveProperty("stdout");
expect(result).toHaveProperty("stderr");
});
expect(result).toHaveProperty("stdout");
expect(result).toHaveProperty("stderr");
},
10_000,
);
it.skipIf(process.platform !== "linux" || !hasBwrap)("runs real bubblewrap hello integration", async () => {
vi.doUnmock("../../sandbox/bubblewrap-detect.js");

View File

@@ -7973,16 +7973,24 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
}
}
private async emitStaleLockAudit(taskId: string, event: string, targetPath: string, metadata: Record<string, unknown>): Promise<void> {
private async emitStaleLockAudit(
taskId: string,
event:
| "worktree:stale-lock-detected"
| "worktree:stale-lock-recovered"
| "worktree:stale-lock-recovery-failed"
| "worktree:stale-lock-refused",
targetPath: string,
metadata: Record<string, unknown>,
): Promise<void> {
if (!this.currentRunContext?.runId || !this.currentRunContext.agentId) return;
const auditor = createRunAuditor(this.store, {
runId: this.currentRunContext.runId,
agentId: this.currentRunContext.agentId,
taskId,
phase: this.currentRunContext.phase,
source: this.currentRunContext.source,
phase: "execute",
});
await auditor.git({ type: event as any, target: targetPath, metadata });
await auditor.git({ type: event, target: targetPath, metadata });
}
private async recoverIndexLockIfStale(taskId: string, path: string, conflictInfo: { lockPath?: string; message?: string }): Promise<boolean> {