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.
|
- 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.
|
- 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
|
#### Branch-conflict inspection and auto-reclaim
|
||||||
- `inspectBranchConflict` classifies branch collisions as `stale`, `stale-resolved`, `reclaimable`, or `live-foreign`.
|
- `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.
|
- 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();
|
expect(nativeStub.run).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attempts bwrap execution when available", async () => {
|
it(
|
||||||
|
"attempts bwrap execution when available",
|
||||||
|
async () => {
|
||||||
detectMock.mockResolvedValue({ available: true, path: "bwrap" });
|
detectMock.mockResolvedValue({ available: true, path: "bwrap" });
|
||||||
const backend = new BubblewrapBackend();
|
const backend = new BubblewrapBackend();
|
||||||
await backend.prepare({ allowNetwork: true });
|
await backend.prepare({ allowNetwork: true });
|
||||||
@@ -77,9 +79,11 @@ describe("BubblewrapBackend", () => {
|
|||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toHaveProperty("stdout");
|
expect(result).toHaveProperty("stdout");
|
||||||
expect(result).toHaveProperty("stderr");
|
expect(result).toHaveProperty("stderr");
|
||||||
});
|
},
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
it.skipIf(process.platform !== "linux" || !hasBwrap)("runs real bubblewrap hello integration", async () => {
|
it.skipIf(process.platform !== "linux" || !hasBwrap)("runs real bubblewrap hello integration", async () => {
|
||||||
vi.doUnmock("../../sandbox/bubblewrap-detect.js");
|
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;
|
if (!this.currentRunContext?.runId || !this.currentRunContext.agentId) return;
|
||||||
const auditor = createRunAuditor(this.store, {
|
const auditor = createRunAuditor(this.store, {
|
||||||
runId: this.currentRunContext.runId,
|
runId: this.currentRunContext.runId,
|
||||||
agentId: this.currentRunContext.agentId,
|
agentId: this.currentRunContext.agentId,
|
||||||
taskId,
|
taskId,
|
||||||
phase: this.currentRunContext.phase,
|
phase: "execute",
|
||||||
source: this.currentRunContext.source,
|
|
||||||
});
|
});
|
||||||
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> {
|
private async recoverIndexLockIfStale(taskId: string, path: string, conflictInfo: { lockPath?: string; message?: string }): Promise<boolean> {
|
||||||
|
|||||||
Reference in New Issue
Block a user