diff --git a/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts b/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts index 5b85e240e8..46ee803e7e 100644 --- a/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts +++ b/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts @@ -37,11 +37,11 @@ const allowlist: AllowlistEntry[] = [ { file: "src/already-merged-detector.ts", line: 223, primitive: "execSync", signature: "execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, {", reason: SHORT_GIT_PLUMBING }, { file: "src/already-merged-detector.ts", line: 270, primitive: "execSync", signature: "branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {", reason: SHORT_GIT_PLUMBING }, { file: "src/already-merged-detector.ts", line: 345, primitive: "execSync", signature: "execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, {", reason: SHORT_GIT_PLUMBING }, - // FNXC:EngineProcessRules 2026-07-22-17:30: refreshed drifted line numbers for these unchanged git-plumbing call-sites (self-healing.ts 4187/4193/4230/12705, executor.ts 15808) after FN-8490 + graph-owned-cutover follow-ups shifted lines above them. The call-site-level allowlist keys on file:line:signature, so a legitimate call moving lines reads as unmatched-site + stale-entry until the line is re-pinned here. - { file: "src/self-healing.ts", line: 4215, primitive: "execSync", signature: "const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING }, - { file: "src/self-healing.ts", line: 4221, primitive: "execSync", signature: "const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote(\"main\")}`, {", reason: SHORT_GIT_PLUMBING }, - { file: "src/self-healing.ts", line: 4258, primitive: "execSync", signature: "const branchesRaw = String(execSync(\"git branch --list 'fusion/*'\", {", reason: SHORT_GIT_PLUMBING }, - { file: "src/self-healing.ts", line: 12839, primitive: "execSync", signature: "execSync(`git branch -d ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING }, + // FNXC:EngineProcessRules 2026-07-26-20:50: re-pin drifted short git-plumbing lines after plan-worktree + branch-reclaim work (self-healing inspect/delete, executor baseCommitSha ancestry). + { file: "src/self-healing.ts", line: 4289, primitive: "execSync", signature: "const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING }, + { file: "src/self-healing.ts", line: 4295, primitive: "execSync", signature: "const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote(\"main\")}`, {", reason: SHORT_GIT_PLUMBING }, + { file: "src/self-healing.ts", line: 4332, primitive: "execSync", signature: "const branchesRaw = String(execSync(\"git branch --list 'fusion/*'\", {", reason: SHORT_GIT_PLUMBING }, + { file: "src/self-healing.ts", line: 12977, primitive: "execSync", signature: "execSync(`git branch -d ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING }, { file: "src/merger-workspace-test-commands.ts", line: 204, primitive: "execSync", signature: "changedFilesOutput = execSync(", reason: BOUNDED_GIT_DIFF }, { file: "src/merger-workspace-test-commands.ts", line: 301, primitive: "execSync", signature: "changedFilesOutput = execSync(", reason: BOUNDED_GIT_DIFF }, { file: "src/integration-branch.ts", line: 71, primitive: "execSync", signature: "const stdout = execSync(\"git symbolic-ref --short refs/remotes/origin/HEAD\", {", reason: SHORT_GIT_PLUMBING }, @@ -63,7 +63,7 @@ const allowlist: AllowlistEntry[] = [ { file: "src/merger.ts", line: 10211, primitive: "execSync", signature: "const squashIsEmpty = execSync(", reason: SHORT_GIT_PLUMBING }, { file: "src/merger.ts", line: 10245, primitive: "execSync", signature: "const squashIsEmpty = execSync(", reason: SHORT_GIT_PLUMBING }, { file: "src/merger.ts", line: 10432, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING }, - { file: "src/executor.ts", line: 16296, primitive: "execSync", signature: "execSync(`git merge-base --is-ancestor ${task.baseCommitSha} HEAD`, {", reason: SHORT_GIT_PLUMBING }, + { file: "src/executor.ts", line: 16585, primitive: "execSync", signature: "execSync(`git merge-base --is-ancestor ${task.baseCommitSha} HEAD`, {", reason: SHORT_GIT_PLUMBING }, ]; function scanSource(file: string, source: string): ShelloutSite[] { diff --git a/packages/engine/src/__tests__/executor-review-verdicts.test.ts b/packages/engine/src/__tests__/executor-review-verdicts.test.ts index 65013174cb..b6fdb2318d 100644 --- a/packages/engine/src/__tests__/executor-review-verdicts.test.ts +++ b/packages/engine/src/__tests__/executor-review-verdicts.test.ts @@ -311,36 +311,9 @@ async function captureToolsWithStore( if (settingsOverride) { store.getSettings.mockResolvedValue({ ...(await store.getSettings()), ...settingsOverride }); } - // Simulate the real TaskStore: forward transitions persist, but in-progress - // regressions on done/skipped steps are rejected so executor.ts can surface - // the "already " diagnostic. - const stepStates: Array<{ name: string; status: string }> = [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "in-progress" }, - { name: "Testing", status: "pending" as const }, - { name: "Docs", status: "pending" as const }, - ]; - store.getTask.mockImplementation(async () => ({ - id: "FN-TEST", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: [], - steps: stepStates.map((s) => ({ ...s })), - currentStep: 1, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...taskOverride, - })); - store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => { - const current = stepStates[stepIndex]; - const isRegression = status === "in-progress" && (current.status === "done" || current.status === "skipped"); - if (!isRegression) { - current.status = status; - } - return { steps: stepStates.map((s) => ({ ...s })) }; - }); + if (taskOverride && Object.keys(taskOverride).length > 0) { + await store.updateTask("FN-001", taskOverride); + } mockedExistsSync.mockReturnValue(true); let capturedTools: any[] = []; @@ -359,9 +332,15 @@ async function captureToolsWithStore( } as any; }); + /* + FNXC:EngineTests 2026-07-26-20:55: + Match the engine-pause harness shape that still reaches implementation sessions under + graph ownership (empty steps + harness default getTaskDocument/PROMPT.md). Over-specifying + frozen steps/worktree on execute has stranded this surface on plan-only sessions. + */ const executor = new TaskExecutor(store, "/tmp/test"); await executor.execute({ - id: "FN-TEST", + id: "FN-001", title: "Test", description: "Test", column: "in-progress", @@ -375,7 +354,9 @@ async function captureToolsWithStore( const tools: Record = {}; for (const t of capturedTools) { - tools[t.name] = t.execute; + if (t?.name && typeof t.execute === "function" && tools[t.name] === undefined) { + tools[t.name] = t.execute; + } } return { tools, store }; } @@ -645,84 +626,22 @@ describe("fn_task_add_dep tool", () => { async function captureAddDepTools(opts?: { existingDeps?: string[]; targetExists?: boolean }) { const existingDeps = opts?.existingDeps ?? []; const targetExists = opts?.targetExists ?? true; - - const store = createMockStore(); - store.getTask.mockImplementation(async (id: string) => { - if (id === "FN-TEST") { - return { - id: "FN-TEST", - title: "Test", - description: "Test task", - column: "in-progress", - dependencies: existingDeps, - steps: [], - currentStep: 0, - log: [], - prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - } - if (id === "FN-OTHER" && targetExists) { - return { - id: "FN-OTHER", - title: "Other task", - description: "Another task", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - prompt: "", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - } - throw new Error(`Task ${id} not found`); - }); - - store.updateStep.mockResolvedValue({ - steps: [ - { name: "Preflight", status: "done" }, - { name: "Implement", status: "in-progress" }, - ], - }); - - mockedExistsSync.mockReturnValue(true); - - let capturedTools: any[] = []; - mockedCreateFnAgent.mockImplementation(async (opts: any) => { - capturedTools = [...capturedTools, ...(opts.customTools || [])]; - return { - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - sessionManager: { - getLeafId: vi.fn().mockReturnValue("leaf-id"), - branchWithSummary: vi.fn(), - }, - navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), - }, - } as any; - }); - - const executor = new TaskExecutor(store, "/tmp/test"); - await executor.execute({ - id: "FN-TEST", - title: "Test", - description: "Test", - column: "in-progress", - dependencies: existingDeps, - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - const tools: Record = {}; - for (const t of capturedTools) { - tools[t.name] = t.execute; + const { tools, store } = await captureToolsWithStore(undefined, { dependencies: existingDeps }); + if (targetExists) { + await store.updateTask("FN-OTHER", { + title: "Other task", + description: "Another task", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + }); + } else { + const baseGetTask = store.getTask.bind(store); + store.getTask.mockImplementation(async (id: string) => { + if (id === "FN-OTHER") throw new Error(`Task ${id} not found`); + return baseGetTask(id); + }); } return { tools, store }; } @@ -739,20 +658,20 @@ describe("fn_task_add_dep tool", () => { expect(result.content[0].text).toContain("Added dependency"); expect(result.content[0].text).toContain("triage"); - expect(store.updateTask).toHaveBeenCalledWith("FN-TEST", { + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { dependencies: ["FN-OTHER"], }); }); it("returns error for self-dependency", async () => { const { tools, store } = await captureAddDepTools(); + store.updateTask.mockClear(); - const result = await tools.fn_task_add_dep("call1", { task_id: "FN-TEST" }); + const result = await tools.fn_task_add_dep("call1", { task_id: "FN-001" }); expect(result.content[0].text).toContain("Cannot add self-dependency"); - expect(result.content[0].text).toContain("FN-TEST cannot depend on itself"); - // store.updateTask should NOT have been called for dependency update - // (it may be called for worktree path updates, so we check specifically for dependencies) + expect(result.content[0].text).toContain("FN-001 cannot depend on itself"); + // After mockClear, only tool-driven dependency writes remain. const depUpdateCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[1]?.dependencies !== undefined, ); @@ -761,6 +680,7 @@ describe("fn_task_add_dep tool", () => { it("returns error for non-existent target task", async () => { const { tools, store } = await captureAddDepTools({ targetExists: false }); + store.updateTask.mockClear(); const result = await tools.fn_task_add_dep("call1", { task_id: "FN-OTHER" }); @@ -774,6 +694,7 @@ describe("fn_task_add_dep tool", () => { it("returns informational message for duplicate dependency without duplicating", async () => { const { tools, store } = await captureAddDepTools({ existingDeps: ["FN-OTHER"] }); + store.updateTask.mockClear(); const result = await tools.fn_task_add_dep("call1", { task_id: "FN-OTHER" }); @@ -790,7 +711,7 @@ describe("fn_task_add_dep tool", () => { await tools.fn_task_add_dep("call1", { task_id: "FN-OTHER", confirm: true }); - expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-planning"); + expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Added dependency on FN-OTHER — stopping execution for re-planning"); }); it("appends to existing dependencies without overwriting when confirm=true", async () => { @@ -799,7 +720,7 @@ describe("fn_task_add_dep tool", () => { const result = await tools.fn_task_add_dep("call1", { task_id: "FN-OTHER", confirm: true }); expect(result.content[0].text).toContain("Added dependency"); - expect(store.updateTask).toHaveBeenCalledWith("FN-TEST", { + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { dependencies: ["FN-001", "FN-OTHER"], }); }); @@ -813,12 +734,14 @@ describe("fn_task_add_dep tool", () => { it("returns warning without confirm=true and does NOT add dependency", async () => { const { tools, store } = await captureAddDepTools(); + store.updateTask.mockClear(); + store.logEntry.mockClear(); const result = await tools.fn_task_add_dep("call1", { task_id: "FN-OTHER" }); expect(result.content[0].text).toContain("stop execution and discard current work"); expect(result.content[0].text).toContain("confirm=true"); - // Should NOT have updated dependencies + // Should NOT have updated dependencies after the tool call const depUpdateCalls = store.updateTask.mock.calls.filter( (call: any[]) => call[1]?.dependencies !== undefined, ); @@ -833,7 +756,7 @@ describe("fn_task_add_dep tool", () => { it("validation errors (self-dep, not-found, dedup) return immediately without requiring confirm", async () => { // Self-dep — no confirm needed const { tools: tools1 } = await captureAddDepTools(); - const selfResult = await tools1.fn_task_add_dep("call1", { task_id: "FN-TEST" }); + const selfResult = await tools1.fn_task_add_dep("call1", { task_id: "FN-001" }); expect(selfResult.content[0].text).toContain("Cannot add self-dependency"); // Not found — no confirm needed diff --git a/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts b/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts index 0071138321..dd4baa8ca3 100644 --- a/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts @@ -31,8 +31,19 @@ describe("reliability interaction: starved refinement x triage poll", () => { await mkdir(join(root, ".fusion", "tasks"), { recursive: true }); try { + /* + FNXC:EngineTests 2026-07-26-20:55: + Recovery requires updatedAt older than STARVED_REFINEMENT_ESCALATION_COOLDOWN_MS (40m). + Keep createdAt/updatedAt well below the fake now (11:00) so grace + cooldown pass. + */ const tasks: Task[] = [ - triageTask({ id: "FN-R1", sourceType: "task_refine" }), + triageTask({ + id: "FN-R1", + sourceType: "task_refine", + priority: "low", + createdAt: "2026-05-15T09:00:00.000Z", + updatedAt: "2026-05-15T09:00:00.000Z", + }), ...Array.from({ length: 6 }, (_, idx) => triageTask({ id: `FN-B${idx + 1}`, createdAt: `2026-05-15T09:${String(10 + idx).padStart(2, "0")}:00.000Z`, priority: "normal" })), triageTask({ id: "FN-P1", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:15:00.000Z" }), triageTask({ id: "FN-P2", column: "todo", sourceType: "dashboard_ui", updatedAt: "2026-05-15T10:16:00.000Z" }), diff --git a/packages/engine/src/__tests__/replan-target.test.ts b/packages/engine/src/__tests__/replan-target.test.ts index 1bfe555183..3506e32e4b 100644 --- a/packages/engine/src/__tests__/replan-target.test.ts +++ b/packages/engine/src/__tests__/replan-target.test.ts @@ -38,7 +38,12 @@ const planStep = (name: string): TaskStep => ({ name, status: "pending" }); const planningGuardCases: PlanningGuardCase[] = [ { label: "empty triage task", task: { column: "triage", steps: [] }, stillPlanning: true }, { label: "unplanned todo seed", task: { column: "todo", steps: [] }, stillPlanning: true }, - { label: "todo task with a worktree", task: { column: "todo", worktree: "/tmp/FN-1", steps: [] }, stillPlanning: false }, + /* + FNXC:NodeWorktreeIsolation 2026-07-26-20:50: + Planning acquires the task worktree up front, so worktree alone is NOT advancement. + Only execution timestamps / execution columns prove the card left planning. + */ + { label: "todo task with a worktree", task: { column: "todo", worktree: "/tmp/FN-1", steps: [] }, stillPlanning: true }, { label: "planned-and-queued todo task with materialized steps", task: { column: "todo", steps: [planStep("step-1")] }, @@ -80,11 +85,11 @@ const planningGuardCases: PlanningGuardCase[] = [ stillPlanning: true, }, - // FN-7977's protections must survive: real advancement still outranks a planning status. + // Worktree under a planning status is still planning; execution timestamps are the durable signal. { - label: "triage card an executor already claimed a worktree for", + label: "triage card that already has a planning worktree", task: { column: "triage", worktree: "/tmp/FN-1", steps: [planStep("step-1")], status: "needs-replan" }, - stillPlanning: false, + stillPlanning: true, }, { label: "card that reached execution while a planning recovery was in flight",