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:
gsxdsm
2026-04-30 10:19:54 -07:00
parent f11860619f
commit e7c86aa08f
13 changed files with 1441 additions and 7 deletions

View File

@@ -0,0 +1,215 @@
import { describe, it, expect } from "vitest";
import { detectPseudoPause, type PseudoPauseResult } from "../executor.js";
describe("detectPseudoPause", () => {
describe("returns 'none' for empty/whitespace", () => {
it("empty string", () => {
const result = detectPseudoPause("");
expect(result).toEqual({ kind: "none" });
});
it("whitespace only", () => {
const result = detectPseudoPause(" \n\t ");
expect(result).toEqual({ kind: "none" });
});
});
describe("returns 'none' for normal short prose", () => {
it("normal short text without question", () => {
const result = detectPseudoPause("This is just a normal statement about the work.");
expect(result).toEqual({ kind: "none" });
});
it("short text ending with question", () => {
const result = detectPseudoPause("Do you want this?");
expect(result).toEqual({ kind: "none" });
});
it("text under 200 chars ending with question", () => {
const text = "a".repeat(150) + "?";
const result = detectPseudoPause(text);
expect(result).toEqual({ kind: "none" });
});
});
describe("detects regex patterns", () => {
it('detects "if you want" pattern (regex match 1)', () => {
const result = detectPseudoPause(
"I've completed the first part. If you want, I can continue with the next section."
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("if you want");
});
it('detects "should I continue" pattern (regex match 2)', () => {
const result = detectPseudoPause(
"The basic setup is done. Should I continue with the implementation?"
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("should");
expect(result.matched?.toLowerCase()).toContain("continue");
});
it('detects "let me know" pattern (regex match 3)', () => {
const result = detectPseudoPause(
"I've fixed the main issue. Let me know if you want me to run tests."
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("let me know");
});
it('detects "want me to continue" pattern (regex match 4)', () => {
const result = detectPseudoPause(
"The framework is set up. Do you want me to continue with the API routes?"
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("want");
});
it('detects "ready to proceed" pattern (regex match 5)', () => {
const result = detectPseudoPause(
"Configuration is complete. Ready to proceed with testing?"
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("ready");
});
it('detects "shall I" pattern (regex match 6)', () => {
const result = detectPseudoPause(
"The server is running properly. Shall I deploy it now?"
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("shall");
});
it('detects "awaiting approval" pattern (regex match 7)', () => {
const result = detectPseudoPause(
"All changes have been made according to spec. Awaiting your approval to merge."
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("awaiting");
});
it("handles case-insensitive matching", () => {
const result = detectPseudoPause(
"The setup is done. IF YOU WANT, I can continue immediately."
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
});
it("returns matched snippet with surrounding context (~120 chars)", () => {
const result = detectPseudoPause(
"Lorem ipsum dolor sit amet. If you want, I can continue with the implementation. This is additional text."
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched!.length).toBeGreaterThan(10);
expect(result.matched!.length).toBeLessThanOrEqual(150);
expect(result.matched).toContain("If you want");
});
it("removes newlines from matched snippet", () => {
const result = detectPseudoPause(
"Some work done.\nIf you want,\nI can continue."
);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched).not.toContain("\n");
});
});
describe("detects structural pseudo-pauses (>200 chars)", () => {
it("long text ending with question mark", () => {
const text = "a".repeat(250) + "?";
const result = detectPseudoPause(text);
expect(result.kind).toBe("structural");
expect(result.matched).toBeDefined();
expect(result.matched).toContain("?");
});
it("long text ending with ## Notes heading", () => {
const text = "a".repeat(250) + "\n## Notes";
const result = detectPseudoPause(text);
expect(result.kind).toBe("structural");
expect(result.matched).toBeDefined();
});
it("long text ending with ## Next steps heading", () => {
const text = "a".repeat(250) + "\n## Next steps";
const result = detectPseudoPause(text);
expect(result.kind).toBe("structural");
expect(result.matched).toBeDefined();
});
it("long text ending with ### Next steps: line", () => {
const text = "a".repeat(250) + "\n### Next steps:";
const result = detectPseudoPause(text);
expect(result.kind).toBe("structural");
expect(result.matched).toBeDefined();
});
it("long text ending with plain 'Next steps:' text", () => {
const text = "a".repeat(250) + "\nNext steps:";
const result = detectPseudoPause(text);
expect(result.kind).toBe("structural");
expect(result.matched).toBeDefined();
});
it("returns 'none' for long normal narrative without question/heading", () => {
const text = "a".repeat(300);
const result = detectPseudoPause(text);
expect(result).toEqual({ kind: "none" });
});
});
describe("real-world regression test", () => {
it("detects FN-2978 pseudo-pause ending (if you want)", () => {
const fn2978Text = `If you want, I can continue immediately and finish Steps 49 (dashboard backend/frontend wiring, daemon/serve/dashboard/engine integration, full gates \`pnpm lint && pnpm test && pnpm build\`, and changeset/doc/memory finalization).`;
const result = detectPseudoPause(fn2978Text);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("if you want");
});
});
describe("edge cases", () => {
it("handles text with only question marks", () => {
const result = detectPseudoPause("???");
expect(result.kind).toBe("none");
});
it("handles text with mixed whitespace", () => {
const result = detectPseudoPause("\r\n \t\r\n");
expect(result).toEqual({ kind: "none" });
});
it("handles very long text with regex match", () => {
const text = "a".repeat(5000) + " If you want, I can continue. " + "b".repeat(5000);
const result = detectPseudoPause(text);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
});
it("handles multiple matching patterns (returns first regex match)", () => {
const text = "If you want to continue, should I proceed? Let me know.";
const result = detectPseudoPause(text);
expect(result.kind).toBe("regex");
expect(result.matched).toBeDefined();
expect(result.matched?.toLowerCase()).toContain("if you want");
});
it("prioritizes regex over structural detection", () => {
const text = "a".repeat(250) + " If you want to continue?\n## Next steps";
const result = detectPseudoPause(text);
expect(result.kind).toBe("regex");
});
});
});