FN-9167: clear interrupted manual merge status

Prevent interrupted manual merges from leaving tasks stranded in a transient merging state.

- track locally owned merge stamps and clear them through fenced abort cleanup
- handle SIGINT, SIGTERM, and SIGHUP for foreground task merges with documented exit behavior
- reconcile only age-proven orphan stamps before manual CLI and UI-only dashboard merges
- cover merge stamp authorization, signal cleanup, and mock completeness with regression tests
- add a patch changeset and operator documentation

Files changed:
 .../fn-9167-interrupted-manual-merge-stamp.md      |   7 +
 docs/cli-reference.md                              |   1 +
 docs/task-management.md                            |   2 +-
 .../task-command-github-import-tracking.test.ts    |   3 +
 packages/cli/src/commands/__tests__/task.test.ts   | 179 +++++-
 packages/cli/src/commands/dashboard.ts             |   3 +
 packages/cli/src/commands/task.ts                  | 138 +++--
 .../merge-orphan-durable-write-inventory.json      | 608 +++++++++++----------
 .../src/__tests__/merge-active-status.test.ts      |  49 ++
 packages/engine/src/index.ts                       |   2 +
 .../engine/src/merge/clear-orphaned-merge-stamp.ts |  80 +++
 packages/engine/src/merge/merger-ai.ts             |  12 +
 packages/engine/src/project-engine.ts              |  20 +-
 13 files changed, 735 insertions(+), 369 deletions(-)

Fusion-Task-Id: FN-9167

Fusion-Task-Lineage: 554380e2-ce55-4e7a-89fd-f5c24341937b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-19 20:17:42 -07:00
parent 416c6a0215
commit 7ded57e550
13 changed files with 735 additions and 369 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Clear interrupted manual merge status so cards do not remain stuck as merging.
category: fix
dev: Adds clearOwnedMergeStamp, reconcileUnownedStaleMergeStamp, fenced runAiMerge cleanup, and SIGINT/SIGTERM/SIGHUP CLI handlers.

View File

