feat(FN-4885): complete Step 1 — core deadlock threshold primitives

Fusion-Task-Id: FN-4885
Fusion-Task-Lineage: 31110eed-5ca3-4d67-8f11-581185056dc6
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 09:03:17 -07:00
committed by gsxdsm
parent 67aff5de80
commit e3cfd0b05e
4 changed files with 79 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
countRecentIdenticalStallEntries,
DEFAULT_MAX_AUTO_MERGE_RETRIES,
DEFAULT_STALE_MERGING_MIN_AGE_MS,
getInReviewStallReason,
@@ -21,6 +22,48 @@ const baseTask = {
updatedAt: new Date(NOW).toISOString(),
};
describe("countRecentIdenticalStallEntries", () => {
const reason = "Failed to create worktree after 3 attempts";
const task = (log: Array<{ timestamp: string; action: string }>) => ({ log });
it("returns 0 with no log entries", () => {
expect(countRecentIdenticalStallEntries(task([]), { code: "merge-blocker", reason })).toBe(0);
});
it("counts three identical most-recent entries", () => {
expect(countRecentIdenticalStallEntries(task([
{ timestamp: "2026-05-12T11:57:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
{ timestamp: "2026-05-12T11:58:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
{ timestamp: "2026-05-12T11:59:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
]), { code: "merge-blocker", reason })).toBe(3);
});
it("stops at first non-stall entry and only counts the suffix", () => {
expect(countRecentIdenticalStallEntries(task([
{ timestamp: "2026-05-12T11:56:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
{ timestamp: "2026-05-12T11:57:00.000Z", action: "something else" },
{ timestamp: "2026-05-12T11:58:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
{ timestamp: "2026-05-12T11:59:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
]), { code: "merge-blocker", reason })).toBe(2);
});
it("stops counting on different code", () => {
expect(countRecentIdenticalStallEntries(task([
{ timestamp: "2026-05-12T11:57:00.000Z", action: "In-review stall surfaced [merge-retries-exhausted]: retries exhausted" },
{ timestamp: "2026-05-12T11:58:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
{ timestamp: "2026-05-12T11:59:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
]), { code: "merge-blocker", reason })).toBe(2);
});
it("stops counting on different reason text", () => {
expect(countRecentIdenticalStallEntries(task([
{ timestamp: "2026-05-12T11:57:00.000Z", action: "In-review stall surfaced [merge-blocker]: another reason" },
{ timestamp: "2026-05-12T11:58:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
{ timestamp: "2026-05-12T11:59:00.000Z", action: `In-review stall surfaced [merge-blocker]: ${reason}` },
]), { code: "merge-blocker", reason })).toBe(2);
});
});
describe("getInReviewStallReason", () => {
it("returns transient-merge-status-no-owner for FN-4110 fixture", () => {
const signal = getInReviewStallReason(

View File

@@ -1,5 +1,5 @@
import { getTaskMergeBlocker } from "./task-merge.js";
import type { Task } from "./types.js";
import type { Task, TaskLogEntry } from "./types.js";
/**
* State-based in-review stall detection. This is complementary to FN-4168's
@@ -32,9 +32,39 @@ export interface InReviewStallContext {
export const DEFAULT_STALE_MERGING_MIN_AGE_MS = 5 * 60_000;
/** Keep aligned with engine MAX_AUTO_MERGE_RETRIES (core must not import engine). */
export const DEFAULT_MAX_AUTO_MERGE_RETRIES = 3;
export const IN_REVIEW_STALL_LOG_PREFIX = "In-review stall surfaced [";
export const IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX = "In-review stall auto-disposed [";
const TRANSIENT_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
export function countRecentIdenticalStallEntries(
task: Pick<Task, "log">,
signal: Pick<InReviewStallSignal, "code" | "reason">,
): number {
const trimmedReason = signal.reason.trim();
const reversed = [...(task.log ?? [])].reverse();
let count = 0;
for (const entry of reversed) {
if (!entry.action.startsWith(IN_REVIEW_STALL_LOG_PREFIX)) {
break;
}
if (!matchesStallEntry(entry, signal.code, trimmedReason)) {
break;
}
count += 1;
}
return count;
}
function matchesStallEntry(entry: TaskLogEntry, code: InReviewStallCode, reason: string): boolean {
const prefix = `${IN_REVIEW_STALL_LOG_PREFIX}${code}]:`;
if (!entry.action.startsWith(prefix)) return false;
const rawReason = entry.action.slice(prefix.length).trim();
return rawReason === reason;
}
export function getInReviewStallReason(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults" | "worktree" | "mergeDetails" | "mergeRetries" | "updatedAt"> & { id?: string },
context: InReviewStallContext = {},

View File

@@ -267,6 +267,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
specStalenessEnabled: false,
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
taskStuckTimeoutMs: 600_000,
inReviewStallDeadlockThreshold: 3,
stalePausedReviewThresholdMs: 24 * 60 * 60_000,
// Capacity risk warning default: only warn once todo is meaningfully backlogged.
capacityRiskBannerEnabled: false,

View File

@@ -2851,6 +2851,10 @@ export interface ProjectSettings {
* than this duration, the task is considered stuck and will be terminated and retried.
* Default: 600000 (10 minutes). Set to 0 to disable. */
taskStuckTimeoutMs?: number;
/** Minimum number of identical consecutive in-review stall log entries (same code + reason)
* before the task is auto-disposed with `pausedReason='in-review-stall-deadlock'`.
* Default: 3. Set to 0 to disable. */
inReviewStallDeadlockThreshold?: number;
/** Threshold in milliseconds for surfacing paused in-review tasks as stale.
* Age is measured from columnMovedAt when present, otherwise updatedAt.
* Default: 86400000 (24 hours). Set to 0 or undefined to disable surfacing. */