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:
committed by
gsxdsm
parent
161cb565e6
commit
27dd927213
5
.changeset/fn-4830-stale-worktree-lock-recovery.md
Normal file
5
.changeset/fn-4830-stale-worktree-lock-recovery.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user