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:
committed by
gsxdsm
parent
67aff5de80
commit
e3cfd0b05e
@@ -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(
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user