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:
gsxdsm
2026-05-22 09:37:23 -07:00
parent d3dff94424
commit 16b18bf338
12 changed files with 66 additions and 294 deletions

View File

@@ -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)

View File

@@ -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", () => {

View File

@@ -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();
});
});

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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' +

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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();

View File

@@ -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,

View File

@@ -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}`);
}
}
}