feat(engine): preserve branches on auto-requeue + add fn_run_verification
Three coordinated fixes for the FN-2978 incident class — auto-requeues that orphaned committed work and watchdog kills on long verification runs. **Auto-requeue branch reuse** (executor.ts, worktree-pool.ts) - executor.ts:1782 now uses `task.branch || fusion/<id>` so persisted branches are honored on requeue. Previously the hardcoded fallback always tried to re-create the original branch, hit a conflict with the prior run's ref, and got suffix -2/-3. Other call sites already honor task.branch — this aligns the worktree-acquisition path. - worktree-pool.ts:181 prepareForTask now probes existing branches with `git rev-parse --verify` and checks them out as-is. Falls through to suffixed creation only when the branch is genuinely in use by another live worktree. Previously force-reset with `checkout -B`, destroying prior commits. - New private reconcileStepsFromGitHistory walks `git log baseCommitSha..HEAD` for `feat(FN-X): complete Step N` commits and marks matching steps[] as done so resumes don't redo committed work. **Manual reset endpoint + UI** (dashboard) - POST /api/tasks/:id/reset (requires `confirm: true`) — clears worktree, branch, all retry counters, resets steps[] to pending, moves to todo. Distinct from /retry which is the soft-resume path. - Reset button alongside Retry in TaskDetailModal with confirm dialog, wired through useTasks → AppModals → API. **fn_run_verification tool** (run-verification-tool.ts, executor.ts) - New custom tool wrapping test/lint/build commands with a heartbeat callback (per-line + 60s synthetic), 200KB head+tail output cap, hard timeout with SIGTERM→SIGKILL escalation, and auto-bootstrap detection for missing node_modules. Prevents the inactivity watchdog from killing sessions during long compiles. - Cross-platform via `shell: true` (Node picks /bin/sh on POSIX, cmd.exe on Windows). Prompt section in EXECUTOR_SYSTEM_PROMPT and EXECUTOR_PROMPT_TEXT instructs agents to prefer package-scoped verification first and reserve workspace-scoped runs for final integration. **Tests** (64 passing) - detect-pseudo-pause.test.ts (27 tests) — covers all 7 regex patterns, structural fallback, FN-2978 regression text. - reconcile-step-regex.test.ts (25 tests) — pins the commit-message regex against a wide variant set. - run-verification-command.test.ts (12 tests) — basic execution, output capture, heartbeat callbacks, timeout, error handling. POSIX-specific cases (multi-cmd `;`, `>&2`, `\$USER`) gated behind itPosix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -211,7 +211,7 @@ function AppInner() {
|
||||
// Tasks hook with project context and search query
|
||||
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
|
||||
const taskSseEnabled = taskView === "board" || taskView === "list";
|
||||
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
|
||||
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
|
||||
{
|
||||
...(currentProject ? { projectId: currentProject.id } : {}),
|
||||
searchQuery: searchQuery || undefined,
|
||||
@@ -275,10 +275,10 @@ function AppInner() {
|
||||
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const { keyboardOverlap } = useMobileKeyboard({ enabled: isMobile });
|
||||
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
// Keyboard visibility controls both MobileNavBar rendering and whether
|
||||
// the project content reserves bottom padding for the mobile nav bar.
|
||||
const mobileKeyboardOpen = isMobile && keyboardOverlap > 0;
|
||||
const mobileKeyboardOpen = isMobile && keyboardOpen;
|
||||
|
||||
// App-level mailbox unread count state (used for header/mobile nav badges)
|
||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||
@@ -1038,7 +1038,7 @@ function AppInner() {
|
||||
handleSubtaskTasksCreated,
|
||||
handleGitHubImport,
|
||||
}}
|
||||
taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, duplicateTask }}
|
||||
taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask }}
|
||||
deepLink={{ handleDetailClose }}
|
||||
settings={{ prAuthAvailable, themeMode, colorTheme, setThemeMode, setColorTheme }}
|
||||
onSettingsClose={() => {
|
||||
|
||||
@@ -378,6 +378,14 @@ export function retryTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function resetTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/reset`, projectId), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export function duplicateTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/duplicate`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ interface AppModalsProps {
|
||||
deleteTask: (taskId: string) => Promise<Task>;
|
||||
mergeTask: (taskId: string) => Promise<MergeResult>;
|
||||
retryTask: (taskId: string) => Promise<Task>;
|
||||
resetTask: (taskId: string) => Promise<Task>;
|
||||
duplicateTask: (taskId: string) => Promise<Task>;
|
||||
};
|
||||
deepLink: {
|
||||
@@ -164,6 +165,7 @@ export function AppModals({
|
||||
onDeleteTask={taskOperations.deleteTask}
|
||||
onMergeTask={taskOperations.mergeTask}
|
||||
onRetryTask={taskOperations.retryTask}
|
||||
onResetTask={taskOperations.resetTask}
|
||||
onDuplicateTask={taskOperations.duplicateTask}
|
||||
onTaskUpdated={modalManager.updateDetailTask}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -160,6 +160,7 @@ interface TaskDetailModalProps {
|
||||
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
@@ -294,6 +295,7 @@ export function TaskDetailModal({
|
||||
onDeleteTask,
|
||||
onMergeTask,
|
||||
onRetryTask,
|
||||
onResetTask,
|
||||
onDuplicateTask,
|
||||
onTaskUpdated,
|
||||
addToast,
|
||||
@@ -939,6 +941,19 @@ export function TaskDetailModal({
|
||||
});
|
||||
}, [task.id, onRetryTask, onClose, addToast]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (!onResetTask) return;
|
||||
if (!window.confirm(`This will erase all progress for ${task.id} and start the task from scratch. Continue?`)) return;
|
||||
onClose();
|
||||
onResetTask(task.id)
|
||||
.then(() => {
|
||||
addToast(`Reset ${task.id} — fresh run will be allocated`, "success");
|
||||
})
|
||||
.catch((err) => {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
});
|
||||
}, [task.id, onResetTask, onClose, addToast]);
|
||||
|
||||
const handleDuplicate = useCallback(async () => {
|
||||
if (!onDuplicateTask) return;
|
||||
const shouldDuplicate = await confirm({
|
||||
@@ -2294,6 +2309,17 @@ export function TaskDetailModal({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reset (nuclear) — wipes all progress and reallocates worktree */}
|
||||
{onResetTask && task.column !== "done" && task.column !== "archived" && (
|
||||
<button
|
||||
className="detail-actions-menu-item detail-actions-menu-item-danger"
|
||||
role="menuitem"
|
||||
onClick={() => handleActionsMenuItemClick(handleReset)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pause/Unpause */}
|
||||
{task.column !== "done" && (
|
||||
<button
|
||||
|
||||
@@ -348,6 +348,10 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return normalizeTask(await api.retryTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const resetTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.resetTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = normalizeTask(await api.duplicateTask(id, projectId));
|
||||
setTasks((prev) => {
|
||||
@@ -457,5 +461,5 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
|
||||
return { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
|
||||
}
|
||||
|
||||
@@ -323,6 +323,58 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
// Nuclear reset — erase all progress and allocate a fresh worktree+branch on next run
|
||||
router.post("/tasks/:id/reset", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
|
||||
const { confirm: confirmed } = (req.body ?? {}) as { confirm?: boolean };
|
||||
if (!confirmed) {
|
||||
throw badRequest(
|
||||
"This operation is destructive and will erase all task progress. Pass { \"confirm\": true } in the request body to proceed.",
|
||||
);
|
||||
}
|
||||
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
|
||||
// Reset all steps to pending
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
if (task.steps[i].status !== "pending") {
|
||||
await scopedStore.updateStep(req.params.id, i, "pending");
|
||||
}
|
||||
}
|
||||
|
||||
await scopedStore.updateTask(req.params.id, {
|
||||
worktree: null,
|
||||
branch: null,
|
||||
currentStep: 0,
|
||||
status: null,
|
||||
error: null,
|
||||
stuckKillCount: 0,
|
||||
taskDoneRetryCount: null,
|
||||
workflowStepRetries: undefined,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
postReviewFixCount: 0,
|
||||
verificationFailureCount: 0,
|
||||
mergeConflictBounceCount: 0,
|
||||
});
|
||||
|
||||
await scopedStore.logEntry(
|
||||
req.params.id,
|
||||
"Task reset by user — all progress cleared, fresh worktree and branch will be allocated",
|
||||
);
|
||||
|
||||
const updated = await scopedStore.moveTask(req.params.id, "todo");
|
||||
res.json(updated);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Duplicate task
|
||||
router.post("/tasks/:id/duplicate", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user