@@ -820,6 +820,7 @@ fn task delete FN-001 --force
```
Notes:
- Interrupting `fn task merge` aborts its merge and clears its transient merge status: Ctrl-C (`SIGINT`) exits 130, `SIGTERM` exits 143, and a closed terminal (`SIGHUP`) exits 129. Unlike `fn serve`, `fn dashboard`, and the daemon, this one-shot foreground command deliberately does not survive terminal disconnects.
- `fn task archive` accepts live-board tasks and preserves the original column for restore. It refuses tasks in a WIP lane or active merge pipeline to protect another process's worktrees; a human operator may use `--force` to override this destructive guard.
- The agent-facing `fn_task_archive` tool returns a structured error for the same live-task refusal and deliberately has no force parameter.
- `fn task unarchive` restores to the saved pre-archive column when available, with legacy archives falling back to `done`.

View File

@@ -298,7 +298,7 @@ This is a forward-safety guard for stranded completed tasks. See FN-4055/FN-4079
Fusion now derives `task.inReviewStall` for non-paused `in-review` tasks when a known stuck-state shape is detected. This signal is state-based (not log-heuristic) and is computed server-side on task hydration.
`InReviewStallCode` values:
- `transient-merge-status-no-owner` — task is still in `merging`/`merging-pr`/`merging-fix` after the stale-merging age threshold, but no active merger owns it. `recoverStaleMergingStatus()` clears this stamp and re-enqueues auto-merge-eligible, non-workspace, non-`mergeConfirmed` **unpaused** tasks. Paused tasks never re-enqueue; the sole clear-only exception is the engine-owned `merge-deadlock-detected` hold, whose status-preserving park can otherwise retain an orphan stamp indefinitely. Explicit human, approval, and unknown pauses remain intentionally suppressed. The signal itself remains diagnostic-only.
- `transient-merge-status-no-owner` — task is still in `merging`/`merging-pr`/`merging-fix` after the stale-merging age threshold, but no active merger owns it. `recoverStaleMergingStatus()` clears this stamp and re-enqueues auto-merge-eligible, non-workspace, non-`mergeConfirmed` **unpaused** tasks. Manual merge doors now clear a stamp owned by their interrupted `fn task merge` process on Ctrl-C, SIGTERM, or terminal-close SIGHUP, and reconcile only age-proven residue before claiming. This follows three distinct authorizations: the merge body's abort fence (A), its proven in-process writer (B), or age evidence without owner proof (C). The five-minute engine sweep remains the backstop for hard kills and power loss. Paused tasks never re-enqueue; the sole clear-only exception is the engine-owned `merge-deadlock-detected` hold, whose status-preserving park can otherwise retain an orphan stamp indefinitely. Explicit human, approval, and unknown pauses remain intentionally suppressed. The signal itself remains diagnostic-only.
- `merge-retries-exhausted` — `mergeRetries` reached the auto-merge retry cap without `mergeDetails.mergeConfirmed === true`.
- `no-worktree-no-merge-confirmed` — task has no worktree path and merge is not confirmed (excluding explicit no-op merges).
- `merge-blocker` — `getTaskMergeBlocker()` reports a merge/finalization blocker.

View File

@@ -63,6 +63,9 @@ vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(),
runAiMerge: vi.fn(),
landWorkspaceTask: vi.fn(),
// FNXC:TestInfrastructure 2026-08-20-03:16: Keep this engine barrel mock complete when task merge adds stamp-recovery dependencies.
clearOwnedMergeStamp: vi.fn(),
reconcileUnownedStaleMergeStamp: vi.fn(),
// FNXC:TestInfrastructure 2026-07-13-10:25: extension.ts named-imports this from @fusion/engine.
isInReviewMissingWorktreeSessionStartFailure: vi.fn(),
}));

View File

@@ -130,6 +130,8 @@ vi.mock("@fusion/engine", () => ({
aiMergeTask: vi.fn(),
runAiMerge: vi.fn(),
landWorkspaceTask: vi.fn(),
clearOwnedMergeStamp: vi.fn().mockResolvedValue(false),
reconcileUnownedStaleMergeStamp: vi.fn().mockResolvedValue(false),
// FNXC:CliTests 2026-07-12-07:10: task.ts imports isInReviewMissingWorktreeSessionStartFailure from @fusion/engine (FN-7798 in-review stale worktree guard); the hand-written engine mock must surface it.
isInReviewMissingWorktreeSessionStartFailure: vi.fn(() => false),
}));
@@ -268,7 +270,7 @@ import {
import { GitHubClient, generatePrMetadata, isGitHubIssueAlreadyImported } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject, createLocalStore } from "../../project-context.js";
import { aiMergeTask, runAiMerge, landWorkspaceTask } from "@fusion/engine";
import { aiMergeTask, runAiMerge, landWorkspaceTask, reconcileUnownedStaleMergeStamp, clearOwnedMergeStamp } from "@fusion/engine";
const mockedExec = vi.mocked(exec);
@@ -1499,22 +1501,188 @@ describe("project-aware task command behavior", () => {
expect(logEntry).toHaveBeenCalled();
// FNXC:GrokCliRouting 2026-07-15-10:17: bare `fn task merge` has no ProjectEngine and does not invent a PluginRunner.
expect(runAiMerge).toHaveBeenCalledWith(
resolvedStore,
expect.any(Object),
"/test",
"FN-123",
expect.objectContaining({
onAgentText: expect.any(Function),
signal: expect.any(AbortSignal),
}),
);
const mergeOpts = vi.mocked(runAiMerge).mock.calls.at(-1)?.[3] as { pluginRunner?: unknown } | undefined;
expect(mergeOpts?.pluginRunner).toBeUndefined();
expect(landWorkspaceTask).not.toHaveBeenCalled();
expect(aiMergeTask).not.toHaveBeenCalled();
expect(reconcileUnownedStaleMergeStamp).toHaveBeenCalledWith(resolvedStore, "FN-123");
expect(process.listenerCount("SIGINT")).toBe(0);
expect(process.listenerCount("SIGTERM")).toBe(0);
expect(process.listenerCount("SIGHUP")).toBe(0);
expect(exitSpy).not.toHaveBeenCalled();
expect(duplicateTask).toHaveBeenCalledWith("FN-123");
expect(refineTask).toHaveBeenCalledWith("FN-123", "more tests");
});
it.each([
["SIGINT", 130],
["SIGTERM", 143],
["SIGHUP", 129],
] as const)("%s aborts the body, clears its owned stamp, and keeps the signal exit", async (signal, exitCode) => {
const task = makeTask({ id: `FN-${signal}`, column: "in-review", status: "merging" });
const getTask = vi.fn().mockImplementation(async () => task);
const updateTask = vi.fn().mockImplementation(async (_id: string, patch: { status?: string | null }) => {
if (patch.status !== undefined) task.status = patch.status;
});
const close = vi.fn().mockResolvedValue(undefined);
const resolvedStore = { getTask, updateTask, close } as unknown as TaskStore;
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true, store: resolvedStore,
});
// Model the helper's independently tested authorization-B mutation so this door test
// proves the command wires its abort, cleanup, close, and exit paths together.
vi.mocked(clearOwnedMergeStamp).mockImplementation(async () => {
task.status = null;
return true;
});
let bodySignal: AbortSignal | undefined;
vi.mocked(runAiMerge).mockImplementation((async (_store, _path, taskId, options) => {
// A completed local transient write is the authorization-B proof required before cleanup.
await _store.updateTask(taskId, { status: "merging" });
return await new Promise((_resolve, reject) => {
bodySignal = options.signal;
options.signal?.addEventListener("abort", () => reject(new Error("merge aborted")), { once: true });
});
}) as never);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
const pending = runTaskMerge(task.id, "demo-project");
await vi.waitFor(() => expect(process.listenerCount(signal)).toBeGreaterThan(0));
process.emit(signal, signal);
// A terminal close arriving after Ctrl-C (or vice versa) must share the same cleanup.
process.emit("SIGHUP", "SIGHUP");
await pending;
expect(bodySignal?.aborted).toBe(true);
expect(task.status).toBeNull();
expect(clearOwnedMergeStamp).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(exitCode);
expect(process.listenerCount(signal)).toBe(0);
exitSpy.mockRestore();
});
it("does not clear a stamp it never wrote when a merge body rejects before claiming", async () => {
const task = makeTask({ id: "FN-MERGE-ERROR", column: "in-review", status: "merging" });
const getTask = vi.fn().mockImplementation(async () => task);
const close = vi.fn().mockResolvedValue(undefined);
const resolvedStore = { getTask, close } as unknown as TaskStore;
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true, store: resolvedStore,
});
vi.mocked(runAiMerge).mockRejectedValue(new Error("merge failed"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation((((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as unknown) as (code?: string | number | null | undefined) => never);
await expect(runTaskMerge(task.id, "demo-project")).rejects.toThrow("process.exit:1");
expect(task.status).toBe("merging");
expect(clearOwnedMergeStamp).not.toHaveBeenCalled();
expect(close).toHaveBeenCalled();
exitSpy.mockRestore();
});
it("waits for the owned clear before signal exit", async () => {
const task = makeTask({ id: "FN-CLEAR-ORDER", column: "in-review", status: "merging" });
const getTask = vi.fn().mockResolvedValue(task);
const updateTask = vi.fn().mockResolvedValue(undefined);
const close = vi.fn().mockResolvedValue(undefined);
const resolvedStore = { getTask, updateTask, close } as unknown as TaskStore;
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true, store: resolvedStore,
});
vi.mocked(reconcileUnownedStaleMergeStamp).mockResolvedValue(false);
let resolveClear!: () => void;
vi.mocked(clearOwnedMergeStamp).mockImplementation(() => new Promise<boolean>((resolve) => {
resolveClear = () => resolve(true);
}));
vi.mocked(runAiMerge).mockImplementation((async (candidateStore, _path, taskId, options) => {
await candidateStore.updateTask(taskId, { status: "merging" });
return await new Promise((_resolve, reject) => {
options.signal?.addEventListener("abort", () => reject(new Error("merge aborted")), { once: true });
});
}) as never);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
const pending = runTaskMerge(task.id, "demo-project");
await vi.waitFor(() => expect(process.listenerCount("SIGINT")).toBeGreaterThan(0));
process.emit("SIGINT", "SIGINT");
await vi.waitFor(() => expect(clearOwnedMergeStamp).toHaveBeenCalledOnce());
expect(exitSpy).not.toHaveBeenCalled();
resolveClear();
await pending;
expect(exitSpy).toHaveBeenCalledWith(130);
exitSpy.mockRestore();
});
it.each([
["clears aged residue", "merging", 6 * 60_000, true],
["preserves fresh residue", "merging", 60_000, false],
["does nothing for a clean row", null, 0, false],
])("%s through the manual-door pre-claim reconcile", async (_label, status, ageMs, shouldClear) => {
const task = makeTask({
id: `FN-PRECLAIM-${String(status ?? "clean")}`,
column: "in-review",
status,
updatedAt: new Date(Date.now() - ageMs).toISOString(),
});
const getTask = vi.fn().mockResolvedValue(task);
const resolvedStore = { getTask } as unknown as TaskStore;
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true, store: resolvedStore,
});
vi.mocked(reconcileUnownedStaleMergeStamp).mockImplementation(async (candidateStore) => {
expect(candidateStore).toBe(resolvedStore);
if (shouldClear) task.status = null;
return shouldClear;
});
vi.mocked(runAiMerge).mockResolvedValue({
merged: true, task, branch: "fusion/preclaim", worktreeRemoved: true, branchDeleted: true,
} as never);
await runTaskMerge(task.id, "demo-project");
expect(reconcileUnownedStaleMergeStamp).toHaveBeenCalledWith(resolvedStore, task.id);
expect(task.status).toBe(shouldClear ? null : status);
vi.mocked(reconcileUnownedStaleMergeStamp).mockResolvedValue(false);
});
it("does not clear a pre-existing stamp when interrupted before the body claims it", async () => {
const task = makeTask({ id: "FN-NO-LOCAL-CLAIM", column: "in-review", status: "merging" });
const getTask = vi.fn().mockResolvedValue(task);
const close = vi.fn().mockResolvedValue(undefined);
const resolvedStore = { getTask, close } as unknown as TaskStore;
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test", projectPath: "/test", projectName: "demo-project", isRegistered: true, store: resolvedStore,
});
vi.mocked(reconcileUnownedStaleMergeStamp).mockResolvedValue(false);
vi.mocked(runAiMerge).mockImplementation(((_store, _path, _id, options) => new Promise((_resolve, reject) => {
options.signal?.addEventListener("abort", () => reject(new Error("merge aborted before claim")), { once: true });
})) as never);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
const pending = runTaskMerge(task.id, "demo-project");
await vi.waitFor(() => expect(process.listenerCount("SIGINT")).toBeGreaterThan(0));
process.emit("SIGINT", "SIGINT");
await pending;
expect(clearOwnedMergeStamp).not.toHaveBeenCalled();
expect(task.status).toBe("merging");
expect(close).toHaveBeenCalledOnce();
expect(exitSpy).toHaveBeenCalledWith(130);
exitSpy.mockRestore();
});
it("exits non-zero when a workspace finalize is blocked after all repos landed", async () => {
const getTask = vi.fn().mockResolvedValue(makeTask({
id: "FN-WS-BLOCKED",
@@ -1529,6 +1697,7 @@ describe("project-aware task command behavior", () => {
isRegistered: true,
store: resolvedStore,
});
vi.mocked(reconcileUnownedStaleMergeStamp).mockResolvedValue(false);
vi.mocked(landWorkspaceTask).mockResolvedValue({
allLanded: true,
finalized: false,
@@ -1551,6 +1720,12 @@ describe("project-aware task command behavior", () => {
expect(output).toContain("Merge blocked — operator review required");
expect(output).not.toContain("task finalized to done");
expect(landWorkspaceTask).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
"/test",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("routes GitHub import commands through the resolved project store", async () => {

View File

@@ -101,6 +101,7 @@ import {
createFusionModelRegistry,
refreshFusionModelRegistry,
setLocalDashboardPort,
reconcileUnownedStaleMergeStamp,
} from "@fusion/engine";
import { setHostTaskStore, clearHostTaskStores } from "../extension.js";
import { DefaultPackageManager, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent";
@@ -1652,6 +1653,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
- UI-only (--no-engine): createServer receives uiOnlyOnMerge which calls runAiMerge/landWorkspaceTask with pluginRunner undefined — dual-remediation for grok-cli/no-key is correct because there is no ProjectEngine PluginRunner. Do not invent a bootstrap here and do not pass the bare PluginLoader (lacks getRuntimeById).
*/
const uiOnlyOnMerge = async (taskId: string) => {
// Authorization C: this dashboard has no exclusive process-level merge ownership proof.
await reconcileUnownedStaleMergeStamp(store, taskId);
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// Dashboard merge button (UI-only mode). A workspace-mode task routes through
// the ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its

View File

@@ -1,5 +1,5 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, resolveProjectColumnsForRoles, TERMINAL_ROLES, resolveReviewColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isValidRepoSlug, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, evaluateArchiveTaskLiveness, describeArchiveLiveness, TaskIsLiveError, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer } from "@fusion/engine";
import { isInReviewMissingWorktreeSessionStartFailure, runAiMerge, landWorkspaceTask, installBaselineArchiveWorktreeDisposer, clearOwnedMergeStamp, reconcileUnownedStaleMergeStamp } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, createTaskFromPlanSession, ensureDurablePlanningSessionStore, getSession as getPlanningSession, submitResponse, validateSession, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
@@ -1246,87 +1246,133 @@ async function runTaskShowWithStore(id: string, store: TaskStore) {
}
export async function runTaskMerge(id: string, projectName?: string) {
// FNXC:CliBoardMutation 2026-07-09-00:00 (FN-7734): resolve context ONCE
// (retried — replaces the previous double resolution via `getStore` +
// `getProjectPath`, each of which independently called `getCommandContext`)
// and close it in a `finally` covering EVERY exit path, including the
// `process.exit(1)` calls below. The AI merge (`runAiMerge`) and
// workspace-land (`landWorkspaceTask`) subflows are deliberately NOT
// retry-wrapped — they drive non-idempotent external git/AI operations,
// so retrying the whole flow on a lock blip could double-drive a merge or
// land (Step 1 audit decision).
// FNXC:CliBoardMutation 2026-07-09-00:00 (FN-7734): resolve context ONCE.
const context = await resolveBoardContext(projectName, id, "resolve project");
const store = context.store;
const projectPath = context.projectPath;
const abortController = new AbortController();
let wroteLocalMergeStamp = false;
let handlingSignal = false;
let handlersInstalled = false;
let storeClosed = false;
let signalShutdown: Promise<void> | undefined;
const closeStoreOnce = async () => {
if (storeClosed) return;
storeClosed = true;
await closeProjectStore(context).catch(() => undefined);
};
/*
FNXC:MergeReliability 2026-08-20-02:41:
Authorization B requires proof that this one-shot process wrote the stamp; a signal can arrive
after handlers install but before a merge body claims anything. Observe a successful local
`merging` write instead of inferring ownership from an indistinguishable row status, so cleanup
cannot clear another process's fresh generation. A write whose database outcome is unknown stays
unowned and is recoverable later only through authorization C's age evidence.
*/
const mergeStore = new Proxy(store, {
get(target, property) {
if (property === "updateTask") {
return async (...args: Parameters<TaskStore["updateTask"]>) => {
const result = await target.updateTask(...args);
const patch = args[1];
if (args[0] === id && patch?.status === "merging") wroteLocalMergeStamp = true;
return result;
};
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as TaskStore;
const removeSignalHandlers = () => {
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.off(signal, onSignal);
handlersInstalled = false;
};
const onSignal = (signal: NodeJS.Signals) => {
if (handlingSignal) return;
handlingSignal = true;
const exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 129;
// FNXC:MergeReliability 2026-08-20-02:00: A signal can make the merge body reject
// before its owner-clear settles. Share this promise with catch so only the signal
// path exits and it cannot terminate before authorization-B cleanup commits.
signalShutdown = (async () => {
abortController.abort();
if (wroteLocalMergeStamp) {
await clearOwnedMergeStamp(store, id, "MergeAborted").catch(() => undefined);
}
await closeStoreOnce();
removeSignalHandlers();
process.exit(exitCode);
})();
};
console.log(`\n Merging ${id} with AI...\n`);
try {
/*
FNXC:GrokCliRouting 2026-07-15-10:17:
`fn task merge` is a bare CLI door: ProjectContext only has store/path, not a live ProjectEngine, so no engine.getPluginRunner() is available. Do not invent a full PluginRunner bootstrap here (that belongs to InProcessRuntime / ProjectEngineManager). Omitting pluginRunner is intentional — grok-cli/no-key merge selections surface the dual-remediation error. Engine-backed merge already forwards this.getPluginRunner().
FNXC:MergeReliability 2026-08-20-02:00:
Authorization C applies before this one-shot CLI claims a merge: residue may belong to a hard-
killed process, so age evidence (not an indistinguishable `merging` compare) is required.
*/
if (await reconcileUnownedStaleMergeStamp(store, id)) {
console.log(" Cleared an age-proven stale merge status before claiming the task.");
}
/*
FNXC:MergeReliability 2026-08-20-02:00:
Terminal closure is a named interruption. Unlike long-lived serve/dashboard/daemon processes,
this foreground command handles SIGHUP by aborting, owner-clearing (authorization B), closing,
and exiting 129; ignoring it would leave an invisible detached merge and Node otherwise exits.
*/
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, onSignal);
handlersInstalled = true;
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2):
// User-triggered `fn task merge`. A workspace-mode task routes through the
// ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own
// LOCAL integration ref, no push) instead of throwing — manual merge works in
// Phase C (user decision). U0's R7 throw is replaced here by routing; the
// engine chokepoint + store.mergeTask/aiMergeTask keep throwing.
const mergeTaskRecord = await store.getTask(id).catch(() => null);
// FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask`
// (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check.
const isWorkspaceMerge = !!mergeTaskRecord && isWorkspaceTask(mergeTaskRecord);
if (isWorkspaceMerge) {
const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, {
const workspaceResult = await landWorkspaceTask(mergeStore, mergeTaskRecord!, projectPath, {
onAgentText: (delta) => process.stdout.write(delta),
signal: abortController.signal,
});
console.log();
for (const repo of workspaceResult.repos) {
const label =
repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}`
: repo.status === "empty" ? "no net changes"
: `failed: ${repo.error ?? "unknown"}`;
const label = repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}` : repo.status === "empty" ? "no net changes" : `failed: ${repo.error ?? "unknown"}`;
console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`);
}
/*
FNXC:Workspace 2026-08-15-04:22:
`finalized`, not `allLanded`, is the merged signal. A blocked finalize is already parked
with progress preserved, so the CLI must report it as blocked and exit non-zero rather than
claiming success for sub-repos that landed without the task reaching `done`.
*/
const workspaceMerged = workspaceResult.allLanded && workspaceResult.finalized;
console.log(
`\n ${workspaceMerged
? "✓ All sub-repos landed — task finalized to done"
: workspaceResult.allLanded
? `✗ Merge blocked — ${workspaceResult.finalizeBlockedReason ?? "workspace finalize was blocked"} (task moved back with progress preserved)`
: "✗ Partial land — see failures above (task remains in review; landed repos stay landed locally)"}\n`,
);
console.log(`\n ${workspaceMerged ? "✓ All sub-repos landed — task finalized to done" : workspaceResult.allLanded ? `✗ Merge blocked — ${workspaceResult.finalizeBlockedReason ?? "workspace finalize was blocked"} (task moved back with progress preserved)` : "✗ Partial land — see failures above (task remains in review; landed repos stay landed locally)"}\n`);
if (!workspaceMerged) await closeBoardContextAndExit(context, 1);
return;
}
const result = await runAiMerge(store, projectPath, id, {
const result = await runAiMerge(mergeStore, projectPath, id, {
onAgentText: (delta) => process.stdout.write(delta),
signal: abortController.signal,
});
console.log();
if (result.merged) {
console.log(` ✓ Merged ${result.task.id}`);
console.log(` Branch: ${result.branch}`);
console.log(` Worktree: ${result.worktreeRemoved ? "removed" : "not found"}`);
console.log(` Branch: ${result.branchDeleted ? "deleted" : "kept"}`);
} else {
console.log(` ✓ Closed ${result.task.id} (${result.error})`);
}
console.log(` Status: done`);
console.log();
} else console.log(` ✓ Closed ${result.task.id} (${result.error})`);
console.log(" Status: done\n");
} catch (err) {
// FNXC:MergeReliability 2026-08-20-02:27: A signal owns shutdown once installed;
// await its cleanup promise rather than racing a generic exit(1) against its clear.
if (signalShutdown) {
await signalShutdown;
return;
}
abortController.abort();
if (wroteLocalMergeStamp) await clearOwnedMergeStamp(store, id, "MergeAborted");
console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
await closeBoardContextAndExit(context, 1);
} finally {
await closeProjectStore(context).catch(() => {});
if (handlersInstalled) removeSignalHandlers();
await closeStoreOnce();
}
}

View File

@@ -22,6 +22,7 @@ import {
isStaleMergeActiveStatus,
shouldClearOrphanedMergeStamp,
} from "../merge/merge-active-status.js";
import { clearOwnedMergeStamp, reconcileUnownedStaleMergeStamp } from "../merge/clear-orphaned-merge-stamp.js";
const NOW = Date.parse("2026-07-16T00:00:00.000Z");
const ago = (ms: number) => new Date(NOW - ms).toISOString();
@@ -137,3 +138,51 @@ describe("isStaleMergeActiveStatus — the FN-8004 wedge", () => {
}
});
});
describe("merge stamp clear authorizations", () => {
const makeStore = (initial: Record<string, unknown>) => {
let live = { id: "FN-1", updatedAt: LONG_AGO, ...initial } as never;
const updateTask = async (_id: string, patch: Record<string, unknown>) => {
live = { ...live, ...patch } as never;
return live;
};
return {
get live() { return live as Record<string, unknown>; },
store: {
getTask: async () => live,
updateTask,
updateTaskAtomic: async (_id: string, updater: (task: never) => Record<string, unknown> | null) => {
const patch = updater(live);
if (patch) await updateTask("FN-1", patch);
return live;
},
logEntry: async () => undefined,
},
};
};
it("owner authorization clears every unconfirmed active phase but preserves final states", async () => {
for (const status of ACTIVE_MERGE_STATUSES) {
const fixture = makeStore({ status });
await expect(clearOwnedMergeStamp(fixture.store as never, "FN-1", "MergeAborted")).resolves.toBe(true);
expect(fixture.live.status).toBeNull();
}
for (const initial of [{ status: "done" }, { status: "failed" }, { status: "merging", mergeDetails: { mergeConfirmed: true } }]) {
const fixture = makeStore(initial);
await expect(clearOwnedMergeStamp(fixture.store as never, "FN-1", "MergeAborted")).resolves.toBe(false);
expect(fixture.live.status).toBe(initial.status);
}
});
it("unowned authorization requires parseable age evidence", async () => {
const stale = makeStore({ status: "merging" });
await expect(reconcileUnownedStaleMergeStamp(stale.store as never, "FN-1", { nowMs: NOW })).resolves.toBe(true);
expect(stale.live.status).toBeNull();
for (const updatedAt of [ago(1_000), "not-a-date"]) {
const fresh = makeStore({ status: "merging", updatedAt });
await expect(reconcileUnownedStaleMergeStamp(fresh.store as never, "FN-1", { nowMs: NOW })).resolves.toBe(false);
expect(fresh.live.status).toBe("merging");
}
});
});

View File

@@ -932,7 +932,9 @@ export {
DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS,
isMergeActiveStatus,
isStaleMergeActiveStatus,
shouldClearOrphanedMergeStamp,
} from "./merge/merge-active-status.js";
export { clearOwnedMergeStamp, reconcileUnownedStaleMergeStamp } from "./merge/clear-orphaned-merge-stamp.js";
export { PluginRunner, type PluginRunnerOptions } from "./plugins/plugin-runner.js";
export {
registerPluginTraits,

View File

@@ -0,0 +1,80 @@
import type { Task, TaskStore } from "@fusion/core";
import {
DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS,
isStaleMergeActiveStatus,
shouldClearOrphanedMergeStamp,
} from "./merge-active-status.js";
export type OwnedMergeStampSource = "MergeAborted" | "MergeQueue";
type MergeStampStore = Pick<TaskStore, "getTask" | "updateTask" | "logEntry"> & {
updateTaskAtomic?: TaskStore["updateTaskAtomic"];
};
/*
FNXC:MergeReliability 2026-08-20-02:00:
Authorization B lets an owner that has ended its own in-process generation clear its transient
stamp even though that generation's abort fence now rejects lifecycle writes. The atomic re-read
only preserves concurrently finalized or confirmed rows; it cannot distinguish two identical
`merging` stamps and is never a successor guard. The accepted limitation is that a different
process can claim in the tiny abort-to-clear window, matching ProjectEngine's existing owner path.
Authorization C has no owner proof, so it additionally requires age evidence before a manual door
can clear residue from a hard kill. This avoids yanking a fresh stamp written by another process.
*/
const messageFor = (source: OwnedMergeStampSource, status: string) =>
source === "MergeAborted"
? `Auto-recovered: cleared stale '${status}' status`
: `Auto-recovered: reconciled orphaned '${status}' merge status`;
async function clearWhen(
store: MergeStampStore,
taskId: string,
mayClear: (task: Task) => boolean,
): Promise<string | undefined> {
let clearedStatus: string | undefined;
try {
if (typeof store.updateTaskAtomic === "function") {
await store.updateTaskAtomic(taskId, (live) => {
if (!mayClear(live)) return null;
clearedStatus = live.status ?? undefined;
return { status: null };
});
} else {
const live = await store.getTask(taskId);
if (!mayClear(live)) return undefined;
clearedStatus = live.status ?? undefined;
await store.updateTask(taskId, { status: null });
}
} catch {
return undefined;
}
return clearedStatus;
}
/** Clear an ended local generation's stamp (authorization B). */
export async function clearOwnedMergeStamp(
store: MergeStampStore,
taskId: string,
source: OwnedMergeStampSource,
): Promise<boolean> {
const clearedStatus = await clearWhen(store, taskId, shouldClearOrphanedMergeStamp);
if (!clearedStatus) return false;
await store.logEntry(taskId, messageFor(source, clearedStatus), source).catch(() => undefined);
return true;
}
/** Clear only age-proven residue when this caller has no merge-owner proof (authorization C). */
export async function reconcileUnownedStaleMergeStamp(
store: MergeStampStore,
taskId: string,
opts: { nowMs?: number; minAgeMs?: number } = {},
): Promise<boolean> {
const minAgeMs = opts.minAgeMs ?? DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS;
const clearedStatus = await clearWhen(store, taskId, (task) =>
shouldClearOrphanedMergeStamp(task)
&& isStaleMergeActiveStatus(task, { nowMs: opts.nowMs ?? Date.now(), minAgeMs }),
);
return Boolean(clearedStatus);
}

View File

@@ -70,6 +70,7 @@ import {
import { selectUserCommentsForAgentContext } from "../agents/agent-user-comments.js";
import { resolveTaskWorkingBranch } from "../worktree/worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { shouldClearOrphanedMergeStamp } from "./merge-active-status.js";
import { recordWorkspaceBaseBranchDecision, resolveWorkspaceRepoBaseBranch } from "../worktree/workspace-base-branch.js";
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
import { enforceAiMergeSquashGates } from "./merger-ai-squash-gates.js";
@@ -1494,6 +1495,7 @@ export async function runAiMerge(
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
await setStatus("merging");
try {
// FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1):
// runAiMerge is now the SINGLE-REPO caller of the extracted `landOneRepo`. It
// builds the same per-task context it always built and lands the project root
@@ -1704,6 +1706,16 @@ export async function runAiMerge(
const finalized = await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false }, mergeTarget, groupRouting, options.syncGroupPr, fence);
await runPushAfterMergeStep({ store, projectRootDir, taskId, settings, integrationBranch, audit, log, options, result: finalized, fence });
return finalized;
} finally {
/*
FNXC:MergeReliability 2026-08-20-02:00:
Authorization A clears the single-repo transient stamp through the aborted generation's write
fence. The read preserves terminal/confirmed finalization only; the fence, not this predicate,
prevents a late aborted body from clearing a successor's identical `merging` stamp.
*/
const live = await store.getTask(taskId).catch(() => null);
if (live && shouldClearOrphanedMergeStamp(live)) await setStatus(null);
}
}
/*

View File

@@ -98,7 +98,7 @@ import {
resolveActiveTaskCapacityLimit,
} from "./concurrency/concurrency.js";
import { canStartNextMergeBody } from "./merge/merge-reclaim-policy.js";
import { shouldClearOrphanedMergeStamp } from "./merge/merge-active-status.js";
import { clearOwnedMergeStamp } from "./merge/clear-orphaned-merge-stamp.js";
import {
registerProjectVerificationLimit,
unregisterProjectVerificationLimit,
@@ -735,14 +735,7 @@ export class ProjectEngine {
successor attempt.
*/
private async clearAbortedMergeStamp(taskId: string): Promise<void> {
const store = this.runtime.getTaskStore();
const task = await store.getTask(taskId).catch(() => null);
if (!task || !shouldClearOrphanedMergeStamp(task)) return;
const clearedStatus = task.status;
await store.updateTask(taskId, { status: null }).catch(() => undefined);
await store
.logEntry(taskId, `Auto-recovered: cleared stale '${clearedStatus}' status`, "MergeAborted")
.catch(() => undefined);
await clearOwnedMergeStamp(this.runtime.getTaskStore(), taskId, "MergeAborted");
}
/*
@@ -754,14 +747,7 @@ export class ProjectEngine {
and synchronous internalEnqueueMerge callers; pre-enqueue blockers remain self-healing's job.
*/
private async reconcileClaimedMergeStamp(taskId: string): Promise<void> {
const store = this.runtime.getTaskStore();
const task = await store.getTask(taskId).catch(() => null);
if (!task || !shouldClearOrphanedMergeStamp(task)) return;
const clearedStatus = task.status;
await store.updateTask(taskId, { status: null }).catch(() => undefined);
await store
.logEntry(taskId, `Auto-recovered: reconciled orphaned '${clearedStatus}' merge status`, "MergeQueue")
.catch(() => undefined);
await clearOwnedMergeStamp(this.runtime.getTaskStore(), taskId, "MergeQueue");
}
/** FN-5697/FN-5674: cap transient provider/network abort retries in auto-merge.