FN-8925: clear orphaned paused merge stamps safely
Allow only engine-owned deadlock pauses to clear stale merge stamps without resuming work. - Fail closed when merge ownership probing is unavailable. - Clear stale stamps only for merge-deadlock-detected pauses and suppress their enqueue. - Add race coverage, audit documentation, and a patch changeset. Files changed: .changeset/fn-8925-paused-stale-merge.md | 7 + docs/self-healing-backward-move-audit.md | 2 +- docs/task-management.md | 4 +- .../self-healing-query-filter-blindness.test.ts | 5 +- .../self-healing-stale-merge-fanout.test.ts | 1 + packages/engine/src/__tests__/self-healing.test.ts | 203 +++++++++++++++++++-- packages/engine/src/self-healing.ts | 45 ++++- 7 files changed, 241 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-8925 Fusion-Task-Lineage: e5491e1a-ca0a-4a82-b4e0-4002f30e869e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>,
This commit is contained in:
7
.changeset/fn-8925-paused-stale-merge.md
Normal file
7
.changeset/fn-8925-paused-stale-merge.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Clear orphaned merge status from eligible engine-paused review cards without resuming them.
|
||||
category: fix
|
||||
dev: The stale merge sweep permits only merge-deadlock-detected clear-only recovery and never enqueues paused cards.
|
||||
@@ -13,7 +13,7 @@ Stages that cannot satisfy all three must either (a) tighten predicate to requir
|
||||
|---|---:|---|---|---|---|---|---|---|---|
|
||||
| recoverCompletedTasks | 1455 | in-progress + all steps terminal | n/a | step completion | move to in-review | FORWARD | keep | n/a | n/a |
|
||||
| recoverStrandedCompletedTodoTasks | 1507 | todo + all steps terminal | n/a | step completion | move to in-progress (resume) | FORWARD | keep | n/a | n/a |
|
||||
| recoverStaleMergingStatus | 1557 | in-review stale merging status | `staleMergingStatusMinAgeMs` | stale status + no active merge | clear status | RECONCILE-ONLY | keep | n/a | n/a |
|
||||
| recoverStaleMergingStatus | 3521 | in-review stale merging status | `staleMergingStatusMinAgeMs` | stale status + no active merge | clear status; re-enqueue eligible unpaused non-workspace task | RECONCILE + FORWARD RE-ENQUEUE | keep | paused cards never enqueue; only `merge-deadlock-detected` may clear its orphaned stamp | n/a |
|
||||
| reclaimPrConflicts | 1606 | PR mergeable=conflicting | path-dependent | conflict inspect result | delegates to reclaimPrConflictForTask | RECONCILE/BACKWARD mix | keep | n/a | n/a |
|
||||
| reclaimPrConflictForTask | 1622 | reclaimable conflicting PR branch | inspectConflict + usable worktree checks | active-session/usable-worktree checks only | may move in-review→todo | BACKWARD | tighten | triple proof on in-review→todo path | gate move; emit `task:reclaim-pr-conflict-no-action` |
|
||||
| reclaimSelfOwnedBranchConflicts | 1772 | branch conflict self-owned | conflict inspector local checks | conflict classifier only | may move in-review→todo | BACKWARD | tighten | triple proof before backward move | gate move; emit `task:reclaim-self-owned-branch-conflict-no-action` |
|
||||
|
||||
@@ -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` tasks; paused tasks remain skipped and 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. 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.
|
||||
@@ -360,7 +360,7 @@ Scheduler-side reporting emits structured, rate-limited task-log and engine-log
|
||||
Success metric: maintainers can find all stale cards from the board UI in under 30 seconds.
|
||||
|
||||
Auto-completion/finalization remains owned by existing recovery passes:
|
||||
- `recoverStaleMergingStatus`
|
||||
- `recoverStaleMergingStatus` — reconciles unowned aged merge stamps. It may clear the stamp on an engine-owned `merge-deadlock-detected` pause but never resumes, unpauses, moves, finalizes, or enqueues a paused card.
|
||||
- `finalizeNoOpReviewTasks`
|
||||
- `recoverMergeableReviewTasks`
|
||||
- `recoverAlreadyMergedReviewTasks`
|
||||
|
||||
@@ -851,7 +851,10 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
|
||||
} as unknown as Task;
|
||||
const { store, updateTask } = productionFaithfulStore([stuck]);
|
||||
|
||||
await new SelfHealingManager(store, { rootDir: "/repo" }).recoverStaleMergingStatus();
|
||||
await new SelfHealingManager(store, {
|
||||
rootDir: "/repo",
|
||||
getActiveMergeTaskId: () => null,
|
||||
}).recoverStaleMergingStatus();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-STALESTAMP", expect.objectContaining({ status: null }));
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("SelfHealingManager stale merge fanout recovery (FN-4241)", () => {
|
||||
rootDir: "/tmp/test-project",
|
||||
staleMergingStatusMinAgeMs: 5 * 60_000,
|
||||
staleMergingFanoutMinAgeMs: 15 * 60_000,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
|
||||
const recoveredMerging = await manager.recoverStaleMergingStatus();
|
||||
|
||||
@@ -4956,6 +4956,7 @@ describe("SelfHealingManager", () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
enqueueMerge,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
@@ -5002,6 +5003,7 @@ describe("SelfHealingManager", () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
enqueueMerge,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
const staleTask = {
|
||||
id: "FN-8912-enqueue-throws",
|
||||
@@ -5035,6 +5037,7 @@ describe("SelfHealingManager", () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
enqueueMerge,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: false,
|
||||
@@ -5098,6 +5101,7 @@ describe("SelfHealingManager", () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
enqueueMerge,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
const workspaceTask = {
|
||||
id: "FN-8912-workspace-auto-on",
|
||||
@@ -5132,6 +5136,7 @@ describe("SelfHealingManager", () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
clearMergeActive,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
globalPause: false,
|
||||
@@ -5183,25 +5188,162 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("keeps paused stale merge statuses untouched", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
const pausedTask = {
|
||||
id: "FN-8912-paused",
|
||||
column: "in-review",
|
||||
paused: true,
|
||||
status: "merging",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(),
|
||||
steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
/*
|
||||
FNXC:MergeReliability 2026-08-10-05:32:
|
||||
`merge-deadlock-detected` is the sole automation pause allowed to clear an orphaned stamp; the
|
||||
clear must not unpause or enqueue it. REVERT CHECK (measured): restore `|| task.paused` and this
|
||||
test returns 0; remove the live `current.paused !== true` enqueue gate and its enqueue assertion fails.
|
||||
*/
|
||||
it("clears an unowned stale merge stamp on a merge-deadlock pause without resuming it", async () => {
|
||||
const clearMergeActive = vi.fn();
|
||||
const enqueueMerge = vi.fn();
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project", clearMergeActive, enqueueMerge,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
const pausedTask = {
|
||||
id: "FN-8925-merge-deadlock", column: "in-review", paused: true,
|
||||
pausedReason: "merge-deadlock-detected", status: "merging",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([pausedTask]);
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(pausedTask);
|
||||
const patches: Partial<Task>[] = [];
|
||||
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (live: Task) => Partial<Task> | null) => {
|
||||
const patch = updater(pausedTask as Task);
|
||||
if (patch) { patches.push(patch); Object.assign(pausedTask, patch); }
|
||||
return pausedTask as Task;
|
||||
});
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
|
||||
expect(pausedTask.status).toBeNull();
|
||||
expect(clearMergeActive).toHaveBeenCalledWith(pausedTask.id);
|
||||
expect(enqueueMerge).not.toHaveBeenCalled();
|
||||
expect(patches).toEqual([{ status: null }]);
|
||||
expect(pausedTask).toMatchObject({ paused: true, pausedReason: "merge-deadlock-detected", column: "in-review" });
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MergeReliability 2026-08-10-05:49:
|
||||
An unwired ownership probe cannot prove an unowned stamp. REVERT CHECK (measured): restore the
|
||||
null fallback and this fixture clears, recreating the false no-owner assumption that code review found.
|
||||
*/
|
||||
it("fails closed when the active-merge ownership probe is unwired", async () => {
|
||||
const clearMergeActive = vi.fn();
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project", clearMergeActive, enqueueMerge: vi.fn(),
|
||||
});
|
||||
const pausedTask = {
|
||||
id: "FN-8925-unwired-owner", column: "in-review", paused: true,
|
||||
pausedReason: "merge-deadlock-detected", status: "merging",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([pausedTask]);
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(pausedTask.id, { status: null });
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(clearMergeActive).not.toHaveBeenCalled();
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["human pause", { userPaused: true }],
|
||||
["approval pause", { pausedReason: "awaiting-approval" }],
|
||||
["unknown pause", { pausedReason: "future-engine-pause" }],
|
||||
])("keeps %s stale merge stamps fully suppressed", async (_name, extra) => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge: vi.fn(), getActiveMergeTaskId: () => null });
|
||||
const pausedTask = {
|
||||
id: `FN-8925-suppressed-${_name}`, column: "in-review", paused: true, status: "merging",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [], ...extra,
|
||||
};
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([pausedTask]);
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("suppresses enqueue when a live row is paused after an eligible snapshot", async () => {
|
||||
const enqueueMerge = vi.fn();
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge, getActiveMergeTaskId: () => null });
|
||||
const snapshot = {
|
||||
id: "FN-8925-pause-race", column: "in-review", paused: false, status: "merging",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
const live = { ...snapshot, paused: true, pausedReason: "merge-deadlock-detected" };
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([snapshot]);
|
||||
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial<Task> | null) => {
|
||||
const patch = updater(live as Task);
|
||||
if (patch) Object.assign(live, patch);
|
||||
return live as Task;
|
||||
});
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
|
||||
expect(live.status).toBeNull();
|
||||
expect(enqueueMerge).not.toHaveBeenCalled();
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("does not clear when a merge-deadlock snapshot is unpaused into a newly claimed live task", async () => {
|
||||
const snapshot = {
|
||||
id: "FN-8925-unpause-race", column: "in-review", paused: true, pausedReason: "merge-deadlock-detected", status: "merging",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
const live = { ...snapshot, paused: false, pausedReason: undefined };
|
||||
let reads = 0;
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project", getActiveMergeTaskId: () => (++reads === 1 ? null : snapshot.id), enqueueMerge: vi.fn(),
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([snapshot]);
|
||||
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial<Task> | null) => {
|
||||
expect(updater(live as Task)).toBeNull();
|
||||
return live as Task;
|
||||
});
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("does not clear when merge confirmation appears on the live atomic row", async () => {
|
||||
const snapshot = {
|
||||
id: "FN-8925-confirm-race", column: "in-review", paused: false, status: "merging",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
const live = { ...snapshot, mergeDetails: { mergeConfirmed: true } };
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge: vi.fn(), getActiveMergeTaskId: () => null });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([snapshot]);
|
||||
store.updateTaskAtomic = vi.fn(async (_id: string, updater: (task: Task) => Partial<Task> | null) => {
|
||||
expect(updater(live as Task)).toBeNull();
|
||||
return live as Task;
|
||||
});
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(0);
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("applies the merge-deadlock allowlist in the non-atomic compatibility fallback", async () => {
|
||||
const task = {
|
||||
id: "FN-8925-fallback", column: "in-review", paused: true, pausedReason: "merge-deadlock-detected", status: "landing",
|
||||
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
const enqueueMerge = vi.fn();
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", enqueueMerge, getActiveMergeTaskId: () => null });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
store.updateTaskAtomic = undefined;
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: null });
|
||||
expect(enqueueMerge).not.toHaveBeenCalled();
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
@@ -6723,8 +6865,19 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("pauses genuine failures and leaves blockedBy untouched", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
/*
|
||||
FNXC:MergeReliability 2026-08-10-05:42:
|
||||
The deadlock producer must persist provenance so the stale-stamp fixture above represents a real
|
||||
engine park rather than a no-reason human pause. REVERT CHECK (measured): omit pausedReason here
|
||||
and the producer no longer reaches the clear-only merge-deadlock policy.
|
||||
*/
|
||||
it("pauses genuine failures with deadlock provenance and leaves blockedBy untouched", async () => {
|
||||
const clearMergeActive = vi.fn();
|
||||
const enqueueMerge = vi.fn();
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project", clearMergeActive, enqueueMerge,
|
||||
getActiveMergeTaskId: () => null,
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(baseSettings);
|
||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([{ id: "FN-stuck", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, worktree: "/tmp/wt", log: [] }])
|
||||
@@ -6736,11 +6889,29 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverStuckMergeDeadlocks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", { paused: true });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", {
|
||||
paused: true,
|
||||
pausedReason: "merge-deadlock-detected",
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-dep", { blockedBy: null });
|
||||
expect(getSelfHealingLogger().warn).toHaveBeenCalledWith(expect.stringContaining("paused-for-manual"));
|
||||
|
||||
// Model a late superseded merge-body stamp after this exact producer park.
|
||||
const parkedThenStamped = {
|
||||
id: "FN-stuck", column: "in-review", paused: true, pausedReason: "merge-deadlock-detected",
|
||||
status: "merging", updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), steps: [], workflowStepResults: [], log: [],
|
||||
};
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...baseSettings, autoMerge: true });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockReset().mockResolvedValue([parkedThenStamped]);
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parkedThenStamped);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
expect(await managerWithRecovery.recoverStaleMergingStatus()).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-stuck", { status: null });
|
||||
expect(clearMergeActive).toHaveBeenCalledWith("FN-stuck");
|
||||
expect(enqueueMerge).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -3526,8 +3526,16 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const minAgeMs = this.getStaleMergingStatusMinAgeMs();
|
||||
if (!Number.isFinite(minAgeMs) || minAgeMs <= 0) return 0;
|
||||
|
||||
/*
|
||||
FNXC:MergeReliability 2026-08-10-05:49:
|
||||
Stale-stamp recovery needs a positive ownership probe before it can clear any status. An unwired
|
||||
probe is unknown ownership, not proof that no merger exists; fail closed rather than allowing the
|
||||
merge-deadlock pause exception to erase a live merge stamp in a structural integration.
|
||||
*/
|
||||
const getActiveMergeTaskId = this.options.getActiveMergeTaskId;
|
||||
if (typeof getActiveMergeTaskId !== "function") return 0;
|
||||
const now = Date.now();
|
||||
const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null;
|
||||
const activeMergeTaskId = getActiveMergeTaskId();
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-first sweep):
|
||||
Clears a `merging`/`merging-pr` stamp left on a review card with no live merger behind it. The
|
||||
@@ -3559,8 +3567,20 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
staleMergeLanes.set(entry.id, new Set(staleMergeReviewColumns));
|
||||
}
|
||||
}
|
||||
/*
|
||||
FNXC:MergeReliability 2026-08-10-05:32:
|
||||
A bare `merge-deadlock-detected` engine pause can retain an unowned merge stamp forever because,
|
||||
unlike `pauseTaskImpl`, its writer does not replace `status` with `paused`. Admit only that
|
||||
automation-owned shape for a clear-only repair. Every human, approval, known operator-attention,
|
||||
and unknown pause remains default-denied so a future pause writer cannot silently become eligible.
|
||||
*/
|
||||
const canClearPausedMergeDeadlockStamp = (candidate: Pick<Task, "paused" | "userPaused" | "pausedReason">): boolean =>
|
||||
candidate.paused === true
|
||||
&& candidate.userPaused !== true
|
||||
&& candidate.pausedReason === "merge-deadlock-detected";
|
||||
const stale = tasks.filter((task) => {
|
||||
if (!(staleMergeLanes.get(task.id) ?? staleMergeReviewColumns).has(task.column) || task.paused) return false;
|
||||
if (!(staleMergeLanes.get(task.id) ?? staleMergeReviewColumns).has(task.column)) return false;
|
||||
if (task.paused && !canClearPausedMergeDeadlockStamp(task)) return false;
|
||||
/*
|
||||
FNXC:MergeReliability 2026-07-15-21:45 (FN-8004 follow-up):
|
||||
Staleness now comes from the shared `isStaleMergeActiveStatus` leaf, which the dashboard's
|
||||
@@ -3598,9 +3618,10 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
*/
|
||||
let previousStatus: string | null | undefined;
|
||||
const clearIfStillStale = (live: Task): boolean => {
|
||||
const currentActiveMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null;
|
||||
const currentActiveMergeTaskId = getActiveMergeTaskId();
|
||||
if (
|
||||
!shouldClearOrphanedMergeStamp(live)
|
||||
(live.paused === true && !canClearPausedMergeDeadlockStamp(live))
|
||||
|| !shouldClearOrphanedMergeStamp(live)
|
||||
|| !isStaleMergeActiveStatus(live, {
|
||||
activeMergeTaskId: currentActiveMergeTaskId,
|
||||
nowMs: Date.now(),
|
||||
@@ -3636,8 +3657,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
task.id,
|
||||
`Auto-recovered: cleared stale '${previousStatus}' status (no active merger)`,
|
||||
);
|
||||
/*
|
||||
FNXC:MergeReliability 2026-08-10-05:32:
|
||||
Clearing the false badge is not permission to restart parked work. Check the live row returned
|
||||
by the locked updater so a pause that races this sweep suppresses enqueue without weakening it.
|
||||
*/
|
||||
if (
|
||||
allowsAutoMergeProcessing(current, settings)
|
||||
current.paused !== true
|
||||
&& allowsAutoMergeProcessing(current, settings)
|
||||
&& current.mergeDetails?.mergeConfirmed !== true
|
||||
&& !isWorkspaceTask(current)
|
||||
) {
|
||||
@@ -10939,7 +10966,13 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
if (!proof.ok) {
|
||||
await this.emitBackwardMoveNoAction(task, "stuck-merge-deadlock", "task:stuck-merge-deadlock-no-action", proof);
|
||||
} else {
|
||||
await this.store.updateTask(task.id, { paused: true });
|
||||
/*
|
||||
FNXC:MergeReliability 2026-08-10-05:42:
|
||||
A deadlock park preserves its merge-active status so operators can see the failed merge,
|
||||
but it must carry durable automation provenance. That lets stale-stamp recovery repair only
|
||||
this engine-owned false badge without treating a no-reason human pause as eligible.
|
||||
*/
|
||||
await this.store.updateTask(task.id, { paused: true, pausedReason: "merge-deadlock-detected" });
|
||||
await this.store.logEntry(task.id, "merge-deadlock-detected: requires manual intervention — verified content not on main");
|
||||
log.warn(`self-heal:deadlock-recovered ${JSON.stringify({ stuckTaskId: task.id, blockedTaskIds, attributedSha: null, action: "paused-for-manual" })}`);
|
||||
recovered++;
|
||||
|
||||
Reference in New Issue
Block a user