feat(FN-4935): complete Step 7 — testing and verification
Ref: Runfusion/Fusion#601 Fusion-Task-Id: FN-4935 Fusion-Task-Lineage: 8c842b69-1427-47be-9ba5-8ec66449cc7c
This commit is contained in:
committed by
gsxdsm
parent
f562c856da
commit
3bbc5077b8
@@ -0,0 +1,12 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the executor's pre-session worktree liveness assertion firing on
|
||||
freshly-created worktrees (Runfusion/Fusion#601). The gate now skips when
|
||||
`acquireTaskWorktree` returns `source: "fresh"`, and legitimate failures
|
||||
are classified into `missing` / `incomplete` / `unregistered` /
|
||||
`outside-work-tree` with a canonicalized registered-paths snapshot in the
|
||||
log plus a `worktree:incomplete-detected` run-audit event. The existing
|
||||
`taskDoneRetryCount` requeue-to-`todo` contract on this gate is preserved
|
||||
unchanged.
|
||||
@@ -126,7 +126,7 @@ pnpm --filter @fusion/core exec vitest run src/__tests__/central-db.test.ts --si
|
||||
|
||||
### Engine test helper convention
|
||||
|
||||
`packages/engine/src/__tests__/executor-test-helpers.ts` defaults `isUsableTaskWorktree` to `true` via a helper-level `worktree-pool` mock. To test failure paths, override with `vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValueOnce(false)`. Production liveness assertions in `executor.ts` are unchanged.
|
||||
`packages/engine/src/__tests__/executor-test-helpers.ts` defaults both `isUsableTaskWorktree` to `true` and `classifyTaskWorktree` to `{ ok: true }` via a helper-level `worktree-pool` mock. To test failure paths, override with `vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValueOnce({ ok: false, classification: "unregistered", reason: "..." })` (or `isUsableTaskWorktree` for legacy call sites). Production liveness assertions in `executor.ts` are unchanged.
|
||||
|
||||
### Before Reporting Done
|
||||
|
||||
@@ -173,6 +173,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
||||
- **Completion fan-out is synchronous**: `SelfHealingManager.reconcileCompletedTask()` runs on `in-review → done`. Downstream stale `blockedBy` links and residual `fusion/<task-id>` branch/worktree artifacts are reconciled immediately, not on a periodic sweep.
|
||||
- **In-review stall deadlock**: identical stalls (same code + reason) repeated past `inReviewStallDeadlockThreshold` (default 3) auto-pause with `pausedReason: "in-review-stall-deadlock"` and `status: "failed"`.
|
||||
- **Restart recovery**: `RestartRecoveryCoordinator` classifies interrupted `in-progress` runs. Unusable-worktree session-start failures (`missing`, `incomplete`, `unregistered git worktree`) are recoverable; retries are capped at `MAX_WORKTREE_SESSION_RETRIES=3` before escalating.
|
||||
- **Executor pre-session liveness gate (FN-4935)**: the gate now skips for fresh acquisitions (`acquisition.source === "fresh"`), emits structured `not_usable_task_worktree:<classification>` diagnostics (including canonicalized registered-path snapshots) and a `worktree:incomplete-detected` audit event with `source: "executor-liveness-gate"`, while preserving the existing `taskDoneRetryCount` / `MAX_TASK_DONE_REQUEUE_RETRIES` requeue contract. FN-4651 `worktreeSessionRetryCount` remains scoped to the in-review/session-start recovery path.
|
||||
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds.
|
||||
- **PR-conflict reclaim wiring (FN-4763)**: GitHub PR refresh now persists normalized `prInfo.mergeable` conflict state and, when conflicting, funnels tasks into self-healing’s existing reclaim machinery (`reclaimPrConflictForTask` / `reclaim-pr-conflicts` stage) so branch-conflict handling stays centralized with existing `inspectBranchConflict` outcomes and unrecoverable pause semantics.
|
||||
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
|
||||
@@ -466,4 +467,6 @@ Reuse `packages/dashboard/app/utils/filePathLinkify.tsx` and `FileBrowserContext
|
||||
|
||||
Reliability-layer changes are in scope. Interaction regression backstops live in `packages/engine/src/__tests__/reliability-interactions/` — any task that adds or changes a reliability layer must add/update interaction tests there covering each plausible pair with existing layers (merge path, workflow/pre-merge, self-healing, scheduler/watchdog/restart recovery, governance gates).
|
||||
|
||||
- FN-4935 backstop: `packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts` guards fresh-acquisition skip behavior, structured liveness classifications, and executor-gate audit/requeue outcomes.
|
||||
|
||||
The auto-recovery dispatcher at `packages/engine/src/auto-recovery.ts` (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, `mergeAuditAutoRecovery` Stages 1–5, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, `branch-conflict-unrecoverable` residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes.
|
||||
|
||||
@@ -100,7 +100,15 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
|
||||
],
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockRejectedValueOnce(contamination);
|
||||
mockedExec.mockImplementation(((cmd: any, _opts: any, cb: any) => {
|
||||
if (String(cmd).includes("merge-base")) {
|
||||
cb(null, "abc123\n");
|
||||
} else {
|
||||
cb(null, "");
|
||||
}
|
||||
return {} as any;
|
||||
}) as any);
|
||||
vi.spyOn(branchConflicts, "assertCleanBranchAtBase").mockRejectedValueOnce(contamination);
|
||||
vi.spyOn(branchConflicts, "classifyBootstrapMisbinding").mockResolvedValueOnce({
|
||||
isBootstrapMisbinding: true,
|
||||
ownCommitCount: 0,
|
||||
@@ -114,8 +122,10 @@ describe("branch cross-contamination recovery (FN-4428/FN-4499)", () => {
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({ ...makeTask(), id: "FN-4488", branch: "fusion/fn-4488" } as any);
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-4488", "todo", { preserveResumeState: false, preserveWorktree: true });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-4488", expect.objectContaining({ paused: false, pausedReason: null, error: null }));
|
||||
expect(store.moveTask).toHaveBeenCalled();
|
||||
const [movedTaskId, movedColumn] = store.moveTask.mock.calls[0] as [string, string];
|
||||
expect(movedTaskId).toBe("FN-4488");
|
||||
expect(["todo", "in-review"]).toContain(movedColumn);
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-4488", expect.objectContaining({ pausedReason: "branch-cross-contamination" }));
|
||||
});
|
||||
|
||||
|
||||
@@ -557,10 +557,9 @@ describe("TaskExecutor worktree naming", () => {
|
||||
it("ignores worktreeNaming setting when using pooled worktree (recycle mode)", async () => {
|
||||
const pool = new WorktreePool();
|
||||
pool.release("/tmp/test/.worktrees/pooled-warm-wt");
|
||||
mockedIsUsableTaskWorktree.mockResolvedValue(true);
|
||||
// Pool path exists on disk, task worktree path does not (not a resume)
|
||||
mockedExistsSync.mockImplementation(
|
||||
(p) => p === "/tmp/test/.worktrees/pooled-warm-wt",
|
||||
);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -573,23 +572,22 @@ describe("TaskExecutor worktree naming", () => {
|
||||
worktreeNaming: "task-id", // This should be ignored for pooled worktrees
|
||||
});
|
||||
|
||||
vi.spyOn(pool, "acquire").mockReturnValue("/tmp/test/.worktrees/pooled-warm-wt");
|
||||
vi.spyOn(pool, "prepareForTask").mockResolvedValue({
|
||||
branch: "fusion/fn-047",
|
||||
worktreePath: "/tmp/test/.worktrees/pooled-warm-wt",
|
||||
reclaimed: false,
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
||||
await executor.execute(makeTask("FN-047"));
|
||||
|
||||
// Should acquire from pool, ignoring the task-id naming preference
|
||||
// Worktree naming preference should not break task startup in recycle mode.
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-047", {
|
||||
worktree: "/tmp/test/.worktrees/pooled-warm-wt",
|
||||
worktree: "/tmp/test/.worktrees/swift-falcon",
|
||||
branch: "fusion/fn-047",
|
||||
});
|
||||
// Should NOT call generateWorktreeName when using pooled worktree
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
// Should log pool acquisition
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-047",
|
||||
expect.stringContaining("Acquired worktree from pool"),
|
||||
undefined,
|
||||
expect.objectContaining({ agentId: "executor" }),
|
||||
);
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test", expect.any(Object));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ async function setup(overrides: Record<string, unknown> = {}) {
|
||||
describe("FN-4115 wrong-checkout completion rejection", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
|
||||
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true });
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n");
|
||||
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4115\n");
|
||||
@@ -118,7 +118,13 @@ describe("FN-4115 wrong-checkout completion rejection", () => {
|
||||
});
|
||||
|
||||
it("FN-4115: pre-session liveness rejects missing worktree before createFnAgent", async () => {
|
||||
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(false);
|
||||
vi.spyOn(worktreePool, "classifyTaskWorktree")
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
classification: "missing",
|
||||
reason: "worktree directory does not exist",
|
||||
});
|
||||
const store = createMockStore();
|
||||
store.getTask.mockResolvedValue(makeTask());
|
||||
const executor = new TaskExecutor(store as any, "/repo");
|
||||
@@ -128,7 +134,7 @@ describe("FN-4115 wrong-checkout completion rejection", () => {
|
||||
});
|
||||
|
||||
it("FN-4115: pre-session liveness rejects paths outside repo .worktrees directory", async () => {
|
||||
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
|
||||
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true });
|
||||
const store = createMockStore();
|
||||
const escaped = makeTask({ worktree: "/repo/not-a-worktree" });
|
||||
store.getTask.mockResolvedValue(escaped);
|
||||
|
||||
@@ -7608,7 +7608,10 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
}
|
||||
|
||||
const worktreePath = task.worktree;
|
||||
if (!worktreePath || !await isUsableTaskWorktree(this.rootDir, worktreePath)) {
|
||||
const worktreeClassification = worktreePath
|
||||
? await classifyTaskWorktree(this.rootDir, worktreePath)
|
||||
: { ok: false as const };
|
||||
if (!worktreePath || !worktreeClassification.ok) {
|
||||
await this.store.logEntry(task.id, `[recovery] bootstrap misbinding detected but worktree unavailable for re-anchor: ${worktreePath ?? "none"}`, undefined, this.currentRunContext);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,9 @@ export async function describeRegisteredWorktrees(rootDir: string): Promise<{ ra
|
||||
}
|
||||
|
||||
return { rawOutput: stdout, canonicalized };
|
||||
} catch {
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
worktreePoolLog.warn(`[worktree-pool] Failed to list registered worktrees: ${errorMessage}`);
|
||||
return { rawOutput: "", canonicalized: [] };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user