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:
@@ -191,6 +191,58 @@ export class WorktreePool {
|
||||
// Remove untracked files (but not .gitignore'd build caches)
|
||||
await execAsync("git clean -fd", { cwd: worktreePath });
|
||||
|
||||
// If the target branch already exists in the repo, check it out as-is so
|
||||
// we preserve prior commits (resume path). Do NOT force-reset with -B.
|
||||
let branchExists = false;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd: worktreePath });
|
||||
branchExists = true;
|
||||
} catch {
|
||||
// Branch does not exist — will be created below
|
||||
}
|
||||
|
||||
if (branchExists) {
|
||||
// Resume path: switch to the existing branch without destroying its history.
|
||||
try {
|
||||
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
|
||||
return branchName;
|
||||
} catch (err: unknown) {
|
||||
const execError = err instanceof Error ? err : new Error(String(err));
|
||||
const stderr = "stderr" in execError && typeof execError.stderr === "string"
|
||||
? execError.stderr.toString()
|
||||
: execError.message;
|
||||
const match = stderr.match(/already used by worktree at '([^']+)'/);
|
||||
if (!match) {
|
||||
throw err;
|
||||
}
|
||||
const conflictingPath = match[1];
|
||||
if (!existsSync(conflictingPath)) {
|
||||
await execAsync("git worktree prune", { cwd: worktreePath });
|
||||
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
|
||||
return branchName;
|
||||
}
|
||||
// Another live worktree has this branch — create a suffixed one from the same tip
|
||||
for (let suffix = 2; suffix <= 6; suffix++) {
|
||||
const suffixedName = `${branchName}-${suffix}`;
|
||||
try {
|
||||
await execAsync(`git checkout -B "${suffixedName}" "${branchName}"`, { cwd: worktreePath });
|
||||
return suffixedName;
|
||||
} catch (suffixErr: unknown) {
|
||||
const suffixExecError = suffixErr instanceof Error ? suffixErr : new Error(String(suffixErr));
|
||||
const suffixStderr = "stderr" in suffixExecError && typeof suffixExecError.stderr === "string"
|
||||
? suffixExecError.stderr.toString()
|
||||
: "";
|
||||
if (!suffixStderr.includes("already used by worktree")) {
|
||||
throw suffixErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Cannot check out existing branch for task: "${branchName}" and suffixes -2 through -6 are all in use by other worktrees`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const base = startPoint || "main";
|
||||
await execAsync(`git checkout --detach ${base}`, {
|
||||
cwd: worktreePath,
|
||||
|
||||
Reference in New Issue
Block a user