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:
215
packages/engine/src/__tests__/detect-pseudo-pause.test.ts
Normal file
215
packages/engine/src/__tests__/detect-pseudo-pause.test.ts
Normal 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 4–9 (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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user