5-persona review of the Phase-C per-repo merge loop. No P0; the no-push invariant and retry/park accounting verified clean. Fixed: Land mechanics (merger-ai.ts / active-session-registry.ts): - persistRepoLandedSha no longer swallows the DB write: a failed landedSha write after the ref advanced now escalates to WorkspacePartialLandError so the engine parks/retries instead of silently re-landing (duplicate squash). isRepoLanded gains a landedSha-independent fallback — it scans the integration ref for this task's Fusion-Task-Id trailer (a squash commit is NOT a branch descendant, so a branch-ancestor check is provably wrong), so an actually-landed repo is skipped on retry. - The land lease is now taskId-aware across kinds: any foreign-task holder on a sub-repo path is contention (a merging task can't run over an executing task's acquire lease), and registerPath throws ActiveSessionPathHeldByForeignTaskError instead of silently clobbering a different task's entry. - The per-repo loop is wrapped in try/finally(setStatus(null)) so the busy/partial throws can't leave the task stuck 'merging'. WorkspacePartialLandError is a real exported class (not a .name-mutated Error). finalizeWorkspaceTask re-reads fresh and no longer swallows the mergeDetails write (TOCTOU). isRepoLanded exported for Phase D. Dispatch + doors (project-engine.ts / dashboard.ts / task.ts / @fusion/core): - getTask-null in the partial-land catch fails closed (park) instead of defaulting retries to 0 and scheduling an indefinite retry storm. - The merge-confirmed reachability fast-path skips workspace tasks (its representative commitSha is a sub-repo squash sha, unreachable in the root cwd — it was demoting fully-merged tasks); they're verified by per-repo landedSha. - The CLI/dashboard merge doors now return merged:true on full land (were hardcoded merged:false). WorkspaceRepoLandBusyError re-enqueues with backoff WITHOUT burning the mergeRetries quota (bounded busy counter) so contention can't park a healthy task. Backoff capped at 60s. shouldRetryWorkspacePartialLand folded into shouldRetryAutoMergeConflict. Catch switched to instanceof. New canonical isWorkspaceTask predicate in @fusion/core. Gate green: build, typecheck, lint, test:gate (649+58); workspace-merger + oracle + project-engine 174. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
8.2 KiB
TypeScript
193 lines
8.2 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
activeSessionRegistry,
|
|
reconcileSelfOwnedActiveSessionForRemoval,
|
|
ActiveSessionPathHeldByForeignTaskError,
|
|
} from "../active-session-registry.js";
|
|
|
|
describe("activeSessionRegistry", () => {
|
|
beforeEach(() => {
|
|
activeSessionRegistry.clear();
|
|
});
|
|
|
|
it("registers and unregisters paths", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
expect(activeSessionRegistry.isPathActive("/tmp/w1")).toBe(true);
|
|
|
|
activeSessionRegistry.unregisterPath("/tmp/w1");
|
|
expect(activeSessionRegistry.isPathActive("/tmp/w1")).toBe(false);
|
|
});
|
|
|
|
it("supports multiple paths for same task", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
activeSessionRegistry.registerPath("/tmp/w2", { taskId: "FN-1", kind: "workflow-step", ownerKey: "FN-1#workflow-step" });
|
|
|
|
expect(activeSessionRegistry.pathsForTask("FN-1").sort()).toEqual(["/tmp/w1", "/tmp/w2"]);
|
|
});
|
|
|
|
it("returns null for unregistered path", () => {
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/missing")).toBeNull();
|
|
});
|
|
|
|
// FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds):
|
|
// registerPath must NOT silently clobber an entry held by a DIFFERENT task (that was the
|
|
// cross-phase clobber bug: a merging task's land lease overwriting an executing task's
|
|
// acquire lease on a shared sub-repo). A foreign-task overwrite now THROWS; the existing
|
|
// foreign holder is preserved.
|
|
it("rejects a foreign-task overwrite (does not clobber the held entry)", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
expect(() =>
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "workflow-step", ownerKey: "FN-2#workflow-step" }),
|
|
).toThrow(ActiveSessionPathHeldByForeignTaskError);
|
|
// The original holder is untouched.
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1");
|
|
});
|
|
|
|
// Same-task re-registration stays idempotent (an executor re-claiming/refreshing its own path).
|
|
it("allows same-task re-registration (idempotent re-claim)", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
expect(() =>
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "step-session", ownerKey: "FN-1#step-session" }),
|
|
).not.toThrow();
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.kind).toBe("step-session");
|
|
});
|
|
|
|
it("reconcileStaleSelfOwned returns no-entry when path is unregistered", () => {
|
|
expect(activeSessionRegistry.reconcileStaleSelfOwned("/tmp/missing", "FN-1")).toEqual({
|
|
reconciled: false,
|
|
reason: "no-entry",
|
|
});
|
|
});
|
|
|
|
it("reconcileStaleSelfOwned returns foreign-task for mismatched owner", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "executor", ownerKey: "FN-2" });
|
|
|
|
expect(activeSessionRegistry.reconcileStaleSelfOwned("/tmp/w1", "FN-1")).toEqual({
|
|
reconciled: false,
|
|
reason: "foreign-task",
|
|
});
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-2");
|
|
});
|
|
|
|
it("reconcileStaleSelfOwned unregisters matching self-owned entry", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
|
|
expect(activeSessionRegistry.reconcileStaleSelfOwned("/tmp/w1", "FN-1")).toEqual({
|
|
reconciled: true,
|
|
reason: "reconciled",
|
|
});
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")).toBeNull();
|
|
});
|
|
|
|
it("reconcileSelfOwnedActiveSessionForRemoval returns no-entry when path is unregistered", () => {
|
|
expect(
|
|
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/missing", "FN-1", () => false),
|
|
).toEqual({ action: "no-entry" });
|
|
});
|
|
|
|
it("reconcileSelfOwnedActiveSessionForRemoval returns foreign-task without clearing", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "executor", ownerKey: "FN-2" });
|
|
|
|
expect(
|
|
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false),
|
|
).toEqual({ action: "foreign-task", ownerTaskId: "FN-2" });
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-2");
|
|
});
|
|
|
|
it("reconcileSelfOwnedActiveSessionForRemoval returns live-binding-refuses without clearing", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
|
|
expect(
|
|
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => true),
|
|
).toEqual({ action: "live-binding-refuses", ownerTaskId: "FN-1" });
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1");
|
|
});
|
|
|
|
it("reconcileSelfOwnedActiveSessionForRemoval clears stale same-task entry", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
|
|
expect(
|
|
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false, {
|
|
minIdleMs: 0,
|
|
}),
|
|
).toEqual({ action: "reconciled" });
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")).toBeNull();
|
|
});
|
|
|
|
it("reconcileSelfOwnedActiveSessionForRemoval is idempotent", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
|
|
expect(
|
|
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false, {
|
|
minIdleMs: 0,
|
|
}),
|
|
).toEqual({ action: "reconciled" });
|
|
expect(
|
|
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false, {
|
|
minIdleMs: 0,
|
|
}),
|
|
).toEqual({ action: "no-entry" });
|
|
});
|
|
|
|
it("FN-5256: refuses reconcile when processActiveProbe returns true", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
|
|
const outcome = reconcileSelfOwnedActiveSessionForRemoval(
|
|
activeSessionRegistry,
|
|
"/tmp/w1",
|
|
"FN-1",
|
|
() => false,
|
|
{ processActiveProbe: () => true, minIdleMs: 0 },
|
|
);
|
|
expect(outcome).toEqual({ action: "process-active-refuses", ownerTaskId: "FN-1" });
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1");
|
|
});
|
|
|
|
it("FN-5256: refuses reconcile when registration is younger than minIdleMs", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
const registeredAt = activeSessionRegistry.lookupByPath("/tmp/w1")!.registeredAt;
|
|
|
|
const outcome = reconcileSelfOwnedActiveSessionForRemoval(
|
|
activeSessionRegistry,
|
|
"/tmp/w1",
|
|
"FN-1",
|
|
() => false,
|
|
{ minIdleMs: 5000, now: () => registeredAt + 100 },
|
|
);
|
|
expect(outcome).toMatchObject({ action: "too-recent-refuses", ownerTaskId: "FN-1", minIdleMs: 5000 });
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1");
|
|
});
|
|
|
|
it("FN-5256: reconciles when all signals clean (default min-idle window elapsed)", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
const registeredAt = activeSessionRegistry.lookupByPath("/tmp/w1")!.registeredAt;
|
|
|
|
const outcome = reconcileSelfOwnedActiveSessionForRemoval(
|
|
activeSessionRegistry,
|
|
"/tmp/w1",
|
|
"FN-1",
|
|
() => false,
|
|
{
|
|
processActiveProbe: () => false,
|
|
minIdleMs: 5000,
|
|
now: () => registeredAt + 6000,
|
|
},
|
|
);
|
|
expect(outcome).toEqual({ action: "reconciled" });
|
|
expect(activeSessionRegistry.lookupByPath("/tmp/w1")).toBeNull();
|
|
});
|
|
|
|
it("FN-5256: live-binding takes precedence over process-active and too-recent", () => {
|
|
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
|
|
|
|
const outcome = reconcileSelfOwnedActiveSessionForRemoval(
|
|
activeSessionRegistry,
|
|
"/tmp/w1",
|
|
"FN-1",
|
|
() => true,
|
|
{ processActiveProbe: () => true, minIdleMs: 5000 },
|
|
);
|
|
expect(outcome).toEqual({ action: "live-binding-refuses", ownerTaskId: "FN-1" });
|
|
});
|
|
});
|