Merge branch 'main' into fix/validator-premerge-guard

This commit is contained in:
gsxdsm
2026-07-05 21:04:34 -07:00
committed by GitHub
302 changed files with 17684 additions and 1394 deletions

View File

@@ -1,5 +1,19 @@
# @fusion/engine
## 0.56.1
### Patch Changes
- @fusion/core@0.56.1
- @fusion/pi-claude-cli@0.56.1
## 0.56.0
### Patch Changes
- @fusion/core@0.56.0
- @fusion/pi-claude-cli@0.56.0
## 0.55.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@fusion/engine",
"version": "0.55.0",
"version": "0.56.1",
"license": "MIT",
"description": "Fusion engine: executor, merger, scheduler, and automation runtime for the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",

View File

@@ -262,6 +262,56 @@ describe("createFusionAuthStorage", () => {
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("legacy-subscription-access-token");
});
it("restores the subscription card when re-login writes the credential under the legacy anthropic id", async () => {
// Repro of FN: interactive subscription login persists OAuth under `anthropic`,
// but the settings card / status read is keyed on `anthropic-subscription`.
// After an in-session logout the subscription id is suppressed; a successful
// re-login must clear that suppression on BOTH aliases or the card is stuck
// reporting "Login did not complete" despite a valid stored credential.
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
// `set` under the legacy id mirrors what interactive login persists.
authStorage.set("anthropic", {
type: "oauth",
access: "relogin-access-token",
refresh: "relogin-refresh-token",
expires: Date.now() + 3_600_000,
});
expect(authStorage.hasAuth("anthropic-subscription")).toBe(true);
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("relogin-access-token");
});
it("restores the subscription card when re-auth writes under the subscription id", async () => {
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
authStorage.set("anthropic-subscription", {
type: "oauth",
access: "subscription-relogin-token",
refresh: "subscription-relogin-refresh",
expires: Date.now() + 3_600_000,
});
expect(authStorage.hasAuth("anthropic-subscription")).toBe(true);
});
it("does not revive a logged-out subscription card from a raw anthropic API key", async () => {
// A raw `anthropic` API key belongs to its own card and must not alias into
// the subscription's logged-out state — only OAuth credentials do.
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" });
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
});
it("refreshes legacy Anthropic OAuth in place for direct runtime auth", async () => {
writeFusionAuth(homeDir, {
anthropic: {
@@ -365,11 +415,16 @@ describe("createFusionAuthStorage", () => {
// subscription id only — the raw `anthropic` slot stays empty.
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token");
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-subscription-access-token");
// FNXC:ClaudeOAuth 2026-07-05-18:52: the refresh request MUST NOT send `scope`.
// Per RFC 6749 §6 an included scope re-issues the token with exactly that scope
// (never broader), which previously narrowed refreshed tokens to profile-only and
// stripped `user:inference` — leaving the account "logged in" yet 403ing on every
// model call. Omitting scope makes Anthropic preserve the originally-granted scopes.
expect(fetchMock).toHaveBeenCalledWith(
"https://platform.claude.com/v1/oauth/token",
expect.objectContaining({
method: "POST",
body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""),
body: expect.not.stringContaining("\"scope\""),
}),
);
expect(authStorage.get("anthropic-subscription")).toEqual({
@@ -392,6 +447,66 @@ describe("createFusionAuthStorage", () => {
expect(persisted.anthropic).toBeUndefined();
});
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
FN-7574 symptom verification: a healthy subscription OAuth credential that is still
within its validity window but nearing expiry must be refreshed proactively —
BEFORE it actually expires — the first time something reads it (e.g. the periodic
OAuthRefreshScheduler tick), not only reactively once it has already lapsed.
*/
it("proactively refreshes subscription OAuth nearing expiry, ahead of actual expiration", async () => {
const now = Date.now();
writeFusionAuth(homeDir, {
"anthropic-subscription": {
type: "oauth",
access: "soon-to-expire-access-token",
refresh: "subscription-refresh-token",
// Still valid for another 2 minutes — inside the widened proactive-refresh
// window, but not yet actually expired.
expires: now + 120_000,
},
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "proactively-refreshed-access-token",
refresh_token: "rotated-refresh-token",
expires_in: 3600,
}),
} as Response);
globalThis.fetch = fetchMock as typeof fetch;
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("proactively-refreshed-access-token");
expect(fetchMock).toHaveBeenCalledTimes(1);
const refreshed = authStorage.get("anthropic-subscription");
expect(refreshed).toMatchObject({ access: "proactively-refreshed-access-token" });
expect((refreshed as { expires: number }).expires).toBeGreaterThan(now + 120_000);
});
it("does not proactively refresh subscription OAuth that is not yet within the refresh window", async () => {
const now = Date.now();
writeFusionAuth(homeDir, {
"anthropic-subscription": {
type: "oauth",
access: "still-fresh-access-token",
refresh: "subscription-refresh-token",
// Comfortably outside the proactive-refresh buffer.
expires: now + 3_600_000,
},
});
const fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("still-fresh-access-token");
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not resurrect stale Anthropic subscription OAuth after failed refresh", async () => {
writeFusionAuth(homeDir, {
"anthropic-subscription": {
@@ -629,11 +744,13 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-claude-access-token");
// FNXC:ClaudeOAuth 2026-07-05-18:52: refresh must omit `scope` so Anthropic preserves
// the original grant (RFC 6749 §6); sending it previously stripped `user:inference`.
expect(fetchMock).toHaveBeenCalledWith(
"https://platform.claude.com/v1/oauth/token",
expect.objectContaining({
method: "POST",
body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""),
body: expect.not.stringContaining("\"scope\""),
}),
);
expect(authStorage.get("anthropic")).toEqual({

View File

@@ -251,6 +251,32 @@ describe("PlannerOverseerMonitor.observeTask", () => {
expect(store.logEntry).toHaveBeenCalledTimes(1);
});
// FN-7577: an unchanged heartbeat (same stage/signal/reason) must not re-write
// the activity feed on every poll tick — only a CHANGE re-logs; clear() resets
// the dedup so a re-run re-logs its first observation.
it("dedupes consecutive identical feed entries, re-logs on signal change, resets on clear", async () => {
const store = { logEntry: vi.fn().mockResolvedValue(undefined) };
const monitor = new PlannerOverseerMonitor({ store });
const task = taskFixture({ column: "in-progress" });
// Three identical healthy ticks → a single feed entry.
await monitor.observeTask(task, "observe");
await monitor.observeTask(task, "observe");
await monitor.observeTask(task, "observe");
expect(store.logEntry).toHaveBeenCalledTimes(1);
// Signal flips (executor paused → "blocked") → re-logs once.
const paused = { ...task, paused: true, pausedReason: "gate" };
await monitor.observeTask(paused, "observe");
await monitor.observeTask(paused, "observe");
expect(store.logEntry).toHaveBeenCalledTimes(2);
// clear() drops the dedup key so the next identical observation re-logs.
monitor.clear(task.id);
await monitor.observeTask(paused, "observe");
expect(store.logEntry).toHaveBeenCalledTimes(3);
});
it("bounds the per-task ring buffer to the configured cap, keeping the most recent N", async () => {
const monitor = new PlannerOverseerMonitor({ maxObservationsPerTask: 3 });
const task = taskFixture({ column: "in-progress" });

View File

@@ -84,6 +84,35 @@ describe("PlannerRecoveryController.tick", () => {
expect(retryStep).toHaveBeenCalledTimes(PLANNER_RECOVERY_MAX_ATTEMPTS);
});
// FN-7577: a stale recovery attempt must not keep a recovered task badged
// "recovering" — a healthy/human-wait signal on the next tick clears the
// per-(taskId, stage) attempt + last-action records, restoring a fresh budget.
it("clears stale attempt records once the stage reports a healthy signal", async () => {
const retryStep = vi.fn().mockResolvedValue(undefined);
let current: OverseerStageObservation = observation({ signal: "failed" });
const controller = new PlannerRecoveryController({
snapshotProvider: { getSnapshot: () => current },
handlers: { retryStep },
});
await controller.tick(task());
expect(controller.getAttemptCount("FN-1", "executor")).toBe(1);
expect(controller.getLastAction("FN-1", "executor")).toBe("retry_step");
// Task recovers → healthy signal on the next tick clears the registry.
current = observation({ signal: "progressing" });
const healthy = await controller.tick(task());
expect(healthy?.action).toBe("none");
expect(controller.getAttemptCount("FN-1", "executor")).toBe(0);
expect(controller.getLastAction("FN-1", "executor")).toBeUndefined();
// A later genuine failure starts from a fresh budget and dispatches again.
current = observation({ signal: "failed" });
await controller.tick(task());
expect(retryStep).toHaveBeenCalledTimes(2);
expect(controller.getAttemptCount("FN-1", "executor")).toBe(1);
});
it("is inert when effectiveLevel/oversightLevel is off/observe/steer", async () => {
for (const level of ["off", "observe", "steer"] as const) {
const retryStep = vi.fn().mockResolvedValue(undefined);

View File

@@ -5,7 +5,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { SelfHealingManager, STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS } from "../../self-healing.js";
import { activeSessionRegistry } from "../../active-session-registry.js";
import { activeSessionRegistry, executingTaskLock } from "../../active-session-registry.js";
import * as branchConflictModule from "../../branch-conflicts.js";
import * as worktreePoolModule from "../../worktree-pool.js";
@@ -84,6 +84,12 @@ function makeHarness(overrides: Partial<Task> = {}, options: {
recentAuditRows?: AuditRow[];
activeHeartbeat?: boolean;
missingWorktree?: boolean;
// FN-7566: liveness signals that DO track ephemeral executors (agentId: "executor"),
// which never emit heartbeat runs, never take a checkout lease, and write no runAuditEvents.
liveSessionPath?: boolean;
executingTaskLockHeld?: boolean;
isTaskActive?: boolean;
clearReturns?: boolean;
} = {}): Harness {
const rootDir = mkdtempSync(join(tmpdir(), "fn-6736-"));
const worktree = join(rootDir, ".worktrees", "crisp-lotus");
@@ -92,15 +98,24 @@ function makeHarness(overrides: Partial<Task> = {}, options: {
}
const task = makeTask({ worktree, ...overrides });
const store = makeStore(task, { recentAuditRows: options.recentAuditRows });
const clearPhantomExecutorBinding = vi.fn();
const clearPhantomExecutorBinding = options.clearReturns === undefined
? vi.fn()
: vi.fn(() => options.clearReturns);
const agentStore = options.activeHeartbeat
? { listActiveHeartbeatRuns: vi.fn(async () => [{ startedAt: new Date(NOW.getTime() - 60_000).toISOString(), contextSnapshot: { taskId: task.id } }]) }
: { listActiveHeartbeatRuns: vi.fn(async () => []) };
if (options.liveSessionPath) {
activeSessionRegistry.registerPath(worktree, { taskId: task.id, kind: "executor", ownerKey: task.id });
}
if (options.executingTaskLockHeld) {
executingTaskLock.tryClaim(task.id);
}
const manager = new SelfHealingManager(store as any, {
rootDir,
getExecutingTaskIds: () => new Set([task.id]),
clearPhantomExecutorBinding,
agentStore,
...(options.isTaskActive !== undefined ? { isTaskActive: () => options.isTaskActive } : {}),
} as any);
return {
rootDir,
@@ -126,12 +141,14 @@ describe("FN-6736: phantom executor binding reclaim", () => {
vi.setSystemTime(NOW);
vi.restoreAllMocks();
activeSessionRegistry.clear();
executingTaskLock._clearForTest();
vi.spyOn(worktreePoolModule, "isUsableTaskWorktree").mockResolvedValue(true);
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({ kind: "stale" } as any);
});
afterEach(() => {
activeSessionRegistry.clear();
executingTaskLock._clearForTest();
vi.useRealTimers();
});
@@ -230,6 +247,72 @@ describe("FN-6736: phantom executor binding reclaim", () => {
h.cleanup();
});
// FN-7566: the FN-6736 durable-agent liveness gate (heartbeat/checkout/runAudit) is
// structurally blind to ephemeral executors. A live ephemeral executor keeps its worktree
// registered in activeSessionRegistry / holds the executing lock / reports isTaskActive — those
// are the in-process signals that MUST veto the phantom verdict even past the age multiplier,
// otherwise any ephemeral executor task running >30 min is killed mid-flight. Surface
// enumeration: all three live-session signals + the clearPhantomExecutorBinding refusal path.
it("does NOT reclaim a live ephemeral executor whose worktree is registered as an active session", async () => {
const h = makeHarness({}, { liveSessionPath: true });
expect(existsSync(h.worktree)).toBe(true);
const recovered = await h.manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(h.clearPhantomExecutorBinding).not.toHaveBeenCalled();
expect(h.store.moveTask).not.toHaveBeenCalled();
expect(findAudit(h.store, "task:reclaim-phantom-executor-binding")).toBeUndefined();
expect(findAudit(h.store, "task:reclaim-self-owned-branch-conflict-no-action")?.metadata).toEqual(
expect.objectContaining({ reason: "executor-active" }),
);
expect(h.task.column).toBe("in-progress");
h.cleanup();
});
it("does NOT reclaim a live ephemeral executor that still holds the executing lock", async () => {
const h = makeHarness({}, { executingTaskLockHeld: true });
await h.manager.reclaimSelfOwnedBranchConflicts();
expect(h.clearPhantomExecutorBinding).not.toHaveBeenCalled();
expect(h.store.moveTask).not.toHaveBeenCalled();
expect(findAudit(h.store, "task:reclaim-phantom-executor-binding")).toBeUndefined();
expect(h.task.column).toBe("in-progress");
h.cleanup();
});
it("does NOT reclaim a live ephemeral executor that reports isTaskActive", async () => {
const h = makeHarness({}, { isTaskActive: true });
await h.manager.reclaimSelfOwnedBranchConflicts();
expect(h.clearPhantomExecutorBinding).not.toHaveBeenCalled();
expect(h.store.moveTask).not.toHaveBeenCalled();
expect(findAudit(h.store, "task:reclaim-phantom-executor-binding")).toBeUndefined();
expect(h.task.column).toBe("in-progress");
h.cleanup();
});
it("honors clearPhantomExecutorBinding's live-session refusal instead of hard-cancelling to todo", async () => {
// Defense-in-depth: even if the phantom verdict slips past isPhantomExecutorBinding, a clear that
// refuses (returns false — a live session surface is still registered) must NOT be followed by the
// destructive moveTask(→todo). Mirrors reapLeakedConcurrencySlots' `released !== true` guard.
const h = makeHarness({}, { clearReturns: false });
const recovered = await h.manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(h.clearPhantomExecutorBinding).toHaveBeenCalledWith(h.task.id, { preserveWorktrees: true });
expect(h.store.moveTask).not.toHaveBeenCalled();
expect(h.task.column).toBe("in-progress");
expect(findAudit(h.store, "task:reclaim-phantom-executor-binding")).toBeUndefined();
expect(findAudit(h.store, "task:reclaim-self-owned-branch-conflict-no-action")?.metadata).toEqual(
expect.objectContaining({ reason: "phantom-clear-refused-live-session" }),
);
h.cleanup();
});
it("does not increment FN-5704 resume-limbo counters on the phantom-binding requeue", async () => {
const h = makeHarness({ resumeLimboCount: 1 } as Partial<Task>);

View File

@@ -467,4 +467,107 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
await enginePausedManager.recoverAlreadyMergedReviewTasks();
expect(enginePausedStore.listTasks).not.toHaveBeenCalled();
}, 20_000);
// PR3: the local base ref can be stale. When a PR squash-merged on the remote
// but this process never fetched, the owned commit is absent from the LOCAL
// base branch, so the detector finds nothing and the failed card holds its
// file-scope lease forever. Recovery now fetches origin/<base> (gated on a
// recorded PR) and re-runs the SAME evidence detector against origin/<base>.
function setupRepoWithRemote(): { repo: string; remote: string } {
const remoteParent = mkdtempSync(path.join(os.tmpdir(), "fn-stale-remote-"));
repos.push(remoteParent);
const remote = path.join(remoteParent, "origin.git");
execSync(`git init --bare -b main ${JSON.stringify(remote)}`, { stdio: ["pipe", "pipe", "pipe"] });
const repo = setupRepo();
git(repo, `git remote add origin ${JSON.stringify(remote)}`);
git(repo, "git push origin main");
return { repo, remote };
}
// Clone the bare remote, run `mutate` in it, push main back. Advances the
// remote's main WITHOUT touching the primary repo's local main / origin/main
// tracking ref — i.e. it manufactures a genuinely stale local base.
function landOnRemoteMain(remote: string, mutate: (clone: string) => void): string {
const cloneParent = mkdtempSync(path.join(os.tmpdir(), "fn-stale-clone-"));
repos.push(cloneParent);
const clone = path.join(cloneParent, "clone");
execSync(`git clone ${JSON.stringify(remote)} ${JSON.stringify(clone)}`, { stdio: ["pipe", "pipe", "pipe"] });
git(clone, 'git config user.email "test@example.com"');
git(clone, 'git config user.name "Test"');
mutate(clone);
git(clone, "git push origin main");
return git(clone, "git rev-parse HEAD");
}
it("fetch-then-prove: finalizes a failed card whose PR merged on the remote (stale local base)", async () => {
const { repo, remote } = setupRepoWithRemote();
// Task branch tip lives locally (owned by nothing foreign).
git(repo, "git checkout -b fusion/fn-stale");
mkdirSync(path.join(repo, "src"), { recursive: true });
writeFileSync(path.join(repo, "src", "stale.txt"), "work\n", "utf-8");
git(repo, "git add src/stale.txt && git commit -m 'task work'");
git(repo, "git checkout main");
const worktreePath = path.join(repo, ".worktrees", "fn-stale");
mkdirSync(path.dirname(worktreePath), { recursive: true });
git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-stale`);
// Simulate the merge-train squash landing on the remote — never fetched locally.
const landedSha = landOnRemoteMain(remote, (clone) => {
git(clone, "git commit --allow-empty -m 'feat: landed' -m 'Fusion-Task-Id: FN-STALE'");
});
// Local base is stale: neither main nor origin/main has the owned commit yet.
expect(git(repo, "git rev-parse origin/main")).not.toBe(landedSha);
const tasks: TaskMap = new Map([
["FN-STALE", makeTask({ id: "FN-STALE", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-stale", worktree: worktreePath, prInfo: { number: 77 } as any })],
]);
const store = createStore(tasks);
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
await (manager as any).recoverAlreadyMergedReviewTasks();
// Fetch advanced origin/main, and the detector proved the owned commit against it.
expect(git(repo, "git rev-parse origin/main")).toBe(landedSha);
const task = tasks.get("FN-STALE")!;
expect(task.column).toBe("done");
expect(task.status).toBeNull();
expect(task.mergeRetries).toBe(0);
expect(task.mergeDetails?.commitSha).toBe(landedSha);
expect(task.mergeDetails?.mergeConfirmed).toBe(true);
expect(existsSync(worktreePath)).toBe(false);
}, 30_000);
it("fetch-then-prove: does NOT heal when the remote base carries only a foreign commit", async () => {
const { repo, remote } = setupRepoWithRemote();
git(repo, "git checkout -b fusion/fn-guard");
mkdirSync(path.join(repo, "src"), { recursive: true });
writeFileSync(path.join(repo, "src", "guard.txt"), "work\n", "utf-8");
git(repo, "git add src/guard.txt && git commit -m 'task work'");
git(repo, "git checkout main");
// Remote base advances, but the landed commit is owned by a DIFFERENT task.
const foreignSha = landOnRemoteMain(remote, (clone) => {
git(clone, "git commit --allow-empty -m 'feat: other' -m 'Fusion-Task-Id: FN-OTHER'");
});
const tasks: TaskMap = new Map([
["FN-GUARD", makeTask({ id: "FN-GUARD", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-guard", prInfo: { number: 88 } as any })],
]);
const store = createStore(tasks);
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
await (manager as any).recoverAlreadyMergedReviewTasks();
// The fetch still ran (proves the guard, not a missing fetch), but no owned
// commit exists → the card is left untouched, never phantom-finalized.
expect(git(repo, "git rev-parse origin/main")).toBe(foreignSha);
const task = tasks.get("FN-GUARD")!;
expect(task.column).toBe("in-review");
expect(task.status).toBe("failed");
expect(task.mergeDetails?.mergeConfirmed).not.toBe(true);
}, 30_000);
});

View File

@@ -0,0 +1,181 @@
import { describe, expect, it, vi } from "vitest";
import { createAiUndoTask, buildAiUndoTaskDescription, REVERT_OF_METADATA_KEY } from "../task-revert.js";
import type { CreateAiUndoTaskDeps } from "../task-revert.js";
import type { Task, TaskCreateInput } from "@fusion/core";
function makeSourceTask(overrides: Partial<Task> = {}): CreateAiUndoTaskDeps["sourceTask"] {
return {
id: "FN-901",
title: "Add feature a",
description: "Add feature a to the widget renderer.",
prompt: undefined,
mergeDetails: { commitSha: "abc123", landedFiles: ["foo.ts", "bar.ts"] },
priority: "normal",
...overrides,
} as CreateAiUndoTaskDeps["sourceTask"];
}
function makeExistingTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-950",
lineageId: "FN-950",
description: "existing undo task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
...overrides,
} as Task;
}
describe("buildAiUndoTaskDescription (FN-7524)", () => {
it("references the source id, its mission, landed files, the diff pointer, preserve-unrelated instruction, and the revert() commit convention", () => {
const description = buildAiUndoTaskDescription({
task: {
id: "FN-901",
title: "Add feature a",
description: "Add feature a to the widget renderer.",
prompt: undefined,
mergeDetails: { commitSha: "abc", landedFiles: ["foo.ts", "bar.ts"] },
},
});
expect(description).toContain("FN-901");
expect(description).toContain("Add feature a to the widget renderer.");
expect(description).toContain("foo.ts");
expect(description).toContain("bar.ts");
expect(description).toContain("/api/tasks/FN-901/diff");
expect(description).toMatch(/preserv/i);
expect(description).toContain("revert(FN-901):");
expect(description).toContain("Fusion-Task-Id: FN-901");
});
it("prefers task.prompt over task.description for the mission text when present", () => {
const description = buildAiUndoTaskDescription({
task: {
id: "FN-902",
title: "t",
description: "short description",
prompt: "## Full generated mission\nDetailed spec content.",
mergeDetails: undefined,
},
});
expect(description).toContain("Detailed spec content.");
expect(description).not.toContain("short description");
});
it("handles a task with no recorded landed files", () => {
const description = buildAiUndoTaskDescription({
task: { id: "FN-903", title: "t", description: "d", prompt: undefined, mergeDetails: undefined },
});
expect(description).toMatch(/no landed-files list recorded/i);
});
});
describe("createAiUndoTask (FN-7524)", () => {
it("creates a dependency-free board task with the revertOf marker and returns { mode: 'ai', createdTaskId }", async () => {
const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({
id: "FN-960",
description: input.description,
dependencies: input.dependencies ?? [],
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata,
}));
const findOpenRevertTaskForSource = vi.fn(async () => null);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-960" });
expect(createTask).toHaveBeenCalledTimes(1);
const input = createTask.mock.calls[0][0] as TaskCreateInput;
expect(input.dependencies).toEqual([]);
expect(input.source?.sourceMetadata?.[REVERT_OF_METADATA_KEY]).toBe("FN-901");
expect(input.description).toContain("FN-901");
});
it("does not create a duplicate when an open AI-undo task already exists for the source (idempotency)", async () => {
const createTask = vi.fn();
const existing = makeExistingTask({ id: "FN-955", column: "triage" });
const findOpenRevertTaskForSource = vi.fn(async () => existing);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-955", alreadyOpen: true });
expect(createTask).not.toHaveBeenCalled();
});
it("forwards a non-blank workflowId into the createTask input (FN-7556)", async () => {
const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({
id: "FN-961",
description: input.description,
dependencies: input.dependencies ?? [],
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata,
}));
const findOpenRevertTaskForSource = vi.fn(async () => null);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
workflowId: "builtin:review-heavy",
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-961" });
const input = createTask.mock.calls[0][0] as TaskCreateInput;
expect(input.workflowId).toBe("builtin:review-heavy");
});
it("omits the workflowId key entirely when no/blank workflowId is supplied (inherit project default, FN-7556)", async () => {
const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({
id: "FN-962",
description: input.description,
dependencies: input.dependencies ?? [],
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata,
}));
const findOpenRevertTaskForSource = vi.fn(async () => null);
await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
});
const noWorkflowIdInput = createTask.mock.calls[0][0] as TaskCreateInput;
expect("workflowId" in noWorkflowIdInput).toBe(false);
createTask.mockClear();
await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
workflowId: " ",
});
const blankWorkflowIdInput = createTask.mock.calls[0][0] as TaskCreateInput;
expect("workflowId" in blankWorkflowIdInput).toBe(false);
});
it("never creates a task on the idempotent alreadyOpen path regardless of workflowId (FN-7556)", async () => {
const createTask = vi.fn();
const existing = makeExistingTask({ id: "FN-963", column: "triage" });
const findOpenRevertTaskForSource = vi.fn(async () => existing);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
workflowId: "builtin:review-heavy",
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-963", alreadyOpen: true });
expect(createTask).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,206 @@
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareRevertPrBranch } from "../task-revert.js";
import type { Task } from "@fusion/core";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function makeTask(overrides: Partial<Task>): Task {
return {
id: "FN-A",
lineageId: "FN-A",
description: "",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
...overrides,
} as Task;
}
// FN-7554: real-git regression coverage for the PR-based revert branch-prep
// helper — clean → dedicated branch (base never mutated), conflicting/
// already-reverted/unsupported pass-through, idempotent local branch reset,
// and dirty-tree refusal.
describeIfGit("prepareRevertPrBranch real-git scenarios", { timeout: 30_000 }, () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function repoFixture() {
const repo = mkdtempSync(join(tmpdir(), "kb-revert-pr-"));
dirs.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git config commit.gpgsign false");
writeFileSync(join(repo, "foo.ts"), "line1\n");
git(repo, "git add foo.ts && git commit -m 'init'");
return repo;
}
it("clean → eligible: creates fusion/revert-<id> branch with revert commit, base untouched", async () => {
const repo = repoFixture();
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n");
git(repo, "git add foo.ts && git commit -m 'feat(FN-A): add feature a'");
const sha = git(repo, "git rev-parse HEAD");
const mainHeadBefore = git(repo, "git rev-parse main");
const task = makeTask({ mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } });
const result = await prepareRevertPrBranch({
task,
worktreePath: repo,
baseBranch: "main",
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true, revertBranch: "fusion/revert-fn-a" });
if (result.eligible) {
expect(result.revertCommitShas.length).toBe(1);
}
// (a) revert branch exists, tip is a revert(FN-A): commit carrying the trailer.
const branchTipSubject = git(repo, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
const branchTipBody = git(repo, "git log -1 --format=%B fusion/revert-fn-a");
expect(branchTipBody).toContain("Fusion-Task-Id: FN-A");
expect(git(repo, "git show fusion/revert-fn-a:foo.ts")).toBe("line1");
// (b) main HEAD is byte-identical to before the call — base never written.
expect(git(repo, "git rev-parse main")).toBe(mainHeadBefore);
// (c) checkout restored to main and clean.
expect(git(repo, "git rev-parse --abbrev-ref HEAD")).toBe("main");
expect(git(repo, "git status --porcelain")).toBe("");
});
it("conflicting → pass-through: no revert branch left behind, base + checkout unchanged", async () => {
const repo = repoFixture();
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n");
git(repo, "git add foo.ts && git commit -m 'feat(FN-A): add feature a'");
const shaA = git(repo, "git rev-parse HEAD");
// Task B later modifies the exact same region touched by task A.
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a-modified-by-b\n");
git(repo, "git commit -am 'feat(FN-B): modify same region'");
const mainHeadBefore = git(repo, "git rev-parse main");
const statusBefore = git(repo, "git status --porcelain");
const task = makeTask({ mergeDetails: { commitSha: shaA, mergeTargetBranch: "main" } });
const result = await prepareRevertPrBranch({
task,
worktreePath: repo,
baseBranch: "main",
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: false, classification: "conflicting" });
if (!result.eligible && result.classification === "conflicting") {
expect(result.conflicts.length).toBeGreaterThan(0);
expect(result.conflicts.some((c) => c.file === "foo.ts")).toBe(true);
}
const branchList = git(repo, "git branch --list fusion/revert-fn-a");
expect(branchList).toBe("");
expect(git(repo, "git rev-parse main")).toBe(mainHeadBefore);
expect(git(repo, "git rev-parse --abbrev-ref HEAD")).toBe("main");
expect(git(repo, "git status --porcelain")).toBe(statusBefore);
});
it("already-reverted → pass-through: no branch, base unchanged", async () => {
const repo = repoFixture();
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n");
git(repo, "git add foo.ts && git commit -m 'feat(FN-A): add feature a'");
const sha = git(repo, "git rev-parse HEAD");
// Manually revert the change on main before calling prepareRevertPrBranch.
git(repo, `git revert --no-edit ${sha}`);
const mainHeadBefore = git(repo, "git rev-parse main");
const task = makeTask({ mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } });
const result = await prepareRevertPrBranch({
task,
worktreePath: repo,
baseBranch: "main",
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: false, classification: "already-reverted", alreadyReverted: true });
const branchList = git(repo, "git branch --list fusion/revert-fn-a");
expect(branchList).toBe("");
expect(git(repo, "git rev-parse main")).toBe(mainHeadBefore);
expect(git(repo, "git rev-parse --abbrev-ref HEAD")).toBe("main");
});
it("workspace unsupported: a task with workspaceWorktrees populated is refused", async () => {
const repo = repoFixture();
const task = makeTask({
workspaceWorktrees: { "repo-a": { worktreePath: "/tmp/whatever", branch: "main" } },
});
const result = await prepareRevertPrBranch({
task,
worktreePath: repo,
baseBranch: "main",
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: false, unsupported: true, reason: "workspace-task-pr-revert-unsupported" });
});
it("idempotent local branch reset: a stale local branch pointing elsewhere is reset off base with the fresh revert commit", async () => {
const repo = repoFixture();
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n");
git(repo, "git add foo.ts && git commit -m 'feat(FN-A): add feature a'");
const sha = git(repo, "git rev-parse HEAD");
// Pre-create a stale local branch pointing at an unrelated commit.
git(repo, "git branch fusion/revert-fn-a main~1");
const staleTip = git(repo, "git rev-parse fusion/revert-fn-a");
expect(staleTip).not.toBe(git(repo, "git rev-parse main"));
const task = makeTask({ mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } });
const result = await prepareRevertPrBranch({
task,
worktreePath: repo,
baseBranch: "main",
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true });
const branchTipSubject = git(repo, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
expect(git(repo, "git rev-parse --abbrev-ref HEAD")).toBe("main");
});
it("dirty-tree refusal: a stray staged change is refused without any branch/base mutation", async () => {
const repo = repoFixture();
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n");
git(repo, "git add foo.ts && git commit -m 'feat(FN-A): add feature a'");
const sha = git(repo, "git rev-parse HEAD");
writeFileSync(join(repo, "stray.txt"), "stray change\n");
git(repo, "git add stray.txt");
const mainHeadBefore = git(repo, "git rev-parse main");
const preStatus = git(repo, "git status --porcelain");
const task = makeTask({ mergeDetails: { commitSha: sha, mergeTargetBranch: "main" } });
await expect(
prepareRevertPrBranch({ task, worktreePath: repo, baseBranch: "main", revertBranch: "fusion/revert-fn-a" }),
).rejects.toThrow();
const branchList = git(repo, "git branch --list fusion/revert-fn-a");
expect(branchList).toBe("");
expect(git(repo, "git rev-parse main")).toBe(mainHeadBefore);
expect(git(repo, "git status --porcelain")).toBe(preStatus);
});
});

View File

@@ -0,0 +1,371 @@
import { exec, execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { prepareWorkspaceRevertPrBranches } from "../task-revert.js";
import type { Task } from "@fusion/core";
const realExecAsync = promisify(exec);
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function makeTask(overrides: Partial<Task>): Task {
return {
id: "FN-A",
lineageId: "FN-A",
description: "",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
...overrides,
} as Task;
}
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7577):
Real multi-sub-repo git fixture coverage for `prepareWorkspaceRevertPrBranches`
— the Symptom Verification regression suite for the workspace `mode:"pr"`
branch-prep primitive. Mirrors the two-sub-repo fixture pattern from
`task-revert.workspace.real-git.test.ts` (FN-7547) combined with the
single-repo branch-prep assertions from `task-revert-pr.real-git.test.ts`
(FN-7554): clean → per-sub-repo `fusion/revert-<id>` branches with
integration branches left byte-identical; one conflicting sub-repo aborts the
WHOLE preparation with no branch created anywhere; already-reverted →
eligible with empty repos; mixed clean/already-reverted → only the
still-clean sub-repo gets a branch; non-workspace task → unsupported;
idempotent local branch reset; dirty-tree/branch-mismatch refusal; and a
late-conflict multi-branch cleanup.
*/
describeIfGit("prepareWorkspaceRevertPrBranches real-git scenarios", { timeout: 30_000 }, () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function subRepoFixture(workspaceRoot: string, repoRel: string, initialFile: string, initialContent: string): string {
const repoRootDir = join(workspaceRoot, repoRel);
git(workspaceRoot, `mkdir -p ${repoRel}`);
git(repoRootDir, "git init -b main");
git(repoRootDir, 'git config user.email "test@example.com"');
git(repoRootDir, 'git config user.name "Test User"');
git(repoRootDir, "git config commit.gpgsign false");
writeFileSync(join(repoRootDir, initialFile), initialContent);
git(repoRootDir, `git add ${initialFile} && git commit -m 'init'`);
return repoRootDir;
}
function workspaceFixture() {
const workspaceRoot = mkdtempSync(join(tmpdir(), "kb-revert-ws-pr-"));
dirs.push(workspaceRoot);
const repoA = subRepoFixture(workspaceRoot, "repo-a", "a.ts", "line1\n");
const repoB = subRepoFixture(workspaceRoot, "repo-b", "b.ts", "line1\n");
return { workspaceRoot, repoA, repoB };
}
function landTaskCommit(repoRootDir: string, file: string, content: string, commitSubject: string): string {
writeFileSync(join(repoRootDir, file), content);
git(repoRootDir, `git commit -am ${JSON.stringify(commitSubject)}`);
return git(repoRootDir, "git rev-parse HEAD");
}
function makeWorkspaceTask(shaA: string, shaB: string, overrides: Partial<Task> = {}): Task {
return makeTask({
column: "done",
workspaceWorktrees: {
"repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA },
"repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A", landedSha: shaB },
},
mergeDetails: { commitSha: shaA, workspaceLandedShas: { "repo-a": shaA, "repo-b": shaB } },
...overrides,
});
}
it("all clean → eligible, per-sub-repo branches, integration branches unwritten", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true });
if (result.eligible) {
expect(result.repos).toHaveLength(2);
const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r]));
expect(byRepo["repo-a"]).toMatchObject({ revertBranch: "fusion/revert-fn-a", integrationBranch: "main" });
expect(byRepo["repo-b"]).toMatchObject({ revertBranch: "fusion/revert-fn-a", integrationBranch: "main" });
expect(byRepo["repo-a"].revertCommitShas).toHaveLength(1);
expect(byRepo["repo-b"].revertCommitShas).toHaveLength(1);
}
for (const repoRootDir of [repoA, repoB]) {
const branchTipSubject = git(repoRootDir, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
const branchTipBody = git(repoRootDir, "git log -1 --format=%B fusion/revert-fn-a");
expect(branchTipBody).toContain("Fusion-Task-Id: FN-A");
// checkout restored to main, clean.
expect(git(repoRootDir, "git rev-parse --abbrev-ref HEAD")).toBe("main");
expect(git(repoRootDir, "git status --porcelain")).toBe("");
}
// (b) each sub-repo's main HEAD is byte-identical to before — integration
// branch never written.
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
expect(git(repoA, "git show fusion/revert-fn-a:a.ts")).toBe("line1");
expect(git(repoB, "git show fusion/revert-fn-a:b.ts")).toBe("line1");
});
it("one sub-repo conflicting → whole-task aborted, NO branches anywhere (Symptom Verification)", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Task B later modifies the exact same region touched by task A in repo-b only.
landTaskCommit(repoB, "b.ts", "line1\nfeature-a-modified-by-b\n", "feat(FN-B): modify same region in repo-b");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: false, classification: "conflicting" });
if (!result.eligible && result.classification === "conflicting") {
expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true);
}
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
expect(git(repoA, "git rev-parse --abbrev-ref HEAD")).toBe("main");
expect(git(repoB, "git rev-parse --abbrev-ref HEAD")).toBe("main");
});
it("all already-reverted → eligible with empty repos, no branches, integration branches unchanged", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Manually revert both sub-repos on main before calling the branch-prep primitive.
git(repoA, `git revert --no-edit ${shaA}`);
git(repoB, `git revert --no-edit ${shaB}`);
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true, repos: [] });
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("mixed clean + already-reverted → only the still-clean sub-repo gets a branch", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Manually revert repo-b only.
git(repoB, `git revert --no-edit ${shaB}`);
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true });
if (result.eligible) {
expect(result.repos).toHaveLength(1);
expect(result.repos[0].repo).toBe("repo-a");
}
const branchTipSubject = git(repoA, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
expect(git(repoB, "git branch --list fusion/revert-fn-a")).toBe("");
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("non-workspace task → unsupported", async () => {
const { workspaceRoot } = workspaceFixture();
const task = makeTask({ column: "done" });
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: false, unsupported: true, reason: "not-a-workspace-task" });
});
it("idempotent local branch reset: a stale local branch in one sub-repo is reset off integration with the fresh revert commit", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Pre-create a stale local revert branch in repo-a pointing at an unrelated commit.
git(repoA, "git branch fusion/revert-fn-a main~1");
const staleTip = git(repoA, "git rev-parse fusion/revert-fn-a");
expect(staleTip).not.toBe(git(repoA, "git rev-parse main"));
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true });
const branchTipSubject = git(repoA, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
expect(git(repoA, "git rev-parse --abbrev-ref HEAD")).toBe("main");
});
it("dirty-tree refusal: refuses without mutating any sub-repo when one has a stray uncommitted change", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a\nSTRAY UNCOMMITTED CHANGE\n");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
await expect(
prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
}),
).rejects.toMatchObject({ code: "dirty-working-tree" });
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("branch-mismatch refusal: refuses without mutating any sub-repo when one is checked out on a different branch", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
git(repoB, "git checkout -b some-other-branch");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
await expect(
prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
}),
).rejects.toMatchObject({ code: "branch-mismatch" });
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("late-conflict multi-branch cleanup: repo-a's prepped branch is deleted when repo-b conflicts during apply (branch moved between classify and apply)", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 test): both sub-repos classify
CLEAN in Phase 1 (repo-b's main is still untouched at that point). Once
Phase 2 starts checking out repo-a's branch (repo-a sorts first), inject a
conflicting commit directly onto repo-b's `main` — simulating repo-b's
branch moving between classify and apply. When Phase 2 reaches repo-b,
`checkout -B fusion/revert-fn-a main` branches off the NEW (conflicting)
tip, so applying repo-b's revert commit now conflicts — a genuine late
conflict. Assert repo-a's already-prepped branch is rolled back too.
*/
let injected = false;
const execAsyncImpl: typeof realExecAsync = (async (command: string, options: Record<string, unknown>) => {
if (!injected && options?.cwd === repoA && /git checkout -B/.test(command)) {
injected = true;
writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a-modified-by-b\n");
execSync("git commit -am 'feat(FN-B): modify same region in repo-b'", { cwd: repoB, stdio: "pipe" });
}
return realExecAsync(command, options as never);
}) as typeof realExecAsync;
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
execAsyncImpl,
});
expect(result).toMatchObject({ eligible: false, classification: "conflicting" });
if (!result.eligible && result.classification === "conflicting") {
expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true);
}
// repo-a's already-prepped branch from this pass is rolled back too — all-or-nothing.
expect(git(repoA, "git branch --list fusion/revert-fn-a")).toBe("");
expect(git(repoB, "git branch --list fusion/revert-fn-a")).toBe("");
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
// repo-b's main legitimately advanced due to the injected commit (this test
// simulates an external actor landing work mid-preparation) — the
// invariant is that NO revert branch/commit was created anywhere, not that
// repo-b's HEAD is frozen (that HEAD moved before this function ever ran
// Phase 2 for repo-b).
expect(git(repoB, "git rev-parse main")).not.toBe(mainHeadBeforeB);
});
});

View File

@@ -245,4 +245,128 @@ describeIfGit("task-revert real-git scenarios", { timeout: 30_000 }, () => {
expect(result).toMatchObject({ mode: "git", needsHuman: true });
expect(git(repo, "git rev-parse HEAD")).toBe(preHead);
});
// FN-7548: per-sha revert commit granularity — one attributed revert commit
// per original sha instead of a single squashed commit, with the default
// ("squash") staying byte-for-byte unchanged.
function twoCommitRebaseFixture() {
const repo = repoFixture();
const rebaseBase = git(repo, "git rev-parse HEAD");
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n");
git(repo, "git commit -am 'feat(FN-901): part 1' -m 'Fusion-Task-Id: FN-901'");
const shaA = git(repo, "git rev-parse HEAD");
writeFileSync(join(repo, "bar.ts"), "bar-feature\n");
git(repo, "git add bar.ts && git commit -m 'feat(FN-901): part 2' -m 'Fusion-Task-Id: FN-901'");
const shaB = git(repo, "git rev-parse HEAD");
return { repo, rebaseBase, shaA, shaB };
}
it("per-sha granularity: creates one attributed revert commit per original sha", async () => {
const { repo, rebaseBase, shaA, shaB } = twoCommitRebaseFixture();
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" });
expect(result).toMatchObject({ mode: "git", clean: true });
if (result.mode === "git" && result.clean && "revertCommitShas" in result) {
expect(result.revertCommitShas.length).toBe(2);
expect(result.revertCommitSha).toBe(result.revertCommitShas[0]);
}
const subjects = git(repo, "git log --format=%s -n 5").split("\n");
const revertSubjects = subjects.filter((s) => s.startsWith("revert(FN-901):"));
expect(revertSubjects.length).toBe(2);
// Two distinct new commits, both carrying the Fusion-Task-Id trailer and
// each referencing a DIFFERENT original sha in its audit line.
const bodyHead = git(repo, "git log -1 --format=%B HEAD");
const bodyHeadMinus1 = git(repo, "git log -1 --format=%B HEAD~1");
expect(bodyHead).toContain("Fusion-Task-Id: FN-901");
expect(bodyHeadMinus1).toContain("Fusion-Task-Id: FN-901");
expect(bodyHead).toContain(shaA.slice(0, 8));
expect(bodyHeadMinus1).toContain(shaB.slice(0, 8));
expect(git(repo, "git show HEAD:foo.ts")).toBe("line1");
expect(() => git(repo, "git show HEAD:bar.ts")).toThrow();
expect(git(repo, "git status --porcelain")).toBe("");
});
it("default stays squashed: the same two-commit task without granularity produces exactly one revert commit", async () => {
const { repo, rebaseBase, shaB } = twoCommitRebaseFixture();
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" });
expect(result).toMatchObject({ mode: "git", clean: true });
if (result.mode === "git" && result.clean && "revertCommitShas" in result) {
expect(result.revertCommitShas.length).toBe(1);
expect(result.revertCommitSha).toBe(result.revertCommitShas[0]);
}
const subjects = git(repo, "git log --format=%s -n 5").split("\n");
const revertSubjects = subjects.filter((s) => s.startsWith("revert(FN-901):"));
expect(revertSubjects.length).toBe(1);
expect(git(repo, "git show HEAD:foo.ts")).toBe("line1");
expect(() => git(repo, "git show HEAD:bar.ts")).toThrow();
});
it("per-sha granularity: no-op shas are skipped without creating empty commits", async () => {
const { repo, rebaseBase, shaB } = twoCommitRebaseFixture();
// Pre-revert shaB manually so it is already reverted at HEAD before the real call.
git(repo, `git revert --no-edit ${shaB}`);
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" });
expect(result).toMatchObject({ mode: "git", clean: true });
if (result.mode === "git" && result.clean && "revertCommitShas" in result) {
expect(result.revertCommitShas.length).toBe(1);
}
// foo.ts (shaA's change) should now be reverted; bar.ts was already gone from the manual revert.
expect(git(repo, "git show HEAD:foo.ts")).toBe("line1");
expect(() => git(repo, "git show HEAD:bar.ts")).toThrow();
expect(git(repo, "git status --porcelain")).toBe("");
});
it("per-sha granularity: a conflicting batch rolls back entirely — no partially-landed per-sha commits", async () => {
const { repo, rebaseBase, shaB } = twoCommitRebaseFixture();
// Task C later modifies the same region touched by shaA (foo.ts), so reverting shaA conflicts.
writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a-modified-by-c\n");
git(repo, "git commit -am 'feat(FN-903): modify same region as part 1'");
const preCallHead = git(repo, "git rev-parse HEAD");
const preCallStatus = git(repo, "git status --porcelain");
const task = makeTask({
column: "done",
mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" },
});
const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" });
expect(result).toMatchObject({ mode: "git", clean: false });
if (result.mode === "git" && !result.clean && "conflicts" in result) {
expect(result.conflicts.length).toBeGreaterThan(0);
}
// No partial per-sha commits landed — tree/HEAD byte-identical to the pre-call state,
// proving the whole batch (including any earlier per-sha commit) is rolled back.
const postCallHead = git(repo, "git rev-parse HEAD");
const postCallStatus = git(repo, "git status --porcelain");
expect(postCallHead).toBe(preCallHead);
expect(postCallStatus).toBe(preCallStatus);
expect(postCallStatus).toBe("");
});
});

View File

@@ -0,0 +1,272 @@
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveWorkspaceTaskRevertCommits,
revertWorkspaceTask,
} from "../task-revert.js";
import type { Task } from "@fusion/core";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function makeTask(overrides: Partial<Task>): Task {
return {
id: "FN-A",
lineageId: "FN-A",
description: "",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
...overrides,
} as Task;
}
/*
FNXC:TaskRevert 2026-07-04-00:00 (FN-7547):
Real multi-repo git fixture coverage for the workspace revert path — this is
the Symptom Verification regression suite. Mirrors the scratch-repo fixture
pattern from workspace-merger-idempotency.test.ts and the single-repo
task-revert.real-git.test.ts, but with TWO sub-repos under a shared workspace
root so the all-or-nothing multi-repo classification/rollback contract can be
exercised for real.
*/
describeIfGit("task-revert workspace real-git scenarios", { timeout: 30_000 }, () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function subRepoFixture(workspaceRoot: string, repoRel: string, initialFile: string, initialContent: string): string {
const repoRootDir = join(workspaceRoot, repoRel);
git(workspaceRoot, `mkdir -p ${repoRel}`);
git(repoRootDir, "git init -b main");
git(repoRootDir, 'git config user.email "test@example.com"');
git(repoRootDir, 'git config user.name "Test User"');
git(repoRootDir, "git config commit.gpgsign false");
writeFileSync(join(repoRootDir, initialFile), initialContent);
git(repoRootDir, `git add ${initialFile} && git commit -m 'init'`);
return repoRootDir;
}
function workspaceFixture() {
const workspaceRoot = mkdtempSync(join(tmpdir(), "fn-7547-wsrevert-"));
dirs.push(workspaceRoot);
const repoA = subRepoFixture(workspaceRoot, "repo-a", "a.ts", "line1\n");
const repoB = subRepoFixture(workspaceRoot, "repo-b", "b.ts", "line1\n");
return { workspaceRoot, repoA, repoB };
}
function landTaskCommit(repoRootDir: string, file: string, content: string, commitSubject: string): string {
writeFileSync(join(repoRootDir, file), content);
git(repoRootDir, `git commit -am ${JSON.stringify(commitSubject)}`);
return git(repoRootDir, "git rev-parse HEAD");
}
function makeWorkspaceTask(shaA: string, shaB: string, overrides: Partial<Task> = {}): Task {
return makeTask({
column: "done",
workspaceWorktrees: {
"repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA },
"repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A", landedSha: shaB },
},
mergeDetails: { commitSha: shaA, workspaceLandedShas: { "repo-a": shaA, "repo-b": shaB } },
...overrides,
});
}
it("attribution: resolves the correct per-repo squash commit for each sub-repo", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const task = makeWorkspaceTask(shaA, shaB);
const attribution = await resolveWorkspaceTaskRevertCommits(task, { workspaceRootDir: workspaceRoot });
expect(Object.keys(attribution).sort()).toEqual(["repo-a", "repo-b"]);
expect(attribution["repo-a"]).toEqual({ commits: [shaA], source: "squash" });
expect(attribution["repo-b"]).toEqual({ commits: [shaB], source: "squash" });
});
it("attribution: falls back to lineage association when a repo's landed sha is absent", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaBUnrecorded = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const task = makeTask({
column: "done",
workspaceWorktrees: {
"repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA },
"repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A" },
},
mergeDetails: { commitSha: shaA, workspaceLandedShas: { "repo-a": shaA } },
});
const attribution = await resolveWorkspaceTaskRevertCommits(task, {
workspaceRootDir: workspaceRoot,
commitAssociationSource: {
getTaskCommitAssociationsByLineageId: async () => [
{
id: "assoc-1",
taskLineageId: "FN-A",
taskIdSnapshot: "FN-A",
commitSha: shaBUnrecorded,
commitSubject: "feat(FN-A): add feature in repo-b",
authoredAt: new Date().toISOString(),
matchedBy: "canonical-lineage-trailer",
confidence: "canonical",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
});
expect(attribution["repo-a"]).toEqual({ commits: [shaA], source: "squash" });
expect(attribution["repo-b"]).toEqual({ commits: [shaBUnrecorded], source: "lineage" });
});
it("clean all-or-nothing: reverts both sub-repos with a Fusion-Task-Id-trailered commit on each (Symptom Verification)", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const task = makeWorkspaceTask(shaA, shaB);
const result = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} });
expect(result.mode).toBe("git");
expect(result.clean).toBe(true);
if (result.mode === "git" && result.clean) {
expect(result.workspace.repos).toHaveLength(2);
const byRepo = Object.fromEntries(result.workspace.repos.map((r) => [r.repo, r]));
expect(byRepo["repo-a"].classification).toBe("clean");
expect(byRepo["repo-a"].revertCommitSha).toBeTruthy();
expect(byRepo["repo-b"].classification).toBe("clean");
expect(byRepo["repo-b"].revertCommitSha).toBeTruthy();
}
for (const repoRootDir of [repoA, repoB]) {
const subject = git(repoRootDir, "git log -1 --format=%s");
expect(subject).toMatch(/^revert\(FN-A\):/);
const body = git(repoRootDir, "git log -1 --format=%B");
expect(body).toContain("Fusion-Task-Id: FN-A");
expect(git(repoRootDir, "git status --porcelain")).toBe("");
}
expect(git(repoA, "git show HEAD:a.ts")).toBe("line1");
expect(git(repoB, "git show HEAD:b.ts")).toBe("line1");
});
it("partial-conflict rollback: a later task touching repo-b only leaves BOTH repos byte-identical to pre-call (Symptom Verification)", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Task B later modifies the exact same region touched by task A in repo-b only.
landTaskCommit(repoB, "b.ts", "line1\nfeature-a-modified-by-b\n", "feat(FN-B): modify same region in repo-b");
const preCallHeadA = git(repoA, "git rev-parse HEAD");
const preCallStatusA = git(repoA, "git status --porcelain");
const preCallHeadB = git(repoB, "git rev-parse HEAD");
const preCallStatusB = git(repoB, "git status --porcelain");
const task = makeWorkspaceTask(shaA, shaB);
const result = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} });
expect(result.mode).toBe("git");
expect(result.clean).toBe(false);
if (result.mode === "git" && !result.clean && "conflicts" in result) {
expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true);
}
// NO commit created in EITHER repo — all-or-nothing rollback held.
expect(git(repoA, "git rev-parse HEAD")).toBe(preCallHeadA);
expect(git(repoA, "git status --porcelain")).toBe(preCallStatusA);
expect(git(repoB, "git rev-parse HEAD")).toBe(preCallHeadB);
expect(git(repoB, "git status --porcelain")).toBe(preCallStatusB);
// repo-a is NOT left reverted.
expect(git(repoA, "git show HEAD:a.ts")).toBe("line1\nfeature-a");
});
it("already-reverted: reverting a clean task twice reports alreadyReverted for both repos with no second commit", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const task = makeWorkspaceTask(shaA, shaB);
const first = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} });
expect(first.clean).toBe(true);
const headAAfterFirst = git(repoA, "git rev-parse HEAD");
const headBAfterFirst = git(repoB, "git rev-parse HEAD");
const second = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} });
expect(second.mode).toBe("git");
expect(second.clean).toBe(true);
if (second.mode === "git" && second.clean) {
for (const repo of second.workspace.repos) {
expect(repo.alreadyReverted).toBe(true);
}
}
expect(git(repoA, "git rev-parse HEAD")).toBe(headAAfterFirst);
expect(git(repoB, "git rev-parse HEAD")).toBe(headBAfterFirst);
});
it("dirty-tree refusal: refuses without mutating either repo when one sub-repo has a stray uncommitted change", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a\nSTRAY UNCOMMITTED CHANGE\n");
const preCallHeadA = git(repoA, "git rev-parse HEAD");
const preCallHeadB = git(repoB, "git rev-parse HEAD");
const task = makeWorkspaceTask(shaA, shaB);
await expect(revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} })).rejects.toMatchObject({
code: "dirty-working-tree",
});
expect(git(repoA, "git rev-parse HEAD")).toBe(preCallHeadA);
expect(git(repoA, "git status --porcelain")).toBe("");
expect(git(repoB, "git rev-parse HEAD")).toBe(preCallHeadB);
expect(git(repoB, "git show HEAD:b.ts")).toBe("line1\nfeature-a");
});
it("guard rails: refuses a non-done/archived workspace task and never mutates the source task lifecycle", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const task = makeWorkspaceTask(shaA, shaB, { column: "in-progress" });
const result = await revertWorkspaceTask({ task, workspaceRootDir: workspaceRoot, settings: {} });
expect(result).toMatchObject({ mode: "git", needsHuman: true });
expect(task.column).toBe("in-progress");
});
it("guard rails: autoMerge:false refuses with needsHuman instead of force-writing", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const task = makeWorkspaceTask(shaA, shaB);
const result = await revertWorkspaceTask({
task,
workspaceRootDir: workspaceRoot,
settings: {},
effectiveAutoMerge: false,
});
expect(result).toMatchObject({ mode: "git", needsHuman: true });
expect(git(repoA, "git status --porcelain")).toBe("");
expect(git(repoB, "git status --porcelain")).toBe("");
});
});

View File

@@ -4,6 +4,7 @@ import {
evaluateReleaseAuthorizationGate,
isUserAuthoredSource,
parseReleaseAuthorizationMarker,
stripNegatedReleaseClauses,
} from "../triage-release-authorization.js";
const releasePrompt = `# Task: FN-6469 - Release @runfusion/fusion patch
@@ -123,6 +124,80 @@ describe("triage release authorization gate", () => {
}
});
/*
* FN-7560 regression: release disclaimers must not self-incriminate.
* Symptom: FN-7525/FN-7554/FN-7556 (revert/undo/UI tasks) were parked in
* awaiting-release-authorization solely because their AI-authored specs said
* they perform NO release while naming `scripts/release.mjs` as the owner.
* Surface enumeration below covers every documented signal in both its negated
* (disclaimer → not release-class) and actionable (intent → still release-class)
* form so the invariant holds across all known signal surfaces, not just the repro.
*/
describe("negated release disclaimers are not classified as release-class (FN-7560)", () => {
const disclaimerRepros = [
// FN-7525
"This task does not perform any package release or publish (releases are owned by `scripts/release.mjs`).",
// FN-7554
"This task's delivery is the changeset FILE only — it performs no release/publish (`scripts/release.mjs` owns releases).",
// FN-7556
"Delivery is the changeset FILE only; this task performs no package release or publish (releases are owned by `scripts/release.mjs`).",
];
for (const promptText of disclaimerRepros) {
it(`does not flag disclaimer: ${promptText.slice(0, 48)}…`, () => {
const classification = classifyReleaseTask({ promptText });
expect(classification.isReleaseClass, promptText).toBe(false);
expect(classification.signals, promptText).toEqual([]);
});
}
it("clears the awaiting-release-authorization hold for the real FN-7525 shape", () => {
const decision = evaluateReleaseAuthorizationGate({
sourceType: "agent_heartbeat",
title: "Add Revert/Undo affordance to Done and Archived task cards",
promptText:
"## Scope\nThis task does not perform any package release or publish (releases are owned by `scripts/release.mjs`).\n\n## Git Commit Convention\nCommits at step boundaries.",
});
expect(decision.action).toBe("allow");
expect(decision.isReleaseClass).toBe(false);
});
});
it("still flags genuine release intent even alongside a disclaimer clause", () => {
// A real release instruction lives in its own non-negated clause and must survive stripping.
const classification = classifyReleaseTask({
promptText:
"Run pnpm release --yes to publish @runfusion/fusion. This other task performs no release.",
});
expect(classification.isReleaseClass).toBe(true);
expect(classification.signals).toContain("pnpm release");
});
it("still flags every documented signal when phrased as an actionable instruction", () => {
const actionable = [
"Run pnpm release --yes now.",
"Execute node scripts/release.mjs to cut the build.",
"Run pnpm changeset publish to ship.",
"Then npm publish the @runfusion/fusion tarball.",
"Run pnpm publish @runfusion/fusion.",
"Publish the package to npm as the final step.",
"Create git tag v1.2.3 for the release.",
"Author a version bump release commit for v1.2.3.",
];
for (const promptText of actionable) {
expect(classifyReleaseTask({ promptText }).isReleaseClass, promptText).toBe(true);
}
});
it("stripNegatedReleaseClauses drops disclaimer clauses but keeps actionable ones", () => {
const stripped = stripNegatedReleaseClauses(
"Run pnpm release to publish. This task performs no other release; releases are owned by scripts/release.mjs.",
);
expect(stripped).toMatch(/pnpm release/);
expect(stripped).not.toMatch(/scripts\/release\.mjs/);
expect(stripped).not.toMatch(/performs no/);
});
it("handles empty and undefined inputs without throwing or flagging", () => {
expect(classifyReleaseTask({})).toEqual({ isReleaseClass: false, signals: [] });
expect(evaluateReleaseAuthorizationGate({ sourceType: undefined }).action).toBe("allow");

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
import { builtinSeamPrompt, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
import { builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
import {
TriageProcessor,
buildSpecificationPrompt,
@@ -753,6 +753,23 @@ describe("FN-5893 invariant regression wording", () => {
expect(FAST_PLANNING_PROMPT).not.toContain("## Proactive Subtask Breakdown");
});
it("places Before → After Transformation at the top of the definition, ahead of Mission and Review Level (FN-7593)", () => {
const standardTransformationIdx = STANDARD_PLANNING_PROMPT.indexOf("## Before → After Transformation");
const standardReviewLevelIdx = STANDARD_PLANNING_PROMPT.indexOf("## Review Level");
const standardMissionIdx = STANDARD_PLANNING_PROMPT.indexOf("## Mission");
expect(standardTransformationIdx).toBeGreaterThan(-1);
expect(standardReviewLevelIdx).toBeGreaterThan(-1);
expect(standardMissionIdx).toBeGreaterThan(-1);
expect(standardTransformationIdx).toBeLessThan(standardReviewLevelIdx);
expect(standardTransformationIdx).toBeLessThan(standardMissionIdx);
const fastTransformationIdx = FAST_PLANNING_PROMPT.indexOf("## Before → After Transformation");
const fastMissionIdx = FAST_PLANNING_PROMPT.indexOf("## Mission");
expect(fastTransformationIdx).toBeGreaterThan(-1);
expect(fastMissionIdx).toBeGreaterThan(-1);
expect(fastTransformationIdx).toBeLessThan(fastMissionIdx);
});
it("requires invariant-level regression coverage in standard, fast, and core triage prompts", () => {
for (const prompt of [
TRIAGE_POLICY_PROMPT,
@@ -2375,6 +2392,123 @@ describe("TriageProcessor", () => {
});
});
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
FN-7596 pins the Coding (Ideas) manual-intake lifecycle at the poll-dispatch boundary: an `ideas`-column card must stay parked (never auto-dispatched via `eligibleTriageTasks`, which only matches `column === "triage"`), while a promoted `todo`-column card whose PROMPT.md is still the bootstrap stub must be discovered and specified via `eligibleTodoTasks`'s bootstrap-prompt file check. A `todo` card with a real (non-bootstrap) spec must NOT be re-dispatched, guarding against double-specifying an already-planned card.
*/
describe("Coding (Ideas) manual-intake discovery (FN-7596)", () => {
it("excludes a parked ideas-column task from the poll's specify-dispatch set", async () => {
const tasks: Task[] = [
createTriageTask({ id: "FN-IDEAS-PARKED", column: "ideas" as any, priority: "urgent" }),
];
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, rootDir);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).not.toHaveBeenCalled();
});
it("discovers a promoted todo-column task whose PROMPT.md is still the bootstrap stub", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-discovery-");
const promotedId = "FN-IDEAS-PROMOTED";
try {
const promotedTask = createTriageTask({
id: promotedId,
title: "Promoted from Ideas intake",
description: "Promoted intake task",
column: "todo",
priority: "urgent",
});
await mkdir(join(tempRoot, ".fusion", "tasks", promotedId), { recursive: true });
await writeFile(
join(tempRoot, ".fusion", "tasks", promotedId, "PROMPT.md"),
buildBootstrapPrompt(promotedId, promotedTask.title, promotedTask.description),
"utf-8",
);
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([promotedTask]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).toHaveBeenCalledTimes(1);
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: promotedId }));
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it("does not re-dispatch a todo-column task whose PROMPT.md already carries a real (non-bootstrap) spec", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-planned-");
const plannedId = "FN-IDEAS-PLANNED";
try {
const plannedTask = createTriageTask({
id: plannedId,
title: "Already planned todo task",
description: "Already planned intake task",
column: "todo",
priority: "urgent",
});
await mkdir(join(tempRoot, ".fusion", "tasks", plannedId), { recursive: true });
await writeFile(
join(tempRoot, ".fusion", "tasks", plannedId, "PROMPT.md"),
`# Task: ${plannedId} - Already planned todo task\n\n## Mission\n\nThis task carries a real spec, not the bootstrap stub.\n`,
"utf-8",
);
const triageStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([plannedTask]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 10,
maxTriageConcurrent: 10,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
}),
});
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
const specifySpy = vi
.spyOn(triageProcessor, "specifyTask")
.mockResolvedValue(undefined);
(triageProcessor as any).running = true;
await (triageProcessor as any).poll();
expect(specifySpy).not.toHaveBeenCalled();
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
});
it("runs deterministic validation without calling the spec reviewer", async () => {
const taskId = "FN-001";
const testRootDir = await createTriageFixtureRoot("fusion-triage-plan-validation-");
@@ -2664,7 +2798,13 @@ describe("requirePlanApproval setting", () => {
{ requirePlanApproval: false, planApprovalMode: "auto-approve-all" } as Settings,
);
expect(store.updateTask).toHaveBeenCalledWith("FN-RELEASE", expect.objectContaining({ status: "awaiting-approval" }));
/*
* FN-7559: also assert the release gate stamps awaitingApprovalReason so
* the dashboard can distinguish this hold from a manual-approval hold that
* shares the same status — this is the actual fix for "tasks wait for
* approval even though auto-approve is on".
*/
expect(store.updateTask).toHaveBeenCalledWith("FN-RELEASE", expect.objectContaining({ status: "awaiting-approval", awaitingApprovalReason: "release-authorization" }));
expect(store.moveTask).not.toHaveBeenCalled();
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "task:release-authorization-required" }));
expect(store.logEntry).toHaveBeenCalledWith(
@@ -2674,6 +2814,236 @@ describe("requirePlanApproval setting", () => {
);
});
/*
* FNXC:PlanApproval 2026-07-04-21:35:
* FN-7559 — root cause + fix regression test. Confirms all three symptom cases
* from the task's "Symptom Verification" section in one place: (a) a
* release-class hold still parks with a distinct awaitingApprovalReason even
* under auto-approve-all, (b) the manual gate's own awaiting-approval write
* always clears/omits that reason (never "release-authorization"), proving the
* operator can always tell the two holds apart from the persisted task state
* alone — not just from log text.
*/
it("FN-7559: release-authorization hold carries a reason distinct from the manual gate's own awaiting-approval write", async () => {
const releaseTask = createTriageTask({
id: "FN-RELEASE2",
title: "Release @runfusion/fusion patch",
status: "planning",
sourceType: "agent_heartbeat",
} as Partial<Task>);
const releaseStore = createMockStore({
getTask: vi.fn().mockResolvedValue(releaseTask),
} as Partial<TaskStore>);
const releaseProcessor = new TriageProcessor(releaseStore, rootDir);
await (releaseProcessor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
releaseTask,
"# Task: FN-RELEASE2 - Release @runfusion/fusion patch\n\n## Mission\n\nRun pnpm release --yes.\n",
{ requirePlanApproval: false, planApprovalMode: "auto-approve-all" } as Settings,
);
const releaseUpdateCall = (releaseStore.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
(call: unknown[]) => (call[1] as Record<string, unknown>)?.status === "awaiting-approval",
);
expect(releaseUpdateCall?.[1]).toMatchObject({ awaitingApprovalReason: "release-authorization" });
const manualTask = createTriageTask({
id: "FN-MANUAL2",
status: "planning",
} as Partial<Task>);
const manualStore = createMockStore({
getTask: vi.fn().mockResolvedValue(manualTask),
} as Partial<TaskStore>);
const manualProcessor = new TriageProcessor(manualStore, rootDir);
await (manualProcessor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
manualTask,
"# Task: FN-MANUAL2 - Ordinary task\n\n## Mission\n\nDo the thing.\n",
{ requirePlanApproval: true, planApprovalMode: "require-all" } as Settings,
);
const manualUpdateCall = (manualStore.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
(call: unknown[]) => (call[1] as Record<string, unknown>)?.status === "awaiting-approval",
);
expect(manualUpdateCall?.[1]).toMatchObject({ awaitingApprovalReason: null });
});
/*
* FNXC:PlanApproval 2026-07-04-22:41:
* FN-7569 — symptom repro + fix: manual plan approval must be idempotent against
* unchanged plan content. An operator approves a plan (approvedPlanFingerprint gets
* persisted on the task, mirroring POST /tasks/:id/approve-plan), then the SAME task
* re-enters finalizeApprovedTask (replan / plan-review retry / self-healing rebound)
* with byte-identical PROMPT.md. Today's code (pre-fix) would re-park at
* awaiting-approval a second time; the fix must move straight to todo instead.
*/
describe("FN-7569: plan approval fingerprint idempotency", () => {
const planText = "# Task: FN-IDEMPOTENT - Idempotent plan\n\n## Mission\n\nDo the thing.\n\n## File Scope\n\n- a.ts\n";
const changedPlanText = "# Task: FN-IDEMPOTENT - Idempotent plan\n\n## Mission\n\nDo the thing, differently.\n\n## File Scope\n\n- a.ts\n- b.ts\n";
it("re-specifying the SAME approved plan skips the manual gate and moves straight to todo", async () => {
const fingerprint = computePlanApprovalFingerprint(planText);
const task = createTriageTask({
id: "FN-IDEMPOTENT",
status: "planning",
approvedPlanFingerprint: fingerprint,
} as Partial<Task>);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
} as Partial<TaskStore>);
const processor = new TriageProcessor(store, rootDir);
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
planText,
{ requirePlanApproval: true, planApprovalMode: "require-all" } as Settings,
);
expect(store.moveTask).toHaveBeenCalledWith("FN-IDEMPOTENT", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-IDEMPOTENT", expect.objectContaining({ status: "awaiting-approval" }));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-IDEMPOTENT",
"Plan unchanged since prior approval — proceeding without re-approval",
);
});
it("re-specifying a CHANGED plan after prior approval still re-asks for approval", async () => {
const fingerprint = computePlanApprovalFingerprint(planText);
const task = createTriageTask({
id: "FN-IDEMPOTENT-CHANGED",
status: "planning",
approvedPlanFingerprint: fingerprint,
} as Partial<Task>);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
} as Partial<TaskStore>);
const processor = new TriageProcessor(store, rootDir);
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
changedPlanText,
{ requirePlanApproval: true, planApprovalMode: "require-all" } as Settings,
);
expect(store.updateTask).toHaveBeenCalledWith("FN-IDEMPOTENT-CHANGED", expect.objectContaining({ status: "awaiting-approval", awaitingApprovalReason: null }));
expect(store.moveTask).not.toHaveBeenCalled();
});
it("never-approved task (no fingerprint) still parks at awaiting-approval on first specify", async () => {
const task = createTriageTask({
id: "FN-NEVER-APPROVED",
status: "planning",
} as Partial<Task>);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
} as Partial<TaskStore>);
const processor = new TriageProcessor(store, rootDir);
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
planText,
{ requirePlanApproval: true, planApprovalMode: "require-all" } as Settings,
);
expect(store.updateTask).toHaveBeenCalledWith("FN-NEVER-APPROVED", expect.objectContaining({ status: "awaiting-approval" }));
expect(store.moveTask).not.toHaveBeenCalled();
});
it("rejected plan (fingerprint cleared to undefined) re-asks even though the same content was approved before", async () => {
const task = createTriageTask({
id: "FN-REJECTED-THEN-RESPECIFIED",
status: "planning",
approvedPlanFingerprint: undefined,
} as Partial<Task>);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
} as Partial<TaskStore>);
const processor = new TriageProcessor(store, rootDir);
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
planText,
{ requirePlanApproval: true, planApprovalMode: "require-all" } as Settings,
);
expect(store.updateTask).toHaveBeenCalledWith("FN-REJECTED-THEN-RESPECIFIED", expect.objectContaining({ status: "awaiting-approval" }));
expect(store.moveTask).not.toHaveBeenCalled();
});
it("auto-approve-all moves to todo regardless of fingerprint state (manual gate never reached)", async () => {
const task = createTriageTask({
id: "FN-AUTO-APPROVE-FINGERPRINT",
status: "planning",
approvedPlanFingerprint: computePlanApprovalFingerprint(changedPlanText),
} as Partial<Task>);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
} as Partial<TaskStore>);
const processor = new TriageProcessor(store, rootDir);
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
planText,
{ requirePlanApproval: true, planApprovalMode: "auto-approve-all" } as Settings,
);
expect(store.moveTask).toHaveBeenCalledWith("FN-AUTO-APPROVE-FINGERPRINT", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-AUTO-APPROVE-FINGERPRINT", expect.objectContaining({ status: "awaiting-approval" }));
});
/*
* FN-7569: exercise the recoverApprovedTask caller (planning-recovery self-heal),
* not just finalizeApprovedTask directly, so the fingerprint short-circuit is
* proven to reach every finalizeApprovedTask caller, not just a direct-call seam.
*/
it("recoverApprovedTask (self-healing planning recovery) skips re-park for an unchanged already-approved plan", async () => {
const fingerprint = computePlanApprovalFingerprint(planText);
await mkdir(join(rootDir, ".fusion", "tasks", "FN-RECOVER-IDEMPOTENT"), { recursive: true });
await writeFile(join(rootDir, ".fusion", "tasks", "FN-RECOVER-IDEMPOTENT", "PROMPT.md"), planText);
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: true,
} as Settings),
});
const processor = new TriageProcessor(store, rootDir);
const recovered = await processor.recoverApprovedTask({
id: "FN-RECOVER-IDEMPOTENT",
description: "Recovered triage task",
column: "triage",
status: "planning",
approvedPlanFingerprint: fingerprint,
dependencies: [],
steps: [],
currentStep: 0,
log: [
{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" },
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:02:00.000Z",
});
expect(recovered).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-RECOVER-IDEMPOTENT", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-RECOVER-IDEMPOTENT", expect.objectContaining({ status: "awaiting-approval" }));
});
});
/*
* FNXC:PlanApproval 2026-07-04-12:30:
* FN-7526 — auto-approve-all must NOT bypass Workflow Plan Review. A REVISE
@@ -3145,8 +3515,15 @@ Forbidden paths / non-goals:
expect(recovered).toBe(true);
expect(store.moveTask).not.toHaveBeenCalled();
/*
* FN-7559: the manual gate's own awaiting-approval write now explicitly
* clears awaitingApprovalReason (defense against a stale "release-authorization"
* reason surviving a replan) so this genuine manual hold is never mistaken
* for a release-authorization hold in the dashboard.
*/
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
status: "awaiting-approval",
awaitingApprovalReason: null,
});
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",

View File

@@ -424,4 +424,119 @@ describe("WorkflowGraphExecutor traversal", () => {
await expect(executor.run(task, settingsOn(), ir)).rejects.toThrow("Cycle detected");
});
// FN-7579: ask-user (chat reach-out) + exit-gate (early termination) end-to-end
// through the real registered handlers (no override), using deps.runCustomNode
// exactly as the ask-user node is dispatched in production.
describe("ask-user / exit-gate (FN-7579)", () => {
it("ask-user node parks the task awaiting-user-input via the custom-node runner", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "ask-user",
nodes: [
{ id: "start", kind: "start" },
{ id: "ask", kind: "ask-user", config: { question: "Looks good?" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "ask" },
{ from: "ask", to: "end", condition: "success" },
],
};
const runCustomNode = vi.fn(async () => ({ outcome: "failure" as const, value: "awaiting-user-input" }));
const executor = new WorkflowGraphExecutor({ runCustomNode });
const result = await executor.run(task, settingsOn(), ir);
expect(runCustomNode).toHaveBeenCalledOnce();
expect(runCustomNode.mock.calls[0][0]).toMatchObject({ id: "ask", kind: "ask-user" });
expect(result.outcome).toBe("failure");
expect(result.visitedNodeIds).not.toContain("end");
});
it("unconditional exit-gate terminates early, skipping downstream nodes", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "exit-gate-unconditional",
nodes: [
{ id: "start", kind: "start" },
{ id: "exit", kind: "exit-gate" },
{ id: "never", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "exit" },
{ from: "exit", to: "end", condition: "outcome:exit" },
{ from: "exit", to: "never", condition: "outcome:continue" },
],
};
const never = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({ handlers: { prompt: never } });
const result = await executor.run(task, settingsOn(), ir);
expect(never).not.toHaveBeenCalled();
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain("exit");
expect(result.visitedNodeIds).not.toContain("never");
});
it("conditional exit-gate falls through to the next node when the condition does not match", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "exit-gate-conditional",
nodes: [
{ id: "start", kind: "start" },
{ id: "exit", kind: "exit-gate", config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } } },
{ id: "refine", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "exit" },
{ from: "exit", to: "end", condition: "outcome:exit" },
{ from: "exit", to: "refine", condition: "outcome:continue" },
],
};
const refine = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
handlers: { prompt: refine },
});
const result = await executor.run(task, settingsOn(), ir);
expect(refine).toHaveBeenCalledOnce();
expect(result.visitedNodeIds).toContain("refine");
});
it("conditional exit-gate exits early when the referenced context value matches", async () => {
// Seed the ask-user answer via runCustomNode's contextPatch by running a
// graph that first visits an ask-user node, then the exit-gate reads its
// published `input:ask` context key.
const irWithAsk: WorkflowIr = {
version: "v1",
name: "exit-gate-conditional-match-full",
nodes: [
{ id: "start", kind: "start" },
{ id: "ask", kind: "ask-user", config: { question: "Anything to refine?" } },
{ id: "exit", kind: "exit-gate", config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } } },
{ id: "refine", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "ask" },
{ from: "ask", to: "exit", condition: "success" },
{ from: "exit", to: "end", condition: "outcome:exit" },
{ from: "exit", to: "refine", condition: "outcome:continue" },
],
};
const refine = vi.fn(async () => ({ outcome: "success" as const }));
const runCustomNode = vi.fn(async () => ({
outcome: "success" as const,
contextPatch: { "input:ask": "yes, looks good to me" },
}));
const executor2 = new WorkflowGraphExecutor({ runCustomNode, handlers: { prompt: refine } });
const result = await executor2.run(task, settingsOn(), irWithAsk);
expect(refine).not.toHaveBeenCalled();
expect(result.visitedNodeIds).toContain("exit");
expect(result.visitedNodeIds).not.toContain("refine");
});
});
});

