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.
|
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)
|
### 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.
|
`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)
|
## Reference docs (deeper detail)
|
||||||
|
|||||||
@@ -2522,31 +2522,6 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
|||||||
expectRetryCountersReset(updated);
|
expectRetryCountersReset(updated);
|
||||||
expect(updated?.mergeRetries).toBe(0);
|
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", () => {
|
describe("fn_list_agents", () => {
|
||||||
|
|||||||
@@ -35,14 +35,4 @@ describe("buildManualRetryResetPatch", () => {
|
|||||||
it("clears nextRecoveryAt", () => {
|
it("clears nextRecoveryAt", () => {
|
||||||
expect(buildManualRetryResetPatch()).toMatchObject({ nextRecoveryAt: null });
|
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",
|
"mergeAuditBounceCount",
|
||||||
] as const satisfies ReadonlyArray<keyof Task>;
|
] 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> {
|
export function buildManualRetryResetPatch(options?: { resetMergeRetries?: boolean }): Partial<Task> {
|
||||||
const patch: Partial<Task> = {
|
const patch: Partial<Task> = {
|
||||||
nextRecoveryAt: null as unknown as Task["nextRecoveryAt"],
|
nextRecoveryAt: null as unknown as Task["nextRecoveryAt"],
|
||||||
userPaused: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {
|
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "./MobileNavBar.css";
|
import "./MobileNavBar.css";
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -148,10 +148,21 @@ export function MobileNavBar({
|
|||||||
shellConnectionControl,
|
shellConnectionControl,
|
||||||
}: MobileNavBarProps) {
|
}: MobileNavBarProps) {
|
||||||
const mode = useViewportMode();
|
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 [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||||
|
const navRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const scriptEntries = useMemo(
|
const scriptEntries = useMemo(
|
||||||
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
||||||
@@ -204,6 +215,36 @@ export function MobileNavBar({
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [isMoreOpen]);
|
}, [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) {
|
if (mode !== "mobile" || modalOpen || keyboardOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -249,6 +290,7 @@ export function MobileNavBar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
|
ref={navRef}
|
||||||
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
|
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Primary navigation"
|
aria-label="Primary navigation"
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
}
|
}
|
||||||
var dialogs = document.querySelectorAll('[role="dialog"],.modal,.modal-overlay').length;
|
var dialogs = document.querySelectorAll('[role="dialog"],.modal,.modal-overlay').length;
|
||||||
var focused = document.activeElement && document.activeElement.tagName;
|
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 =
|
box.textContent =
|
||||||
'win ' + window.innerWidth + 'x' + window.innerHeight + '\n' +
|
'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' +
|
'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' +
|
'root ' + f(document.getElementById('root')) + '\n' +
|
||||||
'board ' + f(document.getElementById('board')) + '\n' +
|
'board ' + f(document.getElementById('board')) + '\n' +
|
||||||
'mNav ' + navInfo + '\n' +
|
'mNav ' + navInfo + '\n' +
|
||||||
|
'mNavR ' + dbg + '\n' +
|
||||||
|
'pcCls ' + pcCls + '\n' +
|
||||||
'mq768 ' + window.matchMedia('(max-width: 768px)').matches + '\n' +
|
'mq768 ' + window.matchMedia('(max-width: 768px)').matches + '\n' +
|
||||||
'dialogs ' + dialogs + ' focus ' + focused + '\n' +
|
'dialogs ' + dialogs + ' focus ' + focused + '\n' +
|
||||||
'sx ' + window.scrollX + ' sy ' + window.scrollY + '\n' +
|
'sx ' + window.scrollX + ' sy ' + window.scrollY + '\n' +
|
||||||
|
|||||||
@@ -382,24 +382,6 @@ describe("POST /tasks/:id/retry", () => {
|
|||||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
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 () => {
|
it("retries a stuck-killed task and moves it to todo", async () => {
|
||||||
const stuckTask = { ...FAKE_TASK_DETAIL, status: "stuck-killed", column: "in-progress" };
|
const stuckTask = { ...FAKE_TASK_DETAIL, status: "stuck-killed", column: "in-progress" };
|
||||||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
|
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 () => {
|
it("retries zero-step merge-failed in-review task with prior merge attempts by staying in-review", async () => {
|
||||||
const mergeFailedTask = {
|
const mergeFailedTask = {
|
||||||
...FAKE_TASK_DETAIL,
|
...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");
|
.filter((value: unknown) => typeof value === "number");
|
||||||
expect(increments).toEqual([1, 2, 3]);
|
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 () => {
|
it("emits ghost-review no-action when triple proof fails", async () => {
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, autoMerge: true, taskStuckTimeoutMs: 1_000 } as any),
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.recoverGhostReviewTasks();
|
const recovered = await manager.recoverGhostReviewTasks();
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ export class InProcessRuntime
|
|||||||
getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? new Set<string>(),
|
getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? new Set<string>(),
|
||||||
evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? new Set<string>(),
|
evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? new Set<string>(),
|
||||||
enqueueMerge: this.mergeEnqueuer ? (taskId: string) => this.mergeEnqueuer?.(taskId) ?? false : undefined,
|
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),
|
isTaskActive: (taskId: string) => this.executor.isTaskActive(taskId),
|
||||||
clearMergeActive: this.clearMergeActive ? (taskId: string) => this.clearMergeActive?.(taskId) : undefined,
|
clearMergeActive: this.clearMergeActive ? (taskId: string) => this.clearMergeActive?.(taskId) : undefined,
|
||||||
getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null,
|
getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null,
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export interface SelfHealingOptions {
|
|||||||
* the polling sweep's enqueue to silently no-op).
|
* the polling sweep's enqueue to silently no-op).
|
||||||
*/
|
*/
|
||||||
enqueueMerge?: (taskId: string) => boolean;
|
enqueueMerge?: (taskId: string) => boolean;
|
||||||
requeueForAutoMerge?: (taskId: string) => boolean | void | Promise<boolean | void>;
|
requeueForAutoMerge?: (taskId: string) => void | Promise<void>;
|
||||||
isTaskActive?: (taskId: string) => boolean;
|
isTaskActive?: (taskId: string) => boolean;
|
||||||
clearMergeActive?: (taskId: string) => void;
|
clearMergeActive?: (taskId: string) => void;
|
||||||
/**
|
/**
|
||||||
@@ -4842,9 +4842,6 @@ export class SelfHealingManager {
|
|||||||
!task.paused &&
|
!task.paused &&
|
||||||
!executingIds.has(task.id) &&
|
!executingIds.has(task.id) &&
|
||||||
!(task.status && GHOST_REVIEW_PRESERVED_STATUSES.has(task.status)) &&
|
!(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`).
|
// Confirmed merges belong in `done` (handled by `recoverMergedReviewTasks`).
|
||||||
task.mergeDetails?.mergeConfirmed !== true &&
|
task.mergeDetails?.mergeConfirmed !== true &&
|
||||||
now - new Date(task.columnMovedAt ?? task.updatedAt).getTime() >= timeoutMs
|
now - new Date(task.columnMovedAt ?? task.updatedAt).getTime() >= timeoutMs
|
||||||
@@ -5784,33 +5781,6 @@ export class SelfHealingManager {
|
|||||||
continue;
|
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, {
|
await this.store.updateTask(task.id, {
|
||||||
completionHandoffLimboRecoveryCount: currentCount + 1,
|
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");
|
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