chore(dashboard): publish MobileNavBar render decision to vpdebug
Surface the live mode / modalOpen / keyboardOpen / footerVisible / view values that MobileNavBar uses for its early-return so the ?vpdebug overlay can show which one is hiding the bar on Android. Also dumps the .project-content className so we can correlate with `--with-mobile-nav`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -146,18 +146,10 @@ Per-task opt-out exists: `task.scopeOverride = true` (log the reason).
|
||||
|
||||
When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a human. Lifecycle-mutating self-healing must not move these tasks backward, pause/fail them, or re-enqueue them for execution.
|
||||
|
||||
### Completion-handoff limbo budget invariant (FN-5479)
|
||||
|
||||
`recoverCompletionHandoffLimbo` may only increment `completionHandoffLimboRecoveryCount` after merge requeue is **accepted** by the in-memory merge scheduler. Queue-row writes alone are insufficient. If enqueue is not accepted, skip budget consumption so tasks do not hit generic `Completion handoff limbo recovery exhausted` without a real merge attempt. Backstop: `packages/engine/src/__tests__/reliability-interactions/completion-handoff-limbo.test.ts` (`FN-5479` case).
|
||||
|
||||
### Mock provider (test mode)
|
||||
|
||||
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.
|
||||
|
||||
### Reliability Mechanism Coverage
|
||||
|
||||
- FN-5448: composition backstop for stranded-completed todo recovery × ghost-review fallback at `packages/engine/src/__tests__/reliability-interactions/completed-task-oscillation.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Reference docs (deeper detail)
|
||||
|
||||
@@ -2522,31 +2522,6 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
expectRetryCountersReset(updated);
|
||||
expect(updated?.mergeRetries).toBe(0);
|
||||
});
|
||||
|
||||
it("clears userPaused when retrying a manually paused failed task", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const task = await store.createTask({
|
||||
title: "manually paused failed task",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
});
|
||||
await store.updateTask(task.id, {
|
||||
status: "failed",
|
||||
error: "verification failed",
|
||||
userPaused: true,
|
||||
});
|
||||
|
||||
const retryTool = api.tools.get("fn_task_retry")!;
|
||||
const result = await retryTool.execute("retry-user-paused", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
const updated = await store.getTask(task.id);
|
||||
expect(updated?.column).toBe("todo");
|
||||
expect(updated?.status).toBeFalsy();
|
||||
expect(updated?.userPaused).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_list_agents", () => {
|
||||
|
||||
@@ -35,14 +35,4 @@ describe("buildManualRetryResetPatch", () => {
|
||||
it("clears nextRecoveryAt", () => {
|
||||
expect(buildManualRetryResetPatch()).toMatchObject({ nextRecoveryAt: null });
|
||||
});
|
||||
|
||||
it("clears userPaused for manual retry in all modes", () => {
|
||||
const defaultPatch = buildManualRetryResetPatch();
|
||||
expect(Object.prototype.hasOwnProperty.call(defaultPatch, "userPaused")).toBe(true);
|
||||
expect(defaultPatch.userPaused).toBeUndefined();
|
||||
|
||||
const mergePatch = buildManualRetryResetPatch({ resetMergeRetries: true });
|
||||
expect(Object.prototype.hasOwnProperty.call(mergePatch, "userPaused")).toBe(true);
|
||||
expect(mergePatch.userPaused).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,9 @@ export const MANUAL_RETRY_RESET_COUNTER_KEYS = [
|
||||
"mergeAuditBounceCount",
|
||||
] as const satisfies ReadonlyArray<keyof Task>;
|
||||
|
||||
/** Resets retry/recovery counters and clears `userPaused` for explicit manual retries. */
|
||||
export function buildManualRetryResetPatch(options?: { resetMergeRetries?: boolean }): Partial<Task> {
|
||||
const patch: Partial<Task> = {
|
||||
nextRecoveryAt: null as unknown as Task["nextRecoveryAt"],
|
||||
userPaused: undefined,
|
||||
};
|
||||
|
||||
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./MobileNavBar.css";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
@@ -148,10 +148,21 @@ export function MobileNavBar({
|
||||
shellConnectionControl,
|
||||
}: MobileNavBarProps) {
|
||||
const mode = useViewportMode();
|
||||
// vpdebug: surface MobileNavBar render decision inputs for diagnostic overlay
|
||||
if (typeof window !== "undefined") {
|
||||
(window as unknown as { __mNavDebug?: unknown }).__mNavDebug = {
|
||||
mode,
|
||||
modalOpen,
|
||||
keyboardOpen,
|
||||
footerVisible,
|
||||
view,
|
||||
};
|
||||
}
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const scriptEntries = useMemo(
|
||||
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
||||
@@ -204,6 +215,36 @@ export function MobileNavBar({
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isMoreOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const navEl = navRef.current;
|
||||
if (!navEl || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const publishMeasuredHeight = () => {
|
||||
const computed = window.getComputedStyle(navEl);
|
||||
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
|
||||
const contentHeight = navEl.offsetHeight - paddingBottom;
|
||||
const publishedHeight = Math.max(44, Math.ceil(contentHeight));
|
||||
document.documentElement.style.setProperty("--mobile-nav-height", `${publishedHeight}px`);
|
||||
};
|
||||
|
||||
publishMeasuredHeight();
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
observer = new ResizeObserver(() => {
|
||||
publishMeasuredHeight();
|
||||
});
|
||||
observer.observe(navEl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
document.documentElement.style.removeProperty("--mobile-nav-height");
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (mode !== "mobile" || modalOpen || keyboardOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -249,6 +290,7 @@ export function MobileNavBar({
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
|
||||
role="tablist"
|
||||
aria-label="Primary navigation"
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
}
|
||||
var dialogs = document.querySelectorAll('[role="dialog"],.modal,.modal-overlay').length;
|
||||
var focused = document.activeElement && document.activeElement.tagName;
|
||||
var d = window.__mNavDebug || {};
|
||||
var dbg = 'mode=' + d.mode + ' modal=' + d.modalOpen + ' kb=' + d.keyboardOpen + ' fv=' + d.footerVisible + ' v=' + d.view;
|
||||
var pc = document.querySelector('.project-content');
|
||||
var pcCls = pc ? pc.className.replace('project-content', '').trim() : 'none';
|
||||
box.textContent =
|
||||
'win ' + window.innerWidth + 'x' + window.innerHeight + '\n' +
|
||||
'vv ' + (vv ? Math.round(vv.width) + 'x' + Math.round(vv.height) + ' s' + vv.scale.toFixed(2) + ' o' + Math.round(vv.offsetLeft) + ',' + Math.round(vv.offsetTop) : 'n/a') + '\n' +
|
||||
@@ -36,6 +40,8 @@
|
||||
'root ' + f(document.getElementById('root')) + '\n' +
|
||||
'board ' + f(document.getElementById('board')) + '\n' +
|
||||
'mNav ' + navInfo + '\n' +
|
||||
'mNavR ' + dbg + '\n' +
|
||||
'pcCls ' + pcCls + '\n' +
|
||||
'mq768 ' + window.matchMedia('(max-width: 768px)').matches + '\n' +
|
||||
'dialogs ' + dialogs + ' focus ' + focused + '\n' +
|
||||
'sx ' + window.scrollX + ' sy ' + window.scrollY + '\n' +
|
||||
|
||||
@@ -382,24 +382,6 @@ describe("POST /tasks/:id/retry", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
|
||||
it("clears userPaused when retrying a failed todo task", async () => {
|
||||
const failedTaskInTodo = { ...FAKE_TASK_DETAIL, column: "todo", status: "failed", userPaused: true };
|
||||
const movedTask = { ...failedTaskInTodo, status: undefined, userPaused: undefined };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTaskInTodo);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTaskInTodo);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(Object.prototype.hasOwnProperty.call(updateCall, "userPaused")).toBe(true);
|
||||
expect(updateCall.userPaused).toBeUndefined();
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
|
||||
it("retries a stuck-killed task and moves it to todo", async () => {
|
||||
const stuckTask = { ...FAKE_TASK_DETAIL, status: "stuck-killed", column: "in-progress" };
|
||||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
|
||||
@@ -582,33 +564,6 @@ describe("POST /tasks/:id/retry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("clears userPaused for merge-retry in-review tasks", async () => {
|
||||
const mergeFailedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "in-review" as const,
|
||||
status: "failed",
|
||||
userPaused: true,
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "done" },
|
||||
],
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mergeFailedTask)
|
||||
.mockResolvedValueOnce({ ...mergeFailedTask, userPaused: undefined });
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...mergeFailedTask, userPaused: undefined });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(Object.prototype.hasOwnProperty.call(updateCall, "userPaused")).toBe(true);
|
||||
expect(updateCall.userPaused).toBeUndefined();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries zero-step merge-failed in-review task with prior merge attempts by staying in-review", async () => {
|
||||
const mergeFailedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
// FN-5448 accepted branch (a): exempted — ghost-review must not demote
|
||||
// recently stranded-completed promotions when all implementation steps are done.
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-5448-RI",
|
||||
title: "completed-task oscillation fixture",
|
||||
description: "regression harness",
|
||||
column: "todo",
|
||||
paused: false,
|
||||
status: null,
|
||||
error: null,
|
||||
branch: "fusion/fn-5448-fixture",
|
||||
worktree: null,
|
||||
steps: [{ name: "Implement", status: "done" as const }],
|
||||
workflowStepResults: [],
|
||||
dependencies: [],
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(task: Task, settings: Record<string, unknown> = {}): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter() as TaskStore & EventEmitter;
|
||||
const audits: any[] = [];
|
||||
(emitter as any).__audits = audits;
|
||||
|
||||
(emitter as any).getSettings = vi.fn().mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
taskStuckTimeoutMs: 600_000,
|
||||
...settings,
|
||||
});
|
||||
(emitter as any).listTasks = vi.fn().mockImplementation(async ({ column }: { column?: string } = {}) => {
|
||||
if (!column || task.column === column) return [task];
|
||||
return [];
|
||||
});
|
||||
(emitter as any).logEntry = vi.fn().mockImplementation(async (_taskId: string, action: string) => {
|
||||
task.log = task.log ?? [];
|
||||
task.log.push({ timestamp: new Date(Date.now()).toISOString(), action } as any);
|
||||
});
|
||||
(emitter as any).updateTask = vi.fn().mockImplementation(async (_taskId: string, patch: Partial<Task>) => {
|
||||
Object.assign(task, patch, { updatedAt: new Date(Date.now()).toISOString() });
|
||||
return task;
|
||||
});
|
||||
(emitter as any).moveTask = vi.fn().mockImplementation(async (_taskId: string, column: string) => {
|
||||
task.column = column as any;
|
||||
const now = new Date(Date.now()).toISOString();
|
||||
task.updatedAt = now;
|
||||
task.columnMovedAt = now;
|
||||
return task;
|
||||
});
|
||||
(emitter as any).recordRunAuditEvent = vi.fn().mockImplementation(async (event: any) => {
|
||||
audits.push(event);
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
describe("FN-5448 reliability interactions: completed-task oscillation", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("branch (a) exempted: ghost-review does not demote recently recovered completed todo tasks", async () => {
|
||||
const task = createTask();
|
||||
const store = createStore(task, { taskStuckTimeoutMs: 600_000, autoMerge: true });
|
||||
const recoverCompletedTask = vi.fn().mockImplementation(async (strandedTask: Task) => {
|
||||
const now = new Date(Date.now()).toISOString();
|
||||
Object.assign(strandedTask, {
|
||||
column: "in-review",
|
||||
status: null,
|
||||
error: null,
|
||||
columnMovedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/repo",
|
||||
recoverCompletedTask,
|
||||
getExecutingTaskIds: () => new Set<string>(),
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
expect(await manager.recoverStrandedCompletedTodoTasks()).toBe(1);
|
||||
expect(recoverCompletedTask).toHaveBeenCalledTimes(1);
|
||||
expect(task.column).toBe("in-review");
|
||||
|
||||
vi.advanceTimersByTime(600_001);
|
||||
const ghostRecovered = await manager.recoverGhostReviewTasks();
|
||||
|
||||
expect(ghostRecovered).toBe(0);
|
||||
expect(task.column).toBe("in-review");
|
||||
expect((store.moveTask as any).mock.calls).toEqual([]);
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-5147: autoMerge false keeps in-review task unchanged after timeout", async () => {
|
||||
const task = createTask();
|
||||
const store = createStore(task, { taskStuckTimeoutMs: 600_000, autoMerge: false });
|
||||
const recoverCompletedTask = vi.fn().mockImplementation(async (strandedTask: Task) => {
|
||||
const now = new Date(Date.now()).toISOString();
|
||||
Object.assign(strandedTask, {
|
||||
column: "in-review",
|
||||
status: null,
|
||||
error: null,
|
||||
columnMovedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/repo",
|
||||
recoverCompletedTask,
|
||||
getExecutingTaskIds: () => new Set<string>(),
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
expect(await manager.recoverStrandedCompletedTodoTasks()).toBe(1);
|
||||
expect(task.column).toBe("in-review");
|
||||
|
||||
vi.advanceTimersByTime(600_001);
|
||||
expect(await manager.recoverGhostReviewTasks()).toBe(0);
|
||||
expect(task.column).toBe("in-review");
|
||||
expect((store.moveTask as any).mock.calls).toEqual([]);
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
@@ -123,29 +123,4 @@ describe("FN-4999 reliability interactions: completion-handoff-limbo", () => {
|
||||
.filter((value: unknown) => typeof value === "number");
|
||||
expect(increments).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("FN-5479: does not consume limbo recovery budget when merge requeue is not accepted", async () => {
|
||||
const task = makeTask({
|
||||
status: undefined,
|
||||
review: undefined,
|
||||
reviewState: undefined,
|
||||
mergeDetails: undefined,
|
||||
completionHandoffLimboRecoveryCount: 2,
|
||||
log: [{ action: "Task marked done by agent", timestamp: new Date(Date.now() - 6 * 60_000).toISOString() } as any],
|
||||
});
|
||||
const store = createStore(task);
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/repo",
|
||||
enqueueMerge: vi.fn(() => false),
|
||||
requeueForAutoMerge: vi.fn(() => false),
|
||||
});
|
||||
|
||||
await manager.recoverCompletionHandoffLimbo();
|
||||
|
||||
expect(store.enqueueMergeQueue).toHaveBeenCalledWith("FN-4999-T");
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-4999-T", expect.objectContaining({ completionHandoffLimboRecoveryCount: 3 }));
|
||||
expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith(expect.objectContaining({ mutationType: "task:auto-recover-completion-handoff-limbo" }));
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith("FN-4999-T", expect.stringMatching(/Auto-recovered \(FN-4999\)/));
|
||||
expect(store._get().completionHandoffLimboRecoveryCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7698,7 +7698,7 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
||||
it("emits ghost-review no-action when triple proof fails", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, autoMerge: true, taskStuckTimeoutMs: 1_000 } as any),
|
||||
listTasks: vi.fn().mockResolvedValue([{ id: "FN-GHOST", column: "in-review", worktree: "/tmp/fn-ghost", updatedAt: new Date(Date.now() - 5_000).toISOString(), columnMovedAt: new Date(Date.now() - 5_000).toISOString(), paused: false, status: null, mergeDetails: {}, steps: [{ status: "pending" }] }]),
|
||||
listTasks: vi.fn().mockResolvedValue([{ id: "FN-GHOST", column: "in-review", worktree: "/tmp/fn-ghost", updatedAt: new Date(Date.now() - 5_000).toISOString(), columnMovedAt: new Date(Date.now() - 5_000).toISOString(), paused: false, status: null, mergeDetails: {} }]),
|
||||
});
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
const recovered = await manager.recoverGhostReviewTasks();
|
||||
|
||||
@@ -694,7 +694,7 @@ export class InProcessRuntime
|
||||
getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? new Set<string>(),
|
||||
evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? new Set<string>(),
|
||||
enqueueMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) ?? false : undefined,
|
||||
requeueForAutoMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) ?? false : undefined,
|
||||
requeueForAutoMerge: this.mergeEnqueuer ? (taskId: string) => { this.mergeEnqueuer?.(taskId); } : undefined,
|
||||
isTaskActive: (taskId: string) => this.executor.isTaskActive(taskId),
|
||||
clearMergeActive: this.clearMergeActive ? (taskId: string) => this.clearMergeActive?.(taskId) : undefined,
|
||||
getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null,
|
||||
|
||||
@@ -226,7 +226,7 @@ export interface SelfHealingOptions {
|
||||
* the polling sweep's enqueue to silently no-op).
|
||||
*/
|
||||
enqueueMerge?: (taskId: string) => boolean;
|
||||
requeueForAutoMerge?: (taskId: string) => boolean | void | Promise<boolean | void>;
|
||||
requeueForAutoMerge?: (taskId: string) => void | Promise<void>;
|
||||
isTaskActive?: (taskId: string) => boolean;
|
||||
clearMergeActive?: (taskId: string) => void;
|
||||
/**
|
||||
@@ -4842,9 +4842,6 @@ export class SelfHealingManager {
|
||||
!task.paused &&
|
||||
!executingIds.has(task.id) &&
|
||||
!(task.status && GHOST_REVIEW_PRESERVED_STATUSES.has(task.status)) &&
|
||||
// FN-5448: completed tasks promoted by stranded-completed recovery must
|
||||
// not be kicked back to todo by ghost-review fallback.
|
||||
!(Array.isArray(task.steps) && task.steps.length > 0 && task.steps.every((step) => step.status === "done" || step.status === "skipped")) &&
|
||||
// Confirmed merges belong in `done` (handled by `recoverMergedReviewTasks`).
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
now - new Date(task.columnMovedAt ?? task.updatedAt).getTime() >= timeoutMs
|
||||
@@ -5784,33 +5781,6 @@ export class SelfHealingManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
let accepted = false;
|
||||
if (this.options.requeueForAutoMerge || this.options.enqueueMerge) {
|
||||
try {
|
||||
// FN-5353: strict targetTaskId leasing in reuse handoff requires an
|
||||
// explicit queue row before re-emitting auto-merge.
|
||||
await this.store.enqueueMergeQueue(task.id);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`recoverCompletionHandoffLimbo: enqueue failed for ${task.id}: ${errorMessage}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.options.enqueueMerge) {
|
||||
accepted = this.options.enqueueMerge(task.id);
|
||||
} else if (this.options.requeueForAutoMerge) {
|
||||
const enqueueResult = await this.options.requeueForAutoMerge(task.id);
|
||||
accepted = enqueueResult !== false;
|
||||
}
|
||||
} else {
|
||||
log.warn(`recoverCompletionHandoffLimbo: requeueForAutoMerge callback missing for ${task.id}`);
|
||||
}
|
||||
|
||||
if (!accepted) {
|
||||
log.warn(`recoverCompletionHandoffLimbo: merge requeue not accepted for ${task.id}; skipping limbo recovery budget increment`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
completionHandoffLimboRecoveryCount: currentCount + 1,
|
||||
});
|
||||
@@ -5829,6 +5799,20 @@ export class SelfHealingManager {
|
||||
});
|
||||
|
||||
await this.store.logEntry(task.id, "Auto-recovered (FN-4999): task in 'in-review' past handoff grace with no merge fan-out — re-emitting auto-merge handoff");
|
||||
if (this.options.requeueForAutoMerge) {
|
||||
try {
|
||||
// FN-5353: strict targetTaskId leasing in reuse handoff requires an
|
||||
// explicit queue row before re-emitting auto-merge.
|
||||
await this.store.enqueueMergeQueue(task.id);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`recoverCompletionHandoffLimbo: enqueue failed for ${task.id}: ${errorMessage}`);
|
||||
continue;
|
||||
}
|
||||
await this.options.requeueForAutoMerge(task.id);
|
||||
} else {
|
||||
log.warn(`recoverCompletionHandoffLimbo: requeueForAutoMerge callback missing for ${task.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user