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:
7
.changeset/fn-9167-interrupted-manual-merge-stamp.md
Normal file
7
.changeset/fn-9167-interrupted-manual-merge-stamp.md
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
packages/engine/src/merge/clear-orphaned-merge-stamp.ts
Normal file
80
packages/engine/src/merge/clear-orphaned-merge-stamp.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user