View File

@@ -219,6 +219,90 @@ describe("TaskExecutor pre-merge optional-step fix seam", () => {
expect(cappedStore.moveTask).not.toHaveBeenCalled();
});
/*
* FN-7561: the unbounded Plan Review replan default must still stop at a finite
* safety ceiling. Below the cap it keeps replanning; at the cap it halts with a
* loud log entry and leaves the task for a human instead of looping forever
* (FN-7525 ran 13+ attempts overnight with no operator visibility).
*/
it("keeps replanning an unbounded Plan Review loop just below the safety cap", async () => {
const store = createMockStore();
const belowLog = Array.from({ length: 14 }, (_, i) => revisionLog("Plan Review", "plan-review", i + 1));
const loopingTask = task({ postReviewFixCount: 14, column: "in-progress", log: belowLog });
store.getTask.mockResolvedValue(loopingTask);
store.getSettings.mockResolvedValue({ maxPostReviewFixes: 9 }); // no planReviewMaxRevisions → unbounded
const executor = new TaskExecutor(store, "/tmp/test");
await expect((executor as any).requestPreMergeOptionalStepFix(loopingTask.id, loopingTask, {
stepName: "Plan Review",
feedback: "one more disagreement",
phase: "pre-merge" as const,
status: "failed" as const,
verdict: "REVISE",
nodeId: "plan-review",
maxRevisions: "unbounded",
})).resolves.toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-7066", "triage");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-7066",
"Plan Review failed — moved to triage for automatic replan (attempt 15/unbounded)",
expect.anything(),
undefined,
);
});
it("halts the unbounded Plan Review replan loop at the safety cap and leaves the task for a human", async () => {
const store = createMockStore();
const cappedLog = Array.from({ length: 15 }, (_, i) => revisionLog("Plan Review", "plan-review", i + 1));
const loopingTask = task({ postReviewFixCount: 15, column: "in-progress", log: cappedLog });
store.getTask.mockResolvedValue(loopingTask);
store.getSettings.mockResolvedValue({ maxPostReviewFixes: 9 }); // unbounded default
const executor = new TaskExecutor(store, "/tmp/test");
await expect((executor as any).requestPreMergeOptionalStepFix(loopingTask.id, loopingTask, {
stepName: "Plan Review",
feedback: "still disagreeing after fifteen tries",
phase: "pre-merge" as const,
status: "failed" as const,
verdict: "REVISE",
nodeId: "plan-review",
maxRevisions: "unbounded",
})).resolves.toBe(false);
// Halted: no replan side effects.
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith("FN-7066", { postReviewFixCount: 16 }, undefined);
// Loud, human-visible halt log.
expect(store.logEntry).toHaveBeenCalledWith(
"FN-7066",
expect.stringContaining("Plan Review replan safety cap reached (15/15)"),
expect.stringContaining("still disagreeing after fifteen tries"),
undefined,
);
});
it("does not replan a malformed (advisory_failure, no verdict) Plan Review result", async () => {
// FN-7561 invariant: a malformed reviewer response (no parseable verdict) is an
// infra/formatting failure, not a plan defect, and must never bounce the task to triage.
const store = createMockStore();
const liveTask = task({ column: "in-progress" });
store.getTask.mockResolvedValue(liveTask);
store.getSettings.mockResolvedValue({ maxPostReviewFixes: 3 });
const executor = new TaskExecutor(store, "/tmp/test");
await expect((executor as any).requestPreMergeOptionalStepFix(liveTask.id, liveTask, {
stepName: "Plan Review",
feedback: "unparseable reviewer output",
phase: "pre-merge" as const,
status: "advisory_failure" as const,
verdict: undefined,
nodeId: "plan-review",
})).resolves.toBe(false);
expect(store.moveTask).not.toHaveBeenCalled();
});
it("clears stale pause-abort provenance silently before a fresh unpaused execution dispatch", async () => {
const store = createMockStore();
const liveTask = task({ column: "todo", paused: false, userPaused: false });

View File

@@ -125,4 +125,70 @@ describe("workflow node handlers", () => {
expect(result.outcome).toBe("success");
expect(runCustomNode).not.toHaveBeenCalled();
});
// FN-7579: ask-user is registered on the SAME custom-node seam as prompt/script
// (no dedicated seam string) so it always falls through to the custom-node
// runner, which is where the engine special-cases node.kind === "ask-user"
// onto the await-input park/resume path (covered end-to-end in
// workflow-graph-executor-handlers.test.ts).
it("dispatches an ask-user node to the custom-node runner (no seam)", async () => {
const runCustomNode = vi.fn(async () => ({ outcome: "failure" as const, value: "awaiting-user-input" }));
const handlers = createDefaultNodeHandlers(noopSeams(), runCustomNode);
const askNode: WorkflowIrNode = { id: "ask", kind: "ask-user", config: { question: "Anything to refine?" } };
const result = await handlers["ask-user"](askNode, { task, settings: undefined, context: {} });
expect(runCustomNode).toHaveBeenCalledWith(askNode, task, {});
expect(result).toEqual({ outcome: "failure", value: "awaiting-user-input" });
});
describe("exit-gate handler", () => {
it("exits unconditionally when no condition is configured", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{ id: "exit", kind: "exit-gate", config: {} },
{ task, settings: undefined, context: {} },
);
expect(result).toEqual({ outcome: "success", value: "exit" });
});
it("exits when an output-contains condition matches the referenced node's context value", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{
id: "exit",
kind: "exit-gate",
config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } },
},
{ task, settings: undefined, context: { "input:ask": "yes, looks good to me" } },
);
expect(result).toEqual({ outcome: "success", value: "exit" });
});
it("falls through (does not exit) when the condition does not match", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{
id: "exit",
kind: "exit-gate",
config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } },
},
{ task, settings: undefined, context: { "input:ask": "needs more work" } },
);
expect(result).toEqual({ outcome: "success", value: "continue" });
});
it("exits when an output-matches regex condition matches", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{
id: "exit",
kind: "exit-gate",
config: { condition: { type: "output-matches", nodeId: "ask", pattern: "approve(d)?", flags: "i" } },
},
{ task, settings: undefined, context: { "input:ask": "Approved!" } },
);
expect(result).toEqual({ outcome: "success", value: "exit" });
});
});
});

View File

@@ -16,12 +16,35 @@ import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
type StoredCredential = StoredAuthCredential;
const OAUTH_REFRESH_BUFFER_MS = 60_000;
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
FN-7574: a 60s reactive refresh buffer meant a healthy Anthropic subscription token was
only ever refreshed a few seconds before (or after) it actually expired — too late to
reliably beat a slow/failed network round trip, so subscriptions routinely lapsed and
forced a manual re-login even though the refresh token was still valid. Widen the
proactive-refresh window to 5 minutes so both the reactive getApiKey() path AND the new
background OAuthRefreshScheduler (see notification/oauth-refresh-scheduler.ts) renew the
access token well ahead of expiry, without refreshing needlessly often (the scheduler
runs on a multi-minute interval, and the in-flight dedupe + failure cooldown below still
apply so a single stuck token doesn't get hammered).
*/
const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000;
const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const ANTHROPIC_DEFAULT_SCOPES = ["user:profile"];
/*
FNXC:ClaudeOAuth 2026-07-05-18:52:
Anthropic subscription login (delegated to pi-ai) grants the full Claude Code scope set — `user:inference` is what authorizes model calls. Earlier this constant was `["user:profile"]`, which was WRONG twice over: (1) it under-describes the token pi-ai actually obtains, and (2) it was fed into the refresh request's `scope` param, which under RFC 6749 §6 NARROWS the refreshed access token to profile-only and strips `user:inference`. The symptom: the account reads "logged in via OAuth" (token present + unexpired) yet every model call 403s with "OAuth token does not meet scope requirement any_of(user:inference, ...)". The default must mirror pi-ai's granted scopes so any fallback describes a usable token, and the refresh path (below) must NOT send it as a narrowing scope.
*/
const ANTHROPIC_DEFAULT_SCOPES = [
"org:create_api_key",
"user:profile",
"user:inference",
"user:sessions:claude_code",
"user:mcp_servers",
"user:file_upload",
];
const OAUTH_REFRESH_TIMEOUT_MS = 10_000;
const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
@@ -200,6 +223,10 @@ async function refreshAnthropicOAuthCredential(credential: StoredCredential): Pr
Fusion must renew expired Claude OAuth credentials with the stored refresh token so users are not forced through repeated manual Claude re-login when the access token expires.
Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths.
*/
/*
FNXC:ClaudeOAuth 2026-07-05-18:52:
Do NOT send `scope` on refresh. RFC 6749 §6: a refresh request that includes `scope` re-issues the access token with EXACTLY that scope (never broader), so sending our stored/derived scope list can only strip capabilities — and did: it narrowed refreshed tokens to `user:profile` and broke inference. Omitting `scope` makes Anthropic preserve the originally-granted scopes (this is what pi-ai's own `refreshAnthropicToken` does). `scopes` is still resolved above and used only as the parseScopes fallback for the persisted credential record.
*/
const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, {
method: "POST",
headers: {
@@ -210,7 +237,6 @@ async function refreshAnthropicOAuthCredential(credential: StoredCredential): Pr
grant_type: "refresh_token",
refresh_token: refresh,
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
scope: scopes.join(" "),
}),
signal: controller.signal,
});
@@ -337,6 +363,26 @@ export function createFusionAuthStorage(): AuthStorage {
// Cleared when the user re-authenticates via set().
const loggedOutProviders = new Set<string>();
/*
FNXC:ProviderAuth 2026-07-05-00:00:
Re-authenticating a provider must clear its in-memory logged-out suppression so the settings card flips back to connected.
Anthropic subscription OAuth is aliased across the legacy `anthropic` row — where interactive login persists the credential — and the separated `anthropic-subscription` id, where the card's logged-out flag and status read are keyed. Because a re-login only writes `anthropic`, clearing just the written id left `anthropic-subscription` suppressed: a user who logged out of the subscription earlier in the same dashboard session saw every successful re-login reported as "Login did not complete. Please try again." until the process restarted (the credential was valid on disk the whole time). Clear BOTH aliases when either is re-authenticated. Only OAuth credentials alias this way; a raw `anthropic` API key stays scoped to its own card, so api_key writes never clear the subscription alias.
*/
const clearReauthenticatedLogoutState = (
provider: string,
credentialType?: StoredCredential["type"],
) => {
loggedOutProviders.delete(provider);
oauthRefreshCooldownUntil.delete(provider);
const isAnthropicAlias =
provider === ANTHROPIC_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID;
const aliasesSubscriptionOAuth = credentialType === undefined || credentialType === "oauth";
if (isAnthropicAlias && aliasesSubscriptionOAuth) {
loggedOutProviders.delete(ANTHROPIC_PROVIDER_ID);
loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
};
const syncSupplementalOauthCredentials = () => {
for (const [provider, credential] of Object.entries(supplementalCredentials)) {
if (loggedOutProviders.has(provider)) {
@@ -617,11 +663,29 @@ export function createFusionAuthStorage(): AuthStorage {
};
}
if (prop === "login") {
// Preserve the original invocation semantics (bind `this` to the proxy so
// any internal credential writes still flow through the set/logout traps),
// and only ADD the alias-aware logged-out clearing on top.
const originalLogin = Reflect.get(target, prop, receiver) as (
provider: string,
callbacks: unknown,
) => Promise<void>;
return async (provider: string, callbacks: unknown) => {
const result = await originalLogin.call(receiver, provider, callbacks);
/*
FNXC:ProviderAuth 2026-07-05-00:00:
A completed interactive login means the user re-authenticated this provider, so lift its logged-out suppression (and the Anthropic subscription alias) even though the credential is persisted under `anthropic`. Without this, subscription re-login after an in-session logout stays invisible to the status card. See clearReauthenticatedLogoutState.
*/
clearReauthenticatedLogoutState(provider);
return result;
};
}
if (prop === "set") {
return (provider: string, credential: AuthCredential) => {
target.set(provider, credential);
loggedOutProviders.delete(provider);
oauthRefreshCooldownUntil.delete(provider);
clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type);
};
}

View File

@@ -4209,6 +4209,11 @@ export class TaskExecutor {
const liveTask = await this.store.getTask(taskId).catch(() => fallbackTask);
const isPlanReview = info.nodeId === "plan-review" || info.stepName === "Plan Review";
if (isPlanReview) {
/*
* FNXC:PlanReviewReplan 2026-07-05-17:32:
* FN-7561: a malformed reviewer response arrives as `advisory_failure` with NO parsed verdict. That is an infra/formatting failure (e.g. the reviewer could not locate the spec, or fumbled its trailing JSON), not a plan defect — it must NEVER bounce the task to a triage replan. The graph already excludes malformed advisories from the fix handoff (shouldRequestPreMergeFix); this guard defends the explicit remediation-node path and any future caller so a malformed advisory can never drive the replan loop. A genuine REVISE (verdict === "REVISE", also carried as advisory_failure) still replans below.
*/
if (info.status === "advisory_failure" && info.verdict !== "REVISE") return false;
if (info.verdict !== undefined && info.verdict !== "REVISE") return false;
/*
* FNXC:PlanReviewReplan 2026-06-29-00:41:
@@ -4231,6 +4236,21 @@ export class TaskExecutor {
const revisionKey = optionalStepRevisionKey(info.nodeId ?? "plan-review", info.stepName);
const currentCount = countOptionalStepRevisionAttempts(liveTask, revisionKey, info.stepName);
if (!budget.unbounded && currentCount >= budget.max) return false;
/*
* FNXC:PlanReviewReplanCap 2026-07-05-17:28:
* FN-7561: an unset Plan Review revision budget resolves to "unbounded" (see FNXC:WorkflowRevisionBudget above), which by design skips the ceiling check — so a task whose planner and reviewer persistently disagree, or whose reviewer keeps hard-failing, replans triage↔plan-review forever, silently burning a triage + review LLM call every cycle (FN-7525 ran 13+ attempts overnight with zero operator visibility). Enforce a finite safety ceiling even when unbounded: once hit, emit a loud halting log entry and STOP replanning (return false) so the gate falls through to a visible failed/parked state a human can act on, instead of looping indefinitely. Explicit numeric operator budgets are still honored as-is above; this only backstops the unbounded DEFAULT.
*/
const PLAN_REVIEW_REPLAN_HARD_CAP = 15;
if (budget.unbounded && currentCount >= PLAN_REVIEW_REPLAN_HARD_CAP) {
await this.store.logEntry(
taskId,
`Plan Review replan safety cap reached (${currentCount}/${PLAN_REVIEW_REPLAN_HARD_CAP}) — halting automatic replan and leaving the task for human review`,
`Plan Review requested another planning revision but the unbounded replan loop hit its safety ceiling of ${PLAN_REVIEW_REPLAN_HARD_CAP} attempts. This usually means the reviewer and planner disagree persistently, or the reviewer keeps failing to produce a verdict. The task is being left in place for a human to inspect rather than looping further. Latest feedback:\n${feedback}`,
this.getRunContextFor(taskId),
);
executorLog.warn(`${taskId}: Plan Review replan safety cap (${PLAN_REVIEW_REPLAN_HARD_CAP}) reached after ${currentCount} attempts — halting automatic replan`);
return false;
}
const nextCount = currentCount + 1;
const totalFixCount = (liveTask.postReviewFixCount ?? 0) + 1;
const budgetLabel = budget.unbounded ? "unbounded" : String(budget.max);
@@ -6647,9 +6667,21 @@ export class TaskExecutor {
* placement re-walks earlier read-only nodes until CU-U5 checkpoints land.
*/
private async runAwaitInputNode(node: WorkflowIrNode, live: TaskDetail): Promise<WorkflowNodeResult> {
const question = typeof node.config?.prompt === "string" && node.config.prompt.trim()
? node.config.prompt.trim()
: "This workflow is waiting for your input.";
/*
FNXC:WorkflowAskUser 2026-07-05-00:00:
FN-7579's `ask-user` node is the first-class discoverable surface over this
SAME park/resume plumbing that a `prompt` node with `config.awaitInput: true`
already used. Question resolution order: `config.question` (the ask-user
node's dedicated field) first, then `config.prompt` (back-compat with the
original awaitInput alias), then the shared default string. Nothing below
this line branches on node.kind — both node kinds share one pause/resume
contract so behavior can never drift between them.
*/
const question = typeof node.config?.question === "string" && node.config.question.trim()
? node.config.question.trim()
: typeof node.config?.prompt === "string" && node.config.prompt.trim()
? node.config.prompt.trim()
: "This workflow is waiting for your input.";
const marker = `workflow-input:${node.id}`;
const steering = Array.isArray(live.steeringComments) ? live.steeringComments : [];
@@ -7187,7 +7219,10 @@ export class TaskExecutor {
if (staleInput === "clear") live = await this.store.getTask(nodeTask.id);
// Await-input nodes never run a session — they pause for the user.
if (cfg.awaitInput === true) {
// FNXC:WorkflowAskUser 2026-07-05-00:00: `ask-user` is the dedicated,
// discoverable node kind for this same pause; `prompt` + `config.awaitInput:
// true` remains a back-compat alias (both route to the identical runner).
if (cfg.awaitInput === true || node.kind === "ask-user") {
return this.runAwaitInputNode(node, live);
}
@@ -14531,14 +14566,22 @@ ${scopeGuard}
const requireExternalIntegrationEvidence =
workflowStepMetadata.requireExternalIntegrationEvidence === true;
/*
* FNXC:PlanReviewSpecInjection 2026-07-05-17:20:
* FN-7561: the Plan Review reviewer runs readonly with cwd=worktree, but the spec artifact lives at the project root under `.fusion/tasks/<id>/PROMPT.md` — OUTSIDE the task worktree. Instructing the agent to "Read PROMPT.md" therefore had it search the worktree, fail to find the file, and emit "no PROMPT.md file was found / task data lives in a DB" prose instead of a parseable verdict. That malformed/hard-failed output fed the unbounded triage↔plan-review replan loop (FN-7525 looped 13+ times overnight; FN-7575 too). Load the spec text from the store (document layer → on-disk PROMPT.md) ONCE and inject it directly into the reviewer prompt so the verdict never depends on the agent locating the file. Read from the store, not fs, so it is correct regardless of worktree vs project-root layout.
*/
const planReviewSpecArtifact = isPlanReviewStep
? await this.readTaskArtifact(task.id, "PROMPT.md")
: undefined;
const planReviewSpecText = typeof planReviewSpecArtifact === "string" ? planReviewSpecArtifact : "";
if (isPlanReviewStep && requireExternalIntegrationEvidence) {
/*
* FNXC:PlanValidation 2026-06-30-09:03:
* Coding (per-step review) intentionally keeps external-integration evidence as a Plan Review gate. Enforce it here, not in triage, so only workflows that set `requireExternalIntegrationEvidence` block and failures route through the graph's normal plan-replan loop.
*/
const promptContent = await this.readTaskArtifact(task.id, "PROMPT.md");
const evidenceGaps = detectExternalIntegrationEvidenceGaps({
promptContent: typeof promptContent === "string" ? promptContent : "",
promptContent: planReviewSpecText,
});
if (evidenceGaps.length > 0) {
const diagnostic = formatExternalIntegrationEvidenceDiagnostic(evidenceGaps);
@@ -14593,9 +14636,14 @@ ${scopeGuard}
*/
const scopeBlock = isPlanReviewStep
? `Plan Review Scope:
- Review the task plan artifact (PROMPT.md) and task metadata only.
- Review the task plan artifact (PROMPT.md), reproduced verbatim below, and task metadata only.
- The plan is embedded in this prompt — do NOT go looking for a PROMPT.md file in the worktree; it lives at the project root (\`.fusion/tasks/${task.id}/PROMPT.md\`), outside this worktree, so review the embedded copy.
- Do NOT judge current implementation diffs, uncommitted worktree changes, or unrelated repository changes.
- If PROMPT.md is internally consistent, complete, scoped, and verifiable, approve even when the worktree contains unrelated changes from another task.`
- If the plan is internally consistent, complete, scoped, and verifiable, approve even when the worktree contains unrelated changes from another task.
--- BEGIN PROMPT.md ---
${planReviewSpecText || `(The plan artifact could not be loaded into this prompt. Read it read-only from the project root at .fusion/tasks/${task.id}/PROMPT.md before judging; do not treat an unavailable artifact as a plan defect.)`}
--- END PROMPT.md ---`
: `Diff Scope (files changed by THIS task vs base):
${scopeFileBlock}${diffShortstat ? `\nDiff stat: ${diffShortstat}` : ""}
@@ -15052,6 +15100,21 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
if (!primaryOutcome.timedOut && !primaryMalformed) return primaryOutcome;
if (!fallback) {
/*
* FNXC:ReviewLeniency 2026-07-05-17:24:
* FN-7561: when NO fallback model is configured, a MALFORMED primary (unparseable verdict — a single fumbled response) still deserves one retry so a transient formatting fumble does not feed the plan-review replan loop. Self-retry once on the SAME primary model. Timeouts are NOT self-retried — they would likely just time out again and burn another full budget. If the self-retry is still malformed it is returned as a non-blocking advisory downstream.
*/
if (primaryMalformed && !primaryOutcome.timedOut) {
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' produced malformed output and no fallback is configured — retrying once on the primary model`);
const retryOutcome = await runOnce(primaryProvider, primaryModelId, "primary-retry");
const retryMalformed = (retryOutcome as { malformed?: boolean }).malformed === true;
if (!retryMalformed) return retryOutcome;
await this.store.logEntry(
task.id,
`Workflow step '${workflowStep.name}' produced malformed output on both the primary attempt and one self-retry — no fallback model configured (set settings.validatorFallbackProvider/Id or fallbackProvider/Id)`,
);
return retryOutcome;
}
const reason = primaryOutcome.timedOut ? "timed out" : "produced malformed output";
executorLog.warn(`${task.id}: workflow step '${workflowStep.name}' ${reason} and no fallback model is configured`);
await this.store.logEntry(

View File

@@ -270,6 +270,8 @@ export {
resolveTaskRevertCommits,
classifyTaskRevert,
performTaskRevert,
resolveWorkspaceTaskRevertCommits,
revertWorkspaceTask,
TaskRevertError,
type TaskRevertCommitSource,
type ResolvedTaskRevertCommits,
@@ -279,6 +281,24 @@ export {
type ClassifyTaskRevertResult,
type TaskRevertResult,
type TaskCommitAssociationSource,
createAiUndoTask,
buildAiUndoTaskDescription,
REVERT_OF_METADATA_KEY,
type AiUndoTaskResult,
type CreateAiUndoTaskDeps,
type TaskRevertGranularity,
type PerformTaskRevertOptions,
type WorkspaceRepoRevertCommits,
type WorkspaceRepoRevertResult,
type WorkspaceTaskRevertResult,
type RevertWorkspaceTaskOptions,
prepareRevertPrBranch,
type PrepareRevertPrBranchResult,
type PrepareRevertPrBranchOptions,
prepareWorkspaceRevertPrBranches,
type PrepareWorkspaceRevertPrBranchesResult,
type PrepareWorkspaceRevertPrBranchesOptions,
type WorkspaceRepoRevertPrBranch,
} from "./task-revert.js";
export {
resolveBranchGroupMergeRouting,

View File

@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OAuthRefreshScheduler } from "../oauth-refresh-scheduler.js";
describe("OAuthRefreshScheduler", () => {
afterEach(() => {
vi.useRealTimers();
});
it("proactively refreshes an OAuth credential nearing expiry via getApiKey", async () => {
const now = Date.now();
const credentials: Record<string, { type: string; access: string; refresh: string; expires: number }> = {
anthropic: { type: "oauth", access: "old-token", refresh: "refresh", expires: now + 60_000 },
};
const getApiKey = vi.fn(async (providerId: string) => {
const cred = credentials[providerId];
if (!cred) return undefined;
// Simulate the real auth-storage.ts refresh-if-due behavior: rotates the token
// and pushes expiry forward when getApiKey is called while near expiry.
credentials[providerId] = { ...cred, access: "rotated-token", expires: now + 3_600_000 };
return "rotated-token";
});
const authStorage = {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => [{ id: "anthropic", name: "Anthropic" }]),
get: vi.fn((providerId: string) => credentials[providerId]),
getApiKey,
};
const scheduler = new OAuthRefreshScheduler({ authStorage, clock: () => now });
await scheduler.start();
scheduler.stop();
expect(getApiKey).toHaveBeenCalledWith("anthropic");
expect(getApiKey).toHaveBeenCalledWith("anthropic-subscription");
expect(credentials.anthropic.expires).toBe(now + 3_600_000);
});
it("also attempts refresh for the anthropic-subscription alias even though it is never returned by getOAuthProviders", async () => {
const now = Date.now();
const credentials: Record<string, { type: string; access: string; refresh: string; expires: number }> = {
"anthropic-subscription": { type: "oauth", access: "old-token", refresh: "refresh", expires: now + 30_000 },
};
const getApiKey = vi.fn(async (providerId: string) => {
const cred = credentials[providerId];
if (!cred) return undefined;
credentials[providerId] = { ...cred, access: "rotated", expires: now + 3_600_000 };
return "rotated";
});
const authStorage = {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => [{ id: "anthropic", name: "Anthropic" }]),
get: vi.fn((providerId: string) => credentials[providerId]),
getApiKey,
};
const scheduler = new OAuthRefreshScheduler({ authStorage, clock: () => now });
await scheduler.start();
scheduler.stop();
expect(getApiKey).toHaveBeenCalledWith("anthropic-subscription");
expect(credentials["anthropic-subscription"].expires).toBe(now + 3_600_000);
});
it("attempts a cheap no-op refresh for a provider with no stored oauth credential", async () => {
const authStorage = {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => [{ id: "github-copilot", name: "GitHub Copilot" }]),
get: vi.fn(() => undefined),
getApiKey: vi.fn(async () => undefined),
};
const scheduler = new OAuthRefreshScheduler({ authStorage });
await scheduler.start();
scheduler.stop();
// getApiKey() is a cheap no-op for a provider with no stored credential, so the
// scheduler still calls it (rather than special-casing "no credential yet") but
// there's nothing to refresh.
expect(authStorage.getApiKey).toHaveBeenCalledWith("github-copilot");
});
it("swallows per-provider refresh failures and continues with other providers", async () => {
const now = Date.now();
const credentials: Record<string, { type: string; access: string; refresh: string; expires: number }> = {
"anthropic-subscription": { type: "oauth", access: "old-token", refresh: "refresh", expires: now + 30_000 },
github: { type: "oauth", access: "gh-token", refresh: "gh-refresh", expires: now + 30_000 },
};
const getApiKey = vi.fn(async (providerId: string) => {
if (providerId === "anthropic" || providerId === "anthropic-subscription") {
throw new Error("revoked refresh token");
}
const cred = credentials[providerId];
if (!cred) return undefined;
credentials[providerId] = { ...cred, expires: now + 3_600_000 };
return "rotated";
});
const authStorage = {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
{ id: "github", name: "GitHub" },
]),
get: vi.fn((providerId: string) => credentials[providerId]),
getApiKey,
};
const scheduler = new OAuthRefreshScheduler({ authStorage, clock: () => now });
await expect(scheduler.start()).resolves.toBeUndefined();
scheduler.stop();
expect(credentials.github.expires).toBe(now + 3_600_000);
});
it("reloads auth storage and repeats on its interval", async () => {
vi.useFakeTimers();
const now = Date.now();
const authStorage = {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => [{ id: "github-copilot", name: "GitHub Copilot" }]),
get: vi.fn(() => undefined),
getApiKey: vi.fn(async () => undefined),
};
const scheduler = new OAuthRefreshScheduler({ authStorage, intervalMs: 1_000, clock: () => now });
await scheduler.start();
expect(authStorage.reload).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
expect(authStorage.reload).toHaveBeenCalledTimes(2);
scheduler.stop();
await vi.advanceTimersByTimeAsync(5_000);
expect(authStorage.reload).toHaveBeenCalledTimes(2);
});
});

View File

@@ -14,3 +14,6 @@ export { OAuthExpiryMonitor } from "./oauth-expiry-monitor.js";
export type { AuthStorageLike as OAuthExpiryAuthStorageLike, OAuthExpiryMonitorOptions } from "./oauth-expiry-monitor.js";
export { OAuthValidityLogger } from "./oauth-validity-logger.js";
export { OAuthRefreshScheduler } from "./oauth-refresh-scheduler.js";
export type { OAuthRefreshAuthStorageLike, OAuthRefreshSchedulerOptions } from "./oauth-refresh-scheduler.js";

View File

@@ -0,0 +1,143 @@
import { schedulerLog } from "../logger.js";
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
FN-7574: healthy subscriptions must not lapse waiting for a reactive refresh. A stored
OAuth credential's access token was previously only ever refreshed when something
actively requested a runtime API key (model execution, or the dashboard's best-effort
refresh-on-expiry check) — if nothing asked for a key in the window between "about to
expire" and "expired", the token simply expired and forced a manual re-login.
OAuthRefreshScheduler runs as an independent, engine-side background loop (separate from
OAuthExpiryMonitor's detect-and-notify concern, per the task's File Scope "pick ONE and
justify": keeping detection/notification and refresh as separate, independently
toggleable responsibilities is easier to test and reason about than folding a refresh
side effect into the monitor's `check()`). On each tick it reloads auth storage and asks
for a fresh API key for every known OAuth provider (plus the Anthropic subscription
alias); `authStorage.getApiKey(id)` already contains the refresh-if-due logic — see
`shouldRefreshOAuthCredential`/`refreshProviderOAuthCredential` in `auth-storage.ts` — so
this scheduler deliberately reuses that instead of duplicating the token HTTP call.
Never logs or persists access/refresh token material: only `providerId`, `providerName`,
and `expiresAt` (ISO) are ever referenced for observability.
*/
const DEFAULT_INTERVAL_MS = 5 * 60_000;
const ANTHROPIC_OAUTH_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
interface OAuthProviderInfo {
id: string;
name: string;
}
interface OAuthCredential {
type?: string;
expires?: number;
}
export interface OAuthRefreshAuthStorageLike {
reload?(): void;
getOAuthProviders?(): OAuthProviderInfo[];
get?(providerId: string): OAuthCredential | undefined;
getApiKey?(providerId: string): Promise<string | null | undefined> | string | null | undefined;
}
export interface OAuthRefreshSchedulerOptions {
authStorage: OAuthRefreshAuthStorageLike;
intervalMs?: number;
clock?: () => number;
}
export class OAuthRefreshScheduler {
private readonly intervalMs: number;
private readonly clock: () => number;
private timer: NodeJS.Timeout | null = null;
constructor(private readonly opts: OAuthRefreshSchedulerOptions) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.clock = opts.clock ?? Date.now;
}
async start(): Promise<void> {
if (this.timer) {
return;
}
await this.tick();
this.timer = setInterval(() => {
void this.tick();
}, this.intervalMs);
this.timer.unref?.();
}
stop(): void {
if (!this.timer) {
return;
}
clearInterval(this.timer);
this.timer = null;
}
private getRefreshCandidateIds(providers: OAuthProviderInfo[]): string[] {
const ids = new Set<string>();
for (const provider of providers) {
ids.add(provider.id);
if (provider.id === ANTHROPIC_OAUTH_PROVIDER_ID) {
// The dashboard-facing subscription alias is stored/refreshed under its own id
// (see selectAnthropicSubscriptionCredential in auth-storage.ts) and is never
// returned by getOAuthProviders() itself, so it must be attempted explicitly.
ids.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
}
return Array.from(ids);
}
private async tick(): Promise<void> {
this.opts.authStorage.reload?.();
const providers = this.opts.authStorage.getOAuthProviders?.();
if (!providers?.length || !this.opts.authStorage.getApiKey) {
return;
}
for (const providerId of this.getRefreshCandidateIds(providers)) {
try {
const before = this.opts.authStorage.get?.(providerId);
const beforeExpires = before?.type === "oauth" && typeof before.expires === "number" && Number.isFinite(before.expires)
? before.expires
: undefined;
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
Always attempt getApiKey() for every known oauth-provider id (rather than
pre-filtering on whether this scheduler's own `get()` snapshot already shows a
credential): the Anthropic subscription alias legitimately resolves through a
legacy-row fallback inside auth-storage.ts's own selection logic, so a naive
"skip if this exact id has no direct row" check would silently skip refreshing
a legacy-row subscription credential. getApiKey() is a cheap no-op when no
credential exists for that id (see resolveStoredCredentialApiKey/getApiKey).
*/
await this.opts.authStorage.getApiKey(providerId);
const after = this.opts.authStorage.get?.(providerId);
if (
after?.type === "oauth"
&& typeof after.expires === "number"
&& Number.isFinite(after.expires)
&& (beforeExpires === undefined || after.expires > beforeExpires)
) {
const providerName = providers.find((p) => p.id === providerId)?.name ?? providerId;
schedulerLog.log(
`OAuth credential proactively refreshed provider=${providerId} name=${providerName} expiresAt=${new Date(after.expires).toISOString()}`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
schedulerLog.warn(`OAuth proactive refresh failed provider=${providerId}: ${message}`);
}
}
}
}

View File

@@ -255,6 +255,21 @@ export class PlannerOverseerMonitor {
private readonly maxObservationsPerTask: number;
private readonly observations = new Map<string, OverseerStageObservation[]>();
/*
FNXC:PlannerOversight 2026-07-05-11:00:
The overseer logs one activity-feed entry per poll tick. On the healthy path an
executor task re-emits the identical `signal=progressing` heartbeat every tick,
which spammed the task feed (user report FN-7577) with no new information and no
lifecycle change. Dedup the feed write on the composite `stage|signal|reason`
key so a log entry is only written when the observed situation CHANGES — mirrors
the FN-7514 withheld-oversight dedup ("not re-emitted every poll while the reason
is unchanged"). The in-memory ring buffer and `onObservation` callback are left
intact (they are cheap / drive downstream emission façades); only the noisy feed
logEntry is gated. Cleared alongside the ring buffer in `clear()` so a re-run of
the same task re-logs its first observation.
*/
private readonly lastLoggedKey = new Map<string, string>();
constructor(options: PlannerOverseerMonitorOptions = {}) {
this.store = options.store;
this.onObservation = options.onObservation;
@@ -299,9 +314,16 @@ export class PlannerOverseerMonitor {
}
if (this.store?.logEntry) {
await this.store
.logEntry(task.id, `[planner-overseer] stage=${stage} signal=${signal}: ${reason}`)
.catch(() => undefined);
// FNXC:PlannerOversight 2026-07-05-11:00 — only write the feed entry when
// the observed (stage, signal, reason) differs from the last one logged
// for this task, so an unchanged heartbeat does not re-spam the feed.
const loggedKey = `${stage}|${signal}|${reason}`;
if (this.lastLoggedKey.get(task.id) !== loggedKey) {
this.lastLoggedKey.set(task.id, loggedKey);
await this.store
.logEntry(task.id, `[planner-overseer] stage=${stage} signal=${signal}: ${reason}`)
.catch(() => undefined);
}
}
return observation;
@@ -327,6 +349,9 @@ export class PlannerOverseerMonitor {
/** Clear recorded observations for a task (e.g. on task completion). */
clear(taskId: string): void {
this.observations.delete(taskId);
// FNXC:PlannerOversight 2026-07-05-11:00 — drop the feed-dedup key too so a
// re-run of the same task re-logs its first observation.
this.lastLoggedKey.delete(taskId);
}
/** Task IDs that currently retain at least one recorded observation. Used

View File

@@ -265,6 +265,21 @@ export class PlannerRecoveryController {
}
const key = this.attemptKey(task.id, snapshot.stage);
// FNXC:PlannerOversight 2026-07-05-11:00:
// FN-7577: once a task's watched stage reports a HEALTHY (`progressing`/
// `complete`) or human-wait (`awaiting-human`) signal, it is no longer
// being recovered — drop any stale attempt / last-action records for the
// (taskId, stage) so the card badge falls back from "recovering" to
// "watching" on the next `GET /api/tasks` serialization, and a later
// genuine problem starts from a fresh bounded budget. A still-problematic
// signal (`stuck`/`blocked`/`failed`) keeps its attempts so the bound holds.
const signal = snapshot.signal;
if (signal === "progressing" || signal === "complete" || signal === "awaiting-human") {
this.attempts.delete(key);
this.lastActions.delete(key);
}
const attemptCount = this.attempts.get(key) ?? 0;
const decision = decidePlannerRecovery({

View File

@@ -48,7 +48,7 @@ import type { PrNodeGithubOps } from "./pr-nodes.js";
import { PrReconciler, type PrReconcileGithubOps } from "./pr-reconcile.js";
import { PrCommentHandler } from "./pr-comment-handler.js";
import { NtfyNotifier } from "./notifier.js";
import { NotificationService, OAuthAlertStateStore, OAuthExpiryMonitor, OAuthValidityLogger } from "./notification/index.js";
import { NotificationService, OAuthAlertStateStore, OAuthExpiryMonitor, OAuthRefreshScheduler, OAuthValidityLogger } from "./notification/index.js";
import type { NotificationChatStore } from "./notification/notification-service.js";
import { GridlockDetector } from "./gridlock-detector.js";
import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-storage.js";
@@ -396,6 +396,7 @@ export class ProjectEngine {
private notifier?: NtfyNotifier;
private notificationService?: NotificationService;
private oauthExpiryMonitor?: OAuthExpiryMonitor;
private oauthRefreshScheduler?: OAuthRefreshScheduler;
private oauthValidityLogger?: OAuthValidityLogger;
private gridlockDetector?: GridlockDetector;
private cronRunner?: CronRunner;
@@ -721,6 +722,16 @@ export class ProjectEngine {
alertState: oauthAlertState,
});
await this.oauthExpiryMonitor.start();
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
FN-7574: proactively refresh OAuth access tokens ahead of expiry (widened window,
see OAUTH_REFRESH_BUFFER_MS in auth-storage.ts) so a healthy subscription session
never lapses waiting for something else to request a runtime API key. Reuses the
same authStorage instance as OAuthExpiryMonitor above so detection/notification and
proactive refresh observe a consistent, single credential source.
*/
this.oauthRefreshScheduler = new OAuthRefreshScheduler({ authStorage });
await this.oauthRefreshScheduler.start();
this.oauthValidityLogger = new OAuthValidityLogger({
authStorage,
alertState: oauthAlertState,
@@ -954,6 +965,7 @@ export class ProjectEngine {
this.prReconciler?.stopAll();
this.prReconciler = undefined;
this.oauthExpiryMonitor?.stop();
this.oauthRefreshScheduler?.stop();
this.oauthValidityLogger?.stop();
this.notificationService?.stop();
this.notifier?.stop();
@@ -1030,6 +1042,11 @@ export class ProjectEngine {
return this.runtime.getChatStore();
}
/** Get the project-scoped PluginRunner (if initialized). */
getPluginRunner() {
return this.runtime.getPluginRunner();
}
attachChatStore(chatStore: NotificationChatStore): void {
this.notificationService?.attachChatStore(chatStore);
}

View File

@@ -1368,6 +1368,15 @@ export class InProcessRuntime
return this.chatStore;
}
/**
* Get the project-scoped PluginRunner (if initialized).
* Dashboard chat needs this runner, not the top-level PluginLoader, so
* runtime hints such as `hermes` can resolve plugin runtimes correctly.
*/
getPluginRunner(): PluginRunner | undefined {
return this.pluginRunner;
}
/**
* Get the project's Scheduler instance.
* @throws Error if runtime has not been started

View File

@@ -1103,6 +1103,10 @@ export class SelfHealingManager {
/**
* FNXC:SelfHealingReclaim 2026-06-19-00:00:
* FN-6736 requires self-healing to stop treating an in-memory `executor-active` binding as live when the owner is demonstrably dead. Preserve FN-4811 by requiring every live-owner signal to be absent, leave the FN-5219 missing-worktree path untouched, and avoid FN-5704 resume-limbo counters because this path only clears a stale binding and requeues once with progress/worktree preserved.
*
* FNXC:SelfHealingReclaim 2026-07-05-08:15:
* FN-7566: the FN-6736 liveness gate (`agentPresent` heartbeat, `checkedOutBy` lease, `hasRecentRunAudit`) is structurally blind to EPHEMERAL EXECUTOR agents (`agentId: "executor"`): they never emit heartbeat runs (so `activeHeartbeatTaskIds` never contains them), never acquire a checkout lease (`checkedOutBy` stays null), and normal execution activity (sandbox:run / task:log / verification) writes no `runAuditEvents` rows (so `getRecentRunAuditActivityAgeMs` stays null). With all three permanently false, the ONLY surviving gate was age > graceMs*3 (~30 min), so any ephemeral executor task running longer than 30 minutes — a heavy foreach workflow, a slow model — was killed mid-flight on the next self-healing sweep and hard-moved to `todo`, corrupting overlapping-worktree/task-link state.
* The fix adds the in-process live-session truth that DOES track ephemeral executors: a worktree path registered as active in `activeSessionRegistry` (the executor/step-session/workflow-step session holds it for the whole run), the `executingTaskLock`, or `isTaskActive`. This mirrors the canonical `isWorkspaceTaskLive` / `sessionDead` predicate. A genuinely leaked binding (FN-6736) still has an EMPTY registry / no lock / inactive task, so legitimate phantom recovery is preserved; a live ephemeral executor now vetoes the phantom verdict regardless of the durable-agent signals.
*/
private isPhantomExecutorBinding(task: Task, options: {
executionAgeMs: number | null;
@@ -1115,6 +1119,14 @@ export class SelfHealingManager {
const checkedOutBy = typeof task.checkedOutBy === "string" && task.checkedOutBy.trim().length > 0 ? task.checkedOutBy : null;
const worktreeExists = Boolean(task.worktree && existsSync(task.worktree));
const hasRecentRunAudit = options.lastActivityMs !== null && options.lastActivityMs <= RUNNING_ON_INACTIVE_TASK_STALE_RUN_MS;
// FN-7566: in-process liveness that survives for ephemeral executors. The registered
// session path is the faithful proxy for the live session surfaces
// (`activeSessions`/`activeStepExecutors`/`activeWorkflowStepSessions`) that
// `clearPhantomExecutorBinding` itself refuses to detach.
const livePaths = activeSessionRegistry.pathsForTask(task.id).filter((path) => activeSessionRegistry.isPathActive(path));
const hasLiveInProcessSession = livePaths.length > 0
|| executingTaskLock.has(task.id)
|| this.options.isTaskActive?.(task.id) === true;
const safeAgeMs = options.graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER;
const metadata = {
taskId: task.id,
@@ -1125,6 +1137,8 @@ export class SelfHealingManager {
agentPresent,
lastActivityMs: options.lastActivityMs,
hasRecentRunAudit,
hasLiveInProcessSession,
liveSessionPaths: livePaths,
worktree: task.worktree ?? null,
branch: task.branch ?? null,
worktreeExists,
@@ -1137,7 +1151,8 @@ export class SelfHealingManager {
&& options.executionAgeMs > safeAgeMs
&& !checkedOutBy
&& !agentPresent
&& !hasRecentRunAudit,
&& !hasRecentRunAudit
&& !hasLiveInProcessSession,
metadata,
};
}
@@ -1955,6 +1970,39 @@ export class SelfHealingManager {
return findAlreadyMergedTaskCommit(input);
}
/**
* Best-effort refresh of the remote-tracking base ref so the already-merged
* evidence detector can see a squash that landed on the remote after this
* process last fetched. Returns the `origin/<base>` ref to re-run the detector
* against, or null when there is nothing fresher to prove against.
*
* Fail-closed: a fetch error (offline / auth / no remote) is swallowed and we
* still attempt to resolve the (possibly stale) remote-tracking ref; if even
* that is absent we return null and the caller leaves the card untouched.
*/
private async refreshRemoteBaseRef(baseBranch: string): Promise<string | null> {
// Already a remote ref — nothing local to refresh.
if (baseBranch.startsWith("origin/")) return null;
const remoteRef = `origin/${baseBranch}`;
try {
await execAsync(`git fetch origin ${shellQuote(baseBranch)}`, {
cwd: this.options.rootDir,
timeout: 60_000,
});
} catch {
// Swallow: fall through to the existing remote-tracking ref if present.
}
try {
await execAsync(`git rev-parse --verify ${shellQuote(remoteRef)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
});
return remoteRef;
} catch {
return null;
}
}
private async readCommitTaskOwnership(sha: string, taskId: string, lineageId?: string) {
const { stdout } = await execAsync(`git show -s --format=%s%x1f%b ${shellQuote(sha)}`, {
cwd: this.options.rootDir,
@@ -3123,7 +3171,22 @@ export class SelfHealingManager {
// FNXC:SelfHealingReclaim 2026-06-30-00:00: preserveWorktrees keeps the held worktree's
// session-registry entry so the moveTask(preserveWorktree:true) re-dispatch reattaches to
// the same worktree instead of orphaning it and acquiring a new one (FN-7249 regression).
this.options.clearPhantomExecutorBinding?.(task.id, { preserveWorktrees: true });
// FNXC:SelfHealingReclaim 2026-07-05-08:15: FN-7566 — honor clearPhantomExecutorBinding's
// live-session refusal (returns false when any session surface is still registered) as the
// last line of defense before the destructive moveTask(→todo), matching reapLeakedConcurrencySlots.
// Even if a future liveness signal slips past isPhantomExecutorBinding, a refused clear must NOT
// be followed by a hard-cancel of a live executor: fall through to the no-action audit instead.
const released = this.options.clearPhantomExecutorBinding?.(task.id, { preserveWorktrees: true });
if (released === false) {
await this.emitFalsePositiveRequeueNoAction(
task,
"reclaim-self-owned-branch-conflict",
"task:reclaim-self-owned-branch-conflict-no-action",
"phantom-clear-refused-live-session",
{ ...phantomBinding.metadata, signalReason: liveExecutionSignal.reason },
);
continue;
}
await createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-healing-phantom-executor-binding", task.id),
agentId: "self-healing",
@@ -8605,7 +8668,7 @@ export class SelfHealingManager {
}
}
const landed = await this.findAlreadyMergedTaskCommit({
let landed = await this.findAlreadyMergedTaskCommit({
taskId: task.id,
lineageId: task.lineageId,
repoDir: this.options.rootDir,
@@ -8613,6 +8676,29 @@ export class SelfHealingManager {
taskBranch: task.branch,
baseCommitSha: task.baseCommitSha,
});
if (!landed && getPrimaryPrInfo(task)) {
// Fetch-then-prove: the LOCAL base ref can be stale. When a PR merged
// on the remote (human / merge-train squash) but this process never
// fetched, the owned commit is absent from the local base branch, so
// the detector finds nothing and the failed card holds its file-scope
// lease forever. Best-effort refresh the remote-tracking base ref and
// re-run the SAME evidence detector against it. The owned-commit proof
// (and every foreign-ownership guard inside the detector) still gates
// the heal, so this only un-wedges a genuinely-merged task — it never
// phantom-finalizes on unproven state. Gated on a recorded PR: no PR
// ⇒ nothing could have merged remotely ⇒ no fetch.
const refreshedBaseRef = await this.refreshRemoteBaseRef(baseBranch);
if (refreshedBaseRef) {
landed = await this.findAlreadyMergedTaskCommit({
taskId: task.id,
lineageId: task.lineageId,
repoDir: this.options.rootDir,
baseBranch: refreshedBaseRef,
taskBranch: task.branch,
baseCommitSha: task.baseCommitSha,
});
}
}
if (!landed) continue;
const mergeDetails: MergeDetails = {

File diff suppressed because it is too large Load Diff

View File

@@ -42,19 +42,54 @@ const RELEASE_SIGNAL_PATTERNS: ReleaseSignalPattern[] = [
{ label: "version-bump release commit", pattern: /\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b[\s\S]{0,120}\bv\d+\.\d+\.\d+\b|\bv\d+\.\d+\.\d+\b[\s\S]{0,120}\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b/i },
];
/*
FNXC:ReleaseAuthorizationGate 2026-07-05-15:40:
FN-7560: classifyReleaseTask matched a bare mention of a release signal (e.g. `scripts/release.mjs`) even when it appeared inside a disclaimer clause that explicitly states the task does NOT release — "this task performs no release/publish (releases are owned by `scripts/release.mjs`)". AI-authored specs routinely append such disclaimers, so revert/undo/UI tasks (FN-7525, FN-7554, FN-7556) were false-flagged as release-class and parked in awaiting-release-authorization with no in-band exit (their non-user sources make the authorization marker inert). Strip negated release-disclaimer clauses before signal matching so a spec that disclaims releasing does not self-incriminate. Genuine release intent survives because "run pnpm release" / "publish @runfusion/fusion" lives in a non-negated clause and is evaluated normally.
*/
const RELEASE_NEGATION_PATTERNS: RegExp[] = [
// "performs no release", "performs no package release/publish"
/\bperforms?\s+no\s+(?:[\w-]+\s+){0,3}?(?:release|publish)/i,
// "does not perform any package release", "will not publish", "doesn't release"
/\b(?:does|do|did|will|would|shall|can|could|should)(?:\s+not|n['’]?t)\b\s+(?:[\w-]+\s+){0,4}?(?:release|publish)/i,
// "no release/publish", "no package/actual release"
/\bno\s+(?:[\w-]+\s+){0,2}?(?:release|publish)\b/i,
// "releases are owned by scripts/release.mjs" — ownership disclaimer, not intent
/\breleases?\s+are\s+owned\s+by\b/i,
// "never release/publish"
/\bnever\s+(?:[\w-]+\s+){0,3}?(?:release|publish)/i,
];
/**
* FNXC:ReleaseAuthorizationGate 2026-07-05-15:40:
* Split into clause-sized segments (sentence terminators and line breaks) and
* drop any segment carrying a release-negation cue, keeping segments small so
* removing one disclaimer clause never discards an adjacent genuine release
* instruction. Returns the surviving text for signal matching.
*/
export function stripNegatedReleaseClauses(text: string): string {
return text
.split(/(?<=[.!?;])\s+|\n+/)
.filter((clause) => !RELEASE_NEGATION_PATTERNS.some((pattern) => pattern.test(clause)))
.join("\n");
}
export function isUserAuthoredSource(sourceType: string | null | undefined): boolean {
return typeof sourceType === "string" && USER_AUTHORED_SOURCE_TYPES.has(sourceType);
}
export function classifyReleaseTask(input: ReleaseTaskClassificationInput): ReleaseTaskClassification {
const text = [input.title, input.description, input.promptText]
const rawText = [input.title, input.description, input.promptText]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join("\n\n");
if (!text.trim()) {
if (!rawText.trim()) {
return { isReleaseClass: false, signals: [] };
}
// Evaluate signals only against clauses that are not release disclaimers, so a
// spec that says "this task performs no release" is not flagged as one.
const text = stripNegatedReleaseClauses(rawText);
const signals: string[] = [];
for (const { label, pattern } of RELEASE_SIGNAL_PATTERNS) {
if (pattern.test(text)) {

View File

@@ -28,6 +28,7 @@ import {
compareTaskIdNumeric,
resolveAgentMemoryInclusionMode,
resolvePlanApprovalRequired,
computePlanApprovalFingerprint,
extractIntentSignature,
findNearDuplicates,
isNearDuplicateCanonicalInactive,
@@ -2499,7 +2500,14 @@ export class TriageProcessor {
promptText: written,
});
if (releaseGateDecision.action === "block") {
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
/*
* FNXC:ReleaseAuthorizationGate 2026-07-04-21:35:
* FN-7559: stamp awaitingApprovalReason so the dashboard can tell this
* release-authorization hold apart from the (independently gated, never
* bypassed by auto-approve-all) manual plan-approval hold, which shares
* the same status: "awaiting-approval". See FNXC:PlanApproval in types.ts.
*/
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval", awaitingApprovalReason: "release-authorization" };
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
approvalUpdates.title = promptDeclaredTitle;
}
@@ -2566,17 +2574,50 @@ export class TriageProcessor {
FN-7526 re-verified this invariant end to end: every finalizeApprovedTask caller (specifyTask, recoverApprovedTask, retryUnavailablePlanReview, tryFinalizeExplicitDuplicateMarker) already derives `settings` from mergeEffectiveSettings so planApprovalMode (never a MOVED_SETTINGS_KEYS/workflow-owned key) survives any stored workflow requirePlanApproval overlay untouched. No production defect was found; regression tests were added across every surface to lock the invariant so a future bare-settings call site (e.g. `{ requirePlanApproval }` without planApprovalMode) is caught immediately instead of silently reintroducing the reported parking behavior.
*/
if (resolvePlanApprovalRequired(settings)) {
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
approvalUpdates.title = promptDeclaredTitle;
/*
* FNXC:PlanApproval 2026-07-04-22:41:
* FN-7569 — idempotency short-circuit. Compare the freshly written PROMPT.md against
* the fingerprint recorded when the operator last approved a plan for this task
* (POST /tasks/:id/approve-plan, packages/core/src/plan-approval.ts). If they match,
* this is a re-specification of an already-approved, unchanged plan (replan,
* plan-review reviewer-outage retry, self-healing rebound to triage, duplicate-marker
* retry) and must proceed straight through like an approved task rather than re-parking
* at awaiting-approval and asking the operator to re-approve. A genuinely changed plan
* (or one whose approval was cleared by reject-plan) produces a different/absent
* fingerprint and falls through to the ordinary park below. This check lives strictly
* inside the manual-gate branch, after release authorization and Plan Review have
* already made their independent decisions, so it never weakens either of those gates
* or auto-approve-all (which never reaches this branch at all).
*/
const priorFingerprint = latestTransitionTask?.approvedPlanFingerprint ?? task.approvedPlanFingerprint;
const currentFingerprint = computePlanApprovalFingerprint(written);
if (priorFingerprint && priorFingerprint === currentFingerprint) {
await this.store.logEntry(
task.id,
"Plan unchanged since prior approval — proceeding without re-approval",
);
planLog.log(`${task.id} plan unchanged since prior approval — proceeding without re-approval`);
} else {
/*
* FNXC:PlanApproval 2026-07-04-21:35:
* FN-7559: explicitly clear awaitingApprovalReason on the manual gate's own
* awaiting-approval write so a stale "release-authorization" reason left over
* from an earlier pass on this same task (e.g. a replan after the release
* gate parked it, now passing the release gate but still requiring manual
* approval) never survives into this genuinely-manual hold.
*/
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval", awaitingApprovalReason: null };
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
approvalUpdates.title = promptDeclaredTitle;
}
await this.store.updateTask(task.id, approvalUpdates);
await this.store.logEntry(
task.id,
options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval",
);
planLog.log(`✓ ${task.id} specified and awaiting manual approval`);
return;
}
await this.store.updateTask(task.id, approvalUpdates);
await this.store.logEntry(
task.id,
options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval",
);
planLog.log(`✓ ${task.id} specified and awaiting manual approval`);
return;
}
if (shouldClearWorkflowRunStepInstances) {

View File

@@ -25,6 +25,7 @@ import {
createMergeAttemptHandler,
createMergeGateHandler,
} from "./workflow-node-runners/merge-runner.js";
import { createExitGateHandler } from "./workflow-node-runners/exit-gate-runner.js";
export { createGateHandler } from "./workflow-node-runners/gate-runner.js";
export {
@@ -40,6 +41,10 @@ export {
createNotifyHandler,
type WorkflowNotifyDispatch,
} from "./workflow-node-runners/notify-runner.js";
export {
createExitGateHandler,
type WorkflowExitGateConfig,
} from "./workflow-node-runners/exit-gate-runner.js";
// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `workflow-step` seam
// was removed. Workflow quality gates run as the graph's own optional-group /
@@ -592,7 +597,9 @@ export function createDefaultNodeHandlers(
| "branch-group-promotion"
| "pr-create"
| "pr-respond"
| "pr-merge",
| "pr-merge"
| "ask-user"
| "exit-gate",
WorkflowNodeHandler
> {
const promptLike = deps?.primitives
@@ -626,6 +633,15 @@ export function createDefaultNodeHandlers(
return {
prompt: promptLike,
script: promptLike,
// FNXC:WorkflowAskUser 2026-07-05-00:00: `ask-user` is a first-class node
// kind over the SAME custom-node seam as prompt/script — it carries no
// seam config, so it always falls through to the injected custom-node
// runner (runGraphCustomNode in executor.ts), which special-cases
// `node.kind === "ask-user"` onto the existing await-input park/resume path.
"ask-user": promptLike,
// FNXC:WorkflowExitGate 2026-07-05-00:00: dedicated small runner (mirrors
// notify-runner's shape) — no legacy seam, no custom-node execution.
"exit-gate": createExitGateHandler(),
gate,
"step-review": deps?.primitives
? createPrimitiveStepReviewHandler(deps.primitives)

View File

@@ -0,0 +1,81 @@
import type { WorkflowLoopExitCondition } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "../workflow-graph-executor.js";
import type { WorkflowNodeRunner, WorkflowNodeRunnerContext } from "../workflow-node-runner.js";
/*
FNXC:WorkflowExitGate 2026-07-05-00:00:
FN-7579's `exit-gate` node lets a workflow terminate early instead of always
walking to the terminal `end` node the long way (e.g. breaking out of a
brainstorming ask-user/refine loop once the user approves). It is validated
(workflow-ir.ts) to always have a path to `end`, but it is NOT itself an `end`
node — it only routes there.
Contract: `config.condition` is optional and reuses the same shape as a `loop`
node's `exitWhen` (`WorkflowLoopExitCondition`: `output-contains` /
`output-matches`), read against `context[\`input:${condition.nodeId}\`]` — the
same context key an `ask-user` node's answer is published under — so an
exit-gate can gate directly on what the user said. Absent `condition`, the gate
is unconditional and always exits. The runner never throws on a malformed
condition; it degrades to "does not match" so a bad author config can't crash
the walk, it just falls through instead of exiting early.
Routing: the runner returns `outcome: "success"` with `value: "exit"` (match /
unconditional) or `value: "continue"` (no match). Workflow edges select on
`outcome:exit` / `outcome:continue` (or a single unconditional edge, which
matches any `success` outcome) exactly like the existing gate/step-review
outcome-edge convention.
*/
export interface WorkflowExitGateConfig {
condition?: WorkflowLoopExitCondition;
}
function resolveConditionText(
condition: WorkflowLoopExitCondition,
context: Record<string, unknown>,
): string {
const key = typeof condition.nodeId === "string" && condition.nodeId ? `input:${condition.nodeId}` : undefined;
const raw = key ? context[key] : undefined;
if (raw === undefined || raw === null) return "";
return typeof raw === "string" ? raw : String(raw);
}
function matchesExitCondition(
condition: WorkflowLoopExitCondition,
context: Record<string, unknown>,
): boolean {
const text = resolveConditionText(condition, context);
if (condition.type === "output-contains") {
return typeof condition.value === "string" && text.includes(condition.value);
}
if (condition.type === "output-matches") {
try {
return new RegExp(condition.pattern, condition.flags).test(text);
} catch {
// Malformed author-supplied regex: degrade to no-match rather than throw.
return false;
}
}
return false;
}
export class ExitGateNodeRunner implements WorkflowNodeRunner {
public readonly kind = "exit-gate" as const;
public async run(
node: Parameters<WorkflowNodeHandler>[0],
context: WorkflowNodeRunnerContext,
): Promise<WorkflowNodeResult> {
const cfg = (node.config ?? {}) as WorkflowExitGateConfig;
if (!cfg.condition) {
return { outcome: "success", value: "exit" };
}
const matched = matchesExitCondition(cfg.condition, context.context);
return { outcome: "success", value: matched ? "exit" : "continue" };
}
}
export function createExitGateHandler(): WorkflowNodeHandler {
const runner = new ExitGateNodeRunner();
return (node, context) => runner.run(node, context);
}