From abbeaec0a83e88935610fc9b0428ef5c80b7f4b0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 17:58:41 -0700 Subject: [PATCH 01/46] FN-5897: surface linked goals in mission read paths Expose mission-linked goals across mission detail surfaces. - load linked goal records into mission hierarchy reads in core and return them from the mission detail API - show linked goals in mission detail views, add goal-chip navigation, and anchor highlighted goal cards in GoalsView - extend CLI mission output, docs, tests, and add a published changeset for the new read-path support Files changed: .changeset/fn-5897-linked-goals-read-paths.md | 5 +++ docs/missions.md | 6 ++- packages/cli/src/__tests__/extension.test.ts | 48 +++++++++++++++++++++- packages/cli/src/extension.ts | 10 +++++ packages/core/src/__tests__/mission-store.test.ts | 14 +++++++ packages/core/src/mission-store.ts | 29 +++++++++++++ packages/core/src/mission-types.ts | 4 ++ packages/dashboard/app/App.tsx | 16 +++++++- packages/dashboard/app/components/GoalsView.css | 6 +++ packages/dashboard/app/components/GoalsView.tsx | 42 +++++++++++++++++-- packages/dashboard/app/components/MissionManager.css | 44 ++++++++++++++++++++ packages/dashboard/app/components/MissionManager.tsx | 31 +++++++++++++- packages/dashboard/app/components/__tests__/GoalsView.test.tsx | 31 ++++++++++++++ packages/dashboard/app/components/__tests__/MissionManager.test.tsx | 45 ++++++++++++++++++++ packages/dashboard/app/components/mission-types.ts | 2 + packages/dashboard/src/__tests__/mission-e2e.test.ts | 4 ++ packages/dashboard/src/mission-routes.ts | 5 ++- 17 files changed, 332 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-5897 Fusion-Task-Lineage: 5176270d-9885-447a-845b-4e0d69d531a0 --- .changeset/fn-5897-linked-goals-read-paths.md | 5 ++ docs/missions.md | 6 ++- packages/cli/src/__tests__/extension.test.ts | 48 ++++++++++++++++++- packages/cli/src/extension.ts | 10 ++++ .../core/src/__tests__/mission-store.test.ts | 14 ++++++ packages/core/src/mission-store.ts | 29 +++++++++++ packages/core/src/mission-types.ts | 4 ++ packages/dashboard/app/App.tsx | 16 ++++++- .../dashboard/app/components/GoalsView.css | 6 +++ .../dashboard/app/components/GoalsView.tsx | 42 ++++++++++++++-- .../app/components/MissionManager.css | 44 +++++++++++++++++ .../app/components/MissionManager.tsx | 31 +++++++++++- .../components/__tests__/GoalsView.test.tsx | 31 ++++++++++++ .../__tests__/MissionManager.test.tsx | 45 +++++++++++++++++ .../dashboard/app/components/mission-types.ts | 2 + .../src/__tests__/mission-e2e.test.ts | 4 ++ packages/dashboard/src/mission-routes.ts | 5 +- 17 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-5897-linked-goals-read-paths.md diff --git a/.changeset/fn-5897-linked-goals-read-paths.md b/.changeset/fn-5897-linked-goals-read-paths.md new file mode 100644 index 0000000000..ba3976b90f --- /dev/null +++ b/.changeset/fn-5897-linked-goals-read-paths.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Surface mission-linked goals across mission read paths, including `fn_mission_show`, mission detail API payloads, and dashboard mission detail navigation into anchored goal cards. diff --git a/docs/missions.md b/docs/missions.md index b588775e02..dac4290014 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -107,18 +107,20 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e | Endpoint | Purpose | |---|---| +| `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission. | | `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. | | `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. | | `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. | | `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. | -All four endpoints validate mission/goal identifier formats and return `404` for missing mission/goal rows. +The mission detail payload keeps `linkedGoals` separate from the milestone tree so read paths can surface strategy context without traversing slices/features. All five endpoints validate mission/goal identifier formats and return `404` for missing mission/goal rows. ### CLI - `fn mission goals ` — list linked goals for a mission. - `fn mission link-goal ` — idempotently link a goal. - `fn mission unlink-goal ` — idempotently unlink a goal. +- Mission detail screens in the dashboard render linked-goal chips in the mission header; selecting a chip opens the Goals view and scrolls/highlights the anchored goal card. ## Mission Planning Tools (pi extension) @@ -128,7 +130,7 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r |---|---| | `fn_mission_create` | Create a mission with title/description, optional `baseBranch`, and optional auto-advance behavior. | | `fn_mission_list` | List missions and their current status. | -| `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including milestone/feature acceptance criteria and slice verification when present. | +| `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including a **Linked Goals** section plus milestone/feature acceptance criteria and slice verification when present. | | `fn_mission_list_goals` | List the goals linked to a mission. | | `fn_mission_link_goal` | Idempotently link a goal to a mission. | | `fn_mission_unlink_goal` | Idempotently unlink a goal from a mission. | diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 1f448db735..2b4f7fd380 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -1132,9 +1132,10 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega }); describe("fn_mission_show", () => { - it("returns mission with hierarchy", async () => { - // Create mission + it("returns mission with hierarchy and linked goals", async () => { const createTool = api.tools.get("fn_mission_create")!; + const goalTool = api.tools.get("fn_goal_create")!; + const linkTool = api.tools.get("fn_mission_link_goal")!; const created = await createTool.execute( "c1", { title: "Test Mission" }, @@ -1142,6 +1143,20 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega undefined, makeCtx(tmpDir), ); + const goal = await goalTool.execute( + "g1", + { title: "Connect mission work to goals" }, + undefined, + undefined, + makeCtx(tmpDir), + ); + await linkTool.execute( + "link-1", + { missionId: created.details.missionId, goalId: goal.details.goalId }, + undefined, + undefined, + makeCtx(tmpDir), + ); const showTool = api.tools.get("fn_mission_show")!; const result = await showTool.execute( @@ -1154,6 +1169,11 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega expect(result.details.mission).toBeDefined(); expect(result.content[0].text).toContain("Test Mission"); + expect(result.content[0].text).toContain("Linked Goals:"); + expect(result.content[0].text).toContain(`- ${goal.details.goalId}: Connect mission work to goals`); + expect(result.details.mission.linkedGoals).toEqual([ + expect.objectContaining({ id: goal.details.goalId, title: "Connect mission work to goals" }), + ]); }); it("renders acceptanceCriteria / verification for milestones, slices, and features", async () => { @@ -1212,6 +1232,30 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega expect(result.details.mission.milestones[0].acceptanceCriteria).toBe(longValue); }); + it("renders an empty linked goals state when no goals are linked", async () => { + const createTool = api.tools.get("fn_mission_create")!; + const created = await createTool.execute( + "c1", + { title: "Mission Without Goals" }, + undefined, + undefined, + makeCtx(tmpDir), + ); + + const showTool = api.tools.get("fn_mission_show")!; + const result = await showTool.execute( + "call-1", + { id: created.details.missionId }, + undefined, + undefined, + makeCtx(tmpDir), + ); + + expect(result.content[0].text).toContain("Linked Goals:"); + expect(result.content[0].text).toContain("No linked goals."); + expect(result.details.mission.linkedGoals).toEqual([]); + }); + it("returns error when mission not found", async () => { const showTool = api.tools.get("fn_mission_show")!; const result = await showTool.execute( diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 680c51583a..7e5f5476b7 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -2644,6 +2644,16 @@ export default function kbExtension(pi: ExtensionAPI) { } lines.push(""); + lines.push("Linked Goals:"); + if ((mission.linkedGoals?.length ?? 0) === 0) { + lines.push("No linked goals."); + } else { + for (const goal of mission.linkedGoals ?? []) { + lines.push(`- ${goal.id}: ${goal.title}`); + } + } + lines.push(""); + if (mission.milestones.length === 0) { lines.push("No milestones yet."); } else { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 9c1cf0dc90..e095256d11 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js"; +import { GoalStore } from "../goal-store.js"; import { Database } from "../db.js"; import type { MissionFeature } from "../mission-types.js"; import { mkdtempSync } from "node:fs"; @@ -30,6 +31,7 @@ describe("MissionStore", () => { let fusionDir: string; let db: Database; let store: MissionStore; + let goalStore: GoalStore; beforeEach(() => { tmpDir = makeTmpDir(); @@ -40,6 +42,7 @@ describe("MissionStore", () => { db = new Database(fusionDir, { inMemory: true }); db.init(); store = new MissionStore(fusionDir, db); + goalStore = new GoalStore(fusionDir, db); }); afterEach(async () => { @@ -1838,6 +1841,8 @@ describe("MissionStore", () => { title: "Hierarchy Test", description: "Testing full tree loading", }); + const linkedGoal = goalStore.createGoal({ title: "Ship linked goal visibility" }); + store.linkGoal(mission.id, linkedGoal.id); const m1 = store.addMilestone(mission.id, { title: "Milestone 1" }); const m2 = store.addMilestone(mission.id, { title: "Milestone 2" }); const s1 = store.addSlice(m1.id, { title: "Slice 1" }); @@ -1849,6 +1854,7 @@ describe("MissionStore", () => { expect(withHierarchy.id).toBe(mission.id); expect(withHierarchy.title).toBe("Hierarchy Test"); + expect(withHierarchy.linkedGoals).toEqual([linkedGoal]); expect(withHierarchy.milestones).toHaveLength(2); const m1Data = withHierarchy.milestones.find((m) => m.id === m1.id)!; @@ -1859,6 +1865,14 @@ describe("MissionStore", () => { expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f1.id)).toBeDefined(); expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f2.id)).toBeDefined(); }); + + it("returns an empty linkedGoals array when no goals are linked", () => { + const mission = store.createMission({ title: "Hierarchy without goals" }); + + const withHierarchy = store.getMissionWithHierarchy(mission.id)!; + + expect(withHierarchy.linkedGoals).toEqual([]); + }); }); // ── Transaction Tests ──────────────────────────────────────────────── diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 96e1fbf992..f7baece06b 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -14,6 +14,7 @@ import { EventEmitter } from "node:events"; import type { Database } from "./db.js"; import { fromJson, toJson, toJsonNullable } from "./db.js"; +import type { Goal, GoalStatus } from "./goal-types.js"; import type { Mission, MissionBranchStrategy, @@ -261,6 +262,15 @@ interface MissionGoalRow { createdAt: string; } +interface GoalRow { + id: string; + title: string; + description: string | null; + status: GoalStatus; + createdAt: string; + updatedAt: string; +} + /** Database row shape for the mission_contract_assertions table. */ interface AssertionRow { id: string; @@ -462,6 +472,17 @@ export class MissionStore extends EventEmitter { }; } + private rowToGoal(row: GoalRow): Goal { + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + /** * Convert a database row to a MissionContractAssertion object. */ @@ -678,6 +699,13 @@ export class MissionStore extends EventEmitter { const mission = this.getMission(id); if (!mission) return undefined; + const linkedGoals = this.listGoalIdsForMission(id) + .map((goalId) => this.db + .prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?") + .get(goalId) as GoalRow | undefined) + .filter((row): row is GoalRow => Boolean(row)) + .map((row) => this.rowToGoal(row)); + const milestones = this.listMilestones(id); const milestonesWithSlices = milestones.map((milestone) => { const slices = this.listSlices(milestone.id); @@ -693,6 +721,7 @@ export class MissionStore extends EventEmitter { return { ...mission, + linkedGoals, milestones: milestonesWithSlices, }; } diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index 80e801932c..ca02308d2f 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -9,6 +9,8 @@ * The hierarchy: Mission → Milestone → Slice → Feature → (optional) Task */ +import type { Goal } from "./goal-types.js"; + // ── Status Enums ───────────────────────────────────────────────────── /** Status values for a Mission's lifecycle */ @@ -457,6 +459,8 @@ export interface SliceWithFeatures extends Slice { * Mission → Milestones → Slices → Features */ export interface MissionWithHierarchy extends Mission { + /** Goals linked to this mission */ + linkedGoals?: Goal[]; /** Milestones belonging to this mission, each with their slices */ milestones: Array(undefined); const [missionTargetId, setMissionTargetId] = useState(undefined); + const [goalAnchorId, setGoalAnchorId] = useState(undefined); const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState(undefined); + + useEffect(() => { + if (taskView !== "goalsView" && goalAnchorId !== undefined) { + setGoalAnchorId(undefined); + } + }, [goalAnchorId, taskView]); const [quickChatOpen, setQuickChatOpen] = useState(false); const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false); const [dashboardHealth, setDashboardHealth] = useState(null); @@ -1462,6 +1472,10 @@ function AppInner() { targetMissionId={missionTargetId} milestoneSliceResumeSessionId={milestoneSliceResumeSessionId} onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)} + onNavigateToGoal={(goalId) => { + setGoalAnchorId(goalId); + handleChangeTaskView("goalsView"); + }} /> ); @@ -1593,7 +1607,7 @@ function AppInner() { return ( - + ); diff --git a/packages/dashboard/app/components/GoalsView.css b/packages/dashboard/app/components/GoalsView.css index 558aca8b59..e2708b7183 100644 --- a/packages/dashboard/app/components/GoalsView.css +++ b/packages/dashboard/app/components/GoalsView.css @@ -98,6 +98,12 @@ align-items: center; justify-content: space-between; gap: var(--space-md); + scroll-margin-top: var(--space-xl); +} + +.goals-card--anchored { + border-color: var(--color-warning); + box-shadow: var(--focus-ring-strong); } .goals-card-archived { diff --git a/packages/dashboard/app/components/GoalsView.tsx b/packages/dashboard/app/components/GoalsView.tsx index fa67da278d..94500daa9a 100644 --- a/packages/dashboard/app/components/GoalsView.tsx +++ b/packages/dashboard/app/components/GoalsView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Goal } from "@fusion/core"; import { Plus, Sparkles } from "lucide-react"; import ReactMarkdown from "react-markdown"; @@ -8,6 +8,7 @@ import "./GoalsView.css"; export interface GoalsViewProps { initialGoals?: Goal[]; + anchorGoalId?: string; } const MAX_ACTIVE_GOALS = 5; @@ -20,8 +21,10 @@ function isCapError(payload: unknown): boolean { return Boolean(payload && typeof payload === "object" && "code" in payload && (payload as { code?: unknown }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED"); } -export function GoalsView({ initialGoals }: GoalsViewProps) { +export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) { const [goals, setGoals] = useState(() => initialGoals ?? []); + const [highlightedGoalId, setHighlightedGoalId] = useState(null); + const anchorTimeoutRef = useRef | null>(null); const [loading, setLoading] = useState(initialGoals === undefined); const [errorMessage, setErrorMessage] = useState(null); @@ -81,6 +84,38 @@ export function GoalsView({ initialGoals }: GoalsViewProps) { const activeCount = useMemo(() => goals.filter((goal) => goal.status === "active").length, [goals]); const showWarning = activeCount >= WARNING_THRESHOLD && activeCount <= MAX_ACTIVE_GOALS; + useEffect(() => { + if (!anchorGoalId) { + setHighlightedGoalId(null); + return; + } + + const target = document.getElementById(`goal-card-${anchorGoalId}`); + if (!target) { + return; + } + + setHighlightedGoalId(anchorGoalId); + if (typeof target.scrollIntoView === "function") { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + } + + if (anchorTimeoutRef.current) { + clearTimeout(anchorTimeoutRef.current); + } + anchorTimeoutRef.current = setTimeout(() => { + setHighlightedGoalId((current) => (current === anchorGoalId ? null : current)); + anchorTimeoutRef.current = null; + }, 1600); + + return () => { + if (anchorTimeoutRef.current) { + clearTimeout(anchorTimeoutRef.current); + anchorTimeoutRef.current = null; + } + }; + }, [anchorGoalId, goals]); + function openAddForm() { setErrorMessage(null); setAddError(null); @@ -359,7 +394,8 @@ export function GoalsView({ initialGoals }: GoalsViewProps) { {goals.map((goal) => (
{editGoalId === goal.id ? ( diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index 1c6362bd1a..8f731b1458 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -1068,6 +1068,45 @@ gap: var(--space-sm); } +.mission-detail__linked-goals { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding-top: var(--space-xs); +} + +.mission-detail__linked-goals-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.mission-detail__linked-goals-title { + margin: 0; + font-size: calc(var(--space-sm) + var(--space-xs)); + color: var(--text-muted); + font-weight: 600; +} + +.mission-detail__linked-goals-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.mission-detail__linked-goal-chip { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.mission-detail__linked-goals-empty { + margin: 0; + color: var(--text-muted); +} + .mission-detail__run-settings { display: flex; flex-direction: column; @@ -2545,6 +2584,11 @@ flex-wrap: wrap; } + .mission-detail__linked-goals-header, + .mission-detail__linked-goals-list { + align-items: stretch; + } + .mission-detail__run-help, .mission-list__item-run-help { max-width: 100%; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index d7559777bd..f4abf79ee6 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -123,6 +123,8 @@ interface MissionManagerProps { milestoneSliceResumeSessionId?: string; /** Called when milestone/slice resume session fetch fails */ onMilestoneSliceResumeFetchError?: () => void; + /** Navigate to the goals view anchored to a specific goal */ + onNavigateToGoal?: (goalId: string) => void; } // Status badge colors — use CSS custom-property-compatible tokens @@ -581,6 +583,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi return { ...mission, + linkedGoals: Array.isArray(mission.linkedGoals) ? mission.linkedGoals : [], milestones: mission.milestones.map((milestone) => { if (!Array.isArray(milestone.slices)) { throw new Error(`Malformed mission detail response: milestone ${milestone.id} is missing slices`); @@ -603,7 +606,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi }; } -export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError }: MissionManagerProps) { +export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) { const isActive = isInline || isOpen; const cacheSuffix = projectId ?? ""; const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`; @@ -2528,6 +2531,32 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr +
+
+

Linked Goals

+ + {selectedMission.linkedGoals?.length ?? 0} linked + +
+ {(selectedMission.linkedGoals?.length ?? 0) > 0 ? ( +
+ {(selectedMission.linkedGoals ?? []).map((goal) => ( + + ))} +
+ ) : ( +

No linked goals.

+ )} +
+

Mission run settings

{/* ── Autopilot section ── */} diff --git a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx index c83db00097..97a105012a 100644 --- a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx @@ -42,6 +42,37 @@ describe("GoalsView", () => { expect(screen.getByTestId("goals-empty-state")).toBeInTheDocument(); }); + it("anchors the matching goal card without requiring scrollIntoView", async () => { + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: undefined, + }); + + try { + render( + , + ); + + const anchoredCard = screen.getByTestId("goal-card-g2"); + expect(anchoredCard).toHaveAttribute("id", "goal-card-g2"); + await waitFor(() => { + expect(anchoredCard.className).toContain("goals-card--anchored"); + }); + } finally { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: originalScrollIntoView, + }); + } + }); + it("loads goals from API when initialGoals is not provided", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/dashboard/app/components/__tests__/MissionManager.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.test.tsx index a2a5490db9..609963cf94 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.test.tsx @@ -121,6 +121,7 @@ const mockMissionDetail = { title: "Build Auth System", description: "Complete authentication flow", status: "planning", + linkedGoals: [] as Array<{ id: string; title: string; status: "active" | "archived"; createdAt: string; updatedAt: string; description?: string }>, milestones: [ { id: "MS-001", @@ -1683,6 +1684,50 @@ describe("MissionManager", () => { }); }); + it("renders linked goal chips and invokes navigation handler", async () => { + const onNavigateToGoal = vi.fn(); + const missionDetailWithGoals = { + ...mockMissionDetail, + linkedGoals: [ + { + id: "G-001", + title: "Grow extension ecosystem", + status: "active" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }; + globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetailWithGoals); + + render(); + + await waitFor(() => { + expect(screen.getByText("Build Auth System")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText("Build Auth System")); + + const chip = await screen.findByTestId("mission-linked-goal-chip-G-001"); + expect(chip).toHaveTextContent("Grow extension ecosystem"); + + fireEvent.click(chip); + expect(onNavigateToGoal).toHaveBeenCalledWith("G-001"); + }); + + it("renders linked goals empty state without chips", async () => { + globalThis.fetch = createDetailFetchMockForMissionDetail(mockMissionDetail); + + render(); + + await waitFor(() => { + expect(screen.getByText("Build Auth System")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText("Build Auth System")); + + expect(await screen.findByText("No linked goals.")).toBeInTheDocument(); + expect(screen.queryByTestId(/mission-linked-goal-chip-/)).toBeNull(); + }); + it("calls onClose on Escape key press", async () => { globalThis.fetch = createFetchMock(); const onClose = vi.fn(); diff --git a/packages/dashboard/app/components/mission-types.ts b/packages/dashboard/app/components/mission-types.ts index 233dc8eff4..a8cc845d27 100644 --- a/packages/dashboard/app/components/mission-types.ts +++ b/packages/dashboard/app/components/mission-types.ts @@ -1,6 +1,7 @@ // Mission types for MissionManager - local copy to avoid module resolution issues import type { + Goal, MissionEvent as CoreMissionEvent, MissionEventType as CoreMissionEventType, MissionHealth as CoreMissionHealth, @@ -265,6 +266,7 @@ export interface MissionSummary { export type MissionWithSummary = Mission & { summary?: MissionSummary }; export interface MissionWithHierarchy extends Mission { + linkedGoals?: Goal[]; milestones: Milestone[]; } diff --git a/packages/dashboard/src/__tests__/mission-e2e.test.ts b/packages/dashboard/src/__tests__/mission-e2e.test.ts index 346d20edb0..b1080b248a 100644 --- a/packages/dashboard/src/__tests__/mission-e2e.test.ts +++ b/packages/dashboard/src/__tests__/mission-e2e.test.ts @@ -101,6 +101,7 @@ function createMockMissionStore(options?: { return { ...mission, + linkedGoals: [], milestones: missionMilestones.map((m) => ({ ...m, slices: Array.from(slices.values()) @@ -1420,6 +1421,9 @@ describe("Mission API", () => { expect(res.body.title).toBe("Test Mission"); expect(res.body).toHaveProperty("milestones"); expect(Array.isArray(res.body.milestones)).toBe(true); + expect(res.body).toHaveProperty("linkedGoals"); + expect(Array.isArray(res.body.linkedGoals)).toBe(true); + expect(res.body.linkedGoals).toEqual([]); expect(res.body.milestones).toHaveLength(1); expect(res.body.milestones[0]).toHaveProperty("slices"); expect(Array.isArray(res.body.milestones[0].slices)).toBe(true); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index b89cbc0c1b..1f9ae493e6 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -979,7 +979,10 @@ export function createMissionRouter( throw notFound("Mission not found"); } - res.json(mission); + res.json({ + ...mission, + linkedGoals: mission.linkedGoals ?? [], + }); }) ); From cc18206bc594923eaa52e14b5e61766556da299e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 18:11:26 -0700 Subject: [PATCH 02/46] FN-5902: make mission validation AI-run all criteria Route every mission feature through validator-backed completion checks. - lazily restore a managed feature assertion before validation instead of auto-passing zero-assertion features - thread milestone acceptance criteria into validator prompts and system instructions as enforced requirements - update MissionManager copy/tests to present criteria as AI-validated runtime gates and remove informational-only/zero-assertion warnings - document the all-criteria AI-run contract and add a changeset for @runfusion/fusion Files changed: .changeset/fn-5902-mission-validation-ai-run.md | 5 + AGENTS.md | 2 +- docs/architecture.md | 2 +- docs/missions-completion-contract.md | 198 ++++++--------------- docs/missions.md | 5 +- packages/core/src/__tests__/mission-store.test.ts | 23 ++- packages/core/src/mission-store.ts | 10 ++ packages/dashboard/app/components/MissionManager.css | 31 ---- packages/dashboard/app/components/MissionManager.tsx | 86 +++------ packages/dashboard/app/components/__tests__/MissionManager.test.tsx | 60 +++++-- packages/engine/src/__tests__/mission-execution-loop.test.ts | 111 +++++++++--- packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts | 57 +++--- packages/engine/src/mission-execution-loop.ts | 78 ++++---- 13 files changed, 318 insertions(+), 350 deletions(-) Fusion-Task-Id: FN-5902 Fusion-Task-Lineage: 5f25caad-33c9-42ff-822b-1ea092afc29f --- .../fn-5902-mission-validation-ai-run.md | 5 + AGENTS.md | 2 +- docs/architecture.md | 2 +- docs/missions-completion-contract.md | 198 +++++------------- docs/missions.md | 5 +- .../core/src/__tests__/mission-store.test.ts | 23 +- packages/core/src/mission-store.ts | 10 + .../app/components/MissionManager.css | 31 --- .../app/components/MissionManager.tsx | 86 ++------ .../__tests__/MissionManager.test.tsx | 60 +++++- .../__tests__/mission-execution-loop.test.ts | 111 +++++++--- .../mission-validation-trigger-gap.test.ts | 57 ++--- packages/engine/src/mission-execution-loop.ts | 78 ++++--- 13 files changed, 318 insertions(+), 350 deletions(-) create mode 100644 .changeset/fn-5902-mission-validation-ai-run.md diff --git a/.changeset/fn-5902-mission-validation-ai-run.md b/.changeset/fn-5902-mission-validation-ai-run.md new file mode 100644 index 0000000000..c56b14adbd --- /dev/null +++ b/.changeset/fn-5902-mission-validation-ai-run.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Mission validation now AI-validates all mission criteria by lazily ensuring a per-feature managed assertion at runtime and removing the zero-assertion auto-pass path. Milestone acceptance criteria are threaded into validator prompts, and the dashboard now presents mission criteria as AI-validated instead of informational-only. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6a3c56f29d..54dc5c5ce8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-5403 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-stop-aborts-execution.test.ts` locks stop-ordering behavior so engine shutdown aborts executor AI sessions before drain wait and preserves task-row lifecycle semantics. - FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` guards reclaim/unpause no-progress oscillation recovery by capping repeated no-progress resumes, escalating to preserve-work `todo` rebound, and emitting `task:resume-limbo-escalated` audit metadata while exempting progress/user-paused/autoMerge-off cases. - FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` guards mission validation trigger continuity so done task completion and startup recovery both route assertion-linked features through validator runs before completion. -- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission-loop coverage so zero-assertion auto-pass deterministically advances to `loopState="passed"` and emits `validation_auto_passed_no_assertions` without duplicate recovery re-fire. +- FN-5738 backstop (superseded by FN-5902): `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` now guards the inverted contract so legacy zero-link mission features lazily restore a managed assertion, route through validator runs, and never emit `validation_auto_passed_no_assertions` during recovery replays. - FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 write-only-shadow merge-request record + handoff-accepted marker seam (flag OFF = no-op, ON = shadow-only non-authoritative). - FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe parity (dependency + lease diffs, shadow dequeue parity, manual-required shadow skip) while legacy behavior remains authoritative. - FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` plus `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover semantics (merge-request retry state transitions, authoritative user hard-cancel tombstone, and non-user rebound no-op cancel semantics). diff --git a/docs/architecture.md b/docs/architecture.md index 4577103291..694e66504e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1779,7 +1779,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in - FN-5830 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` guards branch-group completion-gate + promotion lifecycle so promotion happens exactly once after all members land, re-calls are idempotent, and gated paths emit `merge:branch-group-promotion-gated` without default-branch promotion. - FN-5819/FN-5846 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts` and `shared-branch-group-lifecycle.test.ts` guard the scoped autoMerge-off exception and deterministic finalize path so shared members integrate into the single group branch, produce `mergeTargetSource: "branch-group-integration"`/`mergeTargetBranch`, do not land on main, and are not moved backward by self-healing maintenance. - FN-5901 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validator-run-reaper.test.ts` guards stale mission-validator-run recovery across manual and automatic trigger types, verifies `mission:validator-run-reaped` audit metadata, ensures archived/complete parents keep their terminal feature state untouched, and proves reaped active features resume validation instead of staying wedged behind abandoned `running` rows. -- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes. +- FN-5738 backstop (superseded by FN-5902): `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` no longer permits zero-assertion auto-pass. Current coverage proves legacy zero-link features lazily restore a managed assertion, route through validator runs, and do not emit `validation_auto_passed_no_assertions` during recovery replays. - FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions. - FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe invariants: legacy dependency satisfaction remains authoritative while parity diffs emit, and shadow dequeue selection never advances `manual-required` rows. - FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` and `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover invariants: transient merge retries mutate merge-request state (no column rebound), user hard-cancel after accepted handoff cancels pending merge requests, and non-user rebounds preserve legacy fail-soft semantics. diff --git a/docs/missions-completion-contract.md b/docs/missions-completion-contract.md index ac9040d4f6..dd1bb0291c 100644 --- a/docs/missions-completion-contract.md +++ b/docs/missions-completion-contract.md @@ -2,175 +2,93 @@ ## Status -- **Decision date:** 2026-05-30 -- **Task:** FN-5718 -- **Depends on enforcement behavior from:** FN-5715 (reference implementation of the trigger/recovery path) -- **Scope:** Product contract and implementation requirements only (no code changes in this task) -- **Implementation status:** Realized by FN-5733 (loop auto-pass advancement, mission/store guard telemetry, MissionManager label reconciliation) +- **Decision date:** 2026-06-02 +- **Current contract task:** FN-5902 +- **Supersedes:** FN-5718 baseline contract +- **Depends on runtime trigger/recovery behavior from:** FN-5715 +- **Implementation status:** Realized by FN-5902 -## Problem +## Decision -Mission validation has had a recurring ambiguity: users can see feature acceptance text and milestone "completion criteria" text, but autopilot enforcement is actually driven by assertion linkage + validator outcomes. This document defines the canonical enforced gate so mission completion cannot silently stall or be misread. +Mission completion now uses an **all-criteria AI-run contract**: -## Canonical Completion Gate (Enforced) - -### Decision - -A feature is autopilot-complete only when **its linked contract assertions are satisfied**. - -Canonical authored source and enforcement path: - -1. `MissionFeature.acceptanceCriteria` is the canonical authored criteria text (authoring surface). -2. MissionStore must maintain a **store-managed per-feature `MissionContractAssertion`** derived from feature content, with text priority: +1. `MissionFeature.acceptanceCriteria` is the canonical authored feature criteria text. +2. MissionStore must maintain or lazily restore **one store-managed per-feature `MissionContractAssertion`** derived from feature content with text priority: - `feature.acceptanceCriteria` - `feature.description` - `Verify implementation of: {feature.title}` -3. The mission validator enforces completion using the feature's **linked assertions** (including its store-managed assertion and any additional linked milestone assertions). -4. Feature/slice/mission advance is gated by the validator outcome (or explicit no-assertions auto-pass behavior defined below). +3. The mission validator must run for every feature completion trigger. Runtime validation may lazily call `ensureFeatureAssertionLinked(feature.id)` before starting the validator so legacy missing-link rows still become validator-backed. +4. `milestone.acceptanceCriteria` is also part of the enforced gate by being threaded into the validator prompt for every feature in that milestone. +5. Feature, slice, milestone, and mission advancement are gated by the validator result — **not** by an informational-only path. -### Precedence and interpretation rules +## Enforcement Model -- `MissionFeature.acceptanceCriteria` is the canonical authoring field for feature-level intent. -- The **linked assertion set** is the canonical enforcement set. -- Milestone `MissionContractAssertion` rows are additive contract rows. They are enforced **only when linked to a feature**. -- `milestone.acceptanceCriteria` is descriptive/informational milestone text and is not directly executed by the validator. +### Feature-level enforcement -### Worked examples +A feature is autopilot-complete only when the validator passes after evaluating: -1. **Feature has acceptance criteria; store-managed assertion linked; validator passes** - - Result: feature may move to done/passed and contribute to slice completion. -2. **Feature has acceptance criteria; additive milestone assertion also linked; one linked assertion fails** - - Result: feature is not complete; no slice advance. -3. **Feature has acceptance criteria visible, but no linked assertions (legacy FN-5696 shape)** - - Result: data inconsistency; must not be interpreted by operators as a separate enforced gate. Repair links (FN-5696 backfill) so enforcement matches displayed intent. +- the feature's linked contract assertions, including its store-managed assertion, and +- the parent milestone's `acceptanceCriteria` text when present. -## Enforced vs. Informational Surfaces +### Milestone-level enforcement -| Surface | Category | Contract meaning | -|---|---|---| -| `MissionFeature.acceptanceCriteria` | Informational authoring source | Canonical authored feature criteria text; enforcement happens through derived/linked assertions | -| Store-managed per-feature `MissionContractAssertion` | Enforced | Primary validator gate for the feature | -| Additive milestone `MissionContractAssertion` (linked to feature) | Enforced | Additional validator gate for that linked feature | -| Additive milestone `MissionContractAssertion` (unlinked) | Informational until linked | Contract candidate, not yet a feature gate | -| `milestone.acceptanceCriteria` | Informational | Milestone summary/pass-bar text for humans; not directly validator-executed | -| MissionManager `milestone-feature-acceptance-rollup` UI (`data-testid="milestone-feature-acceptance-rollup"`) | Informational display | Display-only rendering of feature acceptance text, not a separate enforcement mechanism | +`milestone.acceptanceCriteria` is no longer informational-only. FN-5902 enforces it by threading the milestone pass-bar text into the validator prompt for each feature under that milestone. -## Zero-Assertion Behavior and FN-5696 Failure Shape +This is intentionally **prompt-threading**, not per-feature milestone assertion row synthesis: -### Zero-assertions runtime behavior (canonical FN-5738 path) +- store-managed per-feature assertions remain the canonical feature assertion rows, +- milestone acceptance text remains milestone-authored prose, +- the validator sees both and must satisfy both. -When a feature reaches completion trigger points and has **zero linked assertions**, mission execution must take exactly one canonical auto-pass path (not a silent stall and not a competing behavior): +### Legacy data and lazy repair -- mark feature terminal as `status="done"`, `loopState="passed"`, `lastValidatorStatus="passed"`, -- emit explicit observability/audit evidence with mission event code `validation_auto_passed_no_assertions`, -- continue normal slice/mission advancement checks idempotently (no duplicate re-fire on repeated recovery). +Legacy missions can still contain features with missing assertion links. Runtime enforcement no longer depends on pre-running backfill: -### FN-5696 legacy shape clarification +- mission execution lazily restores the store-managed feature assertion just before validation, and +- `fn_mission_backfill_assertions` / `backfillFeatureAssertions()` remain available as operator repair tooling for data hygiene and visibility. -A feature can show acceptance text while links are missing (legacy pre-repair data). This must be treated as a **linkage/data integrity problem**, not as proof that milestone text alone is enforced. Assertion authoring/backfill (FN-5696) is outside the execution loop; the loop must not synthesize `mission_feature_assertions` rows. The contract prevents ambiguity by separating: +## Removed behavior (FN-5902 inversion) -- authored/informational text surfaces, from -- linked assertion enforcement surfaces. +FN-5718's zero-assertion auto-pass behavior is superseded. -Operators should use the mission assertion backfill operator path to restore expected store-managed linkage for FN-5696 legacy rows: +Removed contract: -- Agent/tool: `fn_mission_backfill_assertions` with `{ missionId?, dryRun? }` (defaults to dry-run). -- API: `POST /api/missions/:missionId/backfill-assertions` with body `{ dryRun?: boolean }` (defaults to `true`). -- Run dry-run first, then apply (`dryRun=false`) once repaired rows look correct. -- This remediation is additive: it derives/links one store-managed assertion per unlinked feature so runtime enforcement uses validator-linked assertions rather than the zero-assertion auto-pass branch. +- no `validation_auto_passed_no_assertions` completion path, +- no silent or explicit rubber-stamp pass because assertions were missing, +- no informational-only feature criteria bucket in MissionManager. -## Slice Status and Mission Autopilot Advance Derivation +Instead, features are routed through validator execution after lazy assertion ensure. -Autopilot may advance only when each active-slice feature is resolved under this contract: +## Worked examples -- Feature with linked assertions: all linked assertions must pass. -- Feature with zero linked assertions: explicit auto-pass path completes it. -- Feature with failed/blocked validation: slice remains incomplete. -- Feature stranded without a task link in an active autopilot slice (`taskId == null`): startup + maintenance reconciliation must repair it (title-match link first, otherwise defined-status re-triage) so `allDone` remains reachable instead of stalling on never-triaged features. +1. **Feature has acceptance criteria; no linked assertion row is present yet** + - Runtime calls `ensureFeatureAssertionLinked(feature.id)`. + - Validator runs against the restored managed assertion. + - Result gates completion normally. -Then: +2. **Feature has acceptance criteria and milestone acceptance criteria** + - Validator evaluates the linked feature assertion(s). + - Validator also evaluates the milestone acceptance text in the prompt. + - Feature passes only when both are satisfied. -1. All features resolved complete → slice flips to `complete`. -2. Completed active slice with pending next slice → next slice activates. -3. All milestone slices complete → milestone complete. -4. All mission milestones complete → mission complete. +3. **Operator runs backfill on legacy data** + - Backfill pre-restores missing managed assertions for visibility/reporting. + - Runtime behavior is unchanged because lazy ensure already guarantees validator-backed enforcement. -This keeps completion logic deterministic and consistent with FN-5715 trigger/recovery behavior. +## UI contract -## UI Reconciliation Requirements (for follow-on engineering task) +MissionManager must present mission criteria as **AI-validated** rather than informational: -✅ Implemented in FN-5733 with MissionManager labels: -- `Contract assertions (autopilot gate)` + enforced indicator -- `Feature acceptance criteria (informational)` + not-enforced indicator -- warning badge when `hasProseButNoAssertions === true` +- assertion heading text reflects AI validation, +- informational / not-enforced labels are removed, +- zero-assertion warning guard is removed, +- fallback feature-criteria rollups, when shown for missing loaded assertions, describe runtime AI validation rather than non-enforced prose. -Target surface: `packages/dashboard/app/components/MissionManager.tsx` +## Success invariant -1. **Disambiguate labels** - - Use distinct wording for: - - feature-authored acceptance text, and - - milestone contract assertions. - - Do not reuse "Completion criteria" to refer to both categories. - - Required wording baseline (or semantically equivalent copy): - - Feature rollup heading: `Feature acceptance criteria (informational source)` - - Assertion list heading: `Contract assertions (validator-enforced when linked)` +For any mission feature that reaches validation trigger points: -2. **Per-row enforcement indicator** - - Every displayed row in the assertions/criteria area must show whether it is: - - `Enforced gate` (validator-blocking when linked), or - - `Informational` (display-only). - -3. **Empty-state contract-correct copy** - - Replace the current implication that completion criteria are absent when assertion rows are empty. - - Empty-state text must acknowledge when feature acceptance text exists but no assertion rows are defined/linked. - - Required behavior: - - If feature acceptance text exists but no assertion rows are present, show copy equivalent to: `No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.` - - If neither feature acceptance text nor assertions exist, show copy equivalent to: `No feature acceptance criteria or contract assertions defined yet.` - -4. **No button/mobile scope expansion** - - No button touch-target/mobile-reflow requirements (standing directive). - -## Engineering Acceptance Criteria (follow-on implementation) - -✅ Implemented in FN-5733: -- Auto-pass path now advances `loopState` to `passed` and emits mission event code `validation_auto_passed_no_assertions` while preserving the `validation:passed` emit contract (`"No assertions linked"` summary). -- Milestone rollup/store guard now exposes `hasProseButNoAssertions` and emits warning mission event code `milestone_missing_structured_assertions` (debounced on transition into condition). -- MissionManager UI now distinguishes enforced assertion gate vs informational feature acceptance criteria. - -1. **Data/model contract** - - Preserve the canonical relationship: feature-authored criteria -> store-managed assertion -> linked assertion enforcement. - - If any model/UI metadata is added for enforced-vs-informational badges, it must be backward compatible with existing mission rows. - -2. **Validator/loop behavior** - - Maintain FN-5715 invariants: - - done mission-linked tasks with linked assertions trigger validation, - - completion-trigger starts loop if needed, - - startup recovery replays done-implementing features with unpassed assertions, - - periodic self-heal maintenance replays the same `recoverActiveMissions` path so historically stranded `implementing` features recover without restart, - - zero-linked-assertions path remains explicit canonical auto-pass. - -3. **UI behavior** - - Implement the Step-2 label reconciliation and per-row indicator requirements. - - Ensure no shared ambiguous terminology remains between feature acceptance text and assertion rows. - -4. **Regression coverage** - - Add at least one regression test pinning the Goals-mission shape: - - feature has `acceptanceCriteria`, - - parent milestone has zero `MissionContractAssertion` rows / no links, - - autopilot behavior is deterministic and observable (explicit auto-pass path, no silent stall). - -5. **Operational observability** - - Ensure mission/audit surfaces make no-assertions auto-pass and subsequent advance decisions queryable in logs/events. - -## Success Metric - -For 30 days after the follow-on implementation ships: - -- **Primary metric:** zero autopilot stalls of the FN-5715 class (done mission task + unresolved validation trigger gap) in production mission runs. -- **Evidence source:** mission audit/event stream (`feature_completed`, `slice_completed`, `mission_completed`) plus `mission_validator_runs` records showing: - - explicit no-assertions auto-pass evidence (summary/reason path such as `No assertions linked` when no validator run is started), and - - downstream advancement evidence without stalled active slices. - -## Follow-on Task Requirement - -Implementation must land in a separate engineering task that references this document and FN-5715 as the enforcement baseline. \ No newline at end of file +- a validator run must occur, +- the feature must not auto-pass due to missing assertion links, +- milestone acceptance text must be visible to the validator when present, +- advancement decisions must derive from validator outcomes only. diff --git a/docs/missions.md b/docs/missions.md index dac4290014..92da633474 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -512,7 +512,7 @@ A feature transitions to `blocked` when: On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states, ensuring no validation work is lost. It also re-triggers `implementing` features whose linked task is already `done`/`archived` and whose assertion validation has not passed yet. When the stale-run reaper has already converted an abandoned validator run into `needs_fix`, `processTaskOutcome()` promotes the feature back through `implementing` and re-validates instead of skipping it. The same recovery path is replayed during periodic self-heal maintenance, so historically stranded `implementing` features can self-heal without requiring an engine restart. -For features with zero linked assertions, the completion path is explicit: the loop marks the feature `done`, advances `loopState` to `passed`, emits `validation:passed` with summary `"No assertions linked"`, and records mission event code `validation_auto_passed_no_assertions`. Contract details (including canonical no-assertions behavior and FN-5696 assertion-authoring separation) are defined in [Mission Completion Gate Contract](./missions-completion-contract.md). +For features with missing linked assertions, the completion path is now validator-first: the loop lazily restores the store-managed per-feature assertion just before validation, then runs the AI validator instead of auto-passing. Milestone `acceptanceCriteria` is threaded into the validator prompt for every feature in that milestone, so all mission criteria are AI-evaluated. Contract details are defined in [Mission Completion Gate Contract](./missions-completion-contract.md). ### Autopilot / Scheduler Interplay @@ -543,8 +543,7 @@ These are independent tracking mechanisms — autopilot monitors mission progres **MissionEvent audit types:** - `slice_activated`, `feature_planned`, `feature_completed` - `validation:started`, `validation:passed`, `validation:failed`, `validation:blocked` -- `validation_auto_passed_no_assertions` (reason: `"No assertions linked"`) -- `milestone_missing_structured_assertions` (warning when prose criteria exist with zero structured assertions) +- `milestone_missing_structured_assertions` (legacy-data warning surface; enforcement still lazy-restores managed assertions at runtime) - `fix_feature:created`, `feature:blocked` **Validator run telemetry:** diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index e095256d11..9a86c17260 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3338,14 +3338,33 @@ describe("MissionStore", () => { expect(linked[0].sourceFeatureId).toBe(feature.id); }); + it("lazily re-links exactly one managed assertion for legacy acceptance-criteria features", () => { + const mission = store.createMission({ title: "M" }); + const milestone = store.addMilestone(mission.id, { title: "MS" }); + const slice = store.addSlice(milestone.id, { title: "SL" }); + const feature = store.addFeature(slice.id, { title: "Feature", acceptanceCriteria: "AC text" }); + const [managed] = store.listAssertionsForFeature(feature.id); + store.unlinkFeatureFromAssertion(feature.id, managed.id); + store.deleteContractAssertion(managed.id); + + const first = store.ensureFeatureAssertionLinked(feature.id); + const second = store.ensureFeatureAssertionLinked(feature.id); + + expect(first).toHaveLength(1); + expect(first[0].assertion).toBe("AC text"); + expect(second).toHaveLength(1); + expect(second[0].id).toBe(first[0].id); + expect(store.listAssertionsForFeature(feature.id)).toHaveLength(1); + }); + it("derives managed assertion text from description or fallback", () => { const mission = store.createMission({ title: "M" }); const milestone = store.addMilestone(mission.id, { title: "MS" }); const slice = store.addSlice(milestone.id, { title: "SL" }); const fromDescription = store.addFeature(slice.id, { title: "Desc Feature", description: "Desc text" }); const fallback = store.addFeature(slice.id, { title: "Fallback Feature" }); - expect(store.listAssertionsForFeature(fromDescription.id)[0].assertion).toBe("Desc text"); - expect(store.listAssertionsForFeature(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature"); + expect(store.ensureFeatureAssertionLinked(fromDescription.id)[0].assertion).toBe("Desc text"); + expect(store.ensureFeatureAssertionLinked(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature"); }); it("syncs managed assertion in place on acceptanceCriteria update", () => { diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index f7baece06b..b0de6d38bc 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -2197,6 +2197,16 @@ export class MissionStore extends EventEmitter { } } + ensureFeatureAssertionLinked(featureId: string): MissionContractAssertion[] { + const feature = this.getFeature(featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + this.ensureFeatureAssertion(feature); + return this.listAssertionsForFeature(featureId); + } + /** * Idempotently seed authored contract assertions for specific features. * diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index 8f731b1458..584e162077 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -1770,15 +1770,6 @@ padding: calc(var(--space-xs) * 0.5) var(--space-sm); } -.mission-assertions__mode-tag--warning { - color: var(--color-warning); - border-color: color-mix(in srgb, var(--color-warning) 40%, var(--border)); -} - -.mission-assertions__mode-tag--informational { - color: var(--text-muted); -} - .mission-assertions__rollup-header { display: flex; align-items: center; @@ -1840,28 +1831,6 @@ margin-bottom: var(--space-sm); } -.mission-assertion__enforcement { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - font-size: calc(var(--space-sm) + var(--space-xs) * 0.5); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: calc(var(--space-xs) * 0.5) var(--space-sm); - color: var(--text-dim); - background: color-mix(in srgb, var(--surface) 85%, var(--bg)); - flex-shrink: 0; -} - -.mission-assertion__enforcement--enforced { - color: var(--color-success); - border-color: color-mix(in srgb, var(--color-success) 40%, var(--border)); -} - -.mission-assertion__enforcement--informational { - color: var(--text-muted); -} - .mission-assertion__linked-count { font-size: calc(var(--space-sm) + var(--space-xs) * 0.75); color: var(--text-dim); diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index f4abf79ee6..91c8cb2ab6 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -900,7 +900,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const [missionHealthById, setMissionHealthById] = useState>(new Map()); const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure"); - const milestoneAssertionGapSignatureRef = useRef>(new Map()); const [missionEvents, setMissionEvents] = useState([]); const missionEventsRef = useRef([]); const missionsRef = useRef([]); @@ -917,30 +916,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const activityEventsContainerRef = useRef(null); - useEffect(() => { - if (!selectedMission) return; - - const nextSignatures = new Map(); - for (const milestone of selectedMission.milestones) { - const featuresWithAcceptanceCriteria = milestone.slices - .flatMap((slice) => slice.features) - .filter((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0); - const assertionCount = assertionsByMilestone.get(milestone.id)?.length ?? 0; - const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && assertionCount === 0; - const signature = `${hasZeroAssertionGuard}:${featuresWithAcceptanceCriteria.length}:${assertionCount}`; - const previousSignature = milestoneAssertionGapSignatureRef.current.get(milestone.id); - if (hasZeroAssertionGuard && previousSignature !== signature) { - console.warn("[MissionManager] milestone_zero_assertion_guard", { - milestoneId: milestone.id, - featureAcceptanceCriteriaCount: featuresWithAcceptanceCriteria.length, - assertionCount, - }); - } - nextSignatures.set(milestone.id, signature); - } - - milestoneAssertionGapSignatureRef.current = nextSignatures; - }, [assertionsByMilestone, selectedMission]); const activityEventsEndRef = useRef(null); // Keep latest state available to long-lived SSE handlers without reconnect churn. @@ -2802,7 +2777,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const milestoneAssertions = Array.isArray(assertionsByMilestone.get(milestone.id)) ? assertionsByMilestone.get(milestone.id)! : [] as MissionContractAssertion[]; - const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && milestoneAssertions.length === 0; return (
@@ -3613,17 +3587,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr {/* Assertions Panel */}
- Contract assertions (validator-enforced when linked) + Contract assertions (AI-validated) - Enforced by autopilot + AI-validated mission gate - {hasZeroAssertionGuard && ( - - - Feature criteria present but no enforced contract assertions linked - - )} {milestoneRollup && ( { const linked = linkedFeaturesByAssertion.get(assertion.id); const count = linked?.length ?? 0; - const isEnforced = count > 0; - return ( - <> - - - {isEnforced ? "Enforced gate" : "Informational"} - - {count > 0 ? ( - - ({count} linked) - - ) : null} - - ); + return count > 0 ? ( + + ({count} linked) + + ) : null; })()} + ) : null}
- )} - {entry.installs !== undefined && ( - - {entry.installs.toLocaleString()} installs - - )} -
- ))} + {entry.description && ( +

{entry.description}

+ )} + {entry.tags && entry.tags.length > 0 && ( +
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {entry.installs !== undefined && ( + + {entry.installs.toLocaleString()} installs + + )} +
+ ); + })} )}
diff --git a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx index d4fe898536..151160561e 100644 --- a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx @@ -8,12 +8,14 @@ import type { DiscoveredSkill, CatalogEntry, SkillContent } from "@fusion/dashbo vi.mock("../../api", () => ({ fetchDiscoveredSkills: vi.fn(), toggleExecutionSkill: vi.fn(), + installSkill: vi.fn(), fetchSkillsCatalog: vi.fn(), fetchSkillContent: vi.fn(), })); const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills); const mockToggleExecutionSkill = vi.mocked(apiModule.toggleExecutionSkill); +const mockInstallSkill = vi.mocked(apiModule.installSkill); const mockFetchSkillsCatalog = vi.mocked(apiModule.fetchSkillsCatalog); const mockFetchSkillContent = vi.mocked(apiModule.fetchSkillContent); @@ -55,6 +57,7 @@ describe("SkillsView", () => { slug: "test-skill", name: "Test Skill", description: "A test skill for testing", + repo: "owner/test-repo", tags: ["testing", "example"], installs: 1234, installation: { @@ -68,6 +71,7 @@ describe("SkillsView", () => { slug: "another-skill", name: "Another Skill", description: "Another example skill", + repo: "owner/another-repo", tags: ["utility"], installs: 5678, installation: { @@ -76,6 +80,19 @@ describe("SkillsView", () => { matchingPaths: ["skills/another-skill"], }, }, + { + id: "cat-003", + slug: "missing-source", + name: "Missing Source", + description: "Cannot be installed from the catalog card", + tags: ["docs"], + installs: 10, + installation: { + installed: false, + matchingSkillIds: [], + matchingPaths: [], + }, + }, ]; beforeEach(() => { @@ -86,6 +103,7 @@ describe("SkillsView", () => { pattern: "+test-skill", targetFile: "/project/.fusion/settings.json", }); + mockInstallSkill.mockResolvedValue({ success: true }); mockFetchSkillsCatalog.mockResolvedValue({ entries: mockCatalogEntries, auth: { @@ -176,6 +194,17 @@ describe("SkillsView", () => { }); }); + it("renders install buttons only for catalog entries with a source repo", async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Install Test Skill" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Install Another Skill" })).toBeTruthy(); + }); + + expect(screen.queryByRole("button", { name: "Install Missing Source" })).toBeNull(); + }); + it("shows loading state while fetching discovered skills", async () => { let resolveSkills: ((value: DiscoveredSkill[]) => void) | undefined; mockFetchDiscoveredSkills.mockImplementation( @@ -357,6 +386,49 @@ describe("SkillsView", () => { }); }); + describe("catalog install", () => { + it("installs a catalog skill and refreshes discovered skills", async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Install Test Skill" })).toBeTruthy(); + }); + + mockFetchDiscoveredSkills.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Install Test Skill" })); + }); + + await waitFor(() => { + expect(mockInstallSkill).toHaveBeenCalledWith("owner/test-repo", "test-skill", undefined); + expect(mockFetchDiscoveredSkills).toHaveBeenCalledTimes(1); + expect(mockAddToast).toHaveBeenCalledWith("Installed Test Skill", "success"); + }); + }); + + it("shows an error toast when install fails", async () => { + mockInstallSkill.mockRejectedValue(new Error("install failed")); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Install Test Skill" })).toBeTruthy(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Install Test Skill" })); + }); + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.stringContaining("Failed to install Test Skill: install failed"), + "error", + ); + }); + }); + }); + describe("catalog search", () => { it("calls fetchSkillsCatalog with projectId when provided", async () => { render(); diff --git a/packages/dashboard/src/__tests__/routes-skills.test.ts b/packages/dashboard/src/__tests__/routes-skills.test.ts index 20276b0edb..2b97758aca 100644 --- a/packages/dashboard/src/__tests__/routes-skills.test.ts +++ b/packages/dashboard/src/__tests__/routes-skills.test.ts @@ -96,6 +96,7 @@ function createMockSkillsAdapter(overrides?: Partial): SkillsAdap targetFile: `${rootDir}/.fusion/settings.json`, }; }), + installSkill: vi.fn().mockResolvedValue({ success: true }), fetchCatalog: vi.fn().mockResolvedValue({ entries: [ { @@ -411,6 +412,72 @@ describe("Skills routes", () => { }); }); + describe("POST /api/skills/install", () => { + it("installs a skill using the scoped store root dir", async () => { + const mockAdapter = createMockSkillsAdapter({ + installSkill: vi.fn().mockResolvedValue({ success: true }), + }); + const store = new MockStore("/tmp/install-project"); + const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter }); + + const res = await request( + app, + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo", skill: "test-skill" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(mockAdapter.installSkill).toHaveBeenCalledWith({ + source: "owner/repo", + skill: "test-skill", + cwd: "/tmp/install-project", + }); + }); + + it("returns 400 for invalid source", async () => { + const mockAdapter = createMockSkillsAdapter(); + const store = new MockStore(); + const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter }); + + const res = await request( + app, + "POST", + "/api/skills/install", + JSON.stringify({ source: "invalid-source" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }); + expect(mockAdapter.installSkill).not.toHaveBeenCalled(); + }); + + it("returns 404 when skills adapter is not configured", async () => { + const store = new MockStore(); + const app = createServer(store as any, {}); + + const res = await request( + app, + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ + error: "Skills adapter not configured", + code: "adapter_not_configured", + }); + }); + }); + describe("GET /api/skills/catalog", () => { it("returns catalog entries with installation info", async () => { const mockAdapter = createMockSkillsAdapter(); diff --git a/packages/dashboard/src/__tests__/skills-adapter.test.ts b/packages/dashboard/src/__tests__/skills-adapter.test.ts index 0f920fccda..7363a7e224 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -3,6 +3,8 @@ import { createSkillsAdapter, extractSkillName, computeSkillId } from "../skills import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; describe("createSkillsAdapter - fetchCatalog fallback behavior", () => { const originalFetch = globalThis.fetch; @@ -642,6 +644,117 @@ describe("createSkillsAdapter - toggleExecutionSkill persistence", () => { }); }); +describe("createSkillsAdapter - installSkill", () => { + it("short-circuits invalid source without spawning", async () => { + const superviseSpawnMock = vi.fn(); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + superviseSpawn: superviseSpawnMock as never, + }); + + const result = await adapter.installSkill({ source: "invalid", cwd: "/tmp/project" }); + + expect(result).toEqual({ + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }); + expect(superviseSpawnMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "without a specific skill", + input: { source: "owner/repo", cwd: "/tmp/project" }, + expectedArgs: ["skills", "add", "owner/repo", "-y", "-a", "pi"], + }, + { + name: "with a specific skill", + input: { source: "owner/repo", skill: "my-skill", cwd: "/tmp/project" }, + expectedArgs: ["skills", "add", "owner/repo", "--skill", "my-skill", "-y", "-a", "pi"], + }, + ])("spawns npx skills add $name", async ({ input, expectedArgs }) => { + const superviseSpawnMock = vi.fn((_command: string, _args: string[]) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + pid: number; + }; + child.stdout = stdout; + child.stderr = stderr; + child.pid = 4242; + process.nextTick(() => { + stdout.end(); + stderr.end(); + }); + return { + pid: 4242, + pgid: null, + child, + kill: vi.fn(), + waitExit: () => Promise.resolve({ code: 0, signal: null }), + }; + }); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + superviseSpawn: superviseSpawnMock as never, + }); + + const result = await adapter.installSkill(input); + + expect(result).toEqual({ success: true }); + expect(superviseSpawnMock).toHaveBeenCalledWith( + "npx", + expectedArgs, + expect.objectContaining({ + cwd: "/tmp/project", + shell: true, + stdio: ["ignore", "pipe", "pipe"], + maxLifetimeMs: 60_000, + }), + ); + }); + + it("returns install_failed when the installer exits non-zero", async () => { + const superviseSpawnMock = vi.fn(() => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + pid: number; + }; + child.stdout = stdout; + child.stderr = stderr; + child.pid = 4242; + process.nextTick(() => { + stderr.write("install failed\n"); + stdout.end(); + stderr.end(); + }); + return { + pid: 4242, + pgid: null, + child, + kill: vi.fn(), + waitExit: () => Promise.resolve({ code: 1, signal: null }), + }; + }); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + superviseSpawn: superviseSpawnMock as never, + }); + + const result = await adapter.installSkill({ source: "owner/repo", cwd: "/tmp/project" }); + + expect(result).toEqual({ error: "install failed", code: "install_failed" }); + }); +}); + describe("extractSkillName", () => { it("normalizes Windows separators before deriving the display name", () => { expect(extractSkillName("skills\\tooling\\windows-fix", "npm")).toBe("tooling/windows-fix"); diff --git a/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts new file mode 100644 index 0000000000..5f882398f7 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node + +import express from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createApiRoutes } from "../../routes.js"; +import { request } from "../../test-request.js"; +import type { SkillsAdapter } from "../../skills-adapter.js"; + +function createStore(rootDir = "/tmp/skills-project") { + return { + getTask: vi.fn(), + listTasks: vi.fn().mockResolvedValue([]), + getSettings: vi.fn().mockResolvedValue({}), + getSettingsFast: vi.fn().mockResolvedValue({}), + getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }), + getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }), + getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })), + getRootDir: vi.fn().mockReturnValue(rootDir), + getFusionDir: vi.fn().mockReturnValue(`${rootDir}/.fusion`), + listWorkflowSteps: vi.fn().mockResolvedValue([]), + getMissionStore: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } as any; +} + +function createSkillsAdapter(overrides?: Partial): SkillsAdapter { + return { + discoverSkills: vi.fn().mockResolvedValue([]), + toggleExecutionSkill: vi.fn(), + installSkill: vi.fn().mockResolvedValue({ success: true }), + fetchCatalog: vi.fn().mockResolvedValue({ + entries: [], + auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false }, + }), + readSkillContent: vi.fn(), + ...overrides, + } as SkillsAdapter; +} + +function app(skillsAdapter?: SkillsAdapter, rootDir?: string) { + const server = express(); + server.use(express.json()); + server.use("/api", createApiRoutes(createStore(rootDir), { skillsAdapter })); + return server; +} + +describe("register-agent-skills-routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("POST /api/skills/install installs a skill", async () => { + const skillsAdapter = createSkillsAdapter({ + installSkill: vi.fn().mockResolvedValue({ success: true }), + }); + + const res = await request( + app(skillsAdapter, "/tmp/install-root"), + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo", skill: "skill-name" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(skillsAdapter.installSkill).toHaveBeenCalledWith({ + source: "owner/repo", + skill: "skill-name", + cwd: "/tmp/install-root", + }); + }); + + it("POST /api/skills/install returns 400 for missing source", async () => { + const skillsAdapter = createSkillsAdapter(); + + const res = await request( + app(skillsAdapter), + "POST", + "/api/skills/install", + JSON.stringify({}), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "source is required", code: "invalid_body" }); + }); + + it("POST /api/skills/install returns 400 for malformed source", async () => { + const skillsAdapter = createSkillsAdapter(); + + const res = await request( + app(skillsAdapter), + "POST", + "/api/skills/install", + JSON.stringify({ source: "bad" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }); + expect(skillsAdapter.installSkill).not.toHaveBeenCalled(); + }); + + it("POST /api/skills/install returns 404 without a skills adapter", async () => { + const res = await request( + app(undefined), + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ + error: "Skills adapter not configured", + code: "adapter_not_configured", + }); + }); + + it("POST /api/skills/install returns 502 for structured adapter errors", async () => { + const skillsAdapter = createSkillsAdapter({ + installSkill: vi.fn().mockResolvedValue({ + error: "installer failed", + code: "install_failed", + }), + }); + + const res = await request( + app(skillsAdapter), + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(502); + expect(res.body).toEqual({ error: "installer failed", code: "install_failed" }); + }); +}); diff --git a/packages/dashboard/src/routes/register-agent-skills-routes.ts b/packages/dashboard/src/routes/register-agent-skills-routes.ts index 76ec46c7f6..58d1423504 100644 --- a/packages/dashboard/src/routes/register-agent-skills-routes.ts +++ b/packages/dashboard/src/routes/register-agent-skills-routes.ts @@ -138,6 +138,57 @@ export function registerAgentSkillsRoutes(ctx: ApiRoutesContext): void { } }); + /** + * POST /api/skills/install + * Install a catalog skill via the shared skills.sh installer. + * Body: { source: string; skill?: string } + * Query: projectId (optional) for multi-project context + * Response: { success: true } + * Error: 400 { error: string; code: "invalid_body"|"invalid_source" } + * Error: 502 { error: string; code: "spawn_error"|"install_failed"|"install_timeout" } + */ + router.post("/skills/install", async (req, res) => { + try { + const scopedStore = await getScopedStore(req); + const skillsAdapter = options?.skillsAdapter; + + if (!skillsAdapter) { + res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" }); + return; + } + + const { source, skill } = req.body as { source?: string; skill?: string }; + if (typeof source !== "string" || !source.trim()) { + res.status(400).json({ error: "source is required", code: "invalid_body" }); + return; + } + + const normalizedSource = source.trim(); + if (!/^[^/]+\/[^/]+$/.test(normalizedSource)) { + res.status(400).json({ error: "Invalid source format. Use owner/repo.", code: "invalid_source" }); + return; + } + + const result = await skillsAdapter.installSkill({ + source: normalizedSource, + skill: typeof skill === "string" ? skill : undefined, + cwd: scopedStore.getRootDir(), + }); + + if ("code" in result) { + res.status(502).json(result); + return; + } + + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to install skill"); + } + }); + /** * GET /api/skills/catalog * Fetch the skills.sh catalog with optional authentication. diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index 91cf4ea14e..654e801d48 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -7,6 +7,8 @@ import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises"; import { join, relative, dirname } from "node:path"; +import { superviseSpawn } from "@fusion/core"; +import type { ChildProcess } from "node:child_process"; /** * Check if a path exists asynchronously using access(). @@ -133,6 +135,17 @@ export interface UpstreamError { /** * Skills adapter interface exposed via ServerOptions. */ +export interface InstallSkillResultSuccess { + success: true; +} + +export interface InstallSkillResultError { + error: string; + code: "invalid_source" | "spawn_error" | "install_failed" | "install_timeout"; +} + +export type InstallSkillResult = InstallSkillResultSuccess | InstallSkillResultError; + export interface SkillsAdapter { /** * Discover all skills available in the project. @@ -149,6 +162,11 @@ export interface SkillsAdapter { input: { skillId: string; enabled: boolean }, ): Promise; + /** + * Install a skill from skills.sh into the current project. + */ + installSkill(input: { source: string; skill?: string; cwd: string }): Promise; + /** * Fetch the skills.sh catalog with optional authentication. */ @@ -193,6 +211,35 @@ function normalizeStoredSkillPath(path: string): string { return path.replaceAll("\\", "/").replace(/^skills\//, ""); } +function isValidInstallSource(source: string): boolean { + return /^[^/]+\/[^/]+$/.test(source); +} + +function captureStream(stream: NodeJS.ReadableStream | null | undefined): Promise { + if (!stream) { + return Promise.resolve(""); + } + + return new Promise((resolve) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + }); + stream.once("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + stream.once("close", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + }); +} + +async function waitForSupervisedExit( + child: ChildProcess, + exitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | null }>, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + const spawnError = new Promise((_, reject) => { + child.once("error", reject); + }); + return Promise.race([exitPromise, spawnError]); +} + /** * Check if a skill path is enabled in the settings. * Checks both top-level skills and package-scoped skills. @@ -255,6 +302,8 @@ export function createSkillsAdapter(options: { }; /** Project settings path helper */ getSettingsPath: (rootDir: string) => string; + /** Optional superviseSpawn seam for tests */ + superviseSpawn?: typeof superviseSpawn; }): SkillsAdapter { return { async discoverSkills(rootDir: string): Promise { @@ -430,6 +479,60 @@ export function createSkillsAdapter(options: { } }, + async installSkill(input: { source: string; skill?: string; cwd: string }): Promise { + const source = input.source.trim(); + if (!isValidInstallSource(source)) { + return { + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }; + } + + const npxArgs = ["skills", "add", source]; + const skill = input.skill?.trim(); + if (skill) { + npxArgs.push("--skill", skill); + } + npxArgs.push("-y", "-a", "pi"); + + const runSpawn = options.superviseSpawn ?? superviseSpawn; + const supervised = runSpawn("npx", npxArgs, { + cwd: input.cwd, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + maxLifetimeMs: 60_000, + }); + + try { + const stderrPromise = captureStream(supervised.child.stderr); + const stdoutPromise = captureStream(supervised.child.stdout); + const exit = await waitForSupervisedExit(supervised.child, supervised.waitExit()); + const [stderr, stdout] = await Promise.all([stderrPromise, stdoutPromise]); + + if (exit.signal === "SIGKILL") { + return { + error: "Skill installation timed out.", + code: "install_timeout", + }; + } + + if ((exit.code ?? 1) !== 0) { + const detail = stderr.trim() || stdout.trim() || "Skill installation failed."; + return { + error: detail, + code: "install_failed", + }; + } + + return { success: true }; + } catch (error) { + return { + error: error instanceof Error ? error.message : "Failed to start skill installer.", + code: "spawn_error", + }; + } + }, + async fetchCatalog(input: { limit: number; query?: string }): Promise { const { limit, query } = input; const boundedLimit = Math.min(Math.max(1, limit), 100); From cf23c6f571362ca04f28ed53049f7135e11bf0ea Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 18:43:29 -0700 Subject: [PATCH 05/46] FN-5909: run configured merge bootstrap before verification Ensure AI merge verification reuses the configured worktree bootstrap command. - run merge dependency sync unconditionally when a non-blank worktreeInitCommand is configured - reuse the configured init command instead of inferred package-manager install commands during merge verification - add merger verification coverage for warm/cold worktrees, inferred-install fallback, and whitespace-only init commands - document the expanded worktreeInitCommand behavior and add a published changeset Files changed: .changeset/fn-5909-merge-install-script.md | 5 + docs/settings-reference.md | 2 +- packages/engine/src/__tests__/merger-verification.test.ts | 159 +++++++++++++++------ packages/engine/src/merger.ts | 30 +++- 4 files changed, 143 insertions(+), 53 deletions(-) Fusion-Task-Id: FN-5909 Fusion-Task-Lineage: 8530c641-9574-45e2-b9ac-82e42c9c00a7 --- .changeset/fn-5909-merge-install-script.md | 5 + docs/settings-reference.md | 2 +- .../src/__tests__/merger-verification.test.ts | 169 ++++++++++++------ packages/engine/src/merger.ts | 30 +++- 4 files changed, 148 insertions(+), 58 deletions(-) create mode 100644 .changeset/fn-5909-merge-install-script.md diff --git a/.changeset/fn-5909-merge-install-script.md b/.changeset/fn-5909-merge-install-script.md new file mode 100644 index 0000000000..ee3e282489 --- /dev/null +++ b/.changeset/fn-5909-merge-install-script.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Run the configured `worktreeInitCommand` on merge worktrees before AI merge verification across warm and cold integration modes, so merge verification uses the same project-specific bootstrap as executor worktrees. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index d02a2df3e2..009f2c585e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -265,7 +265,7 @@ Sandbox backend precedence is: | `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. | | `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. | -| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation. For pnpm repos, prefer `pnpm install --frozen-lockfile` for deterministic bootstrap. | +| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation and again to bootstrap the merge worktree before AI merge verification. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | diff --git a/packages/engine/src/__tests__/merger-verification.test.ts b/packages/engine/src/__tests__/merger-verification.test.ts index 4458762c84..6995d962d7 100644 --- a/packages/engine/src/__tests__/merger-verification.test.ts +++ b/packages/engine/src/__tests__/merger-verification.test.ts @@ -573,7 +573,17 @@ describe("aiMergeTask — build verification", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done"); }); - it("syncs dependencies before build verification when install state is missing", async () => { + function setupDependencySyncVerificationScenario({ + taskId = "FN-050", + installStatePresent, + stagedFiles, + settingsOverrides, + }: { + taskId?: string; + installStatePresent: boolean; + stagedFiles: string[]; + settingsOverrides: Partial; + }) { mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), @@ -583,7 +593,7 @@ describe("aiMergeTask — build verification", () => { mockedExistsSync.mockImplementation((path: any) => { const pathStr = String(path); - if (pathStr.includes("node_modules") || pathStr.endsWith(".pnp.cjs")) return false; + if (pathStr.includes("node_modules") || pathStr.endsWith(".pnp.cjs")) return installStatePresent; return true; }); @@ -598,9 +608,16 @@ describe("aiMergeTask — build verification", () => { if (cmdStr.includes("merge --squash")) return Buffer.from(""); if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any; if (cmdStr.includes("git diff --cached --name-only")) { - return "package.json\npackages/desktop/package.json" as any; + return stagedFiles.join("\n") as any; + } + if ( + cmdStr.includes("pnpm install --frozen-lockfile") || + cmdStr.includes("pnpm run setup:merge") || + cmdStr.includes("pnpm test") || + cmdStr.includes("pnpm build") + ) { + return Buffer.from(""); } - if (cmdStr.includes("pnpm install --frozen-lockfile")) return "Lockfile is up to date" as any; if (cmdStr.includes("diff --cached --quiet")) { cachedQuietChecks += 1; return cachedQuietChecks === 1 ? "1" as any : "0" as any; @@ -612,13 +629,23 @@ describe("aiMergeTask — build verification", () => { }); const store = createMockStore( - { id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" }, - [{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task], + { id: taskId, worktree: `/tmp/root/.worktrees/${taskId}` }, + [{ id: taskId, worktree: `/tmp/root/.worktrees/${taskId}`, column: "in-review" } as Task], ); (store.getSettings as ReturnType).mockResolvedValue({ ...DEFAULT_SETTINGS, mergeIntegrationWorktree: "cwd-main" as const, - buildCommand: "pnpm build", + ...settingsOverrides, + }); + + return { store }; + } + + it("syncs dependencies before build verification when install state is missing", async () => { + const { store } = setupDependencySyncVerificationScenario({ + installStatePresent: false, + stagedFiles: ["package.json", "packages/desktop/package.json"], + settingsOverrides: { buildCommand: "pnpm build" }, }); const result = await aiMergeTask(store, "/tmp/root", "FN-050"); @@ -635,51 +662,11 @@ describe("aiMergeTask — build verification", () => { }); it("syncs dependencies before test verification when install state is missing", async () => { - mockedCreateFnAgent.mockResolvedValue({ - session: { - prompt: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - }, - } as any); - - mockedExistsSync.mockImplementation((path: any) => { - const pathStr = String(path); - if (pathStr.includes("node_modules") || pathStr.endsWith(".pnp.cjs")) return false; - return true; - }); - - let cachedQuietChecks = 0; - mockedExecSync.mockImplementation((cmd: any) => { - const cmdStr = String(cmd); - if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123"); - if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123"; - if (cmdStr.includes("git log")) return "- feat: something" as any; - if (cmdStr.includes("merge-base")) return Buffer.from("abc123"); - if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "2 files changed" as any; - if (cmdStr.includes("merge --squash")) return Buffer.from(""); - if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any; - if (cmdStr.includes("git diff --cached --name-only")) { - return "package.json\npackages/desktop/package.json" as any; - } - if (cmdStr.includes("pnpm install --frozen-lockfile")) return "Lockfile is up to date" as any; - if (cmdStr.includes("diff --cached --quiet")) { - cachedQuietChecks += 1; - return cachedQuietChecks === 1 ? "1" as any : "0" as any; - } - if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any; - if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from(""); - if (cmdStr.includes("worktree remove")) return Buffer.from(""); - return Buffer.from(""); - }); - - const store = createMockStore( - { id: "FN-051", worktree: "/tmp/root/.worktrees/KB-051" }, - [{ id: "FN-051", worktree: "/tmp/root/.worktrees/KB-051", column: "in-review" } as Task], - ); - (store.getSettings as ReturnType).mockResolvedValue({ - ...DEFAULT_SETTINGS, - mergeIntegrationWorktree: "cwd-main" as const, - testCommand: "pnpm test", + const { store } = setupDependencySyncVerificationScenario({ + taskId: "FN-051", + installStatePresent: false, + stagedFiles: ["package.json", "packages/desktop/package.json"], + settingsOverrides: { testCommand: "pnpm test" }, }); const result = await aiMergeTask(store, "/tmp/root", "FN-051"); @@ -689,6 +676,84 @@ describe("aiMergeTask — build verification", () => { mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm install --frozen-lockfile")), ).toBe(true); }); + + it("runs the configured worktree init command before verification when install state is warm and no dependency files are staged", async () => { + const { store } = setupDependencySyncVerificationScenario({ + installStatePresent: true, + stagedFiles: ["packages/engine/src/merger.ts"], + settingsOverrides: { + testCommand: "pnpm test", + worktreeInitCommand: "pnpm run setup:merge", + }, + }); + + const result = await aiMergeTask(store, "/tmp/root", "FN-050"); + + expect(result.merged).toBe(true); + expect(mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm run setup:merge"))).toBe(true); + expect( + mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm install --frozen-lockfile")), + ).toBe(false); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-050", + "Syncing dependencies before merge verification: pnpm run setup:merge", + ); + }); + + it("runs the configured worktree init command before verification when install state is missing", async () => { + const { store } = setupDependencySyncVerificationScenario({ + installStatePresent: false, + stagedFiles: ["package.json"], + settingsOverrides: { + buildCommand: "pnpm build", + worktreeInitCommand: "pnpm run setup:merge", + }, + }); + + const result = await aiMergeTask(store, "/tmp/root", "FN-050"); + + expect(result.merged).toBe(true); + expect(mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm run setup:merge"))).toBe(true); + expect( + mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm install --frozen-lockfile")), + ).toBe(false); + }); + + it("preserves inferred install behavior when no worktree init command is configured", async () => { + expect(shouldSyncDependenciesForMerge(["packages/engine/src/merger.ts"], true, false)).toBe(false); + + const { store } = setupDependencySyncVerificationScenario({ + installStatePresent: true, + stagedFiles: ["packages/engine/src/merger.ts"], + settingsOverrides: { testCommand: "pnpm test" }, + }); + + const result = await aiMergeTask(store, "/tmp/root", "FN-050"); + + expect(result.merged).toBe(true); + expect( + mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm install --frozen-lockfile")), + ).toBe(false); + }); + + it("treats whitespace-only worktree init commands as unset and falls back to inferred install behavior", async () => { + const { store } = setupDependencySyncVerificationScenario({ + installStatePresent: false, + stagedFiles: ["package.json"], + settingsOverrides: { + testCommand: "pnpm test", + worktreeInitCommand: " ", + }, + }); + + const result = await aiMergeTask(store, "/tmp/root", "FN-050"); + + expect(result.merged).toBe(true); + expect(mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm run setup:merge"))).toBe(false); + expect( + mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm install --frozen-lockfile")), + ).toBe(true); + }); }); // ── Deterministic Merge Verification Tests ────────────────────────────── diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 64e3642830..5df20aea11 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -495,14 +495,23 @@ export function hasInstallState(rootDir: string): boolean { export function shouldSyncDependenciesForMerge( stagedFiles: string[], installStatePresent: boolean, + hasConfiguredInitCommand = false, ): boolean { + if (hasConfiguredInitCommand) return true; if (!installStatePresent) return true; return stagedFiles.some((file) => DEPENDENCY_SYNC_TRIGGER_PATTERNS.some((pattern) => matchGlob(file, pattern)), ); } -function getDependencySyncCommand(rootDir: string): string | null { +function getConfiguredWorktreeInitCommand(settings?: Settings | null): string | null { + const trimmed = settings?.worktreeInitCommand?.trim(); + return trimmed ? trimmed : null; +} + +function getDependencySyncCommand(rootDir: string, settings?: Settings | null): string | null { + const configuredCommand = getConfiguredWorktreeInitCommand(settings); + if (configuredCommand) return configuredCommand; if (existsSync(join(rootDir, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile"; if (existsSync(join(rootDir, "package-lock.json"))) return "npm install"; if (existsSync(join(rootDir, "yarn.lock"))) return "yarn install --frozen-lockfile"; @@ -550,17 +559,21 @@ async function syncDependenciesForMerge( store: TaskStore, rootDir: string, taskId: string, + settings?: Settings | null, signal?: AbortSignal, ): Promise { - const installCommand = getDependencySyncCommand(rootDir); + const configuredCommand = getConfiguredWorktreeInitCommand(settings); + const installCommand = getDependencySyncCommand(rootDir, settings); if (!installCommand) return; + const shouldUseInstallMarker = configuredCommand === null; + // Skip the install if node_modules is present and the lockfile content // matches the hash recorded after the last successful install. Caller's // shouldSyncDependenciesForMerge gate already filters most no-ops; this // covers the case where package.json (but not the lockfile) is staged, and // the case where multiple merge attempts hit the same worktree in a row. - const lockHash = computeLockfileHash(rootDir); + const lockHash = shouldUseInstallMarker ? computeLockfileHash(rootDir) : null; if (lockHash && hasInstallState(rootDir) && readInstallMarker(rootDir) === lockHash) { mergerLog.log(`${taskId}: skipping dependency sync (lockfile unchanged since last install)`); await store.logEntry( @@ -10947,8 +10960,15 @@ export async function executeMergeAttempt( if (testCommand || buildCommand) { throwIfAborted(options.signal, taskId); const stagedFiles = await getStagedFiles(rootDir); - if (shouldSyncDependenciesForMerge(stagedFiles, hasInstallState(rootDir))) { - await syncDependenciesForMerge(store, rootDir, taskId, options.signal); + const configuredMergeInitCommand = getConfiguredWorktreeInitCommand(settings as Settings); + if ( + shouldSyncDependenciesForMerge( + stagedFiles, + hasInstallState(rootDir), + configuredMergeInitCommand !== null, + ) + ) { + await syncDependenciesForMerge(store, rootDir, taskId, settings as Settings, options.signal); } } From 3d18872f988808e9b947858c1f3b1f5fcdae7755 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 18:56:36 -0700 Subject: [PATCH 06/46] FN-5917: fix Codex OAuth option selection Prevent multi-option OAuth prompts from cancelling Codex browser login. - add OAuth option selection logic that prefers the browser flow for openai-codex prompts - fall back to default-labelled or first options for other multi-option OAuth prompts - add API route coverage for Codex multi-option, single-option, and generic default-labelled prompt handling Files changed: .changeset/fn-5917-codex-oauth-login.md | 5 ++ packages/dashboard/src/__tests__/routes-auth.test.ts | 72 ++++++++++++++++++++++ packages/dashboard/src/routes/register-auth-routes.ts | 26 ++++++-- 3 files changed, 97 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-5917 Fusion-Task-Lineage: 3b069280-9dbf-47ee-ae04-b7c54ff14d63 --- .changeset/fn-5917-codex-oauth-login.md | 5 ++ .../src/__tests__/routes-auth.test.ts | 72 +++++++++++++++++++ .../src/routes/register-auth-routes.ts | 26 +++++-- 3 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-5917-codex-oauth-login.md diff --git a/.changeset/fn-5917-codex-oauth-login.md b/.changeset/fn-5917-codex-oauth-login.md new file mode 100644 index 0000000000..d8f1c848bc --- /dev/null +++ b/.changeset/fn-5917-codex-oauth-login.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the dashboard OAuth login flow for ChatGPT Plus/Pro (Codex Subscription) so multi-option provider selection prompts no longer cancel the login before browser auth starts. diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 3981846bfc..b8b19959a5 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -1499,6 +1499,78 @@ describe("POST /auth/login", () => { expect(observedPromptInput).toBe("manual-code"); }); + it("prefers browser login for openai-codex multi-option prompts", async () => { + let selectedOption: string | undefined; + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([{ id: "openai-codex", name: "OpenAI Codex" }]); + (authStorage.login as ReturnType).mockImplementation(async (_provider: string, callbacks: any) => { + selectedOption = await callbacks.onSelect({ + message: "Select OpenAI Codex login method:", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device_code", label: "Device code login (headless)" }, + ], + }); + if (!selectedOption) { + throw new Error("Login cancelled"); + } + callbacks.onAuth({ + url: "https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + }); + }); + + const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "openai-codex" }), { + "Content-Type": "application/json", + }); + + expect(selectedOption).toBe("browser"); + expect(res.status).toBe(200); + expect(res.body.url).toBe( + "https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + ); + }); + + it("keeps returning the only option id for single-option prompts", async () => { + let selectedOption: string | undefined; + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([{ id: "openai-codex", name: "OpenAI Codex" }]); + (authStorage.login as ReturnType).mockImplementation(async (_provider: string, callbacks: any) => { + selectedOption = await callbacks.onSelect({ + message: "Only one choice", + options: [{ id: "browser", label: "Browser login" }], + }); + callbacks.onAuth({ url: "https://auth.example.com/login" }); + }); + + const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "openai-codex" }), { + "Content-Type": "application/json", + }); + + expect(selectedOption).toBe("browser"); + expect(res.status).toBe(200); + }); + + it("prefers the default-labelled option for generic multi-option prompts", async () => { + let selectedOption: string | undefined; + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([{ id: "anthropic", name: "Anthropic" }]); + (authStorage.login as ReturnType).mockImplementation(async (_provider: string, callbacks: any) => { + selectedOption = await callbacks.onSelect({ + message: "Select login method:", + options: [ + { id: "device_code", label: "Device code login" }, + { id: "browser", label: "Browser login (DEFAULT)" }, + { id: "manual", label: "Manual login" }, + ], + }); + callbacks.onAuth({ url: "https://claude.ai/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A3210%2Fauth%2Fcallback" }); + }); + + const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), { + "Content-Type": "application/json", + }); + + expect(selectedOption).toBe("browser"); + expect(res.status).toBe(200); + }); + it("returns 400 when provider is missing", async () => { const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), { "Content-Type": "application/json", diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 7a68306fb4..1b251952e8 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -189,6 +189,25 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return providerId === "github-copilot"; } + function selectOauthOption( + providerId: string, + prompt: { options: Array<{ id: string; label?: string }> }, + ): string | undefined { + if (prompt.options.length === 1) { + return prompt.options[0]?.id; + } + + const defaultLabeledOption = prompt.options.find((option) => /\(default\)/i.test(option.label ?? "")); + + // FN-5917: returning undefined here caused pi-ai's openai-codex login + // flow to throw "Login cancelled" before it could open browser auth. + if (providerId === "openai-codex") { + return prompt.options.find((option) => option.id === "browser")?.id ?? defaultLabeledOption?.id ?? prompt.options[0]?.id; + } + + return defaultLabeledOption?.id ?? prompt.options[0]?.id; + } + async function probeDroidCliWithEffectiveBinary(req?: Request) { let pluginSettings: Record | undefined; if (req) { @@ -866,12 +885,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { // to race pasted codes against the localhost callback server. onManualCodeInput: async () => await pendingLogin.inputPromise, onProgress: () => {}, // no-op for web UI - onSelect: async (prompt) => { - if (prompt.options.length === 1) { - return prompt.options[0]?.id; - } - return undefined; - }, + onSelect: async (prompt) => selectOauthOption(provider, prompt), signal: abortController.signal, }); From 38b84a36f4133a29860eaca5f61cfb13fb76dd55 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 18:58:48 -0700 Subject: [PATCH 07/46] FN-5908: recover planning sessions after load failures Keep failed planning session resumes recoverable instead of dead-ending in the empty planner. - route errored persisted planning sessions into the retryable error view during session restore - preserve the session id and surface load/parsing errors through Retry/Dismiss recovery instead of a generic load failure reset - add planning modal coverage for errored sidebar sessions, malformed persisted results, reopen resync, retry reuse, and missing-session fallback behavior Files changed: .changeset/fair-lizards-jump.md | 5 + .../dashboard/app/components/PlanningModeModal.tsx | 27 +- .../PlanningModeModal.planning-flow.test.tsx | 284 ++++++++++++++++++++- 3 files changed, 305 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-5908 Fusion-Task-Lineage: 6904c223-562d-45c5-8965-71b5b90dcbe9 --- .changeset/fair-lizards-jump.md | 5 + .../app/components/PlanningModeModal.tsx | 27 +- .../PlanningModeModal.planning-flow.test.tsx | 284 +++++++++++++++++- 3 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 .changeset/fair-lizards-jump.md diff --git a/.changeset/fair-lizards-jump.md b/.changeset/fair-lizards-jump.md new file mode 100644 index 0000000000..fcbc80115c --- /dev/null +++ b/.changeset/fair-lizards-jump.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Recover failed Planning Mode session loads into the existing retryable error view instead of dropping back to the empty planner. Failed or malformed persisted planning sessions now keep their session id so Retry/Dismiss recovery remains available, while deleted sessions still quietly fall back to a new session. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 571e5f71ed..20651619bf 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -806,6 +806,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ), ); + if (session.status === "error") { + setView({ + type: "error", + session: { sessionId, currentQuestion: null, summary: null }, + errorMessage: session.error || "Session failed", + }); + return; + } + if (session.status === "draft") { // Draft hasn't been started yet — restore the user's saved text + // model selection into the editor, reattach the draft id so a @@ -869,16 +878,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "loading" }); if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput); connectToPlanningStream(sessionId); - } else if (session.status === "error") { - setView({ - type: "error", - session: { sessionId, currentQuestion: null, summary: null }, - errorMessage: session.error || "Session failed", - }); } - } catch { - setError("Failed to load session"); - setView({ type: "initial" }); + } catch (err) { + currentSessionIdRef.current = sessionId; + setLockSessionId(sessionId); + setError(null); + setView({ + type: "error", + session: { sessionId, currentQuestion: null, summary: null }, + errorMessage: getErrorMessage(err) || "Failed to load session", + }); } }, [connectToPlanningStream, projectId], diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 5f71fc33d4..4d7f8847e6 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -1046,7 +1046,7 @@ describe("PlanningModeModal", () => { expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined(); }); - it("shows retry panel when resuming an errored session", async () => { + it("shows retry panel when resuming an errored session and retries the same session", async () => { mockFetchAiSession.mockResolvedValueOnce({ id: "session-error-1", type: "planning", @@ -1062,6 +1062,7 @@ describe("PlanningModeModal", () => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); + mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-error-1" }); render( { ); await waitFor(() => { - expect(screen.getByText("Session interrupted")).toBeDefined(); + expect(screen.getByRole("alert")).toHaveTextContent("Session interrupted"); + }); + expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-error-1", undefined, expect.any(String)); + }); + }); + + it("shows retry panel when selecting an errored session from the sidebar", async () => { + mockFetchAiSessions.mockResolvedValueOnce([ + { + id: "session-sidebar-error", + type: "planning", + status: "error", + title: "Sidebar errored session", + projectId: null, + lockedByTab: null, + updatedAt: "2026-01-02T00:00:00.000Z", + archived: false, + }, + ]); + mockFetchAiSession.mockResolvedValueOnce({ + id: "session-sidebar-error", + type: "planning", + status: "error", + title: "Sidebar errored session", + inputPayload: JSON.stringify({ initialPlan: "Recover sidebar session" }), + conversationHistory: "[]", + currentQuestion: null, + result: null, + thinkingOutput: "", + error: "Sidebar session interrupted", + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + mockRetryPlanningSession.mockResolvedValueOnce({ success: true, sessionId: "session-sidebar-error" }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Sidebar errored session/i })).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /Sidebar errored session/i })); + + await waitFor(() => { + expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-error"); + expect(screen.getByRole("alert")).toHaveTextContent("Sidebar session interrupted"); + }); + expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-sidebar-error", undefined, expect.any(String)); + }); + }); + + it("routes malformed persisted result data from sidebar selection to the recoverable error view", async () => { + mockFetchAiSessions.mockResolvedValueOnce([ + { + id: "session-malformed-result", + type: "planning", + status: "complete", + title: "Malformed result session", + projectId: null, + lockedByTab: null, + updatedAt: "2026-01-02T00:00:00.000Z", + archived: false, + }, + ]); + mockFetchAiSession.mockResolvedValueOnce({ + id: "session-malformed-result", + type: "planning", + status: "complete", + title: "Malformed result session", + inputPayload: JSON.stringify({ initialPlan: "Recover malformed result" }), + conversationHistory: "[]", + currentQuestion: null, + result: "{", + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Malformed result session/i })).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /Malformed result session/i })); + + await waitFor(() => { + expect(mockFetchAiSession).toHaveBeenCalledWith("session-malformed-result"); + expect(screen.getByRole("alert")).toBeDefined(); }); expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); + }); + + it("re-syncs the selected session to the recoverable error view when the modal reopens", async () => { + const reopenedSummary: PlanningSummary = { + title: "Reopen then recover", + description: "First open shows a valid summary", + suggestedSize: "S", + suggestedDependencies: [], + keyDeliverables: ["Recover"], + }; + + mockFetchAiSessions.mockResolvedValue([ + { + id: "session-reopen-recover", + type: "planning", + status: "complete", + title: "Reopen recover session", + projectId: null, + lockedByTab: null, + updatedAt: "2026-01-02T00:00:00.000Z", + archived: false, + }, + ]); + mockFetchAiSession + .mockResolvedValueOnce({ + id: "session-reopen-recover", + type: "planning", + status: "complete", + title: "Reopen recover session", + inputPayload: JSON.stringify({ initialPlan: "Reopen recover session" }), + conversationHistory: "[]", + currentQuestion: null, + result: JSON.stringify(reopenedSummary), + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }) + .mockResolvedValueOnce({ + id: "session-reopen-recover", + type: "planning", + status: "complete", + title: "Reopen recover session", + inputPayload: JSON.stringify({ initialPlan: "Reopen recover session" }), + conversationHistory: "[]", + currentQuestion: null, + result: "{", + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-03T00:00:00.000Z", + }); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Reopen recover session/i })).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /Reopen recover session/i })); + + await waitFor(() => { + expect(screen.getByText("Planning Complete!")).toBeDefined(); + }); + + rerender( + , + ); + + rerender( + , + ); + + await waitFor(() => { + expect(mockFetchAiSession).toHaveBeenLastCalledWith("session-reopen-recover"); + expect(screen.getByRole("alert")).toBeDefined(); + }); + expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Start Planning" })).toBeNull(); + }); + + it("quietly falls back to the initial view when a resumed session no longer exists", async () => { + mockFetchAiSession.mockResolvedValueOnce(null); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined(); + }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText("Failed to load session")).toBeNull(); + }); + + it("quietly falls back to the initial view when a sidebar session no longer exists", async () => { + mockFetchAiSessions.mockResolvedValueOnce([ + { + id: "session-sidebar-deleted", + type: "planning", + status: "complete", + title: "Sidebar deleted session", + projectId: null, + lockedByTab: null, + updatedAt: "2026-01-02T00:00:00.000Z", + archived: false, + }, + ]); + mockFetchAiSession.mockResolvedValueOnce(null); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Sidebar deleted session/i })).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /Sidebar deleted session/i })); + + await waitFor(() => { + expect(mockFetchAiSession).toHaveBeenCalledWith("session-sidebar-deleted"); + expect(screen.getByRole("button", { name: "Start Planning" })).toBeDefined(); + }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText("Failed to load session")).toBeNull(); }); it("creates a task from a resumed complete session and keeps the completed session in local history", async () => { From 6d2a08d7d7188093c9f1665cb05031e692a60aaa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 19:10:50 -0700 Subject: [PATCH 08/46] FN-5910: fix skills toggle mobile anchoring Keep the Skills view toggle anchored correctly after skill enablement on mobile and desktop. - add relative positioning to the Skills view toggle wrapper so the hidden input stays aligned with its label - expand the CSS regression test to verify the hidden input anchoring contract and checked-toggle geometry selectors Files changed: packages/dashboard/app/components/SkillsView.css | 1 + .../components/__tests__/SkillsView.css.test.ts | 30 +++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-5910 Fusion-Task-Lineage: d5988ddb-febe-4319-bfef-d0dbc37909ba --- .../dashboard/app/components/SkillsView.css | 1 + .../__tests__/SkillsView.css.test.ts | 30 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/dashboard/app/components/SkillsView.css b/packages/dashboard/app/components/SkillsView.css index 7fd30eed19..46c779ca23 100644 --- a/packages/dashboard/app/components/SkillsView.css +++ b/packages/dashboard/app/components/SkillsView.css @@ -116,6 +116,7 @@ /* Toggle switch */ .skills-view-item-toggle { + position: relative; display: flex; align-items: center; cursor: pointer; diff --git a/packages/dashboard/app/components/__tests__/SkillsView.css.test.ts b/packages/dashboard/app/components/__tests__/SkillsView.css.test.ts index e454254ce5..b2fdcc3d26 100644 --- a/packages/dashboard/app/components/__tests__/SkillsView.css.test.ts +++ b/packages/dashboard/app/components/__tests__/SkillsView.css.test.ts @@ -55,11 +55,35 @@ describe("SkillsView/runtime-card token guardrails", () => { expect(infoBlock).toContain("width: auto"); }); + it("anchors the hidden toggle input to the toggle label across desktop and mobile", async () => { + const css = await loadAllAppCss(); + const toggleBlock = extractRuleBlock(css, ".skills-view-item-toggle"); + const inputBlock = extractRuleBlock(css, ".skills-view-item-toggle input"); + const mobileMediaBlock = extractMobileMediaBlocks(css); + const mobileToggleBlock = extractRuleBlock(mobileMediaBlock, ".skills-view-item-toggle"); + + expect(toggleBlock).toContain("position: relative"); + expect(inputBlock).toContain("position: absolute"); + expect(inputBlock).toContain("clip: rect(0, 0, 0, 0)"); + expect(mobileToggleBlock).not.toMatch(/position\s*:/); + }); + it("keeps checked and unchecked toggle geometry token-aligned", async () => { const css = await loadAllAppCss(); + const sliderBlock = extractRuleBlock(css, ".skills-view-toggle-slider"); + const checkedSliderBlock = extractRuleBlock( + css, + ".skills-view-item-toggle input:checked + .skills-view-toggle-slider" + ); + const checkedKnobBlock = extractRuleBlock( + css, + ".skills-view-item-toggle input:checked + .skills-view-toggle-slider::after" + ); - expect(css).toContain("width: calc(var(--space-xl) + var(--space-lg))"); - expect(css).toContain("transform: translateX(calc(var(--space-lg) + (var(--space-xs) / 2)))"); - expect(css).toContain("background: var(--color-success)"); + expect(sliderBlock).toContain("width: calc(var(--space-xl) + var(--space-lg))"); + expect(checkedSliderBlock).toContain("background: var(--color-success)"); + expect(checkedKnobBlock).toContain( + "transform: translateX(calc(var(--space-lg) + (var(--space-xs) / 2)))" + ); }); }); From 39d96bc83b721e7ecc1a8ee258231784136ff5c0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 19:13:24 -0700 Subject: [PATCH 09/46] FN-5913: fix dashboard spinner anchoring Keep dashboard loading spinners rotating around the correct center. - switch shared SVG spinner utilities from fill-box to view-box anchoring - factor spinner CSS assertions into a shared contract helper - add lucide Loader2 coverage and a regression test against fill-box anchoring Files changed: .../app/__tests__/spinner-animation.css.test.ts | 50 ++++++++++++++-------- packages/dashboard/app/styles.css | 2 +- 2 files changed, 34 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-5913 Fusion-Task-Lineage: 5fbf2577-301e-4813-ac15-30a73267d155 --- .../__tests__/spinner-animation.css.test.ts | 50 ++++++++++++------- packages/dashboard/app/styles.css | 2 +- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts b/packages/dashboard/app/__tests__/spinner-animation.css.test.ts index e4231946f4..7e982b1028 100644 --- a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts +++ b/packages/dashboard/app/__tests__/spinner-animation.css.test.ts @@ -1,6 +1,9 @@ +import React from "react"; import { describe, expect, it } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; +import { render, screen } from "@testing-library/react"; +import { Loader2 } from "lucide-react"; function extractBlock(content: string, pattern: RegExp): string { const match = content.match(pattern); @@ -20,31 +23,44 @@ function extractBlock(content: string, pattern: RegExp): string { return content.slice(match!.index!, index); } +function assertSharedSpinnerCssContract(css: string): void { + const topLevelSpinBlock = extractBlock(css, /@keyframes\s+spin\s*\{/); + const animateSpinBlock = css.match(/\.animate-spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + const spinBlock = css.match(/\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + const svgSpinnerBlock = css.match(/svg\.animate-spin,\s*svg\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + + expect(topLevelSpinBlock).toContain("transform: rotate(360deg);"); + expect(css.indexOf("@keyframes spin")).toBeLessThan(css.indexOf(":root {\n --bg:")); + + expect(animateSpinBlock).toContain("animation: spin 1s linear infinite;"); + expect(spinBlock).toContain("animation: spin 1s linear infinite;"); + expect(animateSpinBlock).toContain("transform-origin: center;"); + expect(spinBlock).toContain("transform-origin: center;"); + + expect(svgSpinnerBlock).toContain("transform-box: view-box;"); + expect(svgSpinnerBlock).not.toContain("transform-box: fill-box;"); +} + describe("global spinner animation utility", () => { const css = readFileSync(resolve(__dirname, "../styles.css"), "utf8"); - it("keeps top-level spin keyframes rotating to 360deg", () => { - const topLevelSpinBlock = extractBlock(css, /@keyframes\s+spin\s*\{/); - - expect(topLevelSpinBlock).toContain("transform: rotate(360deg);"); - expect(css.indexOf("@keyframes spin")).toBeLessThan(css.indexOf(":root {\n --bg:")); + it("keeps the shared spin utility centered and rotating infinitely", () => { + assertSharedSpinnerCssContract(css); }); - it("keeps the shared animate-spin and spin utilities running infinitely", () => { - const animateSpinBlock = css.match(/\.animate-spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const spinBlock = css.match(/\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + it("keeps the svg spinner contract aligned with lucide stroke-only loaders", () => { + render(React.createElement(Loader2, { className: "animate-spin", "data-testid": "spinner" })); - expect(animateSpinBlock).toContain("animation: spin 1s linear infinite;"); - expect(spinBlock).toContain("animation: spin 1s linear infinite;"); + const spinner = screen.getByTestId("spinner"); + expect(spinner.tagName.toLowerCase()).toBe("svg"); + expect(spinner).toHaveAttribute("class", expect.stringContaining("animate-spin")); + expect(spinner).toHaveAttribute("fill", "none"); + expect(spinner).toHaveAttribute("viewBox", "0 0 24 24"); }); - it("anchors SVG spinners around their own center", () => { - const animateSpinBlock = css.match(/\.animate-spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const spinBlock = css.match(/\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const svgSpinnerBlock = css.match(/svg\.animate-spin,\s*svg\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + it("fails the contract if svg spinners regress back to fill-box anchoring", () => { + const regressedCss = css.replace("transform-box: view-box;", "transform-box: fill-box;"); - expect(animateSpinBlock).toContain("transform-origin: center;"); - expect(spinBlock).toContain("transform-origin: center;"); - expect(svgSpinnerBlock).toContain("transform-box: fill-box;"); + expect(() => assertSharedSpinnerCssContract(regressedCss)).toThrow(); }); }); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 27a19d0427..d3b0ab4d41 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -197,7 +197,7 @@ html { svg.animate-spin, svg.spin { - transform-box: fill-box; + transform-box: view-box; } :root { From 00419623093facaf6c185945be6d23f3b2e33383 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 19:19:33 -0700 Subject: [PATCH 10/46] FN-5915: show unread badge on header mailbox Add unread mailbox status to the desktop header toggle while keeping pending approvals prioritized. - show an unread status dot on the desktop header mailbox toggle when unread mail exists without pending approvals - keep pending-approval indicators taking precedence and hide mailbox indicators while the mailbox view is active - extend Header coverage for unread-only, pending-only, combined, zero-count, and active-mailbox states - update restart integration coverage to reuse an existing worktree during orphaned resume concurrency Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/Header.tsx | 9 +++++-- .../app/components/__tests__/Header.test.tsx | 26 ++++++++++++++++--- .../src/__tests__/restart.integration.test.ts | 29 ++++++++++++++++++++-- 4 files changed, 58 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-5915 Fusion-Task-Lineage: 3a207e2c-ca58-402f-94cf-3a9e514fe263 --- docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/Header.tsx | 9 ++++-- .../app/components/__tests__/Header.test.tsx | 30 +++++++++++++++---- .../src/__tests__/restart.integration.test.ts | 29 ++++++++++++++++-- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 99aedc9ce6..e8195713be 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -211,7 +211,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. - reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading - mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests - in the **Agents** tab, the agent selector now includes **All agents**, which shows one combined agent-to-agent stream (with sender + recipient labels); selecting a specific agent still shows Inbox/Outbox subtabs -- mailbox entry points now show pending-approval indicators: Header mailbox toggle dot, Header overflow mailbox badge, Mobile mailbox tab dot, and Mobile More → Mailbox badge +- mailbox entry points now show unread/pending indicators: the desktop Header mailbox toggle shows a pending-approval dot first or an unread dot when unread mail exists without pending approvals, while Header overflow + Mobile mailbox entry points continue to surface mailbox badges/dots - approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload - when a task newly enters `awaiting-approval`, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; dismissals are remembered per approval item until that item advances or a different one arrives - Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 0feeb7e934..14129259b7 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -1170,9 +1170,14 @@ export function Header({ aria-pressed={view === "mailbox"} > - {mailboxPendingApprovalCount > 0 && view !== "mailbox" && ( + {view !== "mailbox" && mailboxPendingApprovalCount > 0 ? ( - )} + ) : view !== "mailbox" && mailboxUnreadCount > 0 ? ( + + ) : null} {pluginDashboardViews .filter((entry) => entry.view.placement === "primary") diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx index fa03bdd402..679789cc38 100644 --- a/packages/dashboard/app/components/__tests__/Header.test.tsx +++ b/packages/dashboard/app/components/__tests__/Header.test.tsx @@ -178,14 +178,34 @@ describe("Header", () => { expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument(); }); - it("shows mailbox pending-approval indicator when mailbox is not active", () => { - renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2 }); - expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument(); + it("shows mailbox unread indicator when there are unread messages only", () => { + renderHeader({ onChangeView: noop, view: "board", mailboxUnreadCount: 3, mailboxPendingApprovalCount: 0 }); + expect(screen.getByLabelText("3 unread messages")).toBeInTheDocument(); + expect(screen.queryByLabelText("Pending approvals")).toBeNull(); }); - it("hides mailbox pending-approval indicator when mailbox view is active", () => { - renderHeader({ onChangeView: noop, view: "mailbox", mailboxPendingApprovalCount: 2 }); + it("shows mailbox pending-approval indicator when mailbox is not active", () => { + renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2, mailboxUnreadCount: 0 }); + expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument(); + expect(screen.queryByLabelText(/unread messages/)).toBeNull(); + }); + + it("shows only the pending indicator when mailbox has both pending approvals and unread messages", () => { + renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2, mailboxUnreadCount: 4 }); + expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument(); + expect(screen.queryByLabelText("4 unread messages")).toBeNull(); + }); + + it("hides mailbox indicators when counts are zero", () => { + renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 0, mailboxUnreadCount: 0 }); expect(screen.queryByLabelText("Pending approvals")).toBeNull(); + expect(screen.queryByLabelText(/unread messages/)).toBeNull(); + }); + + it("hides mailbox indicators when mailbox view is active", () => { + renderHeader({ onChangeView: noop, view: "mailbox", mailboxPendingApprovalCount: 2, mailboxUnreadCount: 3 }); + expect(screen.queryByLabelText("Pending approvals")).toBeNull(); + expect(screen.queryByLabelText(/unread messages/)).toBeNull(); }); it("hides chat unread indicator when chat view is active", () => { diff --git a/packages/engine/src/__tests__/restart.integration.test.ts b/packages/engine/src/__tests__/restart.integration.test.ts index 5df64cf23c..2222a165c3 100644 --- a/packages/engine/src/__tests__/restart.integration.test.ts +++ b/packages/engine/src/__tests__/restart.integration.test.ts @@ -1298,9 +1298,34 @@ describe("Crash scenario edge cases", () => { it("concurrent resumeOrphaned() calls don't double-execute the same task", async () => { const store = createMockStore(); - const task = makeTask("FN-092", "in-progress"); + const worktreePath = "/tmp/test/.worktrees/swift-falcon"; + const task = makeTask("FN-092", "in-progress", { + worktree: worktreePath, + branch: "fusion/fn-092", + }); store.listTasks.mockResolvedValue([task]); - store.getTask.mockResolvedValue(makeTaskDetail("FN-092", "in-progress")); + store.getTask.mockResolvedValue(makeTaskDetail("FN-092", "in-progress", { + worktree: worktreePath, + branch: "fusion/fn-092", + })); + mockedExecSync.mockImplementation(((cmd: unknown) => { + if (String(cmd) === "git rev-parse --is-inside-work-tree") { + return "true\n" as any; + } + if (String(cmd) === "git worktree list --porcelain") { + return [ + "worktree /tmp/test", + "HEAD abc123", + "branch refs/heads/main", + "", + `worktree ${worktreePath}`, + "HEAD def456", + "branch refs/heads/fusion/fn-092", + "", + ].join("\n") as any; + } + return Buffer.from(""); + }) as any); let resolvePrompt: (() => void) | undefined; mockedCreateFnAgent.mockResolvedValue({ From a411b8a7e024483f75747f41321d3847c659a005 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 19:28:01 -0700 Subject: [PATCH 11/46] FN-5914: fix mission activity counts and chronology Keep mission activity counts accurate before event data loads and show events in chronological order. - add persisted mission event counts to core mission summaries and dashboard mission summary types - use the summary event count for the Activity tab preload badge instead of the currently loaded page total - render mission activity oldest-to-newest, keep load-more prepends stable, and scroll to the latest event on initial/live updates - extend mission store, dashboard, and MissionManager tests for event count and ordering coverage Files changed: packages/core/src/__tests__/mission-store.test.ts | 19 ++++ packages/core/src/mission-store.ts | 24 ++++- packages/dashboard/app/api/legacy.ts | 2 + .../dashboard/app/components/MissionManager.tsx | 19 ++-- .../components/__tests__/MissionManager.test.tsx | 107 ++++++++++++++++----- packages/dashboard/app/components/mission-types.ts | 1 + .../dashboard/src/__tests__/mission-e2e.test.ts | 7 +- 7 files changed, 143 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-5914 Fusion-Task-Lineage: b3119e94-66bd-437d-bcde-d5e4e43cb29e --- .../core/src/__tests__/mission-store.test.ts | 19 ++++ packages/core/src/mission-store.ts | 24 +++- packages/dashboard/app/api/legacy.ts | 2 + .../app/components/MissionManager.tsx | 19 +++- .../__tests__/MissionManager.test.tsx | 107 ++++++++++++++---- .../dashboard/app/components/mission-types.ts | 1 + .../src/__tests__/mission-e2e.test.ts | 7 +- 7 files changed, 143 insertions(+), 36 deletions(-) diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index c3c1fa640e..b300a77116 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -246,6 +246,7 @@ describe("MissionStore", () => { totalFeatures: 0, completedFeatures: 0, linkedGoalCount: 0, + eventCount: 0, progressPercent: 0, }); }); @@ -324,6 +325,18 @@ describe("MissionStore", () => { expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(2); }); + it("getMissionSummary reports unfiltered event counts", () => { + const mission = store.createMission({ title: "Eventful mission" }); + + expect(store.getMissionSummary(mission.id).eventCount).toBe(0); + + store.logMissionEvent(mission.id, "mission_started", "started"); + store.logMissionEvent(mission.id, "warning", "warning"); + store.logMissionEvent(mission.id, "error", "error"); + + expect(store.getMissionSummary(mission.id).eventCount).toBe(3); + }); + it("findNextPendingSlice skips completed slices in earlier milestones", () => { const mission = store.createMission({ title: "Next pending" }); const m1 = store.addMilestone(mission.id, { title: "M1" }); @@ -398,6 +411,7 @@ describe("MissionStore", () => { totalFeatures: 0, completedFeatures: 0, linkedGoalCount: 0, + eventCount: 0, progressPercent: 0, }); @@ -409,6 +423,7 @@ describe("MissionStore", () => { totalFeatures: 0, completedFeatures: 0, linkedGoalCount: 0, + eventCount: 0, progressPercent: 0, }); @@ -420,6 +435,7 @@ describe("MissionStore", () => { totalFeatures: 2, completedFeatures: 1, linkedGoalCount: 0, + eventCount: 0, progressPercent: 50, }); }); @@ -437,6 +453,8 @@ describe("MissionStore", () => { createGoalInDb(db, "G-004", "Reliability"); store.linkGoal(mission.id, "G-003"); store.linkGoal(mission.id, "G-004"); + store.logMissionEvent(mission.id, "mission_started", "started"); + store.logMissionEvent(mission.id, "warning", "warning"); const singleSummary = store.getMissionSummary(mission.id); const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!; @@ -446,6 +464,7 @@ describe("MissionStore", () => { expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures); expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures); expect(batchedResult.summary.linkedGoalCount).toBe(singleSummary.linkedGoalCount); + expect(batchedResult.summary.eventCount).toBe(singleSummary.eventCount); expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent); }); diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index cf3ef04e38..e0839bc084 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -123,6 +123,8 @@ export interface MissionSummary { completedFeatures: number; /** Number of goals linked to the mission */ linkedGoalCount: number; + /** Unfiltered total number of persisted mission lifecycle events */ + eventCount: number; /** Computed progress percentage (0–100), based on features or milestones */ progressPercent: number; } @@ -772,6 +774,11 @@ export class MissionStore extends EventEmitter { .get(missionId) as { count?: number | bigint } | undefined; const linkedGoalCount = Number(linkedGoalRow?.count ?? 0); + const eventCountRow = this.db + .prepare("SELECT COUNT(*) AS count FROM mission_events WHERE missionId = ?") + .get(missionId) as { count?: number | bigint } | undefined; + const eventCount = Number(eventCountRow?.count ?? 0); + let progressPercent = 0; if (totalFeatures > 0) { progressPercent = Math.round((completedFeatures / totalFeatures) * 100); @@ -785,6 +792,7 @@ export class MissionStore extends EventEmitter { totalFeatures, completedFeatures, linkedGoalCount, + eventCount, progressPercent, }; } @@ -829,7 +837,15 @@ export class MissionStore extends EventEmitter { linkedGoalRows.map((row) => [row.missionId, Number(row.count ?? 0)]), ); - // 6. Group in-memory: slices by milestoneId, features by sliceId + // 6. Batch query mission event counts + const eventCountRows = this.db.prepare( + "SELECT missionId, COUNT(*) AS count FROM mission_events GROUP BY missionId" + ).all() as Array<{ missionId: string; count?: number | bigint }>; + const eventCountByMissionId = new Map( + eventCountRows.map((row) => [row.missionId, Number(row.count ?? 0)]), + ); + + // 7. Group in-memory: slices by milestoneId, features by sliceId const slicesByMilestoneId = new Map(); for (const slice of allSlices) { const list = slicesByMilestoneId.get(slice.milestoneId) || []; @@ -844,7 +860,7 @@ export class MissionStore extends EventEmitter { featuresBySliceId.set(feature.sliceId, list); } - // 7. Group milestones by missionId + // 8. Group milestones by missionId const milestonesByMissionId = new Map(); for (const milestone of allMilestones) { const list = milestonesByMissionId.get(milestone.missionId) || []; @@ -852,7 +868,7 @@ export class MissionStore extends EventEmitter { milestonesByMissionId.set(milestone.missionId, list); } - // 8. Compute summary for each mission using grouped data + // 9. Compute summary for each mission using grouped data return missions.map((mission) => { const milestones = milestonesByMissionId.get(mission.id) || []; const totalMilestones = milestones.length; @@ -871,6 +887,7 @@ export class MissionStore extends EventEmitter { } const linkedGoalCount = linkedGoalCountByMissionId.get(mission.id) ?? 0; + const eventCount = eventCountByMissionId.get(mission.id) ?? 0; let progressPercent = 0; if (totalFeatures > 0) { @@ -887,6 +904,7 @@ export class MissionStore extends EventEmitter { totalFeatures, completedFeatures, linkedGoalCount, + eventCount, progressPercent, }, }; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index aa40bd0527..206d9d5b66 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6919,6 +6919,8 @@ export interface MissionSummary { completedMilestones: number; totalFeatures: number; completedFeatures: number; + linkedGoalCount: number; + eventCount: number; progressPercent: number; } diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 7735f9004a..456bb1f795 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -918,6 +918,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const activityEventsEndRef = useRef(null); + const activityTabEventCount = useMemo(() => { + if (!selectedMission?.id) { + return eventsTotal; + } + return missions.find((mission) => mission.id === selectedMission.id)?.summary?.eventCount ?? eventsTotal; + }, [eventsTotal, missions, selectedMission?.id]); + + const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]); + // Keep latest state available to long-lived SSE handlers without reconnect churn. missionsRef.current = missions; selectedMissionRef.current = selectedMission; @@ -1538,10 +1547,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr if (shouldAutoScroll) { requestAnimationFrame(() => { - const container = activityEventsContainerRef.current; - if (container) { - container.scrollTop = 0; - } + scrollActivityToLatest(); }); } } catch { @@ -1584,6 +1590,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr projectId, refreshMissionSidebar, refreshValidationTelemetry, + scrollActivityToLatest, ]); // Mission handlers @@ -2752,7 +2759,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr aria-selected={activeTab === "activity"} data-testid="mission-tab-activity" > - Activity ({eventsTotal}) + Activity ({activityTabEventCount}) @@ -3994,7 +4001,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr className="mission-events" data-testid="mission-activity-events" > - {missionEvents.map((event) => { + {displayedMissionEvents.map((event) => { const hasMetadata = Boolean(event.metadata && Object.keys(event.metadata).length > 0); const metadataExpanded = expandedEventMetadata.has(event.id); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.test.tsx index b76a05e67d..a8cb3e7abc 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.test.tsx @@ -92,6 +92,15 @@ const mockMissions = [ status: "planning", interviewState: "not_started", milestones: [], + summary: { + totalMilestones: 1, + completedMilestones: 0, + totalFeatures: 2, + completedFeatures: 0, + linkedGoalCount: 0, + eventCount: 4, + progressPercent: 0, + }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, @@ -110,6 +119,7 @@ const mockMissions = [ totalFeatures: 5, completedFeatures: 3, linkedGoalCount: 1, + eventCount: 2, progressPercent: 60, }, createdAt: "2026-01-02T00:00:00.000Z", @@ -302,20 +312,12 @@ const mockMilestoneValidationTelemetry = { const mockMissionEvents = [ { - id: "E-001", + id: "E-004", missionId: "M-001", - eventType: "mission_started", - description: "Mission started", - metadata: null, - timestamp: "2026-01-03T10:00:00.000Z", - }, - { - id: "E-002", - missionId: "M-001", - eventType: "warning", - description: "Task queue is delayed", - metadata: { queueDepth: 4 }, - timestamp: "2026-01-03T10:10:00.000Z", + eventType: "autopilot_state_changed", + description: "Autopilot moved to watching", + metadata: { previous: "inactive", next: "watching" }, + timestamp: "2026-01-03T10:30:00.000Z", }, { id: "E-003", @@ -326,12 +328,20 @@ const mockMissionEvents = [ timestamp: "2026-01-03T10:20:00.000Z", }, { - id: "E-004", + id: "E-002", missionId: "M-001", - eventType: "autopilot_state_changed", - description: "Autopilot moved to watching", - metadata: { previous: "inactive", next: "watching" }, - timestamp: "2026-01-03T10:30:00.000Z", + eventType: "warning", + description: "Task queue is delayed", + metadata: { queueDepth: 4 }, + timestamp: "2026-01-03T10:10:00.000Z", + }, + { + id: "E-001", + missionId: "M-001", + eventType: "mission_started", + description: "Mission started", + metadata: null, + timestamp: "2026-01-03T10:00:00.000Z", }, ]; @@ -342,7 +352,7 @@ const mockMissionEventsPaged = Array.from({ length: 65 }, (_, index) => ({ description: `Mission event ${index + 1}`, metadata: { index: index + 1 }, timestamp: new Date(Date.UTC(2026, 0, 3, 10, index)).toISOString(), -})); +})).reverse(); /** Create a mock Response that matches the real api() function's expectations (text + content-type headers) */ function mockApiResponse(data: unknown) { @@ -1158,7 +1168,7 @@ describe("MissionManager", () => { expect(screen.queryByText(/"queueDepth": 4/)).toBeNull(); }); - it("loads more mission activity events", async () => { + it("loads more older mission activity events at the top", async () => { globalThis.fetch = createDetailFetchMock(mockMissionEventsPaged as unknown as typeof mockMissionEvents); render(); @@ -1173,6 +1183,8 @@ describe("MissionManager", () => { fireEvent.click(screen.getByTestId("mission-tab-activity")); + const eventsContainer = await screen.findByTestId("mission-activity-events"); + await waitFor(() => { expect(screen.getByText("Mission event 50")).toBeDefined(); expect( @@ -1181,6 +1193,10 @@ describe("MissionManager", () => { }), ).toBeDefined(); expect(screen.getByTestId("mission-activity-load-more")).toBeDefined(); + + const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description")); + expect(eventDescriptions[0]?.textContent).toBe("Mission event 16"); + expect(eventDescriptions[eventDescriptions.length - 1]?.textContent).toBe("Mission event 65"); }); fireEvent.click(screen.getByTestId("mission-activity-load-more")); @@ -1189,10 +1205,30 @@ describe("MissionManager", () => { const activityCount = document.querySelector(".mission-detail__activity-count"); expect(activityCount?.textContent?.trim()).toBe("65 of 65"); expect(screen.queryByTestId("mission-activity-load-more")).toBeNull(); + + const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description")); + expect(eventDescriptions[0]?.textContent).toBe("Mission event 1"); + expect(eventDescriptions[eventDescriptions.length - 1]?.textContent).toBe("Mission event 65"); }, { timeout: 5000 }); }, 15000); - it("auto-scrolls to latest mission activity on initial load", async () => { + it("shows the summary event count before activity events load", async () => { + globalThis.fetch = createDetailFetchMock(mockMissionEvents); + globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource; + + render(); + + await waitFor(() => { + expect(screen.getByText("Build Auth System")).toBeDefined(); + }); + fireEvent.click(screen.getByText("Build Auth System")); + + await waitFor(() => { + expect(screen.getByTestId("mission-tab-activity")).toHaveTextContent("Activity (4)"); + }); + }); + + it("auto-scrolls to the latest mission activity on initial load", async () => { globalThis.fetch = createDetailFetchMock(mockMissionEvents); globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource; @@ -1217,14 +1253,29 @@ describe("MissionManager", () => { await waitFor(() => { expect(screen.getByText("Mission started")).toBeDefined(); - expect(scrollIntoViewSpy).toHaveBeenCalled(); + expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "end", behavior: "auto" }); }); + + const eventsContainer = await screen.findByTestId("mission-activity-events"); + const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description")); + expect(eventDescriptions.map((node) => node.textContent)).toEqual([ + "Mission started", + "Task queue is delayed", + "Feature F-001 completed", + "Autopilot moved to watching", + ]); }); - it("prepends real-time mission events and scrolls to top when near bottom", async () => { + it("appends real-time mission events at the bottom and scrolls to latest when near bottom", async () => { globalThis.fetch = createDetailFetchMock(mockMissionEvents); globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource; + const scrollIntoViewSpy = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoViewSpy, + }); + render(); await waitFor(() => { @@ -1258,11 +1309,17 @@ describe("MissionManager", () => { await waitFor(() => { expect(screen.getByText("Real-time warning event")).toBeDefined(); - expect(eventsContainer.scrollTop).toBe(0); + expect(scrollIntoViewSpy).toHaveBeenLastCalledWith({ block: "end", behavior: "auto" }); }); const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description")); - expect(eventDescriptions[0]?.textContent).toBe("Real-time warning event"); + expect(eventDescriptions.map((node) => node.textContent)).toEqual([ + "Mission started", + "Task queue is delayed", + "Feature F-001 completed", + "Autopilot moved to watching", + "Real-time warning event", + ]); }); it("ignores real-time mission events for non-selected missions", async () => { diff --git a/packages/dashboard/app/components/mission-types.ts b/packages/dashboard/app/components/mission-types.ts index b83cdb4d53..031cac6dad 100644 --- a/packages/dashboard/app/components/mission-types.ts +++ b/packages/dashboard/app/components/mission-types.ts @@ -260,6 +260,7 @@ export interface MissionSummary { totalFeatures: number; completedFeatures: number; linkedGoalCount?: number; + eventCount?: number; progressPercent: number; } diff --git a/packages/dashboard/src/__tests__/mission-e2e.test.ts b/packages/dashboard/src/__tests__/mission-e2e.test.ts index b1080b248a..e405f1f17f 100644 --- a/packages/dashboard/src/__tests__/mission-e2e.test.ts +++ b/packages/dashboard/src/__tests__/mission-e2e.test.ts @@ -133,6 +133,8 @@ function createMockMissionStore(options?: { completedMilestones: 0, totalFeatures: 0, completedFeatures: 0, + linkedGoalCount: 0, + eventCount: 0, progressPercent: 0, }, })) @@ -141,10 +143,11 @@ function createMockMissionStore(options?: { getMissionSummary: vi.fn((_missionId: string) => ({ totalMilestones: 0, completedMilestones: 0, - totalSlices: 0, - completedSlices: 0, totalFeatures: 0, completedFeatures: 0, + linkedGoalCount: 0, + eventCount: 0, + progressPercent: 0, })), getMissionEvents: vi.fn((missionId: string, options?: { limit?: number; offset?: number; eventType?: string }) => { From dbab9e1fd9d9df9a60a0ab16503fa4dc1aaa8e38 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 19:44:37 -0700 Subject: [PATCH 12/46] FN-5906: defer first planning turn until stream connect Ensure initial planning reasoning streams live to the UI instead of being emitted before subscribers connect. - register each new or restarted planning session's first turn as pending until the SSE stream attaches - consume the deferred initial turn on the first subscriber so thinking output and first question are broadcast live - add route-planning coverage for deferred first-turn behavior, existing drafts, concurrent subscribers, and settings-resolution flows Files changed: .../src/__tests__/routes-planning.test.ts | 190 +++++++++++++++++++++ packages/dashboard/src/planning.ts | 63 +++++-- .../src/routes/register-planning-subtask-routes.ts | 2 + 3 files changed, 238 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-5906 Fusion-Task-Lineage: 204fcbaa-15b4-46a0-b794-6dae6ebd97fe --- .../src/__tests__/routes-planning.test.ts | 190 ++++++++++++++++++ packages/dashboard/src/planning.ts | 63 ++++-- .../register-planning-subtask-routes.ts | 2 + 3 files changed, 238 insertions(+), 17 deletions(-) diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 05a2187301..055ff8644b 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -421,6 +421,14 @@ describe("Planning Mode Routes", () => { return app; } + async function connectPlanningStreamUntilComplete(sessionId: string): Promise { + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); + setTimeout(() => { + planningStreamManager.broadcast(sessionId, { type: "complete" }); + }, 0); + await streamPromise; + } + /** Mock agent for planning session tests */ function setupPlanningMockAgent() { const questionResponses = [ @@ -685,6 +693,7 @@ describe("Planning Mode Routes", () => { expect(res.status).toBe(201); expect(res.body.sessionId).toBeDefined(); + await connectPlanningStreamUntilComplete(res.body.sessionId); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledWith( @@ -728,6 +737,7 @@ describe("Planning Mode Routes", () => { ); expect(res.status).toBe(201); + await connectPlanningStreamUntilComplete(res.body.sessionId); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -764,6 +774,7 @@ describe("Planning Mode Routes", () => { ); expect(res.status).toBe(201); + await connectPlanningStreamUntilComplete(res.body.sessionId); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -800,6 +811,7 @@ describe("Planning Mode Routes", () => { ); expect(res.status).toBe(201); + await connectPlanningStreamUntilComplete(res.body.sessionId); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -836,6 +848,7 @@ describe("Planning Mode Routes", () => { ); expect(res.status).toBe(201); + await connectPlanningStreamUntilComplete(res.body.sessionId); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -869,6 +882,7 @@ describe("Planning Mode Routes", () => { ); expect(res.status).toBe(201); + await connectPlanningStreamUntilComplete(res.body.sessionId); await vi.waitFor(() => { // No explicit defaultProvider/defaultModelId means automatic resolution expect(createFnAgentSpy).toHaveBeenCalledWith( @@ -925,6 +939,7 @@ describe("Planning Mode Routes", () => { ); expect(res.status).toBe(201); + await connectPlanningStreamUntilComplete(res.body.sessionId); // Partial project lane should be ignored, falls through to next tier await vi.waitFor(() => { // Should NOT use the partial provider @@ -938,6 +953,41 @@ describe("Planning Mode Routes", () => { }); describe("GET /planning/:sessionId/stream", () => { + function setupStreamingPlanningAgent() { + const promptCalls: string[] = []; + const messages: Array<{ role: string; content: string }> = []; + const createFnAgentSpy = vi.fn(async (options?: { onThinking?: (delta: string) => void }) => ({ + session: { + state: { messages }, + prompt: vi.fn(async (message: string) => { + promptCalls.push(message); + await new Promise((resolve) => { + setTimeout(() => { + options?.onThinking?.("live first-turn reasoning"); + messages.push({ role: "user", content: message }); + messages.push({ + role: "assistant", + content: JSON.stringify({ + type: "question", + data: { + id: "q-live-first-turn", + type: "text", + question: "What should the plan prioritize first?", + }, + }), + }); + resolve(); + }, 1); + }); + }), + dispose: vi.fn(), + }, + })); + + __setCreateFnAgent(createFnAgentSpy as any); + return { createFnAgentSpy, promptCalls }; + } + it("replays buffered events when Last-Event-ID header is provided", async () => { const startRes = await REQUEST( buildApp(), @@ -998,6 +1048,130 @@ describe("Planning Mode Routes", () => { expect(streamRes.body).toContain("event: complete"); }); + it("defers the first streamed turn until SSE connect for new sessions", async () => { + vi.useFakeTimers(); + try { + const { promptCalls } = setupStreamingPlanningAgent(); + + const startRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ initialPlan: "Stream the first planning turn live" }), + { "Content-Type": "application/json" }, + ); + + expect(startRes.status).toBe(201); + const sessionId = startRes.body.sessionId as string; + expect(promptCalls).toHaveLength(0); + expect( + planningStreamManager.getBufferedEvents(sessionId, 0).filter((event) => event.event === "thinking"), + ).toHaveLength(0); + + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(1); + planningStreamManager.broadcast(sessionId, { type: "complete" }); + + const streamRes = await streamPromise; + expect(streamRes.status).toBe(200); + expect(promptCalls).toHaveLength(1); + expect(streamRes.body).toContain("event: thinking"); + expect(streamRes.body).toContain("live first-turn reasoning"); + expect(streamRes.body).toContain("event: question"); + } finally { + vi.useRealTimers(); + } + }); + + it("defers the first streamed turn until SSE connect when starting an existing draft session", async () => { + vi.useFakeTimers(); + try { + const { promptCalls } = setupStreamingPlanningAgent(); + + const draftRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-draft", + JSON.stringify({ initialPlan: "Draft that will be started later" }), + { "Content-Type": "application/json" }, + ); + + expect(draftRes.status).toBe(201); + const sessionId = draftRes.body.sessionId as string; + + const startPromise = REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ + initialPlan: "Draft that will be started later", + existingSessionId: sessionId, + }), + { "Content-Type": "application/json" }, + ); + + await vi.advanceTimersByTimeAsync(1); + const startRes = await startPromise; + expect(startRes.status).toBe(201); + expect(promptCalls).toHaveLength(0); + expect( + planningStreamManager.getBufferedEvents(sessionId, 0).filter((event) => event.event === "thinking"), + ).toHaveLength(0); + + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(1); + planningStreamManager.broadcast(sessionId, { type: "complete" }); + + const streamRes = await streamPromise; + expect(streamRes.status).toBe(200); + expect(promptCalls).toHaveLength(1); + expect(streamRes.body).toContain("event: thinking"); + expect(streamRes.body).toContain("live first-turn reasoning"); + expect(streamRes.body).toContain("event: question"); + } finally { + vi.useRealTimers(); + } + }); + + it("starts the deferred first turn exactly once even with concurrent subscribers", async () => { + vi.useFakeTimers(); + try { + const { promptCalls } = setupStreamingPlanningAgent(); + + const startRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ initialPlan: "Only start the first turn once" }), + { "Content-Type": "application/json" }, + ); + + expect(startRes.status).toBe(201); + const sessionId = startRes.body.sessionId as string; + expect(promptCalls).toHaveLength(0); + + const firstStreamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); + const secondStreamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(1); + planningStreamManager.broadcast(sessionId, { type: "complete" }); + + const [firstStreamRes, secondStreamRes] = await Promise.all([firstStreamPromise, secondStreamPromise]); + expect(promptCalls).toHaveLength(1); + expect(firstStreamRes.status).toBe(200); + expect(secondStreamRes.status).toBe(200); + expect(firstStreamRes.body).toContain("live first-turn reasoning"); + expect(secondStreamRes.body).toContain("live first-turn reasoning"); + } finally { + vi.useRealTimers(); + } + }); + it("treats invalid Last-Event-ID values as first connect", async () => { const startRes = await REQUEST( buildApp(), @@ -3492,6 +3666,10 @@ describe("POST /planning/start-streaming with projectId scoping", () => { ); expect(res.status).toBe(201); + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${res.body.sessionId}/stream`); + await Promise.resolve(); + planningStreamManager.broadcast(res.body.sessionId, { type: "complete" }); + await streamPromise; expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId); expect(scopedStore.getSettings).toHaveBeenCalled(); expect(scopedStore.getRootDir()).toBe("/scoped/planning/project"); @@ -3541,6 +3719,10 @@ describe("POST /planning/start-streaming with projectId scoping", () => { ); expect(res.status).toBe(201); + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${res.body.sessionId}/stream`); + await Promise.resolve(); + planningStreamManager.broadcast(res.body.sessionId, { type: "complete" }); + await streamPromise; // Request body override takes precedence over scoped settings await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledWith( @@ -3582,6 +3764,10 @@ describe("POST /planning/start-streaming with projectId scoping", () => { ); expect(res.status).toBe(201); + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${res.body.sessionId}/stream`); + await Promise.resolve(); + planningStreamManager.broadcast(res.body.sessionId, { type: "complete" }); + await streamPromise; // Scoped settings planning lane should be used await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledWith( @@ -3626,6 +3812,10 @@ describe("POST /planning/start-streaming with projectId scoping", () => { ); expect(res.status).toBe(201); + const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${res.body.sessionId}/stream`); + await Promise.resolve(); + planningStreamManager.broadcast(res.body.sessionId, { type: "complete" }); + await streamPromise; // Default store should be used (getOrCreateProjectStore should not be called) expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); await vi.waitFor(() => { diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index ebfa5ee1cb..254f51ee5b 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -595,6 +595,7 @@ process.on("beforeExit", () => { export class PlanningStreamManager extends EventEmitter { private readonly sessions = new Map>(); private readonly buffers = new Map(); + private readonly pendingInitialTurns = new Map void>(); constructor(private readonly bufferSize = 100) { super(); @@ -662,6 +663,22 @@ export class PlanningStreamManager extends EventEmitter { return buffer.getEventsSince(sinceId); } + registerInitialTurn(sessionId: string, start: () => void): void { + if (this.pendingInitialTurns.has(sessionId)) { + throw new Error(`Initial planning turn already registered for session ${sessionId}`); + } + this.pendingInitialTurns.set(sessionId, start); + } + + consumeInitialTurn(sessionId: string): (() => void) | undefined { + const start = this.pendingInitialTurns.get(sessionId); + if (!start) { + return undefined; + } + this.pendingInitialTurns.delete(sessionId); + return start; + } + /** * Check if a session has active subscribers. */ @@ -683,6 +700,7 @@ export class PlanningStreamManager extends EventEmitter { cleanupSession(sessionId: string): void { this.sessions.delete(sessionId); this.buffers.delete(sessionId); + this.pendingInitialTurns.delete(sessionId); } /** @@ -691,6 +709,7 @@ export class PlanningStreamManager extends EventEmitter { reset(): void { this.sessions.clear(); this.buffers.clear(); + this.pendingInitialTurns.clear(); this.removeAllListeners(); } } @@ -1194,7 +1213,16 @@ export async function startExistingSession( } persistSession(session, "generating"); - await initializeAgent(session, rootDir, store, modelProvider, modelId, promptOverrides); + planningStreamManager.registerInitialTurn(sessionId, () => { + initializeAgent(session, rootDir, store, modelProvider, modelId, promptOverrides).catch((err) => { + diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); + persistSession(session, "error", err.message || "Failed to initialize AI agent"); + planningStreamManager.broadcast(sessionId, { + type: "error", + data: err.message || "Failed to initialize AI agent", + }); + }); + }); } /** @@ -1260,22 +1288,23 @@ export async function createSessionWithAgent( sessions.set(sessionId, session); persistSession(session, "generating"); - // Initialize AI agent in background - it will stream via planningStreamManager - initializeAgent( - session, - rootDir, - store, - modelProvider, - modelId, - promptOverrides, - options?.planningDepth, - options?.customQuestionCount, - ).catch((err) => { - diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); - persistSession(session, "error", err.message || "Failed to initialize AI agent"); - planningStreamManager.broadcast(sessionId, { - type: "error", - data: err.message || "Failed to initialize AI agent", + planningStreamManager.registerInitialTurn(sessionId, () => { + initializeAgent( + session, + rootDir, + store, + modelProvider, + modelId, + promptOverrides, + options?.planningDepth, + options?.customQuestionCount, + ).catch((err) => { + diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); + persistSession(session, "error", err.message || "Failed to initialize AI agent"); + planningStreamManager.broadcast(sessionId, { + type: "error", + data: err.message || "Failed to initialize AI agent", + }); }); }); diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 72a179e60f..8105cbe6ca 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -1464,6 +1464,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } }); + planningStreamManager.consumeInitialTurn(sessionId)?.(); + // Handle client disconnect req.on("close", () => { unsubscribe(); From ed80f982162d7b889fe7b801c840e1c11b4ffbb9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 19:51:12 -0700 Subject: [PATCH 13/46] FN-5918: reap dashboard vitest workers on wrapper exit Prevent dashboard test wrappers from leaving orphaned Vitest subprocesses after interruption or timeout. - run dashboard vitest wrappers in a detached process group and forward shutdown signals to the whole group - add an exit-time cleanup path and spawn override seam for process-lifecycle handling without launching real vitest - cover SIGINT/SIGTERM orphan reaping and keep the dashboard test config guard aligned with the new script test Files changed: .../scripts/__tests__/run-vitest-with-heap.test.ts | 172 +++++++++++++++++++++ .../dashboard/scripts/run-vitest-with-heap.mjs | 89 ++++++++++- .../__tests__/dashboard-test-config-guard.test.ts | 1 + packages/dashboard/vitest.config.ts | 1 + 4 files changed, 256 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-5918 Fusion-Task-Lineage: 5c695b3b-14c5-4749-ad9c-eb9f33d5ecd5 --- .../__tests__/run-vitest-with-heap.test.ts | 172 ++++++++++++++++++ .../scripts/run-vitest-with-heap.mjs | 89 ++++++++- .../dashboard-test-config-guard.test.ts | 1 + packages/dashboard/vitest.config.ts | 1 + 4 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 packages/dashboard/scripts/__tests__/run-vitest-with-heap.test.ts diff --git a/packages/dashboard/scripts/__tests__/run-vitest-with-heap.test.ts b/packages/dashboard/scripts/__tests__/run-vitest-with-heap.test.ts new file mode 100644 index 0000000000..de2ef12642 --- /dev/null +++ b/packages/dashboard/scripts/__tests__/run-vitest-with-heap.test.ts @@ -0,0 +1,172 @@ +// @vitest-environment node + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dashboardRoot = join(__dirname, "..", ".."); +const wrapperPath = join(dashboardRoot, "scripts", "run-vitest-with-heap.mjs"); + +const activeWrappers = new Set(); +const tempDirs = new Set(); +const trackedGroupLeaders = new Set(); +const trackedPids = new Set(); + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ESRCH") { + return false; + } + throw error; + } +} + +async function waitFor(condition: () => boolean, timeoutMs = 5_000, intervalMs = 50): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Condition not met within ${timeoutMs}ms`); +} + +function registerPid(pid: number) { + trackedPids.add(pid); +} + +function registerGroupLeader(pid: number) { + trackedGroupLeaders.add(pid); + registerPid(pid); +} + +function createStubProcessTree() { + const tempDir = mkdtempSync(join(tmpdir(), "fusion-run-vitest-")); + tempDirs.add(tempDir); + + const pidFile = join(tempDir, "pids.json"); + const grandchildPath = join(tempDir, "grandchild.mjs"); + const childPath = join(tempDir, "child.mjs"); + + writeFileSync( + grandchildPath, + ['setInterval(() => {}, 1_000);'].join("\n"), + ); + + writeFileSync( + childPath, + [ + 'import { writeFileSync } from "node:fs";', + 'import { spawn } from "node:child_process";', + '', + 'const pidFile = process.argv[2];', + 'const grandchildPath = process.argv[3];', + 'const grandchild = spawn(process.execPath, [grandchildPath], { stdio: "ignore" });', + 'writeFileSync(pidFile, JSON.stringify({ childPid: process.pid, grandchildPid: grandchild.pid }));', + 'setInterval(() => {}, 1_000);', + ].join("\n"), + ); + + return { pidFile, childPath, grandchildPath, tempDir }; +} + +async function spawnWrapperTree(signal: NodeJS.Signals) { + const { pidFile, childPath, grandchildPath } = createStubProcessTree(); + const wrapper = spawn( + process.execPath, + [wrapperPath, "--heap=6144", "run", "--project", "dashboard-api-quality"], + { + cwd: dashboardRoot, + stdio: "pipe", + env: { + ...process.env, + FUSION_RUN_VITEST_SPAWN_OVERRIDE: JSON.stringify({ + command: process.execPath, + args: [childPath, pidFile, grandchildPath], + }), + }, + }, + ); + activeWrappers.add(wrapper); + + let pids: { childPid: number; grandchildPid: number } | null = null; + await waitFor(() => { + try { + pids = JSON.parse(readFileSync(pidFile, "utf8")) as { childPid: number; grandchildPid: number }; + return Boolean( + pids && + Number.isInteger(pids.childPid) && + Number.isInteger(pids.grandchildPid) && + isProcessAlive(pids.childPid) && + isProcessAlive(pids.grandchildPid), + ); + } catch { + return false; + } + }); + + registerGroupLeader(pids!.childPid); + registerPid(pids!.grandchildPid); + + wrapper.kill(signal); + await new Promise((resolve, reject) => { + wrapper.once("error", reject); + wrapper.once("close", () => resolve()); + }); + activeWrappers.delete(wrapper); + + await waitFor(() => !isProcessAlive(pids!.childPid) && !isProcessAlive(pids!.grandchildPid)); +} + +afterEach(async () => { + for (const wrapper of activeWrappers) { + wrapper.kill("SIGKILL"); + } + activeWrappers.clear(); + + for (const leaderPid of trackedGroupLeaders) { + try { + process.kill(-leaderPid, "SIGKILL"); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH") { + throw error; + } + } + } + trackedGroupLeaders.clear(); + + for (const pid of trackedPids) { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH") { + throw error; + } + } + } + trackedPids.clear(); + + for (const tempDir of tempDirs) { + rmSync(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("run-vitest-with-heap", () => { + it("reaps the spawned process group on SIGTERM", async () => { + await spawnWrapperTree("SIGTERM"); + }); + + it("reaps the spawned process group on SIGINT", async () => { + await spawnWrapperTree("SIGINT"); + }); +}); diff --git a/packages/dashboard/scripts/run-vitest-with-heap.mjs b/packages/dashboard/scripts/run-vitest-with-heap.mjs index 8da283ec37..f177b4bc8c 100644 --- a/packages/dashboard/scripts/run-vitest-with-heap.mjs +++ b/packages/dashboard/scripts/run-vitest-with-heap.mjs @@ -2,6 +2,7 @@ /* global clearInterval, console, process, setInterval */ import { spawn } from "node:child_process"; + const rawArgs = process.argv.slice(2); const heapArg = rawArgs.find((arg) => arg.startsWith("--heap=")); const heapMb = heapArg?.slice("--heap=".length) || "6144"; @@ -15,7 +16,34 @@ if (vitestArgs.length === 0) { const nodeOptions = [`--max-old-space-size=${heapMb}`, process.env.NODE_OPTIONS || ""] .join(" ") .trim(); -const child = spawn("pnpm", ["exec", "vitest", ...vitestArgs], { + +function resolveSpawnCommand() { + const override = process.env.FUSION_RUN_VITEST_SPAWN_OVERRIDE; + if (!override) { + return { command: "pnpm", args: ["exec", "vitest", ...vitestArgs] }; + } + + const parsedOverride = JSON.parse(override); + if ( + !parsedOverride || + typeof parsedOverride.command !== "string" || + parsedOverride.command.length === 0 || + !Array.isArray(parsedOverride.args) || + parsedOverride.args.some((arg) => typeof arg !== "string") + ) { + throw new Error( + "FUSION_RUN_VITEST_SPAWN_OVERRIDE must be valid JSON with string command and string[] args", + ); + } + + // Test seam for process-lifecycle coverage without launching real vitest. + return { command: parsedOverride.command, args: parsedOverride.args }; +} + +const { command, args } = resolveSpawnCommand(); +// process-supervisor-allowlist: foreground wrapper signals the entire vitest process group on death/timeout; not a background daemon +const child = spawn(command, args, { + detached: true, stdio: "inherit", env: { ...process.env, NODE_OPTIONS: nodeOptions }, }); @@ -24,15 +52,62 @@ const heartbeat = setInterval(() => { console.log(`[dashboard-vitest] still running: ${vitestArgs.join(" ")}`); }, 5_000); -const forwardSignal = (signal) => { - child.kill(signal); -}; +function clearHeartbeat() { + clearInterval(heartbeat); +} -process.on("SIGINT", () => forwardSignal("SIGINT")); -process.on("SIGTERM", () => forwardSignal("SIGTERM")); +function forwardSignal(signal) { + clearHeartbeat(); + + try { + process.kill(-child.pid, signal); + return; + } catch (error) { + if (!(error instanceof Error) || !("code" in error)) { + throw error; + } + + if (error.code !== "ESRCH" && error.code !== "EPERM") { + throw error; + } + } + + try { + child.kill(signal); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH") { + throw error; + } + } +} + +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => forwardSignal(signal)); +} + +process.on("exit", () => { + clearHeartbeat(); + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if ( + !(error instanceof Error) || + !("code" in error) || + (error.code !== "ESRCH" && error.code !== "EPERM") + ) { + throw error; + } + } +}); + +child.on("error", (error) => { + clearHeartbeat(); + console.error(error); + process.exit(1); +}); child.on("close", (code, signal) => { - clearInterval(heartbeat); + clearHeartbeat(); if (signal) { process.kill(process.pid, signal); return; diff --git a/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts b/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts index 93424637cb..223822f794 100644 --- a/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts +++ b/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts @@ -73,5 +73,6 @@ describe("dashboard test config guard", () => { } expect(vitestConfig).toContain('"app/__tests__/spinner-animation.css.test.ts"'); + expect(vitestConfig).toContain('"scripts/__tests__/run-vitest-with-heap.test.ts"'); }); }); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 896eea4f84..8b66dbff32 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -213,6 +213,7 @@ const qualityApiTests = [ "src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts", "src/__tests__/dashboard-test-config-guard.test.ts", "src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts", + "scripts/__tests__/run-vitest-with-heap.test.ts", ]; export default defineConfig({ From 9a58c8efacf98dbd438052bb894cf3c33bff6f0b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 20:14:24 -0700 Subject: [PATCH 14/46] Clean up agents --- AGENTS.md | 34 ------------------- ...ication-spawn-supervision.real-git.test.ts | 2 +- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 54dc5c5ce8..153ce19417 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,16 +2,6 @@ ## Essential rules -### STANDING DIRECTIVE: Buttons Are Frozen (2026-05-13) - -Do not file, plan, or implement tasks that adjust button mobile-responsiveness, touch-target sizing, or mobile reflow of header/action button rows anywhere in the dashboard (TaskCard, SettingsModal, ChatView, MissionManager, AgentsView, FAB, etc.). **Keep buttons as they are.** - -This supersedes earlier guidance about mobile touch targets, primary/secondary control sizing on mobile, and `.touch-target` minimums for buttons. The `Frontend UX Design` workflow step (WS-006) is disabled and must stay disabled. - -If you find yourself opening `SettingsModal.css`, `TaskCard.css`, `ChatView.css`, etc. inside an `@media (max-width: 768px)` block to touch a `.btn`, `.modal-close`, `.settings-header-actions`, or `.card-*` button — stop. Confirm with the user in chat before proceeding. - -Exception: explicit named user request in chat that overrides this directive. - ### Spec Generation Hygiene - Do not cite `.fusion/tasks//` paths in Context/Steps/File Scope unless the file already exists, is explicitly created as a `(new)` Artifact, or is sibling `PROMPT.md`/`task.json`/`attachments/*`. @@ -163,30 +153,6 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes. -### Reliability Mechanism Coverage - -- FN-5432 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` extends FN-5256 coverage with long-cycle ambiguous sweep, write-boundary/sweep race, self-defeating+cycle non-contradiction across one maintenance flow, and audit-event shape regression; core regression cases (long cycle, self-loop via update, incremental-update closes a loop, moveTask seam invariant, DependencyCycleError shape) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`. User-facing pull/stash audit event behavior (`pull:fast-forward`, `stash:pop-conflict`) is documented in `docs/dashboard-guide.md` under Merge Advance Notice / Smart Pull. -- FN-5403 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-stop-aborts-execution.test.ts` locks stop-ordering behavior so engine shutdown aborts executor AI sessions before drain wait and preserves task-row lifecycle semantics. -- FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` guards reclaim/unpause no-progress oscillation recovery by capping repeated no-progress resumes, escalating to preserve-work `todo` rebound, and emitting `task:resume-limbo-escalated` audit metadata while exempting progress/user-paused/autoMerge-off cases. -- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` guards mission validation trigger continuity so done task completion and startup recovery both route assertion-linked features through validator runs before completion. -- FN-5738 backstop (superseded by FN-5902): `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` now guards the inverted contract so legacy zero-link mission features lazily restore a managed assertion, route through validator runs, and never emit `validation_auto_passed_no_assertions` during recovery replays. -- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 write-only-shadow merge-request record + handoff-accepted marker seam (flag OFF = no-op, ON = shadow-only non-authoritative). -- FN-5742 backstop: `packages/engine/src/__tests__/reliability-interactions/dual-observe-merge-seam.test.ts` guards Phase-2 dual-observe parity (dependency + lease diffs, shadow dequeue parity, manual-required shadow skip) while legacy behavior remains authoritative. -- FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` plus `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover semantics (merge-request retry state transitions, authoritative user hard-cancel tombstone, and non-user rebound no-op cancel semantics). -- FN-5754 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts` guards startup/maintenance stranded-feature re-triage for active autopilot slices, including link-first dedupe, non-defined skip safety, non-autopilot no-op, idempotency, and `mission:stranded-feature-triaged` audit shape. -- FN-5755 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission validation coverage so bounded periodic maintenance replays `recoverActiveMissions` for stranded `implementing` features and remains idempotent on repeated passes. -- FN-5783 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` guards grouped merge precedence so per-task `autoMerge` remains member→integration only, group `autoMerge` gates promotion eligibility, and promotion-gate audit events capture pause/automerge override reasons. -- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards merger-side promotion-gate telemetry on shared member landings, including pause/settings/group autoMerge reason mapping and no default-branch auto-promotion side effects. -- FN-5830 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` guards branch-group completion-gate + promotion lifecycle so completion detection drives exactly one shared→default promotion, re-calls stay idempotent, and gated paths emit promotion-gated telemetry without promoting. -- FN-5820 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts` guards the full shared-branch-group lifecycle—concurrent distinct-worktree execution, member→shared-branch accumulation, single shared→main completion-gate promotion with idempotent re-evaluation, gate-disabled integration-without-promotion, and per-task-derived/ungrouped no-regression. -- FN-5866 backstop: `packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts` guards the post-done non-continuable-session seam so completed executor work stays cleanly in `in-review` while incomplete tasks still fail normally. -- FN-5888 backstop: `packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts` also covers the incomplete-task non-continuable-session fresh-session retry path, ensuring within-budget failures clear `sessionFile` and requeue to `todo` with preserved resume state while exhausted budgets still fall through to terminal failure. -- FN-5889 backstop: `packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts` extends the seam to the step-session post-done continuation path and the `recoverPostDoneNonContinuableWedge` self-heal, so completed work never wedges to `in-review` + `status="failed"` and already-wedged rows are cleared before stall surfacing. -- FN-5891 backstop: `packages/engine/src/__tests__/mission-execution-loop.test.ts` guards mission validation session model resolution (assigned-agent runtime, validator lane settings, test mode) and infrastructure-error surfacing so validator session failures emit `validation_error` instead of silently entering fix-feature retries. -- FN-5901 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validator-run-reaper.test.ts` guards stale mission-validator-run recovery across manual and automatic trigger types, verifies `mission:validator-run-reaped` audit metadata, preserves complete/archived parent feature state during reap, and proves reaped active features resume validation instead of staying wedged behind abandoned `running` rows. -- FN-5874 backstop: `packages/engine/src/__tests__/reliability-interactions/ai-merge-ff-landed-files.test.ts` guards AI-merge fast-forward finalizer persistence of `mergeDetails.commitSha`, `landedFiles`, and `modifiedFiles`, verifies no-op landings do not fabricate metadata, and confirms normal squash landings do not set FN-5103 attribution-restriction flags; companion coverage in `packages/engine/src/__tests__/self-healing.test.ts` extends `recoverDoneTaskMergeMetadata` so done tasks with empty `mergeDetails` but a recorded `baseCommitSha` are backfilled via owned-commit discovery while FN-5103 skip guards still prevent overwrite. - ---- ## Reference docs (deeper detail) diff --git a/packages/engine/src/__tests__/reliability-interactions/verification-spawn-supervision.real-git.test.ts b/packages/engine/src/__tests__/reliability-interactions/verification-spawn-supervision.real-git.test.ts index 49d3bb305e..cae9b6a752 100644 --- a/packages/engine/src/__tests__/reliability-interactions/verification-spawn-supervision.real-git.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/verification-spawn-supervision.real-git.test.ts @@ -37,7 +37,7 @@ function buildParentScript(scenario: Scenario): string { const child = superviseSpawn(process.execPath, [${JSON.stringify(fixturePath)}, "keepalive"], { stdio: "ignore", killGraceMs: 50, - maxLifetimeMs: 5_000, + maxLifetimeMs: 500, }); console.log(String(child.pid)); if (${JSON.stringify(scenario)} === "clean-exit") { From 6d3a07753922f0a3f6395f148a85f91445ce9f47 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 20:47:42 -0700 Subject: [PATCH 15/46] FN-5912: separate planning summary button loading states Keep planning summary actions responsive with operation-specific loading indicators. - pass separate single-task and breakdown loading flags into the planning summary view - show the Creating spinner only on Create Single Task and the Breaking down spinner only on Break into Tasks while keeping the sibling action disabled - add regression coverage for both pending-action paths and normalize the restart integration test temp worktree root under /private/tmp Files changed: .../dashboard/app/components/PlanningModeModal.tsx | 14 ++- .../PlanningModeModal.planning-flow.test.tsx | 138 +++++++++++++++++++++ .../src/__tests__/restart.integration.test.ts | 7 +- 3 files changed, 151 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-5912 Fusion-Task-Lineage: b93da566-0c2b-4ff8-83ea-b6e009dfd650 --- .../app/components/PlanningModeModal.tsx | 14 +- .../PlanningModeModal.planning-flow.test.tsx | 138 ++++++++++++++++++ .../src/__tests__/restart.integration.test.ts | 7 +- 3 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 20651619bf..ced95affaa 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -2141,7 +2141,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat onRefine={() => { void handleRefineFurther(); }} - isLoading={isCreatingTask || isStartingBreakdown} + isCreatingTask={isCreatingTask} + isStartingBreakdown={isStartingBreakdown} /> )} @@ -2435,7 +2436,8 @@ interface SummaryViewProps { onCreateTask: () => void; onBreakIntoTasks: () => void; onRefine: () => void; - isLoading: boolean; + isCreatingTask: boolean; + isStartingBreakdown: boolean; } function SummaryView({ @@ -2452,7 +2454,8 @@ function SummaryView({ onCreateTask, onBreakIntoTasks, onRefine, - isLoading, + isCreatingTask, + isStartingBreakdown, }: SummaryViewProps) { const [isExpanded, setIsExpanded] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(false); @@ -2468,6 +2471,7 @@ function SummaryView({ const selectedPriority = normalizeTaskPriority(summary.priority); const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new"; const hasInvalidBranchSelection = isBranchNameRequired && !branchName.trim(); + const isLoading = isCreatingTask || isStartingBreakdown; const handleDependencyToggle = (taskId: string) => { const newDeps = selectedDependencies.includes(taskId) @@ -2657,7 +2661,7 @@ function SummaryView({
+
+
+ + +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalSearxngUrl: event.target.value || undefined, + })) + } + placeholder="https://searx.example.com" + /> +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalGoogleSearchCx: event.target.value || undefined, + })) + } + placeholder="custom-search-engine-id" + /> +
+
+ Configure Brave, Tavily, and Google API keys in Authentication. + +
diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 26087719d1..eb0f290d13 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -4793,6 +4793,13 @@ describe("SettingsModal", () => { expect(details).toHaveAttribute("open"); expect(await screen.findByLabelText("SearXNG URL")).toBeInTheDocument(); expect(screen.getByText(/Open Authentication Settings/i)).toBeInTheDocument(); + + const advancedBody = details?.querySelector(".settings-research-provider-advanced-body"); + expect(advancedBody).toBeTruthy(); + expect(advancedBody?.querySelectorAll(".form-group")).toHaveLength(3); + expect(screen.getByLabelText("Search Provider")).toHaveClass("input"); + expect(screen.getByLabelText("SearXNG URL")).toHaveClass("input"); + expect(screen.getByLabelText("Google Search CX")).toHaveClass("input"); }); it("keeps default max sources outside advanced details and groups provider controls", async () => { @@ -4813,6 +4820,54 @@ describe("SettingsModal", () => { expect(providerGroup).toContainElement(screen.getByText(/No API key required\./i)); }); + it("keeps research limits and source controls inside desktop containment grids for both sections", async () => { + renderModal(); + await waitForSettingsModalReady(); + await openResearchGlobalSection(); + + const defaultMaxConcurrent = screen.getByLabelText("Default Max Concurrent Runs"); + const defaultMaxSources = screen.getByLabelText("Default Max Sources Per Run"); + const defaultMaxDuration = screen.getByLabelText("Default Max Duration (ms)"); + const defaultRequestTimeout = screen.getByLabelText("Request Timeout (ms)"); + + const globalLimitsGrid = defaultMaxConcurrent.closest(".settings-research-limits-grid"); + expect(globalLimitsGrid).toBeTruthy(); + expect(defaultMaxSources.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid); + expect(defaultMaxDuration.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid); + expect(defaultRequestTimeout.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid); + expect(defaultMaxConcurrent).toHaveClass("input"); + expect(defaultMaxSources).toHaveClass("input"); + expect(defaultMaxDuration).toHaveClass("input"); + expect(defaultRequestTimeout).toHaveClass("input"); + + const globalSourceGrid = screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid"); + expect(globalSourceGrid).toBeTruthy(); + expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(globalSourceGrid); + + await openResearchProjectSection(); + + const projectMaxConcurrent = screen.getByLabelText("Max Concurrent Runs"); + const projectMaxSources = screen.getByLabelText("Max Sources Per Run"); + const projectMaxDuration = screen.getByLabelText("Max Duration (ms)"); + const projectRequestTimeout = screen.getByLabelText("Request Timeout (ms)"); + + const projectLimitsGrid = projectMaxConcurrent.closest(".settings-research-limits-grid"); + expect(projectLimitsGrid).toBeTruthy(); + expect(projectMaxSources.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid); + expect(projectMaxDuration.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid); + expect(projectRequestTimeout.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid); + expect(projectMaxConcurrent).toHaveClass("input"); + expect(projectMaxSources).toHaveClass("input"); + expect(projectMaxDuration).toHaveClass("input"); + expect(projectRequestTimeout).toHaveClass("input"); + + const projectSourceGrid = screen.getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid"); + expect(projectSourceGrid).toBeTruthy(); + expect(screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid); + expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid); + expect(screen.getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid); + }); + it("groups project limits fields in one grid and keeps validation error visible", async () => { renderModal(); await waitForSettingsModalReady(); @@ -4828,6 +4883,16 @@ describe("SettingsModal", () => { expect(maxSources.closest(".settings-research-limits-grid")).toBe(limitsGrid); expect(maxDuration.closest(".settings-research-limits-grid")).toBe(limitsGrid); expect(requestTimeout.closest(".settings-research-limits-grid")).toBe(limitsGrid); + expect(maxConcurrent).toHaveClass("input"); + expect(maxSources).toHaveClass("input"); + expect(maxDuration).toHaveClass("input"); + expect(requestTimeout).toHaveClass("input"); + + const sourceGrid = screen.getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid"); + expect(sourceGrid).toBeTruthy(); + expect(screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(sourceGrid); + expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(sourceGrid); + expect(screen.getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(sourceGrid); fireEvent.change(maxConcurrent, { target: { value: "0" } }); await userEvent.click(screen.getByText("Save")); diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 22c98e6db1..d6c69ab8e3 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -261,6 +261,48 @@ describe("SettingsModal mobile adaptations", () => { expect(getByLabelText("Memory File")).toBeTruthy(); }); + it("keeps research settings controls inside mobile containment wrappers", async () => { + vi.mocked(fetchSettings).mockResolvedValueOnce({ + ...defaultSettings, + experimentalFeatures: { researchView: true }, + }); + + mockSettingsViewport(true); + const user = userEvent.setup(); + const { getByLabelText } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + const picker = getByLabelText("Settings Section"); + await user.selectOptions(picker, "research-global"); + + const details = await within(document.body).findByText(/Advanced — external search providers/i); + await user.click(details); + + const advancedPanel = document.querySelector(".settings-research-provider-advanced-body"); + expect(advancedPanel).toBeTruthy(); + expect(document.querySelector(".settings-research-provider-advanced-details")).toBeTruthy(); + expect(document.querySelector(".settings-research-limits-grid")).toBeTruthy(); + expect(document.querySelector(".settings-research-source-grid")).toBeTruthy(); + + await user.selectOptions(picker, "research-project"); + + const maxConcurrent = await within(document.body).findByLabelText("Max Concurrent Runs"); + expect(maxConcurrent).toHaveClass("input"); + expect(document.querySelectorAll(".settings-research-limit-field").length).toBeGreaterThan(0); + + const projectLimitsGrid = maxConcurrent.closest(".settings-research-limits-grid"); + expect(projectLimitsGrid).toBeTruthy(); + expect(within(document.body).getByLabelText("Max Sources Per Run").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid); + expect(within(document.body).getByLabelText("Max Duration (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid); + expect(within(document.body).getByLabelText("Request Timeout (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid); + + const projectSourceGrid = within(document.body).getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid"); + expect(projectSourceGrid).toBeTruthy(); + expect(within(document.body).getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid); + expect(within(document.body).getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid); + expect(within(document.body).getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid); + }); + it("renders settings nav items with active class for touch styling", async () => { const { container } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); @@ -354,10 +396,16 @@ describe("SettingsModal mobile adaptations", () => { // Remote Access header elements must use the same mobile gutter as other remote blocks expectMobileRule(css, ".remote-status-bar", "margin: 0 var(--space-lg) var(--space-md);"); expectMobileRule(css, ".remote-share-block", "margin: 0 var(--space-lg) var(--space-md);"); + expectMobileRule(css, ".settings-research-provider-advanced-details", "padding-inline-start: 0;"); + expectMobileRule(css, ".settings-research-source-grid", "grid-template-columns: 1fr;"); + expectMobileRule(css, ".settings-research-limits-grid", "grid-template-columns: 1fr;"); // Base rules: desktop uses --space-xl horizontal margin for remote header elements expectBaseRule(css, ".remote-status-bar", "margin: 0 var(--space-xl) var(--space-md);"); expectBaseRule(css, ".remote-share-block", "margin: 0 var(--space-xl) var(--space-md);"); + expectBaseRule(css, ".settings-research-provider-advanced-details", "padding-inline-start: var(--space-md);"); + expectBaseRule(css, ".settings-research-provider-advanced-body > .form-group", "padding: 0;"); + expectBaseRule(css, ".settings-research-limits-grid", "min-width: 0;"); // Settings header actions keep compact controls on a shared height contract on desktop; mobile inherits this height (FN-4354 reverted prior mobile inflation). expectBaseRule(css, ".settings-header-actions", "--settings-header-action-height: calc(var(--space-md) * 2 + var(--space-xs) / 2);"); From 43c3fa9edc0ef3d490e00e9c0c81194781764f48 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 00:53:24 -0700 Subject: [PATCH 33/46] FN-5935: fix dashboard interop plugin context types Align the dependency graph plugin's dashboard interop declarations with the current dashboard contract. - import ReactNode for plugin task card rendering support - add DetailTaskTab, PluginToastType, and PluginTaskView type exports - update PluginDashboardViewContext to require workflowSteps and the expanded openTaskDetail signature - add optional renderTaskCard and addToast hooks to match dashboard expectations Files changed: .../src/dashboard-interop.d.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-5935 Fusion-Task-Lineage: 32313a96-1008-4de6-a7cb-e6bfbc385534 --- .../src/dashboard-interop.d.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts b/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts index f46c27c7dd..71a09d62b9 100644 --- a/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +++ b/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts @@ -5,14 +5,23 @@ declare module "@fusion/dashboard/app/utils/taskStuck" { } declare module "@fusion/dashboard/app/plugins/types" { + import type { ReactNode } from "react"; import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; + export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries"; + + export type PluginToastType = "success" | "error" | "warning" | "info"; + export interface PluginDashboardViewContext { - tasks: Task[]; projectId?: string; - workflowSteps?: WorkflowStep[]; - openTaskDetail?: (task: Task | TaskDetail) => void; + tasks: Task[]; + workflowSteps: WorkflowStep[]; + openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; + renderTaskCard?: (task: Task | TaskDetail) => ReactNode; + addToast?: (message: string, type?: PluginToastType) => void; } + + export type PluginTaskView = `plugin:${string}:${string}`; } declare module "@fusion/dashboard/app/components/TaskCard" { From 5336ad1071ca5558f65d0980259cc10d2fc5716c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 01:04:04 -0700 Subject: [PATCH 34/46] FN-5923: remove duplicate merger fixture key Remove the duplicate merger override from the SettingsModal test fixture. - delete the earlier legacy merger object from the mocked settings payload - keep the deterministic merger override as the single effective fixture value Files changed: packages/dashboard/app/components/__tests__/SettingsModal.test.tsx | 1 - 1 file changed, 1 deletion(-) Fusion-Task-Id: FN-5923 Fusion-Task-Lineage: 206239b0-af4a-4e45-93b8-2940a42d431a --- .../dashboard/app/components/__tests__/SettingsModal.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index eb0f290d13..0941db7d29 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -469,7 +469,6 @@ describe("SettingsModal", () => { mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]); mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, - merger: { mode: "legacy" }, mergeIntegrationWorktree: "cwd-main", merger: { mode: "deterministic" }, }); From 1962bea0fc5e7b6f3edf02fec102237ee0839bd8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 01:08:09 -0700 Subject: [PATCH 35/46] FN-5934: replace undefined dashboard spacing token Replace dashboard CSS uses of the undefined --space-2xs token with supported spacing values. - swap var(--space-2xs) for var(--space-xs) in confirm dialog, merge advance notice, and PR checks styles - add a dashboard test that scans component stylesheets for undefined --space-2xs references - assert shared token sources continue to omit an intentional --space-2xs definition Files changed: .../app/__tests__/space-token-defined.test.ts | 45 ++++++++++++++++++++++ .../dashboard/app/components/ConfirmDialog.css | 2 +- .../app/components/MergeAdvanceNotice.css | 8 ++-- packages/dashboard/app/components/PrChecksList.css | 2 +- 4 files changed, 51 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-5934 Fusion-Task-Lineage: aa77df19-3f76-4ede-aa42-0396230edde5 --- .../app/__tests__/space-token-defined.test.ts | 45 +++++++++++++++++++ .../app/components/ConfirmDialog.css | 2 +- .../app/components/MergeAdvanceNotice.css | 8 ++-- .../dashboard/app/components/PrChecksList.css | 2 +- 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 packages/dashboard/app/__tests__/space-token-defined.test.ts diff --git a/packages/dashboard/app/__tests__/space-token-defined.test.ts b/packages/dashboard/app/__tests__/space-token-defined.test.ts new file mode 100644 index 0000000000..ac3f63796d --- /dev/null +++ b/packages/dashboard/app/__tests__/space-token-defined.test.ts @@ -0,0 +1,45 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const appDir = resolve(__dirname, ".."); +const componentsDir = resolve(appDir, "components"); +const stylesPath = resolve(appDir, "styles.css"); +const themeDataPath = resolve(appDir, "public/theme-data.css"); + +function listComponentCssFiles(): string[] { + return readdirSync(componentsDir) + .filter((name) => name.endsWith(".css")) + .sort(); +} + +describe("dashboard spacing token hygiene", () => { + it("does not reference undefined --space-2xs in any component stylesheet", () => { + const violations: string[] = []; + + for (const fileName of listComponentCssFiles()) { + const filePath = join(componentsDir, fileName); + const source = readFileSync(filePath, "utf8"); + const lines = source.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].includes("var(--space-2xs)")) { + violations.push(`${fileName}:${index + 1}:${lines[index].trim()}`); + } + } + } + + expect(violations).toEqual([]); + }); + + it("documents that --space-2xs remains intentionally undefined in shared token sources", () => { + const tokenSources = [ + { name: "styles.css", source: readFileSync(stylesPath, "utf8") }, + { name: "theme-data.css", source: readFileSync(themeDataPath, "utf8") }, + ]; + + for (const { name, source } of tokenSources) { + expect(source).not.toContain("--space-2xs:"); + } + }); +}); diff --git a/packages/dashboard/app/components/ConfirmDialog.css b/packages/dashboard/app/components/ConfirmDialog.css index 8bd255f25a..ddbcac1a86 100644 --- a/packages/dashboard/app/components/ConfirmDialog.css +++ b/packages/dashboard/app/components/ConfirmDialog.css @@ -12,7 +12,7 @@ .confirm-dialog__checkbox { display: grid; - gap: var(--space-2xs); + gap: var(--space-xs); margin: 0 var(--space-xl) var(--space-lg); } diff --git a/packages/dashboard/app/components/MergeAdvanceNotice.css b/packages/dashboard/app/components/MergeAdvanceNotice.css index b446e3b157..0aea2b1e7c 100644 --- a/packages/dashboard/app/components/MergeAdvanceNotice.css +++ b/packages/dashboard/app/components/MergeAdvanceNotice.css @@ -79,8 +79,8 @@ .merge-advance-notice__push-advanced label { display: inline-flex; align-items: center; - gap: var(--space-2xs); - margin-top: var(--space-2xs); + gap: var(--space-xs); + margin-top: var(--space-xs); } .merge-advance-notice__push-error { @@ -88,8 +88,8 @@ } .merge-advance-notice__push-error pre { - margin: var(--space-2xs) 0; - padding: var(--space-2xs); + margin: var(--space-xs) 0; + padding: var(--space-xs); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--color-error) 12%, transparent); color: var(--color-error); diff --git a/packages/dashboard/app/components/PrChecksList.css b/packages/dashboard/app/components/PrChecksList.css index 0031927b31..034b4b4ea2 100644 --- a/packages/dashboard/app/components/PrChecksList.css +++ b/packages/dashboard/app/components/PrChecksList.css @@ -111,7 +111,7 @@ border-radius: var(--radius-sm); color: var(--color-error); justify-content: center; - padding: var(--space-2xs) var(--space-sm); + padding: var(--space-xs) var(--space-sm); } .pr-checks__details-link--failing:hover { From d72cb2ab2b6b42abcda6b27869d1b9e8edcfc249 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 01:10:18 -0700 Subject: [PATCH 36/46] FN-5911: move agent logs to per-task JSONL storage Store agent logs in per-task JSONL files instead of the legacy SQLite table. - add a file-backed agent log store with JSONL append/read/prune helpers and task-scoped source refs - migrate legacy SQLite agentLogEntries data into task files, rewrite goal citation references, and preserve soft-deleted logs for forensics - update task store, settings, docs, dashboard route coverage, and add regression tests for migration, retention, and log access Files changed: .changeset/fn-5911-agent-log-jsonl.md | 5 + AGENTS.md | 2 +- docs/diagnostics.md | 2 +- docs/settings-reference.md | 2 + docs/soft-delete-verification-matrix.md | 7 +- docs/storage.md | 8 +- .../src/__tests__/agent-log-file-store.test.ts | 123 ++++++ .../core/src/__tests__/agent-log-migration.test.ts | 186 +++++++++ .../core/src/__tests__/agent-log-retention.test.ts | 208 ++++++++++ packages/core/src/__tests__/db-migrate.test.ts | 14 +- packages/core/src/__tests__/db.test.ts | 39 +- .../src/__tests__/goal-citations-store.test.ts | 38 +- packages/core/src/__tests__/goals-schema.test.ts | 2 +- packages/core/src/__tests__/insight-store.test.ts | 10 +- .../src/__tests__/merge-request-record.test.ts | 2 +- packages/core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- packages/core/src/__tests__/secrets-schema.test.ts | 6 +- .../src/__tests__/soft-delete-agent-logs.test.ts | 71 ++-- .../src/__tests__/store-agent-log-file.test.ts | 91 +++++ .../core/src/__tests__/store-merge-queue.test.ts | 2 +- packages/core/src/__tests__/store-test-helpers.ts | 43 +- packages/core/src/__tests__/store-upsert.test.ts | 37 +- packages/core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/agent-log-constants.ts | 25 ++ packages/core/src/agent-log-file-store.ts | 267 ++++++++++++ packages/core/src/db.ts | 51 ++- packages/core/src/settings-schema.ts | 1 + packages/core/src/store.ts | 453 ++++++++++++--------- packages/core/src/types.ts | 10 +- .../__tests__/agent-log-routes.integration.test.ts | 48 +++ .../src/__tests__/evaluator-evidence.test.ts | 47 ++- packages/engine/src/self-healing.ts | 12 + .../src/store/__tests__/roadmap-store.test.ts | 4 +- 34 files changed, 1477 insertions(+), 345 deletions(-) Fusion-Task-Id: FN-5911 Fusion-Task-Lineage: 07c42f3a-87cf-4558-8f01-ac8460b5558b --- .changeset/fn-5911-agent-log-jsonl.md | 5 + AGENTS.md | 2 +- docs/diagnostics.md | 2 +- docs/settings-reference.md | 2 + docs/soft-delete-verification-matrix.md | 7 +- docs/storage.md | 8 +- .../__tests__/agent-log-file-store.test.ts | 123 +++++ .../src/__tests__/agent-log-migration.test.ts | 186 +++++++ .../src/__tests__/agent-log-retention.test.ts | 208 ++++++++ .../core/src/__tests__/db-migrate.test.ts | 14 +- packages/core/src/__tests__/db.test.ts | 39 +- .../__tests__/goal-citations-store.test.ts | 38 +- .../core/src/__tests__/goals-schema.test.ts | 2 +- .../core/src/__tests__/insight-store.test.ts | 10 +- .../__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- .../core/src/__tests__/secrets-schema.test.ts | 6 +- .../__tests__/soft-delete-agent-logs.test.ts | 71 ++- .../__tests__/store-agent-log-file.test.ts | 91 ++++ .../src/__tests__/store-merge-queue.test.ts | 2 +- .../core/src/__tests__/store-test-helpers.ts | 43 +- .../core/src/__tests__/store-upsert.test.ts | 37 +- .../core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/agent-log-constants.ts | 25 + packages/core/src/agent-log-file-store.ts | 267 ++++++++++ packages/core/src/db.ts | 51 +- packages/core/src/settings-schema.ts | 1 + packages/core/src/store.ts | 455 ++++++++++-------- packages/core/src/types.ts | 10 +- .../agent-log-routes.integration.test.ts | 48 ++ .../src/__tests__/evaluator-evidence.test.ts | 47 +- packages/engine/src/self-healing.ts | 12 + .../src/store/__tests__/roadmap-store.test.ts | 4 +- 34 files changed, 1478 insertions(+), 346 deletions(-) create mode 100644 .changeset/fn-5911-agent-log-jsonl.md create mode 100644 packages/core/src/__tests__/agent-log-file-store.test.ts create mode 100644 packages/core/src/__tests__/agent-log-migration.test.ts create mode 100644 packages/core/src/__tests__/agent-log-retention.test.ts create mode 100644 packages/core/src/__tests__/store-agent-log-file.test.ts create mode 100644 packages/core/src/agent-log-constants.ts create mode 100644 packages/core/src/agent-log-file-store.ts create mode 100644 packages/dashboard/src/__tests__/agent-log-routes.integration.test.ts diff --git a/.changeset/fn-5911-agent-log-jsonl.md b/.changeset/fn-5911-agent-log-jsonl.md new file mode 100644 index 0000000000..f027f00df3 --- /dev/null +++ b/.changeset/fn-5911-agent-log-jsonl.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Move agent logs out of the SQLite `agentLogEntries` table into per-task `.fusion/tasks/{ID}/agent-log.jsonl` files, add one-time migration + source-ref rewrite support, preserve soft-deleted log files for forensics while hiding them from live reads, and switch goal-citation source refs to `agentLog:{taskId}:{lineNo}`. diff --git a/AGENTS.md b/AGENTS.md index dd35784274..266a5e1053 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - `./docs/PLUGIN_AUTHORING.md` — plugin authoring guide, lifecycle hooks, routes, tools, and dashboard-extension surfaces. - `./docs/agents.md` — pi extension scope, coordination tools, checkout leasing, runtime config. - `./docs/settings-reference.md` — model-selection hierarchy, mock provider mode, token budget precedence, presets. -- `./docs/storage.md` — hybrid storage model details. +- `./docs/storage.md` — hybrid storage model details, including per-task `agent-log.jsonl` storage and retention semantics. - `./docs/multi-project.md` — central/per-project DB and isolation modes. - `./docs/missions.md` — mission/milestone/slice/feature model. - `./docs/workflow-steps.md` — prompt/script gates and merge-blocking behavior. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 35ca219fd2..f8ae327790 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -126,7 +126,7 @@ FN-5416 extends resume-correlation coverage to stream-focused hooks and their pr - `useDevServerLogs`: `project-context-change`, `sse-open`, `sse-reconnect` - `useResearch`: `sse-open`, `sse-reconnect` - `useBackgroundSessions`: `sse-open`, `sse-reconnect` - - `useAgentLogs`: `project-context-change`, `sse-open`, `sse-reconnect` on `/api/tasks/:id/logs/stream` + - `useAgentLogs`: `project-context-change`, `sse-open`, `sse-reconnect` on `/api/tasks/:id/logs/stream` (live tail via SSE; historical reads are backed by `.fusion/tasks/{ID}/agent-log.jsonl`) - Route shells - `DevServerView`: `remount` / `route-active` / `route-inactive` - `ResearchView`: `remount` / `route-active` / `route-inactive` diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 93c79c7d0a..08dcf82ad7 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -430,6 +430,8 @@ Default notes: | `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). | | `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. | | `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. | +| `operationalLogRetentionDays` | `number` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). Periodic maintenance prunes rows older than this many days using each row's `timestamp`. Set `0` to disable pruning. | +| `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. | | `chatRoomRecentVerbatimMessages` | `number` | `25` | Number of newest chat-room messages kept verbatim in responder context before older entries are compacted (about 2× prior default history). | | `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). | | `chatRoomSummaryMaxChars` | `number` | `3000` | Hard cap for the synthesized “Earlier room context” summary block (about 2× the prior summary budget). | diff --git a/docs/soft-delete-verification-matrix.md b/docs/soft-delete-verification-matrix.md index 0dcbc99036..993a592b0a 100644 --- a/docs/soft-delete-verification-matrix.md +++ b/docs/soft-delete-verification-matrix.md @@ -17,8 +17,7 @@ | 5. Soft-delete an `in-progress` task with an active workflow-step session and reviewer subagent | Live `in-progress`; workflow step child session exists | Delete succeeds; no public recovery/undelete flag | New execution attempts refuse; workflow-step + reviewer abort/cleanup is **pending FN-5142** | N/A | N/A | SSE removes card; reload stays clean | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 | | 6. Soft-delete an `in-review` task that is currently being merged | Live `in-review`; active merge session in flight | Delete succeeds; live readers omit afterward | Scheduler must not requeue it | Active merge abort, queue removal, and controller cleanup are **pending FN-5142** | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5142 | | 7. Soft-delete an `in-review` task queued for merge but not yet active | Live `in-review`; merge queued only | Delete succeeds; row stays for forensics only | Scheduler/executor must not pick it up again | Merge queue must filter it out; pending FN-5142 covers deterministic abort/filter assertions | N/A | SSE removes card; reload keeps it absent | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5142 | -| 8. Soft-delete a `done` task with archived/visible agent logs and saved task documents | Live `done`; has task docs + agent logs | Delete succeeds; live task readers omit afterward; forensic reads still allowed internally | Not runnable after any engine tick or restart | N/A | N/A | SSE removes card; refresh does not show it in board/ListView/TodoView | `agentLogEntries` must clear atomically; **pending FN-5143** | `/api/documents` and per-task docs must disappear while DB rows remain; **pending FN-5140** | FN-5140 / FN-5143 | -| 9. Soft-delete an archived task | Task already archived / moved out of live `tasks` table | Current contract is not pinned; matrix gate requires deterministic error-or-no-op behavior. Follow-up filed as **FN-5196**. | Must never affect active queues either way | N/A | N/A | No dashboard resurrection; exact UX blocked by FN-5196 | N/A | N/A | FN-5196 | +| 8. Soft-delete a `done` task with archived/visible agent logs and saved task documents | Live `done`; has task docs + agent logs | Delete succeeds; live task readers omit afterward; forensic reads still allowed internally | Not runnable after any engine tick or restart | N/A | N/A | SSE removes card; refresh does not show it in board/ListView/TodoView | `agent-log.jsonl` file is preserved, but `getAgentLogs*` / `getAgentLogCount` return zero once `deletedAt` is set | `/api/documents` and per-task docs must disappear while DB rows remain; **pending FN-5140** | FN-5140 / FN-5143 || 9. Soft-delete an archived task | Task already archived / moved out of live `tasks` table | Current contract is not pinned; matrix gate requires deterministic error-or-no-op behavior. Follow-up filed as **FN-5196**. | Must never affect active queues either way | N/A | N/A | No dashboard resurrection; exact UX blocked by FN-5196 | N/A | N/A | FN-5196 | | 10. Soft-delete a task that is checked out by an agent (`checkedOutBy` set) | Live task with lease / checkout metadata | Delete succeeds; linked agent task references clear with delete | Soft-deleted checked-out task must not be auto-claimed or executed after refresh/tick; extra deterministic coverage filed as **FN-5195** | If merge-owned, FN-5142 owns active merge abort details | If triage-owned, FN-5142 owns active triage abort details | SSE removes card; refresh must not show stale checked-out task | Preserved until FN-5143 | Hidden from live readers once FN-5140 lands | FN-5137 / FN-5195 | | 11. Retry-exhausted `in-review` blocker is soft-deleted (`mergeRetries >= 3`) | Row remains in `tasks` with `deletedAt` set; downstream live tasks may still reference blocker in `dependencies`/`blockedBy` | Default task readers still hide it; opt-in surfaces expose it (`GET /api/tasks/exhausted-in-review?includeDeleted=true`, `GET /api/tasks/:id?includeDeleted=true`, `fn_task_show` fallback, `fn_task_list includeDeleted`) | Deadlock/stuck-merge/in-review-stall scans must exclude soft-deleted rows via `listTasks` (`ACTIVE_TASKS_WHERE`), plus per-sweep `task.deletedAt` guards as belt-and-suspenders | No merge-state mutation; blocker remains terminal unless explicit operator action | N/A | ReliabilityView panel explicitly lists hidden exhausted blockers + blocked dependents; main board stays unchanged | Existing logs preserved | Documents readable via opt-in task-detail fetch; no automatic restore | FN-5513 / FN-5528 | @@ -40,7 +39,7 @@ | 1,10 | Triage abort on `task:deleted` | No deterministic triage abort assertion in current corpus | Missing active triage session + subagent abort coverage | `packages/engine/src/__tests__/triage-soft-delete-abort.test.ts` | FN-5142 | | 11 | Deadlock/stuck-merge/in-review-stall scan exclusion for soft-deleted exhausted blockers | Added in this task | GREEN — defensive sweep guards + script `WHERE deletedAt IS NULL` backstop | `packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts`, `scripts/__tests__/recover-stale-blocked-by.test.mjs` | FN-5528 | | 2,11 | Soft-delete blocker residue + legacy column drift reconciliation (`deletedAt` + non-archived column) | Added in this task | GREEN — in-transaction blocker cleanup, periodic/startup column-drift reconciler, and audit mutation `task:soft-delete-column-reconciled` | `packages/engine/src/__tests__/reliability-interactions/soft-delete-blocker-residue.test.ts`, `packages/core/src/__tests__/store-delete-task-blocker-residue.test.ts` | FN-5566 (re-land FN-5446) | -| 8 | `agentLogEntries` cleared on soft-delete | No dedicated coverage today | Missing atomic clear + post-delete empty-reader assertion | `packages/core/src/__tests__/soft-delete-agent-logs.test.ts` | FN-5143 | +| 8 | Preserved `agent-log.jsonl` file is hidden from live readers after soft-delete | `packages/core/src/__tests__/soft-delete-agent-logs.test.ts` | GREEN — read APIs return zero while the on-disk file remains available for forensics | — | FN-5143 / FN-5911 | | 8 | `/api/documents` and per-task docs exclude soft-deleted parents | No dedicated soft-delete document visibility assertion today | Missing store + route coverage | `packages/core/src/__tests__/task-documents.test.ts` and `packages/dashboard/src/__tests__/routes-tasks.test.ts` | FN-5140 | | 3 | Lineage-unlink 409 flow through API + UI | Store lineage guards are covered; route/UI flow is not | Missing 409 payload + confirm-retry UX coverage | `packages/dashboard/src/__tests__/routes-tasks-ops.test.ts`, `packages/dashboard/app/utils/__tests__/taskDelete.test.ts`, `packages/dashboard/app/components/__tests__/TaskCard.test.tsx`, `packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx`, `packages/dashboard/app/components/__tests__/ListView.test.tsx` | FN-5139 | | Stream-wide | `fn_task_delete` tool / skill terminology | No regression asserting soft-delete wording | Missing user-facing copy coverage | `packages/cli/src/__tests__/extension.test.ts` | FN-5141 | @@ -65,7 +64,7 @@ Supported forensic access is internal only: - `readTaskFromDb(id, { includeDeleted: true })` in `packages/core/src/store.ts` -- direct SQL against `tasks`, `task_documents`, and `agentLogEntries` +- direct SQL against `tasks` and `task_documents`, plus on-disk reads of `.fusion/tasks/{ID}/agent-log.jsonl` No public API flag exposes deleted-task forensics today. Adding one requires a new FN with its own review. diff --git a/docs/storage.md b/docs/storage.md index f44ead0681..492fadc239 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -15,10 +15,12 @@ - Archived-task flows (`archiveTask`, archived cleanup/migration) still hard-delete from the active `tasks` table after copying to cold storage (`archive.db`). - ID reservation is unchanged: soft-deleted IDs remain reserved. `distributed-task-id` and `task-id-integrity` intentionally scan all task rows (including soft-deleted rows), and must not filter on `deletedAt`. -### Agent log clearing (FN-5143) +### Agent log storage + soft-delete visibility (FN-5143 / FN-5911) -- `TaskStore.deleteTask` now clears `agentLogEntries` rows for the soft-deleted task in the same transaction that writes `deletedAt`, so downstream `getAgentLogs*` / `getAgentLogCount` calls observe zero logs immediately. -- This is soft-delete-specific cleanup; archived-task agent log snapshot behavior (`taskToArchiveEntry` / `archiveTask`) is unchanged. +- Agent logs are no longer stored in SQLite. Each task now appends newline-delimited JSON records to `/.fusion/tasks/{ID}/agent-log.jsonl`. +- `TaskStore.deleteTask` keeps that JSONL file on disk for forensics, but all live read APIs (`getAgentLogs*`, `getAgentLogCount`) gate on task liveness and return zero entries once `deletedAt` is set. +- Archived-task snapshot behavior (`taskToArchiveEntry` / `archiveTask`) is unchanged in spirit: archive payloads still embed a capped agent-log snapshot, now sourced from the JSONL file instead of `fusion.db`. +- Retention is now independent from SQLite operational-log pruning. `settings.agentLogFileRetentionDays` controls age-based pruning of JSONL entries for soft-deleted and archived tasks only. Default: `0` (disabled). ### Dashboard delete-event handling (FN-5135) diff --git a/packages/core/src/__tests__/agent-log-file-store.test.ts b/packages/core/src/__tests__/agent-log-file-store.test.ts new file mode 100644 index 0000000000..57fccd4e34 --- /dev/null +++ b/packages/core/src/__tests__/agent-log-file-store.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + appendAgentLogEntriesSync, + countAgentLogEntries, + getAgentLogFilePath, + readAgentLogEntries, + readAgentLogEntriesByTimeRange, +} from "../agent-log-file-store.js"; +import { AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE } from "../agent-log-constants.js"; + +const tempDirs: string[] = []; + +function createTaskDir(): string { + const dir = mkdtempSync(join(tmpdir(), "fusion-agent-log-file-store-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("agent-log-file-store", () => { + it("appends and reads entries with stable line-number source refs", () => { + const taskDir = createTaskDir(); + + const appended = appendAgentLogEntriesSync(taskDir, [ + { timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "first", type: "text" }, + { timestamp: "2026-01-01T00:01:00.000Z", taskId: "FN-1", text: "second", type: "tool", detail: "readme.md", agent: "executor" }, + ]); + + expect(appended.map((entry) => entry.sourceRef)).toEqual([ + "agentLog:FN-1:1", + "agentLog:FN-1:2", + ]); + expect(readAgentLogEntries(taskDir)).toEqual(appended); + }); + + it("supports most-recent tail pagination with offset", () => { + const taskDir = createTaskDir(); + appendAgentLogEntriesSync( + taskDir, + Array.from({ length: 5 }, (_, index) => ({ + timestamp: `2026-01-01T00:0${index}:00.000Z`, + taskId: "FN-1", + text: `entry-${index}`, + type: "text" as const, + })), + ); + + expect(readAgentLogEntries(taskDir, { limit: 2 }).map((entry) => entry.text)).toEqual(["entry-3", "entry-4"]); + expect(readAgentLogEntries(taskDir, { limit: 2, offset: 2 }).map((entry) => entry.text)).toEqual(["entry-1", "entry-2"]); + expect(readAgentLogEntries(taskDir, { limit: 2, offset: 5 })).toEqual([]); + }); + + it("filters by type and inclusive time range", () => { + const taskDir = createTaskDir(); + appendAgentLogEntriesSync(taskDir, [ + { timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "before", type: "text" }, + { timestamp: "2026-01-01T01:00:00.000Z", taskId: "FN-1", text: "tool", type: "tool", detail: "ls" }, + { timestamp: "2026-01-01T02:00:00.000Z", taskId: "FN-1", text: "thinking", type: "thinking" }, + { timestamp: "2026-01-01T03:00:00.000Z", taskId: "FN-1", text: "after", type: "text" }, + ]); + + expect(readAgentLogEntries(taskDir, { type: "text" }).map((entry) => entry.text)).toEqual(["before", "after"]); + expect( + readAgentLogEntriesByTimeRange(taskDir, "2026-01-01T01:00:00.000Z", "2026-01-01T02:00:00.000Z").map((entry) => entry.text), + ).toEqual(["tool", "thinking"]); + expect(countAgentLogEntries(taskDir, { type: "text" })).toBe(2); + }); + + it("truncates oversized tool detail on append and on read of legacy oversized rows", () => { + const taskDir = createTaskDir(); + const oversized = "X".repeat(5_000); + appendAgentLogEntriesSync(taskDir, [ + { timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "Bash", type: "tool_result", detail: oversized }, + ]); + + const filePath = getAgentLogFilePath(taskDir); + writeFileSync( + filePath, + `${JSON.stringify({ timestamp: "2026-01-01T01:00:00.000Z", taskId: "FN-1", text: "legacy", type: "tool_error", detail: oversized })}\n`, + "utf8", + ); + + const [legacy] = readAgentLogEntries(taskDir); + expect(legacy.detail).toContain(AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE.trim()); + expect(legacy.detail!.length).toBeLessThan(oversized.length); + }); + + it("skips malformed and partial lines with a warning", () => { + const taskDir = createTaskDir(); + const filePath = getAgentLogFilePath(taskDir); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + writeFileSync( + filePath, + [ + JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", taskId: "FN-1", text: "good", type: "text" }), + "{bad-json", + JSON.stringify({ taskId: "FN-1", text: "missing timestamp", type: "text" }), + "", + ].join("\n"), + "utf8", + ); + + const entries = readAgentLogEntries(taskDir); + expect(entries.map((entry) => entry.text)).toEqual(["good"]); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it("treats a missing file as empty", () => { + const taskDir = createTaskDir(); + expect(readAgentLogEntries(taskDir)).toEqual([]); + expect(countAgentLogEntries(taskDir)).toBe(0); + }); +}); diff --git a/packages/core/src/__tests__/agent-log-migration.test.ts b/packages/core/src/__tests__/agent-log-migration.test.ts new file mode 100644 index 0000000000..35532eb7e4 --- /dev/null +++ b/packages/core/src/__tests__/agent-log-migration.test.ts @@ -0,0 +1,186 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { countAgentLogEntries, getAgentLogFilePath, readAgentLogEntries } from "../agent-log-file-store.js"; +import { SCHEMA_VERSION } from "../db.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("Agent log migration: SQLite → JSONL", () => { + const harness = createTaskStoreTestHarness(); + + const taskDir = (taskId: string) => join(harness.rootDir(), ".fusion", "tasks", taskId); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("migrates legacy agentLogEntries rows to per-task JSONL files and rewrites citations", async () => { + await harness.reopenDiskBackedStore(); + const store = harness.store(); + const taskA = await harness.createTestTask(); + const taskB = await harness.createTestTask(); + const db = store.getDatabase(); + + db.exec(` + CREATE TABLE IF NOT EXISTS agentLogEntries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskId TEXT NOT NULL, + timestamp TEXT NOT NULL, + text TEXT NOT NULL, + type TEXT NOT NULL, + detail TEXT, + agent TEXT + ) + `); + + const insertLegacyRow = db.prepare(` + INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id + `); + const legacyA1 = insertLegacyRow.get(taskA.id, "2026-06-02T00:00:01.000Z", "task-a-1 G-MIG001", "text", null, "executor") as { id: number }; + const legacyB1 = insertLegacyRow.get(taskB.id, "2026-06-02T00:00:02.000Z", "task-b-1", "tool", '{"tool":"scan"}', "reviewer") as { id: number }; + const legacyA2 = insertLegacyRow.get(taskA.id, "2026-06-02T00:00:03.000Z", "task-a-2 G-MIG001", "text", null, "executor") as { id: number }; + + const insertCitation = db.prepare(` + INSERT INTO goal_citations (goalId, agentId, taskId, surface, sourceRef, snippet, timestamp) + VALUES (?, ?, ?, 'agent_log', ?, ?, ?) + `); + insertCitation.run("G-MIG001", "executor", taskA.id, `agentLog:${legacyA1.id}`, "task-a-1 G-MIG001", "2026-06-02T00:00:01.000Z"); + insertCitation.run("G-MIG001", "executor", taskA.id, `agentLog:${legacyA2.id}`, "task-a-2 G-MIG001", "2026-06-02T00:00:03.000Z"); + + db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogEntriesToFileMigrationVersion"); + db.prepare("UPDATE __meta SET value = '101' WHERE key = 'schemaVersion'").run(); + + expect(existsSync(getAgentLogFilePath(taskDir(taskA.id)))).toBe(false); + expect(existsSync(getAgentLogFilePath(taskDir(taskB.id)))).toBe(false); + + await harness.reopenDiskBackedStore(); + + const migratedStore = harness.store(); + const migratedDb = migratedStore.getDatabase(); + + expect(migratedDb.getSchemaVersion()).toBe(SCHEMA_VERSION); + const hasTable = migratedDb + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + expect(hasTable).toBeUndefined(); + + expect(countAgentLogEntries(taskDir(taskA.id))).toBe(2); + expect(countAgentLogEntries(taskDir(taskB.id))).toBe(1); + expect(readAgentLogEntries(taskDir(taskA.id)).map((entry) => entry.text)).toEqual(["task-a-1 G-MIG001", "task-a-2 G-MIG001"]); + expect(readAgentLogEntries(taskDir(taskB.id)).map((entry) => entry.text)).toEqual(["task-b-1"]); + + const citations = migratedStore.listGoalCitations({ goalId: "G-MIG001" }); + expect(new Set(citations.map((citation) => citation.sourceRef))).toEqual( + new Set([`agentLog:${taskA.id}:1`, `agentLog:${taskA.id}:2`]), + ); + }); + + it("does not create agentLogEntries table on fresh init", async () => { + const store = harness.store(); + const db = store.getDatabase(); + + const hasTable = db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + + expect(hasTable).toBeUndefined(); + }); + + it("sets the migration guard on fresh init", async () => { + const store = harness.store(); + const db = store.getDatabase(); + const migrationRow = db + .prepare("SELECT value FROM __meta WHERE key = ?") + .get("agentLogEntriesToFileMigrationVersion") as { value: string } | undefined; + + expect(migrationRow?.value).toBe("1"); + }); + + it("handles empty legacy agentLogEntries tables gracefully", async () => { + await harness.reopenDiskBackedStore(); + const db = harness.store().getDatabase(); + + db.exec(` + CREATE TABLE IF NOT EXISTS agentLogEntries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskId TEXT NOT NULL, + timestamp TEXT NOT NULL, + text TEXT NOT NULL, + type TEXT NOT NULL, + detail TEXT, + agent TEXT + ) + `); + db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogEntriesToFileMigrationVersion"); + db.prepare("UPDATE __meta SET value = '101' WHERE key = 'schemaVersion'").run(); + + await harness.reopenDiskBackedStore(); + + const reopenedDb = harness.store().getDatabase(); + const migrationRow = reopenedDb + .prepare("SELECT value FROM __meta WHERE key = ?") + .get("agentLogEntriesToFileMigrationVersion") as { value: string } | undefined; + const hasTable = reopenedDb + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + + expect(migrationRow?.value).toBe("1"); + expect(reopenedDb.getSchemaVersion()).toBe(SCHEMA_VERSION); + expect(hasTable).toBeUndefined(); + }); + + it("keeps file-backed citation source-refs stable after rereads", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + await store.appendAgentLog(task.id, "working on G-MIG001", "text", undefined, "executor"); + await store.getAgentLogs(task.id); + + const firstRead = store.listGoalCitations({ goalId: "G-MIG001" }); + await store.getAgentLogs(task.id, { limit: 10 }); + const secondRead = store.listGoalCitations({ goalId: "G-MIG001" }); + + expect(firstRead).toHaveLength(1); + expect(secondRead).toHaveLength(1); + expect(firstRead[0]?.sourceRef).toBe(`agentLog:${task.id}:1`); + expect(secondRead[0]?.sourceRef).toBe(firstRead[0]?.sourceRef); + }); + + it("drops the legacy table once and does not recreate it on later init", async () => { + await harness.reopenDiskBackedStore(); + const db = harness.store().getDatabase(); + + db.exec(` + CREATE TABLE IF NOT EXISTS agentLogEntries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskId TEXT NOT NULL, + timestamp TEXT NOT NULL, + text TEXT NOT NULL, + type TEXT NOT NULL, + detail TEXT, + agent TEXT + ) + `); + db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogEntriesToFileMigrationVersion"); + db.prepare("UPDATE __meta SET value = '101' WHERE key = 'schemaVersion'").run(); + + await harness.reopenDiskBackedStore(); + await harness.reopenDiskBackedStore(); + + const reopenedDb = harness.store().getDatabase(); + const hasTable = reopenedDb + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1") + .get(); + + expect(reopenedDb.getSchemaVersion()).toBe(SCHEMA_VERSION); + expect(hasTable).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/agent-log-retention.test.ts b/packages/core/src/__tests__/agent-log-retention.test.ts new file mode 100644 index 0000000000..02fe7b124f --- /dev/null +++ b/packages/core/src/__tests__/agent-log-retention.test.ts @@ -0,0 +1,208 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + countAgentLogEntries, + getAgentLogFilePath, + pruneAgentLogFiles, + readAgentLogEntries, +} from "../agent-log-file-store.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("Agent log file retention pruning", () => { + const harness = createTaskStoreTestHarness(); + + const taskDir = (taskId: string) => join(harness.rootDir(), ".fusion", "tasks", taskId); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("returns zeroed counts when retention is disabled", () => { + const result = pruneAgentLogFiles(join(harness.rootDir(), ".fusion", "tasks"), 0); + expect(result).toEqual({ prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }); + }); + + it("returns zeroed counts when retention is negative", () => { + const result = pruneAgentLogFiles(join(harness.rootDir(), ".fusion", "tasks"), -5); + expect(result).toEqual({ prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }); + }); + + it("returns zeroed counts when tasksDir does not exist", () => { + const result = pruneAgentLogFiles("/nonexistent/path", 30); + expect(result).toEqual({ prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }); + }); + + it("removes old entries and keeps recent ones", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + // Write entries with controlled timestamps + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + const oldEntry = JSON.stringify({ + timestamp: "2020-01-01T00:00:00.000Z", + taskId: task.id, + text: "old-entry", + type: "text", + }); + const recentEntry = JSON.stringify({ + timestamp: "2099-06-01T00:00:00.000Z", + taskId: task.id, + text: "recent-entry", + type: "text", + }); + writeFileSync(filePath, `${oldEntry}\n${recentEntry}\n`, "utf8"); + + expect(countAgentLogEntries(td)).toBe(2); + + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task.id]), + ); + + expect(result.prunedEntries).toBe(1); + expect(result.prunedFiles).toBe(1); + expect(result.freedBytes).toBeGreaterThan(0); + + const remaining = readAgentLogEntries(td); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.text).toBe("recent-entry"); + }); + + it("deletes the file when all entries are pruned", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + const oldEntry = JSON.stringify({ + timestamp: "2020-01-01T00:00:00.000Z", + taskId: task.id, + text: "old-entry-1", + type: "text", + }); + writeFileSync(filePath, `${oldEntry}\n`, "utf8"); + + expect(existsSync(filePath)).toBe(true); + + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task.id]), + ); + + expect(result.prunedEntries).toBe(1); + expect(result.prunedFiles).toBe(1); + expect(existsSync(filePath)).toBe(false); + }); + + it("keeps malformed lines intact (does not destroy unparseable data)", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + const content = "not-valid-json\n"; + writeFileSync(filePath, content, "utf8"); + + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task.id]), + ); + + // Malformed line is kept, nothing pruned + expect(result.prunedEntries).toBe(0); + expect(existsSync(filePath)).toBe(true); + }); + + it("scopes pruning to specified task IDs only", async () => { + const store = harness.store(); + const task1 = await harness.createTestTask(); + const task2 = await harness.createTestTask(); + + const td1 = taskDir(task1.id); + const td2 = taskDir(task2.id); + mkdirSync(td1, { recursive: true }); + mkdirSync(td2, { recursive: true }); + + const oldEntry = (id: string) => + JSON.stringify({ timestamp: "2020-01-01T00:00:00.000Z", taskId: id, text: "old", type: "text" }); + + writeFileSync(getAgentLogFilePath(td1), `${oldEntry(task1.id)}\n`, "utf8"); + writeFileSync(getAgentLogFilePath(td2), `${oldEntry(task2.id)}\n`, "utf8"); + + // Only prune task1 + const result = pruneAgentLogFiles( + join(harness.rootDir(), ".fusion", "tasks"), + 30, + new Set([task1.id]), + ); + + expect(result.prunedEntries).toBe(1); + expect(countAgentLogEntries(td1)).toBe(0); + expect(countAgentLogEntries(td2)).toBe(1); + }); + + it("store.pruneAgentLogFiles only prunes inactive tasks", async () => { + const store = harness.store(); + const activeTask = await harness.createTestTask(); + const deletedTask = await harness.createTestTask(); + + // Write entries for both tasks + const activeTd = taskDir(activeTask.id); + const deletedTd = taskDir(deletedTask.id); + + const oldEntry = (id: string) => + JSON.stringify({ timestamp: "2020-01-01T00:00:00.000Z", taskId: id, text: "old", type: "text" }); + + mkdirSync(activeTd, { recursive: true }); + mkdirSync(deletedTd, { recursive: true }); + writeFileSync(getAgentLogFilePath(activeTd), `${oldEntry(activeTask.id)}\n`, "utf8"); + writeFileSync(getAgentLogFilePath(deletedTd), `${oldEntry(deletedTask.id)}\n`, "utf8"); + + // Soft-delete one task + await store.deleteTask(deletedTask.id); + + const result = store.pruneAgentLogFiles(30); + + expect(result.prunedEntries).toBe(1); + // Active task's log is untouched + expect(countAgentLogEntries(activeTd)).toBe(1); + // Deleted task's old entries are pruned + expect(countAgentLogEntries(deletedTd)).toBe(0); + }); + + it("leaves in-range entries intact when mixed old/recent entries exist", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + const td = taskDir(task.id); + mkdirSync(td, { recursive: true }); + const filePath = getAgentLogFilePath(td); + + const lines = [ + JSON.stringify({ timestamp: "2020-01-01T00:00:00.000Z", taskId: task.id, text: "old-1", type: "text" }), + JSON.stringify({ timestamp: "2099-06-01T00:00:00.000Z", taskId: task.id, text: "recent-1", type: "text" }), + JSON.stringify({ timestamp: "2020-02-01T00:00:00.000Z", taskId: task.id, text: "old-2", type: "text" }), + JSON.stringify({ timestamp: "2099-07-01T00:00:00.000Z", taskId: task.id, text: "recent-2", type: "text" }), + ]; + writeFileSync(filePath, lines.join("\n") + "\n", "utf8"); + + pruneAgentLogFiles(join(harness.rootDir(), ".fusion", "tasks"), 30, new Set([task.id])); + + const remaining = readAgentLogEntries(td); + expect(remaining.map((e) => e.text)).toEqual(["recent-1", "recent-2"]); + }); +}); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index a2adaf1553..b3797e4e9c 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 3166bb2a10..f90e18658b 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -265,7 +265,7 @@ describe("Database", () => { expect(tableNames).toContain("agents"); expect(tableNames).toContain("agentHeartbeats"); expect(tableNames).toContain("agentRuns"); - expect(tableNames).toContain("agentLogEntries"); + // agentLogEntries removed in migration 102 — now stored in per-task JSONL files expect(tableNames).toContain("agentTaskSessions"); expect(tableNames).toContain("agentApiKeys"); expect(tableNames).toContain("agentConfigRevisions"); @@ -324,8 +324,7 @@ describe("Database", () => { expect(indexNames).toContain("idxTaskDocumentRevisionsTaskKey"); expect(indexNames).toContain("idxAgentRunsAgentIdStartedAt"); expect(indexNames).toContain("idxAgentRunsStatus"); - expect(indexNames).toContain("idxAgentLogEntriesTaskIdTimestamp"); - expect(indexNames).toContain("idxAgentLogEntriesTaskIdType"); + // agentLogEntries indexes removed in migration 102 — now stored in per-task JSONL files expect(indexNames).toContain("idxAgentApiKeysAgentId"); expect(indexNames).toContain("idxAgentConfigRevisionsAgentIdCreatedAt"); expect(indexNames).toContain("idxTasksCreatedAt"); @@ -335,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -394,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1464,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1489,11 +1488,11 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); db.close(); }); @@ -1528,7 +1527,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1569,7 +1568,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1641,7 +1640,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1881,7 +1880,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1955,7 +1954,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1979,7 +1978,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2083,7 +2082,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2302,7 +2301,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(101); + expect(localDb.getSchemaVersion()).toBe(102); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2613,7 +2612,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2767,7 +2766,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(101); + expect(migrated.getSchemaVersion()).toBe(102); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2813,7 +2812,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(101); + expect(migrated.getSchemaVersion()).toBe(102); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2840,7 +2839,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(101); + expect(fresh.getSchemaVersion()).toBe(102); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goal-citations-store.test.ts b/packages/core/src/__tests__/goal-citations-store.test.ts index d47601d419..74b280a599 100644 --- a/packages/core/src/__tests__/goal-citations-store.test.ts +++ b/packages/core/src/__tests__/goal-citations-store.test.ts @@ -1,5 +1,8 @@ +import { join } from "node:path"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as extractor from "../goal-citation-extractor.js"; +import { getAgentLogFilePath, readAgentLogEntries } from "../agent-log-file-store.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; describe("goal citations store integration", () => { @@ -29,7 +32,7 @@ describe("goal citations store integration", () => { taskId: task.id, surface: "agent_log", }); - expect(rows[0]?.sourceRef).toMatch(/^agentLog:/); + expect(rows[0]?.sourceRef).toMatch(/^agentLog:[^:]+:\d+$/); }); it("does not record citations for near-miss log text", async () => { @@ -68,6 +71,7 @@ describe("goal citations store integration", () => { const rows = store.listGoalCitations({ goalId: "G-BATCH001" }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ surface: "agent_log", agentId: "executor", taskId: task.id }); + expect(rows[0]?.sourceRef).toMatch(new RegExp(`^agentLog:${task.id}:\\d+$`)); }); it("deduplicates goal citations per goalId+surface+sourceRef", () => { @@ -127,7 +131,7 @@ describe("goal citations store integration", () => { goalId: "G-WIN", agentId: "agent-1", surface: "agent_log", - sourceRef: "agentLog:1", + sourceRef: "agentLog:FN-WIN-1:1", snippet: "G-WIN older", timestamp: "2026-01-01T00:00:00.000Z", }, @@ -135,7 +139,7 @@ describe("goal citations store integration", () => { goalId: "G-WIN", agentId: "agent-1", surface: "agent_log", - sourceRef: "agentLog:2", + sourceRef: "agentLog:FN-WIN-1:2", snippet: "G-WIN newer", timestamp: "2026-01-02T00:00:00.000Z", }, @@ -143,7 +147,7 @@ describe("goal citations store integration", () => { goalId: "G-OTHER", agentId: "agent-1", surface: "agent_log", - sourceRef: "agentLog:3", + sourceRef: "agentLog:FN-OTHER-1:1", snippet: "other", timestamp: "2026-01-02T00:00:00.000Z", }, @@ -156,7 +160,31 @@ describe("goal citations store integration", () => { }); expect(rows).toHaveLength(1); - expect(rows[0]?.sourceRef).toBe("agentLog:2"); + expect(rows[0]?.sourceRef).toBe("agentLog:FN-WIN-1:2"); + }); + + it("keeps citation source refs stable and resolvable after re-reading logs from file", async () => { + const store = harness.store(); + const task = await store.createTask({ title: "Task", description: "desc" }); + + await store.appendAgentLogBatch([ + { taskId: task.id, text: "tracking G-STABLE001", type: "text", agent: "executor" }, + { taskId: task.id, text: "tracking G-STABLE002", type: "text", agent: "executor" }, + ]); + + const rows = store.listGoalCitations({ taskId: task.id, surface: "agent_log" }); + expect(rows.map((row) => row.sourceRef)).toEqual([ + `agentLog:${task.id}:2`, + `agentLog:${task.id}:1`, + ]); + + const persistedLogs = readAgentLogEntries(join(harness.rootDir(), ".fusion", "tasks", task.id)); + const bySourceRef = new Map(persistedLogs.map((entry) => [entry.sourceRef, entry])); + expect(bySourceRef.get(`agentLog:${task.id}:1`)?.text).toBe("tracking G-STABLE001"); + expect(bySourceRef.get(`agentLog:${task.id}:2`)?.text).toBe("tracking G-STABLE002"); + expect(getAgentLogFilePath(join(harness.rootDir(), ".fusion", "tasks", task.id))).toContain( + `/tasks/${task.id}/agent-log.jsonl`, + ); }); it("does not throw when citation scan fails during appendAgentLog", async () => { diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 744cdb31d4..dacf02a940 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index e1853eadc5..02ffb55712 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(101); + expect(db1.getSchemaVersion()).toBe(102); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(101); + expect(db3.getSchemaVersion()).toBe(102); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(101); + expect(db1.getSchemaVersion()).toBe(102); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(101); + expect(db2.getSchemaVersion()).toBe(102); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(101); + expect(db1.getSchemaVersion()).toBe(102); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ff3fcfbf74..effc22a84f 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 2273503940..2790b84fb6 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3594,7 +3594,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index b4fa854663..5d8b8968ae 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); }); }); }); diff --git a/packages/core/src/__tests__/secrets-schema.test.ts b/packages/core/src/__tests__/secrets-schema.test.ts index 93f4ac4e66..04ce264650 100644 --- a/packages/core/src/__tests__/secrets-schema.test.ts +++ b/packages/core/src/__tests__/secrets-schema.test.ts @@ -42,7 +42,7 @@ describe("secrets schema migrations", () => { const version = db .prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'") .get() as { value: string }; - expect(version.value).toBe("101"); + expect(version.value).toBe("102"); } finally { db.close(); rmSync(dir, { recursive: true, force: true }); @@ -105,7 +105,7 @@ describe("secrets schema migrations", () => { const version = db .prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'") .get() as { value: string }; - expect(version.value).toBe("101"); + expect(version.value).toBe("102"); } finally { db.close(); rmSync(dir, { recursive: true, force: true }); @@ -155,7 +155,7 @@ describe("secrets schema migrations", () => { .prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'") .get() as { value: string }; - expect(projectVersion.value).toBe("101"); + expect(projectVersion.value).toBe("102"); expect(centralVersion.value).toBe("13"); } finally { projectDb.close(); diff --git a/packages/core/src/__tests__/soft-delete-agent-logs.test.ts b/packages/core/src/__tests__/soft-delete-agent-logs.test.ts index 87c97d0a2f..ca9fb550a0 100644 --- a/packages/core/src/__tests__/soft-delete-agent-logs.test.ts +++ b/packages/core/src/__tests__/soft-delete-agent-logs.test.ts @@ -1,10 +1,16 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { countAgentLogEntries, getAgentLogFilePath } from "../agent-log-file-store.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { const harness = createTaskStoreTestHarness(); + const taskDir = (taskId: string) => join(harness.rootDir(), ".fusion", "tasks", taskId); + beforeEach(async () => { await harness.beforeEach(); }); @@ -13,7 +19,7 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await harness.afterEach(); }); - it("deletes pre-existing persisted agent logs on soft-delete", async () => { + it("hides pre-existing persisted agent logs on soft-delete while preserving the file", async () => { const store = harness.store(); const task = await harness.createTestTask(); @@ -22,32 +28,27 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.appendAgentLog(task.id, "entry-3", "text"); await store.getAgentLogs(task.id); - const before = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(before.count).toBe(3); + expect(countAgentLogEntries(taskDir(task.id))).toBe(3); await store.deleteTask(task.id); - const after = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(after.count).toBe(0); + expect(existsSync(getAgentLogFilePath(taskDir(task.id)))).toBe(true); + expect(countAgentLogEntries(taskDir(task.id))).toBe(3); await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); await expect(store.getAgentLogCount(task.id)).resolves.toBe(0); + await expect( + store.getAgentLogsByTimeRange(task.id, "2000-01-01T00:00:00.000Z", null), + ).resolves.toEqual([]); }); - it("discards buffered unflushed entries when task is soft-deleted", async () => { + it("flushes buffered entries before soft-delete, then hides them while preserving the file", async () => { const store = harness.store(); const task = await harness.createTestTask(); await store.appendAgentLog(task.id, "buffered-only", "text"); await store.deleteTask(task.id); - const rows = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(rows.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); }); @@ -59,10 +60,7 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.getAgentLogs(task.id); await store.deleteTask(task.id); - const firstDeleteCount = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(firstDeleteCount.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); const rowBefore = (store as any).db .prepare('SELECT deletedAt, updatedAt, "column" FROM tasks WHERE id = ?') @@ -76,11 +74,7 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { expect(rowAfter.deletedAt).toBe(rowBefore.deletedAt); expect(rowAfter.updatedAt).toBe(rowBefore.updatedAt); expect(rowAfter.column).toBe("archived"); - - const secondDeleteCount = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(secondDeleteCount.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); }); it("clears only the soft-deleted parent logs when removing lineage references", async () => { @@ -93,21 +87,14 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.getAgentLogs(parent.id); await store.getAgentLogs(child.id); - const childBefore = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(child.id) as { count: number }; - expect(childBefore.count).toBe(1); + expect(countAgentLogEntries(taskDir(child.id))).toBe(1); await store.deleteTask(parent.id, { removeLineageReferences: true }); - const parentAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(parent.id) as { count: number }; - const childAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(child.id) as { count: number }; - expect(parentAfter.count).toBe(0); - expect(childAfter.count).toBe(1); + expect(countAgentLogEntries(taskDir(parent.id))).toBe(1); + expect(countAgentLogEntries(taskDir(child.id))).toBe(1); + await expect(store.getAgentLogs(parent.id)).resolves.toEqual([]); + await expect(store.getAgentLogs(child.id)).resolves.toMatchObject([{ text: "child-log" }]); }); it("does not affect other tasks' agent logs", async () => { @@ -122,17 +109,13 @@ describe("TaskStore soft-delete agent log clearing (FN-5143)", () => { await store.deleteTask(first.id); - const firstAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(first.id) as { count: number }; - const secondAfter = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(second.id) as { count: number }; - expect(firstAfter.count).toBe(0); - expect(secondAfter.count).toBe(1); + expect(countAgentLogEntries(taskDir(first.id))).toBe(1); + expect(countAgentLogEntries(taskDir(second.id))).toBe(1); + await expect(store.getAgentLogs(first.id)).resolves.toEqual([]); + await expect(store.getAgentLogs(second.id)).resolves.toMatchObject([{ text: "second-log" }]); }); - it("emits task:deleted only after agent logs are cleared", async () => { + it("emits task:deleted only after read APIs hide persisted agent logs", async () => { const store = harness.store(); const task = await harness.createTestTask(); await store.appendAgentLog(task.id, "event-order", "text"); diff --git a/packages/core/src/__tests__/store-agent-log-file.test.ts b/packages/core/src/__tests__/store-agent-log-file.test.ts new file mode 100644 index 0000000000..33cc1e97b7 --- /dev/null +++ b/packages/core/src/__tests__/store-agent-log-file.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("TaskStore file-backed agent logs", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("preserves append, read, count, pagination, and time-range parity", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + harness.insertLogEntryWithTimestamp( + store, + task.id, + "first", + "text", + "2026-01-01T00:00:00.000Z", + ); + harness.insertLogEntryWithTimestamp( + store, + task.id, + "tool", + "tool", + "2026-01-01T00:01:00.000Z", + "readme.md", + "executor", + ); + harness.insertLogEntryWithTimestamp( + store, + task.id, + "third", + "thinking", + "2026-01-01T00:02:00.000Z", + undefined, + "reviewer", + ); + + await expect(store.getAgentLogCount(task.id)).resolves.toBe(3); + await expect(store.getAgentLogs(task.id)).resolves.toMatchObject([ + { text: "first", type: "text" }, + { text: "tool", type: "tool", detail: "readme.md", agent: "executor" }, + { text: "third", type: "thinking", agent: "reviewer" }, + ]); + await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([ + { text: "tool" }, + { text: "third" }, + ]); + await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([ + { text: "first" }, + ]); + await expect( + store.getAgentLogsByTimeRange(task.id, "2026-01-01T00:01:00.000Z", "2026-01-01T00:02:00.000Z"), + ).resolves.toMatchObject([{ text: "tool" }, { text: "third" }]); + }); + + it("emits SSE-facing agent:log events per single and batch append while skipping persistence for deleted tasks", async () => { + const store = harness.store(); + const liveTask = await harness.createTestTask(); + const deletedTask = await harness.createTestTask(); + const events: Array<{ taskId: string; text: string }> = []; + store.on("agent:log", (entry) => events.push({ taskId: entry.taskId, text: entry.text })); + + await store.deleteTask(deletedTask.id); + await store.appendAgentLog(liveTask.id, "live-single", "text"); + await store.appendAgentLog(deletedTask.id, "deleted-single", "text"); + await store.appendAgentLogBatch([ + { taskId: liveTask.id, text: "live-batch", type: "text" }, + { taskId: deletedTask.id, text: "deleted-batch", type: "text" }, + ]); + + expect(events).toEqual([ + { taskId: liveTask.id, text: "live-single" }, + { taskId: deletedTask.id, text: "deleted-single" }, + { taskId: liveTask.id, text: "live-batch" }, + { taskId: deletedTask.id, text: "deleted-batch" }, + ]); + await expect(store.getAgentLogs(liveTask.id)).resolves.toMatchObject([ + { text: "live-single" }, + { text: "live-batch" }, + ]); + await expect(store.getAgentLogs(deletedTask.id)).resolves.toEqual([]); + }); +}); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 96fb8612e1..5c4959026a 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(101); + expect(store.getDatabase().getSchemaVersion()).toBe(102); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/store-test-helpers.ts b/packages/core/src/__tests__/store-test-helpers.ts index 4a7e0f7ad8..26a33eb16e 100644 --- a/packages/core/src/__tests__/store-test-helpers.ts +++ b/packages/core/src/__tests__/store-test-helpers.ts @@ -1,7 +1,8 @@ -import { mkdtempSync } from "node:fs"; +import { appendFileSync, mkdtempSync, mkdirSync } from "node:fs"; import { readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { getAgentLogFilePath } from "../agent-log-file-store.js"; import { setTimeout as delay } from "node:timers/promises"; import { vi } from "vitest"; @@ -214,12 +215,20 @@ export function createTaskStoreTestHarness() { [taskId, text, type, timestamp, detail, agent] = args; } - (targetStore as any).db - .prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `) - .run(taskId, timestamp, text, type, detail ?? null, agent ?? null); + const taskDir = join((targetStore as any).getFusionDir(), "tasks", taskId); + mkdirSync(taskDir, { recursive: true }); + appendFileSync( + getAgentLogFilePath(taskDir), + `${JSON.stringify({ + taskId, + timestamp, + text, + type, + ...(detail !== undefined && { detail }), + ...(agent !== undefined && { agent }), + })}\n`, + "utf8", + ); }, }; } @@ -437,12 +446,20 @@ export function createSharedTaskStoreTestHarness() { [taskId, text, type, timestamp, detail, agent] = args; } - (targetStore as any).db - .prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `) - .run(taskId, timestamp, text, type, detail ?? null, agent ?? null); + const taskDir = join((targetStore as any).getFusionDir(), "tasks", taskId); + mkdirSync(taskDir, { recursive: true }); + appendFileSync( + getAgentLogFilePath(taskDir), + `${JSON.stringify({ + taskId, + timestamp, + text, + type, + ...(detail !== undefined && { detail }), + ...(agent !== undefined && { agent }), + })}\n`, + "utf8", + ); }, }; } diff --git a/packages/core/src/__tests__/store-upsert.test.ts b/packages/core/src/__tests__/store-upsert.test.ts index 4e3d38b319..b66a667b00 100644 --- a/packages/core/src/__tests__/store-upsert.test.ts +++ b/packages/core/src/__tests__/store-upsert.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { existsSync } from "node:fs"; import * as projectMemory from "../project-memory.js"; import { AgentStore } from "../agent-store.js"; +import { getAgentLogFilePath, countAgentLogEntries, readAgentLogEntries } from "../agent-log-file-store.js"; import { CentralDatabase } from "../central-db.js"; import { TaskStore, TaskHasDependentsError } from "../store.js"; import { buildResearchDocumentKey, type Task } from "../types.js"; @@ -32,6 +33,7 @@ describe("TaskStore", () => { const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId); const createSourceIssueFixture = () => harness.createSourceIssueFixture(); const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args); + const taskDir = (taskId: string) => join(rootDir, ".fusion", "tasks", taskId); describe("upsertTask regression coverage", () => { it("creates tasks successfully on a fresh database schema", async () => { @@ -111,19 +113,14 @@ describe("TaskStore", () => { describe("agent log persistence", () => { - it("appendAgentLog inserts into agentLogEntries and getAgentLogs reads it back", async () => { + it("appendAgentLog persists to JSONL and getAgentLogs reads it back", async () => { const task = await createTestTask(); await store.appendAgentLog(task.id, "Hello world", "text"); await store.appendAgentLog(task.id, "Read", "tool"); (store as any).flushAgentLogBuffer(); - const rows = (store as any).db.prepare(` - SELECT taskId, text, type FROM agentLogEntries - WHERE taskId = ? - ORDER BY timestamp ASC - `).all(task.id) as Array<{ taskId: string; text: string; type: string }>; - expect(rows).toEqual([ + expect(readAgentLogEntries(taskDir(task.id))).toMatchObject([ { taskId: task.id, text: "Hello world", type: "text" }, { taskId: task.id, text: "Read", type: "tool" }, ]); @@ -253,7 +250,7 @@ describe("TaskStore", () => { expect(await store.getAgentLogCount(task.id)).toBe(2); }); - it("returns the most recent agent log entries from SQLite in chronological order", async () => { + it("returns the most recent agent log entries in chronological order", async () => { const task = await createTestTask(); for (let i = 0; i < 5; i++) { @@ -607,17 +604,12 @@ describe("TaskStore", () => { await store.appendAgentLog(task.id, "cascade me", "text"); (store as any).flushAgentLogBuffer(); - const before = (store as any).db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(task.id) as { count: number }; - expect(before.count).toBe(1); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); await store.deleteTask(task.id); - const after = (store as any).db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(task.id) as { count: number }; - expect(after.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); + await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); }); it("deleteTask clears linked agent task assignments", async () => { @@ -716,10 +708,7 @@ describe("TaskStore", () => { } // Validate DB persistence without invoking read-path auto-flush helpers. - const row = (store as any).db - .prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?") - .get(task.id) as { count: number }; - expect(row.count).toBe(50); + expect(countAgentLogEntries(taskDir(task.id))).toBe(50); }); it("auto-flushes buffered entries when getAgentLogs is called", async () => { @@ -744,7 +733,7 @@ describe("TaskStore", () => { expect(count).toBe(1); }); - it("auto-flushes before deleteTask and soft-delete clears resulting rows", async () => { + it("auto-flushes before deleteTask and soft-delete hides resulting file-backed rows", async () => { const task = await createTestTask(); await store.appendAgentLog(task.id, "to be cascaded", "text"); @@ -754,10 +743,8 @@ describe("TaskStore", () => { expect(flushSpy).toHaveBeenCalled(); flushSpy.mockRestore(); - const after = (store as any).db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(task.id) as { count: number }; - expect(after.count).toBe(0); + expect(countAgentLogEntries(taskDir(task.id))).toBe(1); + await expect(store.getAgentLogs(task.id)).resolves.toEqual([]); }); it("flushes remaining entries on close without throwing", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 84364d2666..47c08de78b 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(101); + expect(db.getSchemaVersion()).toBe(102); const index = db .prepare( diff --git a/packages/core/src/agent-log-constants.ts b/packages/core/src/agent-log-constants.ts new file mode 100644 index 0000000000..cce4bd0ad4 --- /dev/null +++ b/packages/core/src/agent-log-constants.ts @@ -0,0 +1,25 @@ +import type { AgentLogEntry } from "./types.js"; + +export const AGENT_LOG_FILENAME = "agent-log.jsonl"; +export const AGENT_LOG_TOOL_DETAIL_LIMIT = 4_096; +export const AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE = + "\n\n[tool output truncated to keep dashboard log views responsive]"; +export const AGENT_LOG_TOOL_TYPES = new Set([ + "tool", + "tool_result", + "tool_error", +]); + +export function truncateAgentLogDetail( + detail: string | null | undefined, + type: AgentLogEntry["type"], +): string | undefined { + if (detail == null) return undefined; + if (!AGENT_LOG_TOOL_TYPES.has(type)) return detail; + if (detail.length <= AGENT_LOG_TOOL_DETAIL_LIMIT) return detail; + return `${detail.slice(0, AGENT_LOG_TOOL_DETAIL_LIMIT)}${AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE}`; +} + +export function buildAgentLogSourceRef(taskId: string, lineNo: number): string { + return `agentLog:${taskId}:${lineNo}`; +} diff --git a/packages/core/src/agent-log-file-store.ts b/packages/core/src/agent-log-file-store.ts new file mode 100644 index 0000000000..17fc385539 --- /dev/null +++ b/packages/core/src/agent-log-file-store.ts @@ -0,0 +1,267 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import type { AgentLogEntry } from "./types.js"; +import { + AGENT_LOG_FILENAME, + buildAgentLogSourceRef, + truncateAgentLogDetail, +} from "./agent-log-constants.js"; +import { createLogger } from "./logger.js"; + +const log = createLogger("agent-log-file-store"); + +export interface StoredAgentLogEntry extends AgentLogEntry { + lineNo: number; + sourceRef: string; +} + +export interface AgentLogFileStoreReadOptions { + limit?: number; + offset?: number; + type?: AgentLogEntry["type"]; + startTime?: string; + endTime?: string | null; +} + +export interface AgentLogFileAppendInput { + timestamp: string; + taskId: string; + text: string; + type: AgentLogEntry["type"]; + detail?: string | null; + agent?: AgentLogEntry["agent"] | null; +} + +interface AgentLogJsonlRow { + timestamp: string; + taskId: string; + text: string; + type: AgentLogEntry["type"]; + detail?: string; + agent?: AgentLogEntry["agent"]; +} + +export function getAgentLogFilePath(taskDir: string): string { + return join(taskDir, AGENT_LOG_FILENAME); +} + +export function appendAgentLogEntriesSync( + taskDir: string, + entries: AgentLogFileAppendInput[], +): StoredAgentLogEntry[] { + if (entries.length === 0) return []; + + const filePath = getAgentLogFilePath(taskDir); + mkdirSync(dirname(filePath), { recursive: true }); + const startingLineNo = countLineNumbers(filePath); + const payload = entries + .map((entry) => serializeEntry(entry)) + .join(""); + appendFileSync(filePath, payload, "utf8"); + + return entries.map((entry, index) => materializeEntry(entry, startingLineNo + index + 1)); +} + +export function readAgentLogEntries( + taskDir: string, + options: AgentLogFileStoreReadOptions = {}, +): StoredAgentLogEntry[] { + const entries = readAllAgentLogEntries(taskDir, options); + const offset = Math.max(0, options.offset ?? 0); + if (options.limit == null) { + return offset === 0 ? entries : entries.slice(0, Math.max(0, entries.length - offset)); + } + const limit = Math.max(0, options.limit); + const endExclusive = Math.max(0, entries.length - offset); + const startInclusive = Math.max(0, endExclusive - limit); + return entries.slice(startInclusive, endExclusive); +} + +export function countAgentLogEntries( + taskDir: string, + options: Omit = {}, +): number { + return readAllAgentLogEntries(taskDir, options).length; +} + +export function readAgentLogEntriesByTimeRange( + taskDir: string, + startTime: string, + endTime: string | null, + options: Omit = {}, +): StoredAgentLogEntry[] { + return readAllAgentLogEntries(taskDir, { + ...options, + startTime, + endTime, + }); +} + +function readAllAgentLogEntries( + taskDir: string, + options: Omit = {}, +): StoredAgentLogEntry[] { + const filePath = getAgentLogFilePath(taskDir); + if (!existsSync(filePath)) { + return []; + } + + const content = readFileSync(filePath, "utf8"); + if (content.length === 0) { + return []; + } + + const lines = content.split("\n"); + const entries: StoredAgentLogEntry[] = []; + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; + if (!rawLine) continue; + const lineNo = index + 1; + try { + const parsed = JSON.parse(rawLine) as Partial; + if ( + typeof parsed.timestamp !== "string" + || typeof parsed.taskId !== "string" + || typeof parsed.text !== "string" + || typeof parsed.type !== "string" + ) { + throw new Error("missing required agent-log fields"); + } + const entry = materializeEntry(parsed as AgentLogFileAppendInput, lineNo); + if (options.type != null && entry.type !== options.type) { + continue; + } + if (options.startTime != null && entry.timestamp < options.startTime) { + continue; + } + if (options.endTime != null && entry.timestamp > options.endTime) { + continue; + } + entries.push(entry); + } catch (error) { + log.warn(`Skipping malformed JSONL line ${lineNo} in ${filePath}`, error); + } + } + + return entries; +} + +function serializeEntry(entry: AgentLogFileAppendInput): string { + const normalizedDetail = truncateAgentLogDetail(entry.detail, entry.type); + const row: AgentLogJsonlRow = { + timestamp: entry.timestamp, + taskId: entry.taskId, + text: entry.text, + type: entry.type, + ...(normalizedDetail !== undefined && { detail: normalizedDetail }), + ...(entry.agent != null && { agent: entry.agent }), + }; + return `${JSON.stringify(row)}\n`; +} + +function materializeEntry(entry: AgentLogFileAppendInput, lineNo: number): StoredAgentLogEntry { + const normalizedDetail = truncateAgentLogDetail(entry.detail, entry.type); + return { + timestamp: entry.timestamp, + taskId: entry.taskId, + text: entry.text, + type: entry.type, + ...(normalizedDetail !== undefined && { detail: normalizedDetail }), + ...(entry.agent != null && { agent: entry.agent }), + lineNo, + sourceRef: buildAgentLogSourceRef(entry.taskId, lineNo), + }; +} + +function countLineNumbers(filePath: string): number { + if (!existsSync(filePath)) { + return 0; + } + const content = readFileSync(filePath, "utf8"); + if (content.length === 0) { + return 0; + } + const lines = content.split("\n"); + return lines.at(-1) === "" ? lines.length - 1 : lines.length; +} + +/** + * Prune agent log JSONL files by removing entries older than the retention cutoff. + * Only affects tasks whose directory exists under `tasksDir`. + * + * @param tasksDir - Root `.fusion/tasks/` directory + * @param retentionDays - Number of days to retain; 0 or negative disables pruning + * @param scanTaskIds - Optional set of task IDs to scope pruning to. If omitted, all task subdirectories are scanned. + * @returns Counts of pruned files and approximate bytes freed. + */ +export function pruneAgentLogFiles( + tasksDir: string, + retentionDays: number, + scanTaskIds?: Set, +): { prunedFiles: number; prunedEntries: number; freedBytes: number } { + if (!Number.isFinite(retentionDays) || retentionDays <= 0 || !existsSync(tasksDir)) { + return { prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }; + } + + const cutoffIso = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); + let prunedFiles = 0; + let prunedEntries = 0; + let freedBytes = 0; + + const entries = readdirSync(tasksDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (scanTaskIds != null && !scanTaskIds.has(entry.name)) continue; + + const taskDirPath = join(tasksDir, entry.name); + const filePath = getAgentLogFilePath(taskDirPath); + if (!existsSync(filePath)) continue; + + try { + const content = readFileSync(filePath, "utf8"); + if (content.length === 0) continue; + + const lines = content.split("\n"); + const keptLines: string[] = []; + let removed = 0; + + for (const line of lines) { + if (!line) continue; + try { + const parsed = JSON.parse(line) as { timestamp?: unknown }; + const ts = typeof parsed.timestamp === "string" ? parsed.timestamp : null; + if (ts != null && ts < cutoffIso) { + removed += 1; + continue; + } + } catch { + // Malformed line — keep it (don't destroy data we can't parse) + } + keptLines.push(line); + } + + if (removed > 0) { + const newSize = keptLines.map((l) => l.length + 1).reduce((a, b) => a + b, 0); + freedBytes += content.length - newSize; + prunedEntries += removed; + + if (keptLines.length === 0) { + unlinkSync(filePath); + prunedFiles += 1; + } else { + // Atomic-ish rewrite: write to temp then rename + const tmpPath = filePath + ".tmp"; + writeFileSync(tmpPath, keptLines.join("\n") + "\n", "utf8"); + renameSync(tmpPath, filePath); + prunedFiles += 1; + } + } + } catch (err) { + log.warn(`Failed to prune agent log file ${filePath}`, err); + } + } + + return { prunedFiles, prunedEntries, freedBytes }; +} diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 8c7e4d1f52..8c7385f131 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,9 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 101; +const SCHEMA_VERSION = 102; + +export { SCHEMA_VERSION }; function normalizeTaskComments( steeringComments: SteeringComment[] | undefined, @@ -483,19 +485,6 @@ CREATE TABLE IF NOT EXISTS agentRuns ( CREATE INDEX IF NOT EXISTS idxAgentRunsAgentIdStartedAt ON agentRuns(agentId, startedAt); CREATE INDEX IF NOT EXISTS idxAgentRunsStatus ON agentRuns(status); -CREATE TABLE IF NOT EXISTS agentLogEntries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - taskId TEXT NOT NULL, - timestamp TEXT NOT NULL, - text TEXT NOT NULL, - type TEXT NOT NULL, - detail TEXT, - agent TEXT, - FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idxAgentLogEntriesTaskIdTimestamp ON agentLogEntries(taskId, timestamp); -CREATE INDEX IF NOT EXISTS idxAgentLogEntriesTaskIdType ON agentLogEntries(taskId, type); - CREATE TABLE IF NOT EXISTS agentTaskSessions ( agentId TEXT NOT NULL, taskId TEXT NOT NULL, @@ -1317,6 +1306,18 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record mentions: "TEXT", createdAt: "TEXT NOT NULL", }, + // agentLogEntries is created by migration 40 for legacy DBs and dropped by + // migration 102. Included here so the architecture-schema-compat test + // recognizes it as a covered migration-only table. + agentLogEntries: { + id: "INTEGER PRIMARY KEY AUTOINCREMENT", + taskId: "TEXT NOT NULL", + timestamp: "TEXT NOT NULL", + text: "TEXT NOT NULL", + type: "TEXT NOT NULL", + detail: "TEXT", + agent: "TEXT", + }, }; /** @@ -1814,7 +1815,6 @@ export class Database { */ private static readonly OPERATIONAL_LOG_TABLES = [ "activityLog", - "agentLogEntries", "runAuditEvents", "agentHeartbeats", ] as const; @@ -4016,6 +4016,27 @@ export class Database { }); } + // Migration 102: Drop agentLogEntries after store-level migration has + // copied legacy rows into per-task JSONL files. Database.init() runs before + // TaskStore.init(), so we must defer the destructive drop until the store + // writes the migration guard into __meta and re-runs init(). + if (version < 102) { + const agentLogMigrationComplete = this.getMetaValue("agentLogEntriesToFileMigrationVersion") === "1"; + const hasLegacyAgentLogTable = this.hasTable("agentLogEntries"); + const legacyAgentLogTableIsEmpty = hasLegacyAgentLogTable + ? ((this.db.prepare("SELECT COUNT(*) as count FROM agentLogEntries").get() as { count: number }).count === 0) + : true; + const hasLegacyAgentLogCitations = + (this.db.prepare( + "SELECT 1 FROM goal_citations WHERE surface = 'agent_log' AND sourceRef GLOB 'agentLog:[0-9]*' LIMIT 1", + ).get() ?? undefined) !== undefined; + if (!hasLegacyAgentLogTable || agentLogMigrationComplete || (legacyAgentLogTableIsEmpty && !hasLegacyAgentLogCitations)) { + this.applyMigration(102, () => { + this.db.exec(`DROP TABLE IF EXISTS agentLogEntries`); + }); + } + } + } /** diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 0f77561dc0..c6db71808f 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -404,6 +404,7 @@ export const DEFAULT_PROJECT_SETTINGS = { chatAutoCleanupDays: 0, mailAutoCleanupDays: 0, operationalLogRetentionDays: 30, + agentLogFileRetentionDays: 0, chatRoomRecentVerbatimMessages: 25, chatRoomCompactionFetchLimit: 200, chatRoomSummaryMaxChars: 3_000, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index a3dd0cd780..d5ff59dcb9 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -11,7 +11,7 @@ import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk import { normalizeTaskPriority } from "./task-priority.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; import { GlobalSettingsStore } from "./global-settings.js"; -import { Database, toJson, toJsonNullable, fromJson } from "./db.js"; +import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; import { ArchiveDatabase } from "./archive-db.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; import { buildSnippet, extractGoalCitations } from "./goal-citation-extractor.js"; @@ -37,6 +37,14 @@ import { getTaskAgeStalenessSignal, type TaskAgeStalenessThresholds } from "./ta import { ensureMemoryFileWithBackend } from "./project-memory.js"; import { runCommandAsync } from "./run-command.js"; import { createLogger } from "./logger.js"; +import { + appendAgentLogEntriesSync, + countAgentLogEntries, + pruneAgentLogFiles as pruneAgentLogFileEntries, + readAgentLogEntries, + readAgentLogEntriesByTimeRange, +} from "./agent-log-file-store.js"; +import { truncateAgentLogDetail } from "./agent-log-constants.js"; import { validateNodeOverrideChange } from "./node-override-guard.js"; import { sanitizeTitle, summarizeTitle } from "./ai-summarize.js"; import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js"; @@ -382,10 +390,6 @@ let taskActivityLogEntryLimit = DEFAULT_TASK_ACTIVITY_LOG_ENTRY_LIMIT; let taskActivityLogOutcomeLimit = DEFAULT_TASK_ACTIVITY_LOG_OUTCOME_LIMIT; const ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25; const ARCHIVE_AGENT_LOG_SNIPPET_LIMIT = 160; -const AGENT_LOG_TOOL_DETAIL_LIMIT = 4_096; -const AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE = - "\n\n[tool output truncated to keep dashboard log views responsive]"; -const AGENT_LOG_TOOL_TYPES = new Set(["tool", "tool_result", "tool_error"]); const storeLog = createLogger("task-store"); const coreLog = createLogger("core"); @@ -472,16 +476,6 @@ function truncateTaskLogOutcome(outcome: string | undefined): string | undefined return `${outcome.slice(0, taskActivityLogOutcomeLimit)}\n... outcome truncated to ${taskActivityLogOutcomeLimit} characters ...`; } -function truncateAgentLogDetail( - detail: string | null | undefined, - type: AgentLogEntry["type"], -): string | undefined { - if (detail == null) return undefined; - if (!AGENT_LOG_TOOL_TYPES.has(type)) return detail; - if (detail.length <= AGENT_LOG_TOOL_DETAIL_LIMIT) return detail; - return `${detail.slice(0, AGENT_LOG_TOOL_DETAIL_LIMIT)}${AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE}`; -} - function compactTaskActivityLog(entries: TaskLogEntry[]): TaskLogEntry[] { const recentEntries = entries.slice(-taskActivityLogEntryLimit); return recentEntries.map((entry) => ({ @@ -1189,9 +1183,9 @@ export class TaskStore extends EventEmitter { taskId: string; timestamp: string; text: string; - type: string; + type: AgentLogEntry["type"]; detail: string | null; - agent: string | null; + agent: AgentLogEntry["agent"] | null; }> = []; /** Timer for flushing the agent log buffer. */ private agentLogFlushTimer: ReturnType | null = null; @@ -1389,6 +1383,10 @@ export class TaskStore extends EventEmitter { await migrateFromLegacy(this.fusionDir, this._db); } await this.migrateActiveArchivedTasksToArchiveDb(); + await this.migrateAgentLogEntriesToFilesOnce(); + if (this.db.getSchemaVersion() < SCHEMA_VERSION) { + this.db.init(); + } await this.importLegacyAgentLogsOnce(); this.taskIdStateReconciled = false; this.reconcileDistributedTaskIdStateOnOpen(); @@ -7663,11 +7661,10 @@ export class TaskStore extends EventEmitter { }, }); this.clearLinkedAgentTaskIds(id, deletedAt); - // FN-5143: clear historical agent logs for the soft-deleted task so - // downstream readers (evaluator evidence, self-healing diagnostics, - // dashboard log views, register-task-workflow-routes) observe zero logs - // immediately after deletedAt is set. Atomic with the deletedAt write. - this.db.prepare("DELETE FROM agentLogEntries WHERE taskId = ?").run(id); + // FN-5143: agent log reads are gated on deletedAt (see getAgentLogs / + // getAgentLogCount / getAgentLogsByTimeRange), so downstream readers + // observe zero logs immediately after deletedAt is set. The JSONL file + // remains on disk for forensic analysis; only the read API hides it. this.db.bumpLastModified(); }); @@ -8841,7 +8838,7 @@ export class TaskStore extends EventEmitter { } /** - * Insert an agent log entry into the agentLogEntries SQLite table. + * Buffer an agent log entry for file-backed persistence. * Also emits an `agent:log` event for live streaming. * * @param taskId - The task ID (e.g. "KB-001") @@ -8911,7 +8908,7 @@ export class TaskStore extends EventEmitter { } /** - * Flush all buffered agent log entries in a single transaction. + * Flush all buffered agent log entries to per-task JSONL files. * Called when the buffer is full or on a timer. */ private flushAgentLogBuffer(): void { @@ -8921,55 +8918,45 @@ export class TaskStore extends EventEmitter { } if (this.agentLogBuffer.length === 0) return; - // Snapshot the entries to flush. New entries appended during the - // synchronous transaction will appear past batch.length in - // this.agentLogBuffer, so we splice only the flushed count. const batch = this.agentLogBuffer.slice(); const flushCount = batch.length; let validEntries = batch; - let flushSucceeded = false; + const flushedEntries = new Set(); try { - this.db.transaction(() => { - // Query live task IDs inside the transaction so the check is - // atomic with the inserts (prevents TOCTOU FK violations). - const liveTaskIds = new Set( - (this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((r) => r.id), + const liveTaskIds = new Set( + (this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id), + ); + validEntries = batch.filter((entry) => liveTaskIds.has(entry.taskId)); + const dropped = batch.length - validEntries.length; + if (dropped > 0) { + console.warn( + `[fusion] Dropped ${dropped} buffered agent log entries for deleted tasks (${this.db.path})`, ); - validEntries = batch.filter((e) => liveTaskIds.has(e.taskId)); - const dropped = batch.length - validEntries.length; - if (dropped > 0) { - console.warn( - `[fusion] Dropped ${dropped} buffered agent log entries for deleted tasks (${this.db.path})`, - ); + } + + if (validEntries.length > 0) { + const citationInputs: GoalCitationInput[] = []; + const entriesByTask = new Map(); + for (const entry of validEntries) { + const taskEntries = entriesByTask.get(entry.taskId); + if (taskEntries) { + taskEntries.push(entry); + } else { + entriesByTask.set(entry.taskId, [entry]); + } } - if (validEntries.length > 0) { - const stmt = this.db.prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `); - const citationInputs: GoalCitationInput[] = []; - for (const entry of validEntries) { - const insertResult = stmt.run( - entry.taskId, - entry.timestamp, - entry.text, - entry.type, - entry.detail, - entry.agent, - ) as { lastInsertRowid?: number | bigint }; - const insertedId = insertResult.lastInsertRowid; - if (insertedId === undefined || insertedId === null) { - continue; - } - const sourceRef = `agentLog:${String(insertedId)}`; + for (const [taskId, taskEntries] of entriesByTask) { + const appended = appendAgentLogEntriesSync(this.taskDir(taskId), taskEntries); + taskEntries.forEach((entry) => flushedEntries.add(entry)); + for (const entry of appended) { try { citationInputs.push( ...this.scanAndRecordCitations( entry.text, "agent_log", - sourceRef, + entry.sourceRef, entry.agent ?? "unknown", entry.taskId, entry.timestamp, @@ -8979,25 +8966,22 @@ export class TaskStore extends EventEmitter { console.warn("[fusion] Failed to scan goal citations from agent_log:", err); } } - if (citationInputs.length > 0) { - try { - this.recordGoalCitations(citationInputs); - } catch (err) { - console.warn("[fusion] Failed to record goal citations from agent_log batch:", err); - } - } - this.db.bumpLastModified(); } - }); - flushSucceeded = true; + + if (citationInputs.length > 0) { + try { + this.recordGoalCitations(citationInputs); + } catch (err) { + console.warn("[fusion] Failed to record goal citations from agent_log batch:", err); + } + } + this.db.bumpLastModified(); + } } finally { - // Always drain the original slice from the buffer. this.agentLogBuffer.splice(0, flushCount); - // On transient failures (busy/IO), requeue valid entries for retry. - // Stale rows were already filtered out above. - if (!flushSucceeded && validEntries.length > 0) { - this.agentLogBuffer.unshift(...validEntries); - // Re-arm the flush timer so retried entries don't sit in memory forever. + const remainingValidEntries = validEntries.filter((entry) => !flushedEntries.has(entry)); + if (remainingValidEntries.length > 0) { + this.agentLogBuffer.unshift(...remainingValidEntries); if (!this.agentLogFlushTimer) { this.agentLogFlushTimer = setTimeout(() => { try { @@ -9034,50 +9018,65 @@ export class TaskStore extends EventEmitter { ...entry, detail: truncateAgentLogDetail(entry.detail, entry.type), })); - const stmt = this.db.prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `); + const liveTaskIds = new Set( + (this.db.prepare(`SELECT id FROM tasks WHERE ${TaskStore.ACTIVE_TASKS_WHERE}`).all() as Array<{ id: string }>).map((row) => row.id), + ); + const validEntries = normalizedEntries.filter((entry) => liveTaskIds.has(entry.taskId)); + const dropped = normalizedEntries.length - validEntries.length; + if (dropped > 0) { + console.warn(`[fusion] Dropped ${dropped} batch agent log entries for deleted tasks (${this.db.path})`); + } - this.db.transaction(() => { - const citationInputs: GoalCitationInput[] = []; - for (const entry of normalizedEntries) { - const insertResult = stmt.run( - entry.taskId, + const citationInputs: GoalCitationInput[] = []; + const entriesByTask = new Map(); + for (const entry of validEntries) { + const taskEntries = entriesByTask.get(entry.taskId); + if (taskEntries) { + taskEntries.push(entry); + } else { + entriesByTask.set(entry.taskId, [entry]); + } + } + + for (const [taskId, taskEntries] of entriesByTask) { + const appended = appendAgentLogEntriesSync( + this.taskDir(taskId), + taskEntries.map((entry) => ({ timestamp, - entry.text, - entry.type, - entry.detail ?? null, - entry.agent ?? null, - ) as { lastInsertRowid?: number | bigint }; - const insertedId = insertResult.lastInsertRowid; - if (insertedId === undefined || insertedId === null) { - continue; - } + taskId: entry.taskId, + text: entry.text, + type: entry.type, + detail: entry.detail ?? null, + agent: entry.agent ?? null, + })), + ); + for (const entry of appended) { try { citationInputs.push( ...this.scanAndRecordCitations( entry.text, "agent_log", - `agentLog:${String(insertedId)}`, + entry.sourceRef, entry.agent ?? "unknown", entry.taskId, - timestamp, + entry.timestamp, ), ); } catch (err) { console.warn("[fusion] Failed to scan goal citations from agent log batch:", err); } } - if (citationInputs.length > 0) { - try { - this.recordGoalCitations(citationInputs); - } catch (err) { - console.warn("[fusion] Failed to record goal citations from appendAgentLogBatch:", err); - } + } + if (citationInputs.length > 0) { + try { + this.recordGoalCitations(citationInputs); + } catch (err) { + console.warn("[fusion] Failed to record goal citations from appendAgentLogBatch:", err); } + } + if (validEntries.length > 0) { this.db.bumpLastModified(); - }); + } for (const entry of normalizedEntries) { this.emit("agent:log", { @@ -9091,37 +9090,6 @@ export class TaskStore extends EventEmitter { } } - private mapAgentLogRow(row: Record): AgentLogEntry { - const type = row.type as AgentLogEntry["type"]; - const detail = row.detail != null ? String(row.detail) : undefined; - return { - timestamp: row.timestamp as string, - taskId: row.taskId as string, - text: row.text as string, - type, - ...(detail !== undefined && { detail }), - ...(row.agent != null && { agent: row.agent as AgentLogEntry["agent"] }), - }; - } - - private getAgentLogSelectClause(): string { - const escapedNotice = AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE.replace(/'/g, "''"); - return ` - taskId, - timestamp, - text, - type, - CASE - WHEN type IN ('tool', 'tool_result', 'tool_error') - AND detail IS NOT NULL - AND LENGTH(detail) > ${AGENT_LOG_TOOL_DETAIL_LIMIT} - THEN SUBSTR(detail, 1, ${AGENT_LOG_TOOL_DETAIL_LIMIT}) || '${escapedNotice}' - ELSE detail - END AS detail, - agent - `; - } - async addTaskComment(id: string, text: string, author: string): Promise { // Delegate to unified addComment method return this.addComment(id, text, author); @@ -10030,7 +9998,7 @@ export class TaskStore extends EventEmitter { } /** - * Read historical agent log entries for a task from SQLite. + * Read historical agent log entries for a task from JSONL storage. * Returns entries in chronological order (oldest first). * * Tool-oriented detail payloads are clipped server-side to keep historical @@ -10050,6 +10018,9 @@ export class TaskStore extends EventEmitter { ): Promise { // Ensure buffered entries are visible before reading. this.flushAgentLogBuffer(); + if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { + return []; + } const limit = options?.limit !== undefined ? (Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 0) : undefined; @@ -10059,47 +10030,23 @@ export class TaskStore extends EventEmitter { if (limit === 0) return []; - const selectClause = this.getAgentLogSelectClause(); - - if (limit !== undefined) { - const readCount = offset > 0 ? limit + offset : limit; - const rows = this.db.prepare(` - SELECT ${selectClause} FROM agentLogEntries - WHERE taskId = ? - ORDER BY timestamp DESC, id DESC - LIMIT ? - `).all(taskId, readCount) as Array>; - const entries = rows.map((row) => this.mapAgentLogRow(row)).reverse(); - if (offset > 0) { - return entries.slice(0, Math.max(0, entries.length - offset)); - } - return entries; - } - - const rows = this.db.prepare(` - SELECT ${selectClause} FROM agentLogEntries - WHERE taskId = ? - ORDER BY timestamp ASC, id ASC - `).all(taskId) as Array>; - const entries = rows.map((row) => this.mapAgentLogRow(row)); - if (offset > 0) { - return entries.slice(0, Math.max(0, entries.length - offset)); - } - return entries; + return readAgentLogEntries(this.taskDir(taskId), { limit, offset }).map( + ({ lineNo: _lineNo, sourceRef: _sourceRef, ...entry }) => entry, + ); } /** - * Count total number of persisted agent log entries for a task in SQLite. + * Count total number of persisted agent log entries for a task in JSONL storage. * * @param taskId - The task ID (e.g. "KB-001") * @returns Total number of log entries */ async getAgentLogCount(taskId: string): Promise { this.flushAgentLogBuffer(); - const row = this.db.prepare( - "SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?", - ).get(taskId) as { count: number } | undefined; - return row?.count ?? 0; + if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { + return 0; + } + return countAgentLogEntries(this.taskDir(taskId)); } /** @@ -10117,14 +10064,13 @@ export class TaskStore extends EventEmitter { ): Promise { // Ensure buffered entries are visible before reading. this.flushAgentLogBuffer(); + if (this.readTaskFromDb(taskId, { includeDeleted: true })?.deletedAt) { + return []; + } const end = endIso ?? new Date().toISOString(); - const selectClause = this.getAgentLogSelectClause(); - const rows = this.db.prepare(` - SELECT ${selectClause} FROM agentLogEntries - WHERE taskId = ? AND timestamp >= ? AND timestamp <= ? - ORDER BY timestamp ASC, id ASC - `).all(taskId, startIso, end) as Array>; - return rows.map((row) => this.mapAgentLogRow(row)); + return readAgentLogEntriesByTimeRange(this.taskDir(taskId), startIso, end).map( + ({ lineNo: _lineNo, sourceRef: _sourceRef, ...entry }) => entry, + ); } async importLegacyAgentLogs(): Promise { @@ -10132,18 +10078,23 @@ export class TaskStore extends EventEmitter { const entries = await readdir(this.tasksDir, { withFileTypes: true }); let imported = 0; - const insertStmt = this.db.prepare(` - INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent) - VALUES (?, ?, ?, ?, ?, ?) - `); for (const entry of entries) { if (!entry.isDirectory()) continue; - const logPath = join(this.tasksDir, entry.name, "agent.log"); + const taskDir = join(this.tasksDir, entry.name); + const logPath = join(taskDir, "agent.log"); if (!existsSync(logPath)) continue; try { const content = await readFile(logPath, "utf-8"); + const parsedEntries: Array<{ + timestamp: string; + taskId: string; + text: string; + type: AgentLogEntry["type"]; + detail?: string | null; + agent?: AgentLogEntry["agent"] | null; + }> = []; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; @@ -10155,20 +10106,21 @@ export class TaskStore extends EventEmitter { const type = typeof parsed.type === "string" ? parsed.type : null; if (!timestamp || !parsedTaskId || !type) continue; - const text = typeof parsed.text === "string" ? parsed.text : ""; - const detail = typeof parsed.detail === "string" ? parsed.detail : null; - const agent = typeof parsed.agent === "string" ? parsed.agent : null; - const normalizedDetail = truncateAgentLogDetail( - detail, - type as AgentLogEntry["type"], - ); - - insertStmt.run(parsedTaskId, timestamp, text, type, normalizedDetail ?? null, agent); - imported += 1; + parsedEntries.push({ + timestamp, + taskId: parsedTaskId, + text: typeof parsed.text === "string" ? parsed.text : "", + type: type as AgentLogEntry["type"], + detail: typeof parsed.detail === "string" ? parsed.detail : null, + agent: typeof parsed.agent === "string" ? (parsed.agent as AgentLogEntry["agent"]) : null, + }); } catch { // Skip malformed JSONL lines. } } + + appendAgentLogEntriesSync(taskDir, parsedEntries); + imported += parsedEntries.length; } catch (err) { storeLog.warn("Skipping unreadable legacy agent.log file during import", { phase: "importLegacyAgentLogs:read-file", @@ -10205,6 +10157,110 @@ export class TaskStore extends EventEmitter { this.db.bumpLastModified(); } + /** + * One-time migration: copy `agentLogEntries` rows from SQLite into per-task + * JSONL files, then rewrite goal-citation source-refs from the old + * `agentLog:` format to the new `agentLog:{taskId}:{lineNo}` format. + * Guarded by `__meta` so it runs exactly once. + */ + private async migrateAgentLogEntriesToFilesOnce(): Promise { + const migrationKey = "agentLogEntriesToFileMigrationVersion"; + const migrationVersion = "1"; + const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as + | { value: string } + | undefined; + + if (row?.value === migrationVersion) { + return; + } + + // Only run if the agentLogEntries table still exists + const hasTable = + this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agentLogEntries' LIMIT 1").get() !== + undefined; + if (!hasTable) { + // Table already gone (fresh DB or already migrated) — mark done + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(migrationKey, migrationVersion); + return; + } + + interface AgentLogRow { + id: number; + taskId: string; + timestamp: string; + text: string; + type: string; + detail: string | null; + agent: string | null; + } + + // Read all rows ordered by taskId, id so each task's entries are + // written in their original insertion order + const rows = this.db + .prepare("SELECT id, taskId, timestamp, text, type, detail, agent FROM agentLogEntries ORDER BY taskId, id") + .all() as AgentLogRow[]; + + if (rows.length > 0) { + // Group rows by task + const entriesByTask = new Map(); + for (const row of rows) { + let taskRows = entriesByTask.get(row.taskId); + if (!taskRows) { + taskRows = []; + entriesByTask.set(row.taskId, taskRows); + } + taskRows.push(row); + } + + // Write per-task JSONL files + const rowIdToNewRef = new Map(); + for (const [taskId, taskRows] of entriesByTask) { + const td = this.taskDir(taskId); + const appended = appendAgentLogEntriesSync( + td, + taskRows.map((r) => ({ + timestamp: r.timestamp, + taskId: r.taskId, + text: r.text, + type: r.type as AgentLogEntry["type"], + detail: r.detail, + agent: r.agent as AgentLogEntry["agent"] | null, + })), + ); + // Build mapping from old rowid to new sourceRef + for (let i = 0; i < taskRows.length; i++) { + rowIdToNewRef.set(taskRows[i]!.id, appended[i]!.sourceRef); + } + } + + // Rewrite goal-citation source-refs that use the old agentLog: format + const oldFormatRows = this.db + .prepare("SELECT id, sourceRef FROM goal_citations WHERE surface = 'agent_log' AND sourceRef GLOB 'agentLog:[0-9]*'") + .all() as Array<{ id: number; sourceRef: string }>; + + const updateStmt = this.db.prepare("UPDATE goal_citations SET sourceRef = ? WHERE id = ?"); + this.db.transaction(() => { + for (const citation of oldFormatRows) { + const oldRowId = parseInt(citation.sourceRef.replace("agentLog:", ""), 10); + const newRef = rowIdToNewRef.get(oldRowId); + if (newRef) { + updateStmt.run(newRef, citation.id); + } + } + }); + } + + // Mark migration as done + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(migrationKey, migrationVersion); + this.db.bumpLastModified(); + } + // ── Archive Cleanup Methods ───────────────────────────────────────── /** @@ -10847,6 +10903,27 @@ ${stepsSection}`; return this.db.pruneOperationalLogs(retentionMs); } + /** + * Prune per-task JSONL agent log files by removing entries older than the + * configured retention window. Only prunes files for soft-deleted or archived + * tasks (avoids removing logs for still-active tasks). Returns zeroed counts + * when retention is disabled (`<= 0`). + */ + pruneAgentLogFiles(retentionDays: number): { prunedFiles: number; prunedEntries: number; freedBytes: number } { + if (!Number.isFinite(retentionDays) || retentionDays <= 0) { + return { prunedFiles: 0, prunedEntries: 0, freedBytes: 0 }; + } + // Only prune JSONL files for tasks that are no longer active (soft-deleted or archived) + const inactiveTaskIds = new Set( + ( + this.db + .prepare(`SELECT id FROM tasks WHERE deletedAt IS NOT NULL OR "column" = 'archived'`) + .all() as Array<{ id: string }> + ).map((row) => row.id), + ); + return pruneAgentLogFileEntries(this.tasksDir, retentionDays, inactiveTaskIds); + } + getRootDir(): string { return this.rootDir; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec127dc005..a0ffed9bb0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3697,10 +3697,16 @@ export interface ProjectSettings { * Allowed values: 0 (off, default) or one of 7 | 14 | 30 | 60 | 90. Uses messages.updatedAt inactivity age. */ mailAutoCleanupDays?: number; /** Number of days to retain append-only operational-log rows (activityLog, - * agentLogEntries, runAuditEvents, agentHeartbeats) before periodic maintenance - * prunes them. These tables are the main driver of unbounded database growth. + * runAuditEvents, agentHeartbeats) before periodic maintenance prunes them. + * Agent logs are now stored in per-task JSONL files — see agentLogFileRetentionDays. * Default: 30. Set 0 to disable pruning. Uses each row's `timestamp` column. */ operationalLogRetentionDays?: number; + /** Number of days to retain per-task agent-log JSONL files for soft-deleted + * and archived tasks. Only affects tasks that are no longer active. Entries + * older than this window are removed from the JSONL file during periodic + * maintenance. Default: 0 (disabled). Set to a positive integer (e.g. 90) + * to enable pruning. */ + agentLogFileRetentionDays?: number; /** Number of most-recent chat-room messages kept verbatim in the responder transcript. * Older messages are compacted into a summary block. Default: 12. */ chatRoomRecentVerbatimMessages?: number; diff --git a/packages/dashboard/src/__tests__/agent-log-routes.integration.test.ts b/packages/dashboard/src/__tests__/agent-log-routes.integration.test.ts new file mode 100644 index 0000000000..1fe32d5554 --- /dev/null +++ b/packages/dashboard/src/__tests__/agent-log-routes.integration.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import express from "express"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import { createApiRoutes } from "../routes.js"; +import { get } from "../test-request.js"; + +describe("task log routes with file-backed agent logs", () => { + let rootDir: string; + let store: TaskStore; + let app: express.Express; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "fusion-dashboard-agent-log-routes-")); + store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"), { inMemoryDb: true }); + await store.init(); + app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + }); + + afterEach(() => { + store.close(); + rmSync(rootDir, { recursive: true, force: true }); + }); + + it("GET /api/tasks/:id/logs returns file-backed entries with count headers", async () => { + const task = await store.createTask({ description: "Route reads file-backed agent logs" }); + await store.appendAgentLog(task.id, "first", "text", undefined, "executor"); + await store.appendAgentLog(task.id, "second", "tool", "detail-2", "executor"); + await store.appendAgentLog(task.id, "third", "tool_result", "detail-3", "executor"); + + const expected = await store.getAgentLogs(task.id, { limit: 2 }); + const agentLogPath = join(rootDir, ".fusion", "tasks", task.id, "agent-log.jsonl"); + expect(existsSync(agentLogPath)).toBe(true); + + const res = await get(app, `/api/tasks/${task.id}/logs?limit=2`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(expected); + expect(res.headers["x-total-count"]).toBe("3"); + expect(res.headers["x-has-more"]).toBe("true"); + }); +}); diff --git a/packages/engine/src/__tests__/evaluator-evidence.test.ts b/packages/engine/src/__tests__/evaluator-evidence.test.ts index 1971c6c321..5aa0ddc2c0 100644 --- a/packages/engine/src/__tests__/evaluator-evidence.test.ts +++ b/packages/engine/src/__tests__/evaluator-evidence.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import * as core from "@fusion/core"; import { collectTaskEvaluationEvidence } from "../evaluator-evidence.js"; @@ -30,6 +33,23 @@ function makeStore(overrides: Partial = {}): core.TaskStore { } describe("collectTaskEvaluationEvidence", () => { + let integrationRootDir: string | null = null; + let integrationStore: core.TaskStore | null = null; + + beforeEach(() => { + integrationRootDir = null; + integrationStore = null; + }); + + afterEach(() => { + integrationStore?.close(); + integrationStore = null; + if (integrationRootDir) { + rmSync(integrationRootDir, { recursive: true, force: true }); + integrationRootDir = null; + } + }); + it("collects fixed source groups with bounded excerpts", async () => { const store = makeStore({ getTaskDocuments: vi.fn().mockResolvedValue([{ key: "plan", content: "x".repeat(900), revision: 1, author: "agent", updatedAt: "2026-01-01T00:01:00.000Z" }]), @@ -123,6 +143,31 @@ describe("collectTaskEvaluationEvidence", () => { expect(evidence.agentLogs.at(-1)?.excerpt).toContain("entry-29"); }); + it("reads file-backed agent logs through the TaskStore evidence seam", async () => { + integrationRootDir = mkdtempSync(join(tmpdir(), "fusion-evaluator-evidence-")); + const globalDir = join(integrationRootDir, ".fusion-global-settings"); + integrationStore = new core.TaskStore(integrationRootDir, globalDir, { inMemoryDb: true }); + await integrationStore.init(); + + const task = await integrationStore.createTask({ description: "Collect evaluator evidence from file-backed logs" }); + await integrationStore.appendAgentLog(task.id, "first line", "text", undefined, "executor"); + await integrationStore.appendAgentLog(task.id, "tool finished", "tool_result", "ok", "executor"); + + const detail = await integrationStore.getTask(task.id); + const evidence = await collectTaskEvaluationEvidence({ + store: integrationStore, + task: detail, + runId: "ER-file-backed", + cwd: integrationRootDir, + }); + + expect(evidence.agentLogs).toHaveLength(2); + expect(evidence.agentLogs.map((entry) => entry.label)).toEqual(["text", "tool_result"]); + expect(evidence.agentLogs.map((entry) => entry.excerpt)).toEqual(["first line", "tool finished — ok"]); + expect(evidence.agentLogs.map((entry) => entry.agentId)).toEqual(["executor", "executor"]); + + }); + it("truncates task metadata summary when oversized", async () => { const evidence = await collectTaskEvaluationEvidence({ store: makeStore(), diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index f09d1d941a..4f73c4410a 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -1657,6 +1657,18 @@ export class SelfHealingManager { log.log(`Maintenance batch 1 step "prune-operational-logs" succeeded — deleted=${deletedTotal}${detail ? ` (${detail})` : ""}`); }, }, + { + name: "prune-agent-log-files", + fn: async () => { + const days = Number(settings.agentLogFileRetentionDays ?? 0); + if (!Number.isFinite(days) || days <= 0) { + log.log("Maintenance batch 1 step \"prune-agent-log-files\" skipped — agentLogFileRetentionDays is not enabled"); + return; + } + const { prunedFiles, prunedEntries, freedBytes } = this.store.pruneAgentLogFiles(days); + log.log(`Maintenance batch 1 step "prune-agent-log-files" succeeded — files=${prunedFiles} entries=${prunedEntries} bytes=${freedBytes}`); + }, + }, { name: "checkpoint-wal", fn: () => Promise.resolve(this.checkpointWal()) }, { name: "enforce-worktree-cap", fn: () => this.enforceWorktreeCap() }, ]; diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index d0ebdc7a29..010b2796c9 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -743,8 +743,8 @@ describe("RoadmapStore", () => { }); describe("schema version", () => { - it("schema version is 100 after init", () => { - expect(db.getSchemaVersion()).toBe(101); + it("schema version is 102 after init", () => { + expect(db.getSchemaVersion()).toBe(102); }); }); From 59cd9ea66cf4237437a9ea2ec1061f0658dde5a0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 07:10:02 -0700 Subject: [PATCH 37/46] Planning fixes --- .../src/__tests__/routes-planning-tracking.test.ts | 1 + packages/dashboard/src/planning.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts index 26dca5cb9f..c0e7d2b9c7 100644 --- a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts @@ -26,6 +26,7 @@ const sessions = new Map(); vi.mock("../planning.js", () => ({ getSession: (id: string) => sessions.get(id), getSummary: (id: string) => sessions.get(id)?.summary, + releaseSession: vi.fn(), cleanupSession: vi.fn(), formatInterviewQA: vi.fn(() => ""), mergePlanningSubtaskDrafts: vi.fn((_sessionId: string, subtasks: unknown[]) => subtasks), diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 254f51ee5b..74c3741c8a 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -407,7 +407,12 @@ function cleanupInMemorySession(sessionId: string): boolean { if (session.agent) { try { - session.agent.session.dispose?.(); + const disposeResult = session.agent.session.dispose?.(); + if (disposeResult) { + disposeResult.catch((err: unknown) => { + diagnostics.errorFromException("Error disposing agent for session", err, { sessionId, operation: "dispose-session" }); + }); + } } catch (err) { diagnostics.errorFromException("Error disposing agent for session", err, { sessionId, operation: "dispose-session" }); } From 6f378067f45d4e72618a2cba54f1a58848bf4ba3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 07:51:38 -0700 Subject: [PATCH 38/46] fix(dashboard): show missing Minimax usage rows and weekly windows The Minimax usage panel only rendered one model row. The primary `general` model meters quota purely via `current_interval_remaining_percent` (its `current_interval_total_count` is 0), so the count-based `total > 0` visibility filter dropped it entirely. The percent was also derived from count fields rather than the authoritative `*_remaining_percent` field. fetchMinimaxUsage now builds windows via a helper that prefers `*_remaining_percent` (count-based fallback when absent) and skips a window only when no quota signal exists. Each model's separate weekly quota window is now surfaced as its own indicator alongside the interval window. Verified against the live coding_plan/remains endpoint: 2 models (general, video) now produce 4 windows (interval + weekly each) instead of 1. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/minimax-usage-missing-rows.md | 7 ++ .../dashboard/src/__tests__/usage.test.ts | 89 ++++++++++++++ packages/dashboard/src/usage.ts | 115 ++++++++++++------ 3 files changed, 173 insertions(+), 38 deletions(-) create mode 100644 .changeset/minimax-usage-missing-rows.md diff --git a/.changeset/minimax-usage-missing-rows.md b/.changeset/minimax-usage-missing-rows.md new file mode 100644 index 0000000000..22921874b6 --- /dev/null +++ b/.changeset/minimax-usage-missing-rows.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Fix missing model rows in the Minimax provider usage panel. The primary `general` model meters quota purely via `current_interval_remaining_percent` (its count fields are `0`), so the previous count-based visibility filter dropped it entirely. + +Minimax usage now prefers the authoritative `*_remaining_percent` field (with a count-based fallback) and renders a window only when a model exposes any quota signal. Each model's separate weekly quota window (`current_weekly_remaining_percent`, `weekly_*` timing) is now surfaced as its own indicator alongside the interval window. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 36112a8617..7ac4f6e4dc 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -2607,6 +2607,95 @@ describe("usage", () => { expect(speechWindow).toBeDefined(); }); + it("shows percent-only models and weekly windows from real coding_plan response", async () => { + // Mirrors a real coding_plan/remains response: the primary "general" + // model meters quota purely via *_remaining_percent (its count fields are + // 0), and every model also carries a separate weekly quota window. + const now = Date.now(); + const mockResponse = { + model_remains: [ + { + model_name: "general", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 91, + remains_time: 2_039_915, + start_time: now - 3 * 60 * 60 * 1000, + end_time: now + 2 * 60 * 60 * 1000, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 100, + weekly_remains_time: 380_039_915, + weekly_start_time: now - 1 * 60 * 60 * 1000, + weekly_end_time: now + 6 * 24 * 60 * 60 * 1000, + }, + { + model_name: "video", + current_interval_total_count: 3, + current_interval_usage_count: 3, + current_interval_remaining_percent: 100, + remains_time: 34_439_915, + start_time: now - 1 * 60 * 60 * 1000, + end_time: now + 23 * 60 * 60 * 1000, + current_weekly_total_count: 21, + current_weekly_usage_count: 21, + current_weekly_remaining_percent: 100, + weekly_remains_time: 380_039_915, + weekly_start_time: now - 1 * 60 * 60 * 1000, + weekly_end_time: now + 6 * 24 * 60 * 60 * 1000, + }, + ], + }; + + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.includes(".pi/agent/auth.json")) { + return JSON.stringify({ + minimax: { type: "api_key", key: "test-api-key" }, + }); + } + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from(JSON.stringify(mockResponse))); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + const providers = await fetchAllProviderUsage(); + const minimax = providers.find((p) => p.name === "Minimax")!; + + expect(minimax.status).toBe("ok"); + // 2 models × (interval + weekly) = 4 windows + expect(minimax.windows).toHaveLength(4); + + // "general" interval row must appear even though its count fields are 0 — + // remaining-percent is the source of truth. + const generalInterval = minimax.windows.find((w) => w.label === "general")!; + expect(generalInterval).toBeDefined(); + expect(generalInterval.percentLeft).toBeCloseTo(91, 0); + expect(generalInterval.percentUsed).toBeCloseTo(9, 0); + + // Weekly window is surfaced as its own indicator. + const generalWeekly = minimax.windows.find((w) => w.label === "general (weekly)")!; + expect(generalWeekly).toBeDefined(); + expect(generalWeekly.percentLeft).toBeCloseTo(100, 0); + + expect(minimax.windows.find((w) => w.label === "video")).toBeDefined(); + expect(minimax.windows.find((w) => w.label === "video (weekly)")).toBeDefined(); + }); + it("skips models with zero quota", async () => { const mockResponse = { model_remains: [ diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 27d2f5f143..3d78e838a8 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -1458,50 +1458,89 @@ async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise { + let percentLeft: number; + if (typeof remainingPercent === "number" && Number.isFinite(remainingPercent)) { + percentLeft = remainingPercent; + } else if (totalCount > 0) { + const used = Math.max(0, totalCount - remainingCount); + percentLeft = 100 - (used / totalCount) * 100; + } else { + // No quota signal at all — skip (unused model type / window). + return null; + } + + percentLeft = Math.min(100, Math.max(0, percentLeft)); + const percentUsed = Math.min(100, Math.max(0, 100 - percentLeft)); + + let resetText: string | null = null; + let resetMs: number | undefined; + let resetAt: string | undefined; + if (remainsTime && remainsTime > 0) { + resetMs = remainsTime; + resetText = `resets in ${formatDuration(remainsTime)}`; + resetAt = new Date(Date.now() + remainsTime).toISOString(); + } + + let windowDurationMs: number | undefined; + if (startTime && endTime) { + windowDurationMs = endTime - startTime; + } + + return { + label, + percentUsed, + percentLeft, + resetText, + resetMs, + resetAt, + windowDurationMs, + }; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response const modelRemains: any[] = data?.model_remains || []; - if (Array.isArray(modelRemains) && modelRemains.length > 0) { + if (Array.isArray(modelRemains)) { for (const model of modelRemains) { const modelName: string = model.model_name || "Unknown"; - const total: number = model.current_interval_total_count ?? 0; - // Note: Minimax's current_interval_usage_count is actually REMAINING, not used - // (known API quirk per https://github.com/MiniMax-AI/MiniMax-M2/issues/99) - const remaining: number = model.current_interval_usage_count ?? 0; - const used: number = Math.max(0, total - remaining); - const percentUsed = total > 0 ? (used / total) * 100 : 0; + const interval = buildWindow( + modelName, + model.current_interval_total_count ?? 0, + model.current_interval_usage_count ?? 0, + model.current_interval_remaining_percent, + model.remains_time, + model.start_time, + model.end_time, + ); + if (interval) usage.windows.push(interval); - let resetText: string | null = null; - let resetMs: number | undefined; - let windowDurationMs: number | undefined; - - const remainsTime: number = model.remains_time; - let resetAt: string | undefined; - if (remainsTime && remainsTime > 0) { - resetMs = remainsTime; - resetText = `resets in ${formatDuration(remainsTime)}`; - resetAt = new Date(Date.now() + remainsTime).toISOString(); - } - - const startTime: number = model.start_time; - const endTime: number = model.end_time; - if (startTime && endTime) { - windowDurationMs = endTime - startTime; - } - - // Only show models that have a quota > 0 (skip unused model types) - if (total > 0) { - usage.windows.push({ - label: modelName, - percentUsed: Math.min(100, Math.max(0, percentUsed)), - percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)), - resetText, - resetMs, - resetAt, - windowDurationMs, - }); - } + const weekly = buildWindow( + `${modelName} (weekly)`, + model.current_weekly_total_count ?? 0, + model.current_weekly_usage_count ?? 0, + model.current_weekly_remaining_percent, + model.weekly_remains_time, + model.weekly_start_time, + model.weekly_end_time, + ); + if (weekly) usage.windows.push(weekly); } } } catch (e: unknown) { From ad468813d5944a52d751e3a2fbbda56a0b58d9ac Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:18:24 -0700 Subject: [PATCH 39/46] fix(engine): honor per-task auto-merge override when global auto-merge is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks with autoMerge explicitly enabled never auto-merged when the project-level setting was disabled: the merge enqueue gate (allowInReviewMergeProcessing) and all 19 in-review self-healing sweeps checked only settings.autoMerge, and the board stall-signal hydration passed the raw global into the diagnostic gates. Introduce allowsAutoMergeProcessing(task, settings) in core — additive relative to the global setting so configs with global auto-merge ON are unchanged (explicit autoMerge:false tasks still flow to the merger's manual-required parking) — and use it at the enqueue gate, every self-healing sweep, and the store's stall/stalled signal contexts. --- .changeset/per-task-automerge-override.md | 5 + .../core/src/__tests__/task-merge.test.ts | 18 +++ packages/core/src/index.ts | 1 + packages/core/src/store.ts | 13 +- packages/core/src/task-merge.ts | 17 +++ .../src/__tests__/project-engine.test.ts | 26 ++++ .../engine/src/__tests__/self-healing.test.ts | 88 ++++++++++- packages/engine/src/project-engine.ts | 6 +- packages/engine/src/self-healing.ts | 141 ++++++++++-------- 9 files changed, 241 insertions(+), 74 deletions(-) create mode 100644 .changeset/per-task-automerge-override.md diff --git a/.changeset/per-task-automerge-override.md b/.changeset/per-task-automerge-override.md new file mode 100644 index 0000000000..ae495efe7e --- /dev/null +++ b/.changeset/per-task-automerge-override.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Respect per-task auto-merge overrides when the global auto-merge setting is off. Tasks with auto-merge explicitly enabled now get enqueued for merge and covered by the in-review self-healing sweeps (stall surfacing, merged-task finalization, retry recovery) even when the project-level setting is disabled; tasks without an explicit override keep the PR-based/manual review flow untouched. diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index b9a3854406..c7e26e981f 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -8,6 +8,7 @@ import { getTaskHardMergeBlocker, getTaskMergeBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, @@ -46,6 +47,23 @@ describe("resolveEffectiveAutoMerge", () => { }); }); +describe("allowsAutoMergeProcessing", () => { + it("lets explicit per-task true through when the global setting is off (FN per-task override)", () => { + expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: false })).toBe(true); + }); + + it("blocks tasks without an explicit override when the global setting is off", () => { + expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: false })).toBe(false); + expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: false })).toBe(false); + }); + + it("lets everything through when the global setting is on — explicit false still flows so the merger can park it manual-required", () => { + expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: true })).toBe(true); + }); +}); + describe("resolveEffectiveGroupAutoMerge", () => { it("prefers explicit true over global false", () => { expect(resolveEffectiveGroupAutoMerge({ autoMerge: true }, { autoMerge: false })).toBe(true); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a00a01622f..a362011a06 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -318,6 +318,7 @@ export { getTaskHardMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d5ff59dcb9..f5684fbc5c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -9,6 +9,7 @@ import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_ import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js"; import { normalizeTaskPriority } from "./task-priority.js"; +import { allowsAutoMergeProcessing } from "./task-merge.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; import { GlobalSettingsStore } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; @@ -4597,7 +4598,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4610,7 +4611,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4853,7 +4854,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4866,7 +4867,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5016,7 +5017,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5029,7 +5030,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index 479a965a98..0caaac894d 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -47,6 +47,23 @@ export function resolveEffectiveAutoMerge( return task.autoMerge ?? settings.autoMerge; } +/** + * Gate for auto-merge *processing* (engine enqueue + self-healing sweeps). + * Additive relative to the global setting: when `settings.autoMerge` is on, + * every task flows through — tasks with an explicit `autoMerge: false` are + * parked as `manual-required` downstream by the merger, not silently skipped + * here. When the global setting is off, only tasks with an explicit per-task + * `autoMerge: true` override proceed. Distinct from + * `resolveEffectiveAutoMerge`, which resolves the effective boolean and would + * (incorrectly for processing gates) starve the manual-required parking path. + */ +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} + // Resolves group → default-branch PROMOTION auto-merge. See resolveEffectiveAutoMerge for the per-task member→group-integration step; the two are distinct and must not be conflated. export function resolveEffectiveGroupAutoMerge( group: Pick, diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 4d9774959a..4c46d088ac 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -2670,3 +2670,29 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => { await engine.stop(); }); }); + +describe("allowInReviewMergeProcessing per-task autoMerge override", () => { + const gate = (task: Partial, settings: { autoMerge: boolean }) => + (createEngine() as any).allowInReviewMergeProcessing(task, settings) as boolean; + + it("lets an explicit per-task autoMerge:true through when the global setting is off", () => { + expect(gate({ autoMerge: true }, { autoMerge: false })).toBe(true); + }); + + it("blocks tasks without a per-task override when the global setting is off", () => { + expect(gate({}, { autoMerge: false })).toBe(false); + expect(gate({ autoMerge: false }, { autoMerge: false })).toBe(false); + }); + + it("keeps everything flowing when the global setting is on — explicit autoMerge:false is parked manual-required downstream", () => { + expect(gate({}, { autoMerge: true })).toBe(true); + expect(gate({ autoMerge: false }, { autoMerge: true })).toBe(true); + }); + + it("still exempts shared-branch-group member integration when the global setting is off", () => { + expect(gate( + { branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] }, + { autoMerge: false }, + )).toBe(true); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 18e547aa53..87db2fbb2f 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -3365,7 +3365,8 @@ describe("SelfHealingManager", () => { const result = await managerWithRecovery.recoverMergeableReviewTasks(); expect(result).toBe(0); - expect(store.listTasks).not.toHaveBeenCalled(); + // The sweep may list tasks to discover per-task autoMerge overrides, + // but must not merge or enqueue anything without one. expect(store.mergeTask).not.toHaveBeenCalled(); expect(enqueueMerge).not.toHaveBeenCalled(); @@ -3747,7 +3748,10 @@ describe("SelfHealingManager", () => { const result = await managerWithRecovery.finalizeNoOpReviewTasks(); expect(result).toBe(0); - expect(store.listTasks).not.toHaveBeenCalled(); + // The sweep may list tasks to discover per-task autoMerge overrides, + // but must not finalize anything without one. + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); managerWithRecovery.stop(); }); @@ -8227,26 +8231,98 @@ describe("autoMerge gating for mutating in-review sweeps (FN-5147)", () => { "recoverMissingWorktreeReviewFailures", "recoverPartialProgressNoTaskDoneFailures", "reclaimSelfOwnedBranchConflicts", - ] as const)("skips entirely when autoMerge is disabled (respects PR-based review flow): %s", async (methodName) => { + ] as const)("performs no mutations when autoMerge is disabled and no per-task override exists: %s", async (methodName) => { if (methodName === "recoverReviewTasksWithFailedPreMergeSteps") { manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", recoverFailedPreMergeStep: vi.fn() }); } const result = await (manager as any)[methodName](); expect(result).toBe(0); - expect(store.listTasks).not.toHaveBeenCalled(); + // The sweep may list tasks to discover per-task autoMerge overrides, + // but must not mutate anything without one (respects PR-based review flow). expect(store.moveTask).not.toHaveBeenCalled(); expect(store.updateTask).not.toHaveBeenCalled(); expect(store.logEntry).not.toHaveBeenCalled(); }); - it("skips entirely when autoMerge is disabled (respects PR-based review flow): recoverCompletionHandoffLimbo", async () => { + it("performs no mutations when autoMerge is disabled and no per-task override exists: recoverCompletionHandoffLimbo", async () => { const result = await manager.recoverCompletionHandoffLimbo(); expect(result).toBeUndefined(); - expect(store.listTasks).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled(); expect(store.updateTask).not.toHaveBeenCalled(); expect(store.logEntry).not.toHaveBeenCalled(); }); + + it("surfaces in-review stalls for tasks with an explicit autoMerge:true override when the global setting is off", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); + (store.getSettings as ReturnType).mockResolvedValue({ + autoMerge: false, + globalPause: false, + enginePaused: false, + taskStuckTimeoutMs: 60_000, + }); + (store.listTasks as ReturnType).mockResolvedValue([ + { + id: "FN-OVERRIDE", + column: "in-review", + paused: false, + status: "merging", + autoMerge: true, + steps: [], + log: [], + updatedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + columnMovedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + }, + ]); + + const surfaced = await manager.surfaceInReviewStalls(); + + expect(surfaced).toBe(1); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-OVERRIDE", + expect.stringContaining("In-review stall surfaced ["), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps skipping override-less siblings while processing the override task", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); + const staleFields = { + column: "in-review", + paused: false, + status: "merging", + steps: [], + log: [], + updatedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + columnMovedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(), + }; + (store.getSettings as ReturnType).mockResolvedValue({ + autoMerge: false, + globalPause: false, + enginePaused: false, + taskStuckTimeoutMs: 60_000, + }); + (store.listTasks as ReturnType).mockResolvedValue([ + { id: "FN-OVERRIDE", autoMerge: true, ...staleFields }, + { id: "FN-MANUAL", ...staleFields }, + ]); + + const surfaced = await manager.surfaceInReviewStalls(); + + expect(surfaced).toBe(1); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-MANUAL", + expect.stringContaining("In-review stall surfaced ["), + ); + } finally { + vi.useRealTimers(); + } + }); }); describe("FN-5335 triple-proof no-action unit coverage", () => { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index cfa8d5ca20..fadda27a09 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -10,7 +10,7 @@ import type { ScheduledTask, AutomationRunResult, } from "@fusion/core"; -import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; +import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -1383,8 +1383,8 @@ export class ProjectEngine { * pushed wins. listTasks returns createdAt ASC — without this sort an * older low-priority task would start before a later urgent one. */ - private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { - return settings.autoMerge || isSharedBranchGroupMemberIntegration(task); + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); } private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick): number { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 4f73c4410a..929dbaea75 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -28,7 +28,7 @@ import { promisify } from "node:util"; import { setImmediate as setImmediateCb } from "node:timers"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; +import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js"; @@ -2302,14 +2302,14 @@ export class SelfHealingManager { * Backward lifecycle move gated on triple proof (FN-5335). * When the predicate fails, emits `task:reclaim-self-owned-branch-conflict-no-action` and skips lifecycle mutation. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async reclaimSelfOwnedBranchConflicts(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const todoCandidates = await this.store.listTasks({ column: "todo", slim: true }); const inProgressCandidates = await this.store.listTasks({ column: "in-progress", slim: true }); const inProgressByWorktree = new Map(); @@ -2320,7 +2320,8 @@ export class SelfHealingManager { } const inReviewPausedCandidates = (await this.store.listTasks({ column: "in-review", slim: true })) .filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable"); - const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates]; + const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates] + .filter((task) => allowsAutoMergeProcessing(task, settings)); const activeTaskIds = new Set(); if (this.options.agentStore) { @@ -4484,17 +4485,18 @@ export class SelfHealingManager { * Backward lifecycle move gated on triple proof (FN-5335). * When the unproven fallback predicate fails, emits `task:finalize-no-op-review-no-action` and skips lifecycle mutation. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async finalizeNoOpReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((t) => t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && !t.paused && !isSharedBranchGroupMemberIntegration(t) && Boolean(t.worktree) && @@ -4793,12 +4795,11 @@ export class SelfHealingManager { // "pull-request"`) — see GitHub issue #21. const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const mergeable = tasks.filter((t) => t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && !t.paused && t.status !== "failed" && // Exclude transient merge statuses. Active merges should be left alone; @@ -4898,7 +4899,9 @@ export class SelfHealingManager { * per-task `postReviewFixCount` so a persistently-failing verifier cannot * ping-pong a task forever. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks sent back for fix */ async recoverReviewTasksWithFailedPreMergeSteps(): Promise { @@ -4908,7 +4911,6 @@ export class SelfHealingManager { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const maxFixes = settings.maxPostReviewFixes ?? 1; if (!Number.isFinite(maxFixes) || maxFixes <= 0) return 0; @@ -4917,6 +4919,7 @@ export class SelfHealingManager { const candidates = tasks.filter((task) => { if (task.column !== "in-review") return false; + if (!allowsAutoMergeProcessing(task, settings)) return false; if (task.paused) return false; // Preserve terminal/human-handoff statuses (failed, awaiting-user-review, // merging, etc.). Only revive tasks that are otherwise idle. @@ -4994,13 +4997,14 @@ export class SelfHealingManager { * incomplete step instead of leaving the task stranded in review. * Backward lifecycle move gated on triple proof (FN-5335). * When the predicate fails, emits `task:stale-incomplete-review-no-action` and skips lifecycle mutation. - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverStaleIncompleteReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; @@ -5008,6 +5012,7 @@ export class SelfHealingManager { const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const staleIncomplete = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && !task.status && task.steps.length > 0 && @@ -5056,8 +5061,9 @@ export class SelfHealingManager { * Final-fallback recovery for `in-review` tasks that fell through every other * scan and have sat untouched longer than `taskStuckTimeoutMs`. * - * When `settings.autoMerge` is disabled, this sweep is a no-op because - * PR-based manual review intentionally leaves tasks in `in-review`. + * Tasks not eligible for auto-merge processing (global `autoMerge` off + * without an explicit per-task `autoMerge: true` override) are skipped + * because PR-based manual review intentionally leaves them in `in-review`. * * The other review-recovery scans each require a specific shape (failed * pre-merge step, incomplete steps, mergeable + worktree present, confirmed @@ -5078,8 +5084,10 @@ export class SelfHealingManager { * each kick refreshes `updatedAt`, so a task that re-enters review and gets * stuck again can only be kicked once per `taskStuckTimeoutMs` window. * - * When `settings.autoMerge === false`, this sweep is a no-op because those - * projects intentionally use PR-based/manual in-review ownership. + * Tasks not eligible for auto-merge processing (global `autoMerge` off + * without an explicit per-task `autoMerge: true` override) are skipped + * because those projects intentionally use PR-based/manual in-review + * ownership. * * @returns Number of tasks kicked back to todo */ @@ -5087,8 +5095,6 @@ export class SelfHealingManager { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const cycleStartMs = Date.now(); const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; @@ -5100,6 +5106,7 @@ export class SelfHealingManager { for (const task of tasks) { if (task.deletedAt) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; const signal = getInReviewStallReason(task, { now: cycleStartMs, activeMergeTaskId, @@ -5217,14 +5224,14 @@ export class SelfHealingManager { * - `surfaceStalePausedReviews()` owns paused in-review tasks. * - `surfaceInReviewStalls()` owns reason-driven in-review stalls. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async surfaceInReviewStalled(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const cycleStartMs = Date.now(); const thresholdMs = settings.inReviewStalledThresholdMs; if (!thresholdMs || thresholdMs <= 0) return 0; @@ -5236,6 +5243,7 @@ export class SelfHealingManager { for (const task of tasks) { if (task.deletedAt) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; if (task.paused === true) continue; if (task.id === activeMergeTaskId || executingTaskIds.has(task.id)) continue; @@ -5389,13 +5397,14 @@ export class SelfHealingManager { * Backward lifecycle move gated on triple proof (FN-5335). * When the predicate fails, emits `task:ghost-review-no-action` and skips lifecycle mutation. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverGhostReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; @@ -5404,6 +5413,7 @@ export class SelfHealingManager { const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const ghosts = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && !executingIds.has(task.id) && !(task.status && GHOST_REVIEW_PRESERVED_STATUSES.has(task.status)) && @@ -5465,7 +5475,9 @@ export class SelfHealingManager { * If no landed commit is found, it only clears the stale transient status so * the normal mergeable-review recovery can retry the merge. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks finalized or unblocked */ /** @@ -5486,8 +5498,9 @@ export class SelfHealingManager { * parked as failed and emit `merger:transient-failure-budget-exhausted` * once for diagnostic visibility. * - * No-op when `settings.autoMerge === false`, no `requeueForAutoMerge` - * callback is wired, or global/engine pause is active. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without a per-task `autoMerge: true` override). No-op when no + * `requeueForAutoMerge` callback is wired or global/engine pause is active. * * @returns Number of tasks recovered */ @@ -5496,12 +5509,12 @@ export class SelfHealingManager { if (!requeue) return 0; try { const settings = await this.store.getSettings(); - if (settings.autoMerge === false) return 0; if (settings.globalPause || settings.enginePaused) return 0; const slim = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = slim.filter((t) => t.column === "in-review" + && allowsAutoMergeProcessing(t, settings) && t.status === "failed" && (t.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES && typeof t.error === "string" @@ -5642,13 +5655,13 @@ export class SelfHealingManager { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)) && this.isPastInterruptedMergeGrace(task, timeoutMs), @@ -5956,20 +5969,21 @@ export class SelfHealingManager { * but a later transition failed or another process moved the task before the * final `in-review` → `done` update completed. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks recovered */ async recoverMergedReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const mergedButNotDone = tasks.filter((t) => !t.deletedAt && t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && t.mergeDetails?.mergeConfirmed === true, ); @@ -6087,14 +6101,14 @@ export class SelfHealingManager { * When the no-landed predicate fails, emits `task:stuck-merge-deadlock-no-action` and skips lifecycle mutation. */ /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverStuckMergeDeadlocks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const now = Date.now(); const inReview = await this.store.listTasks({ column: "in-review", slim: true }); const triage = await this.store.listTasks({ column: "triage", slim: true }); @@ -6117,6 +6131,7 @@ export class SelfHealingManager { (dep) => dep.column === "triage" || dep.column === "todo", ); return task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && !task.paused && task.status === "failed" && (task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES && @@ -6278,18 +6293,19 @@ export class SelfHealingManager { } /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverOrphanOnlyScopeViolations(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && task.status === "failed" && task.scopeOverride !== true && task.mergeDetails?.mergeConfirmed !== true && @@ -6442,19 +6458,20 @@ export class SelfHealingManager { * * Idempotency: recovered tasks are moved to `done`, status/error are cleared, * and mergeRetries reset to 0, so subsequent sweeps will not match them. - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverAlreadyMergedReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => !task.deletedAt && task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && task.status === "failed" && (task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES && task.mergeDetails?.mergeConfirmed !== true && @@ -6591,19 +6608,20 @@ export class SelfHealingManager { * Recover completed in-review tasks wedged as failed only because a post-done * session continuation hit a non-continuable signature. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverPostDoneNonContinuableWedge(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: false }); let recovered = 0; for (const task of tasks) { if (task.column !== "in-review" || task.deletedAt) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; if (task.paused || task.userPaused) continue; if (task.status !== "failed") continue; if (this.options.isTaskActive?.(task.id)) continue; @@ -6664,18 +6682,19 @@ export class SelfHealingManager { } /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverCompletionHandoffLimbo(): Promise { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return; - if (settings.autoMerge === false) return; - const tasks = await this.store.listTasks({ column: "in-review", slim: false }); const now = Date.now(); for (const task of tasks) { if (task.column !== "in-review" || task.paused) continue; + if (!allowsAutoMergeProcessing(task, settings)) continue; if (task.status != null || task.mergeDetails != null || task.review != null || task.reviewState != null) continue; if (this.options.isTaskActive?.(task.id)) continue; if (getTaskMergeBlocker(task) !== undefined) continue; @@ -6889,20 +6908,21 @@ export class SelfHealingManager { } /** - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverForeignOnlyContaminatedInReviewTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const inReview = await this.store.listTasks({ column: "in-review", slim: true }); const inProgress = await this.store.listTasks({ column: "in-progress", slim: true }); const candidates = [ ...inReview.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && Boolean(task.branch) && Boolean(task.worktree) && task.mergeDetails?.mergeConfirmed !== true && @@ -6911,6 +6931,7 @@ export class SelfHealingManager { ), ...inProgress.filter((task) => task.column === "in-progress" && + allowsAutoMergeProcessing(task, settings) && task.paused === true && (task.pausedReason === "branch-cross-contamination" || task.pausedReason === "branch-conflict-unrecoverable") && Boolean(task.branch) && @@ -7730,18 +7751,19 @@ export class SelfHealingManager { * `restart-recovery-coordinator.ts`. * We clear stale worktree metadata and failure state, keep step progress and * retry counters, then requeue to todo for a clean retry. - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. */ async recoverMissingWorktreeReviewFailures(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => - isRecoverableMissingWorktreeReviewFailureWithProgress(task) - || isRecoverableMissingWorktreeReviewFailureNoProgress(task), + allowsAutoMergeProcessing(task, settings) + && (isRecoverableMissingWorktreeReviewFailureWithProgress(task) + || isRecoverableMissingWorktreeReviewFailureNoProgress(task)), ); if (candidates.length === 0) return 0; @@ -7813,19 +7835,20 @@ export class SelfHealingManager { * - `recoverNoProgressNoTaskDoneFailures`: `in-progress` with zero progress → clean requeue. * - This one: `in-review` with partial progress → bounded requeue preserving work. * - * No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge. + * Skips tasks not eligible for auto-merge processing (global `autoMerge` + * off without an explicit per-task `autoMerge: true` override) — PR-based + * review flow owns lifecycle until human merge. * @returns Number of tasks requeued for retry */ async recoverPartialProgressNoTaskDoneFailures(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - if (settings.autoMerge === false) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); const candidates = tasks.filter((task) => task.column === "in-review" && + allowsAutoMergeProcessing(task, settings) && task.status === "failed" && isNoTaskDoneFailure(task) && !task.paused && From ff1bb20b8f01a07347db5363e9ee0f3da81ec5ff Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:55:26 -0700 Subject: [PATCH 40/46] docs: capture per-task auto-merge override learning and seed CONCEPTS.md Document the trigger-layer gating bug fixed in this PR under docs/solutions/logic-errors/, seed CONCEPTS.md with the merge-lifecycle vocabulary, and surface both knowledge stores in AGENTS.md's reference docs index. --- AGENTS.md | 2 + CONCEPTS.md | 32 +++++ ...merge-override-ignored-by-trigger-gates.md | 112 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 CONCEPTS.md create mode 100644 docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md diff --git a/AGENTS.md b/AGENTS.md index 266a5e1053..510c306842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,8 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - `./docs/soft-delete-verification-matrix.md` — mandatory soft-delete verification matrix. - `./docs/cli-reference.md` — CLI and terminal UI reference. - `./docs/contributing.md` — contributing conventions and release-adjacent context. +- `./docs/solutions/` — documented solutions to past problems (bugs, patterns, conventions), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas. +- `./CONCEPTS.md` — shared domain vocabulary (entities, named processes, status concepts). Relevant when orienting to the codebase or discussing domain concepts. ### Lazy-Loaded Heavy Views diff --git a/CONCEPTS.md b/CONCEPTS.md new file mode 100644 index 0000000000..9f015d0e39 --- /dev/null +++ b/CONCEPTS.md @@ -0,0 +1,32 @@ +# Concepts + +Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. + +## Merge lifecycle + +### Task +The core board entity: a unit of work that moves through columns (triage, todo, in-progress, in-review, done, archived) and is executed by agents. A Task carries its own per-task settings that can override project-level defaults. + +### Auto-merge +The named process that automatically lands a completed Task's branch onto its merge target once the Task reaches In-review and passes its merge blockers. Gated twice: a project-level setting enables it globally, and each Task may carry an explicit per-task override. + +The per-task override takes precedence in both directions: an explicit per-task enable proceeds even when the global setting is off, and an explicit per-task disable routes the merge to Manual-required even when the global setting is on. Trigger-layer gates (enqueue, Self-healing sweeps) must evaluate additively — global on lets everything through for downstream routing; global off admits only explicit per-task enables — rather than collapsing the override to a single effective value, which would starve Manual-required routing. + +### In-review +The Task status column between execution and completion: work is done and the branch awaits merging. An In-review Task either auto-merges, waits for a human merge (PR-based/manual flow), or surfaces a stall diagnostic when it sits unprocessed longer than expected. Tasks not eligible for Auto-merge processing intentionally remain In-review until a human acts — recovery sweeps must not move them. + +### Merge queue +The ordered line of In-review Tasks awaiting Auto-merge, with a single merge active at a time. Tasks enter only through trigger gates (engine startup sweep, periodic retry, unpause, and the moved-to-review fast path); a Task filtered out at a gate is invisible to the merger regardless of its own settings. + +### Manual-required +The merge-request state for a Task whose merge needs an explicit human go-ahead — typically a Task with auto-merge explicitly disabled under a globally-enabled project. Reaching this state requires the Task to flow through the Merge queue trigger gates; upstream filtering that excludes such Tasks strands them In-review instead of parking them here. + +### Self-healing sweep +A recurring background scan that detects and repairs stuck Task states — stalled In-review Tasks, confirmed merges never finalized, ghost or limbo states, exhausted retries. Sweeps respect the same Auto-merge eligibility as the Merge queue: they may inspect any Task but mutate only those eligible for auto-merge processing. + +### Shared branch group +A set of Tasks integrating into a common shared branch instead of each merging straight to the project's default branch. Member integration (task branch → shared branch) is a soft pre-integration step exempt from the global auto-merge gate; promotion (shared branch → default branch) is gated separately. + +## Flagged ambiguities + +- "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md new file mode 100644 index 0000000000..926d0f86ba --- /dev/null +++ b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md @@ -0,0 +1,112 @@ +--- +title: Per-task auto-merge override ignored by trigger-layer gates +date: 2026-06-03 +category: logic-errors +module: engine +problem_type: logic_error +component: background_job +symptoms: + - "Tasks with per-task autoMerge:true never auto-merged when global settings.autoMerge was off" + - "Override tasks reached in-review and sat there indefinitely with no error surfaced" + - "In-review self-healing sweeps short-circuited on the global setting and never enqueued the merge" +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - merger + - self-healing + - store +tags: + - auto-merge + - per-task-override + - merge-queue + - self-healing + - engine + - trigger-gate +--- + +# Per-task auto-merge override ignored by trigger-layer gates + +## Problem + +A per-task `autoMerge: true` override was honored only by the merger itself, but every *trigger-layer* gate (engine enqueue, 19 self-healing sweeps, store stall-signal hydration) checked the global `settings.autoMerge` alone. With global auto-merge OFF, override tasks were never enqueued and sat in `in-review` forever. Fixed in PR Runfusion/Fusion#1356. + +## Symptoms + +- User disabled auto-merge globally but enabled it on individual tasks. +- Those individually-enabled tasks reached `in-review` and stayed there indefinitely — never picked up, never merged. +- No error surfaced: the tasks were simply never *triggered* into the merge pipeline, so the merger's per-task handling never ran. + +## What Didn't Work + +- **Assuming the downstream merger check was enough.** The only code consulting `task.autoMerge` was the merger (`packages/engine/src/merger.ts` ~7958: `task.autoMerge === false` → `manual-required`). That runs *after* enqueue. The enqueue gate `allowInReviewMergeProcessing` (`packages/engine/src/project-engine.ts:1386`) and 19 self-healing sweeps short-circuited on `settings.autoMerge` before the task ever reached the merger — so the per-task flag was dead code from the user's perspective. Notably, the feature issues (Runfusion/Fusion#1150, #1152, #1153) shipped the data model, a resolver (`resolveEffectiveAutoMerge`), and the dashboard control — #1152 even claimed engine merge-gating used the resolved value — but no trigger gate actually consulted it. +- **Reaching for `resolveEffectiveAutoMerge` at the gates.** The existing resolver `task.autoMerge ?? settings.autoMerge` (`packages/core/src/task-merge.ts`) looks like the natural gate, but using it would *regress* the global-ON + `autoMerge:false` case: those tasks must still flow into the merger so it can park them as `manual-required` (and so merged-task finalization sweeps still finalize them). Plain resolution would skip them at the trigger, stranding manually-merged tasks in `in-review`. +- **Slim-projection gotcha.** Per-task gating reads `task.autoMerge` off rows from slim task projections. If the `autoMerge` column were missing from `getTaskSelectClause` (`packages/core/src/store.ts` ~1976), the gate would silently see `undefined` and the override would fail with no error. (Verified present — but a real trap when adding per-row predicates.) + +## Solution + +New core predicate, **additive** to the global setting (`packages/core/src/task-merge.ts`): + +```ts +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} +``` + +Applied at three trigger layers: + +1. **Enqueue gate** (`project-engine.ts:1386`), which fronts all four enqueue paths (startup sweep, periodic retry, unpause, task-moved fast path): + + ```ts + // before + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return settings.autoMerge || isSharedBranchGroupMemberIntegration(task); + } + // after + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); + } + ``` + +2. **All 19 self-healing sweeps** (`self-healing.ts`): the function-level early returns (`if (settings.autoMerge === false) return 0;`) were replaced by per-task filtering inside each sweep's candidate set, e.g.: + + ```ts + const candidates = tasks.filter((t) => + t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && + !t.paused && /* ... */); + ``` + +3. **Store stall-signal hydration** (`store.ts`, 6 sites): `autoMerge: settings.autoMerge` → `autoMerge: allowsAutoMergeProcessing(task, settings)` in the `getInReviewStallReason` / `getInReviewStalledSignal` contexts, so board diagnostics reflect that override tasks *are* being processed. + +The self-healing contract also changed: from "skip the whole sweep when global is off" to "list tasks, but mutate nothing without a per-task override." FN-5147 tests that asserted `listTasks` was never called were updated to assert the mutation-free guarantee instead. This extends — and stays consistent with — the AGENTS.md `autoMerge: false` callout (FN-5147): self-healing still never moves override-less `in-review` tasks when auto-merge is off. + +## Why This Works + +The root cause was a flag consulted only where the *action* runs, not where processing is *triggered*. Adding the override evaluation to every trigger gate closes the gap. + +Additive (`settings.autoMerge !== false || task.autoMerge === true`) is deliberately chosen over resolution (`task.autoMerge ?? settings.autoMerge`): + +- **Global ON:** `settings.autoMerge !== false` is already `true`, so the predicate is a no-op — every task flows through exactly as before, including `autoMerge:false` tasks that the merger then parks as `manual-required`. Resolution would have excluded those, breaking manual-required parking and finalization. +- **Global OFF:** the first term is `false`, so only `task.autoMerge === true` tasks proceed — exactly the missing override path. + +It changes nothing when global is ON and adds only the explicit-true path when global is OFF. + +## Prevention + +When adding a per-entity override to a behavior that's gated on a global setting, the override must be consulted **where the behavior is TRIGGERED, not just where the action runs.** A check at the merger (the action) is invisible if upstream enqueue/sweep gates already filtered the entity out. + +- **Grep every gate on the global setting** before declaring the override wired: here `settings.autoMerge` appeared at 1 enqueue gate, 19 sweep guards, and 6 hydration sites — all needed updating. A search for the global key, not just the new override field, surfaces the dead-flag sites. +- **Prefer additive gating over effective-value resolution for *processing* gates.** Resolution collapses three states (global-on/off × per-task true/false/unset) into one boolean and can starve a needed downstream branch (the manual-required parking path). Gate on "should this be processed at all," resolve the actual behavior later. +- **Watch slim projections:** per-row predicates require the override column in the SELECT clause, or they silently read `undefined`. +- **Test matrix must cross global × per-task.** The fix shipped red-first unit tests for the predicate (`packages/core/src/__tests__/task-merge.test.ts`), the gate including the shared-group exemption (`packages/engine/src/__tests__/project-engine.test.ts`), and a self-healing test proving an **override task is processed while an override-less sibling stays skipped** (`packages/engine/src/__tests__/self-healing.test.ts`) — the latter is the canonical shape: two tasks differing only in `autoMerge` under global-OFF, asserting divergent outcomes. + +## Related Issues + +- Runfusion/Fusion#1356 — the fix PR (commit `ad468813d`) +- Runfusion/Fusion#1150, Runfusion/Fusion#1152, Runfusion/Fusion#1153 — the per-task auto-merge feature trio (data model + resolver, engine gating, dashboard control); #1152's gating claim is the gap this bug exposed +- Runfusion/Fusion#753 (FN-5147), Runfusion/Fusion#690 (FN-5052) — prior global `autoMerge:false` stall/lifecycle handling that the sweeps' guards came from +- AGENTS.md → "`autoMerge: false` callout (FN-5147)" — standing lifecycle rule this fix extends to per-task granularity From e00bc0235b6c07b4b23fa2bf57e392d126c3fe22 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 13:08:06 -0700 Subject: [PATCH 41/46] Address PR review feedback (#1356) - Add behavior-level tests for the shared merge-enqueue funnel (enqueueEligibleInReviewTasks) with a Surface Enumeration of all in-review entry surfaces, per review - Seed real stale in-review fixtures in the FN-5147 no-mutation regression block so sweeps enumerate candidates and the assertions are non-vacuous - Keep per-task auto-merge gating uniform across reclaim/contamination candidate columns: the suggested in-review-only scoping broke the FN-5704 regression contract (reclaim short-circuits when autoMerge is off); documented the tension in code comments and the learning doc - Drop hardcoded commit hash from the learning doc --- ...merge-override-ignored-by-trigger-gates.md | 3 +- .../src/__tests__/project-engine.test.ts | 68 ++++++++ .../engine/src/__tests__/self-healing.test.ts | 160 +++++++++++++++++- packages/engine/src/self-healing.ts | 10 ++ 4 files changed, 237 insertions(+), 4 deletions(-) diff --git a/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md index 926d0f86ba..4aeb77af47 100644 --- a/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md +++ b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md @@ -101,12 +101,13 @@ When adding a per-entity override to a behavior that's gated on a global setting - **Grep every gate on the global setting** before declaring the override wired: here `settings.autoMerge` appeared at 1 enqueue gate, 19 sweep guards, and 6 hydration sites — all needed updating. A search for the global key, not just the new override field, surfaces the dead-flag sites. - **Prefer additive gating over effective-value resolution for *processing* gates.** Resolution collapses three states (global-on/off × per-task true/false/unset) into one boolean and can starve a needed downstream branch (the manual-required parking path). Gate on "should this be processed at all," resolve the actual behavior later. +- **Check existing regression contracts before re-scoping a gate.** Review of the fix PR suggested exempting `todo`/`in-progress` candidates (execution-stage repair) from the auto-merge gate — but the repo's FN-5704 regression test ("short-circuits reclaim when autoMerge is false") deliberately keeps execution-stage reclaim inert in manual-review projects. Per-task gating applied uniformly preserves that contract while enabling overrides; exempting execution-stage recovery would be a separate, deliberate behavior change. - **Watch slim projections:** per-row predicates require the override column in the SELECT clause, or they silently read `undefined`. - **Test matrix must cross global × per-task.** The fix shipped red-first unit tests for the predicate (`packages/core/src/__tests__/task-merge.test.ts`), the gate including the shared-group exemption (`packages/engine/src/__tests__/project-engine.test.ts`), and a self-healing test proving an **override task is processed while an override-less sibling stays skipped** (`packages/engine/src/__tests__/self-healing.test.ts`) — the latter is the canonical shape: two tasks differing only in `autoMerge` under global-OFF, asserting divergent outcomes. ## Related Issues -- Runfusion/Fusion#1356 — the fix PR (commit `ad468813d`) +- Runfusion/Fusion#1356 — the fix PR - Runfusion/Fusion#1150, Runfusion/Fusion#1152, Runfusion/Fusion#1153 — the per-task auto-merge feature trio (data model + resolver, engine gating, dashboard control); #1152's gating claim is the gap this bug exposed - Runfusion/Fusion#753 (FN-5147), Runfusion/Fusion#690 (FN-5052) — prior global `autoMerge:false` stall/lifecycle handling that the sweeps' guards came from - AGENTS.md → "`autoMerge: false` callout (FN-5147)" — standing lifecycle rule this fix extends to per-task granularity diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 4c46d088ac..849875483a 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -2696,3 +2696,71 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => { )).toBe(true); }); }); + +// ## Surface Enumeration +// +// Known in-review merge entry surfaces in ProjectEngine, and how each enforces +// the per-task `autoMerge` override invariant (a task with `autoMerge:true` must +// still be enqueued for merge even when the global `autoMerge` setting is off): +// +// 1. Startup merge sweep (project-engine.ts ~:2857) ─┐ +// 2. Periodic merge retry sweep (project-engine.ts ~:2916) ─┼─ all call +// 3. Resume-after-unpause sweep (project-engine.ts ~:2977) ─┘ enqueueEligibleInReviewTasks(...) +// 4. task:moved fast path (project-engine.ts ~:1506) ─── inline allowInReviewMergeProcessing(...) +// +// Surfaces 1–3 funnel through `enqueueEligibleInReviewTasks`, whose filter is +// `!t.paused && canMergeTask(t) && allowInReviewMergeProcessing(t, settings)`. +// The behavior tests below exercise that shared funnel directly on a real engine +// instance (with `internalEnqueueMerge` stubbed), so a regression in any of the +// three sweep wrappers (wireAutoMerge / startupMergeSweep / scheduleMergeRetry / +// resumeAfterUnpauseAndSweepInReview) that still routes through the funnel is +// caught. Surface 4 (the task:moved fast path) shares the same +// `allowInReviewMergeProcessing` gate, which is covered by the direct helper +// tests above. + +describe("enqueueEligibleInReviewTasks honors per-task autoMerge override (shared sweep funnel)", () => { + const inReview = (id: string, overrides: Partial = {}): Task => + ({ + id, + column: "in-review", + paused: false, + mergeRetries: 0, + status: null, + ...overrides, + }) as unknown as Task; + + const setup = () => { + const engine = createEngine() as any; + const enqueueSpy = vi + .spyOn(engine, "internalEnqueueMerge") + .mockImplementation(() => true); + const run = (tasks: Task[], settings: { autoMerge: boolean }): number => + engine.enqueueEligibleInReviewTasks(tasks, settings) as number; + return { engine, enqueueSpy, run }; + }; + + it("enqueues an in-review task with autoMerge:true even when the global setting is off", () => { + const { enqueueSpy, run } = setup(); + const count = run([inReview("FN-override", { autoMerge: true })], { autoMerge: false }); + expect(count).toBe(1); + expect(enqueueSpy).toHaveBeenCalledWith("FN-override"); + }); + + it("does not enqueue a sibling task without an override in the same sweep when the global setting is off", () => { + const { enqueueSpy, run } = setup(); + const count = run( + [inReview("FN-override", { autoMerge: true }), inReview("FN-plain")], + { autoMerge: false }, + ); + expect(count).toBe(1); + expect(enqueueSpy).toHaveBeenCalledWith("FN-override"); + expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain"); + }); + + it("still enqueues a task with autoMerge:false when the global setting is on (parked manual-required downstream)", () => { + const { enqueueSpy, run } = setup(); + const count = run([inReview("FN-explicit-false", { autoMerge: false })], { autoMerge: true }); + expect(count).toBe(1); + expect(enqueueSpy).toHaveBeenCalledWith("FN-explicit-false"); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 87db2fbb2f..1b37da510f 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -97,7 +97,7 @@ vi.mock("../merger.js", () => ({ classifyOwnedLandedEvidence: vi.fn(), })); -import { SelfHealingManager, isBranchAheadOfBase } from "../self-healing.js"; +import { SelfHealingManager, isBranchAheadOfBase, MAX_AUTO_MERGE_RETRIES } from "../self-healing.js"; import type { TaskStore, Settings, Task, AgentStore, Agent, NotificationProvider } from "@fusion/core"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; @@ -8212,6 +8212,153 @@ describe("autoMerge gating for mutating in-review sweeps (FN-5147)", () => { taskStuckTimeoutMs: 1_000, maxPostReviewFixes: 1, }); + + // Seed real, stale in-review sweep candidates with NO per-task autoMerge + // override. Each fixture matches a distinct covered sweep's candidate shape + // and would be mutated if the per-task gate (allowsAutoMergeProcessing) were + // ignored. Because the global setting is autoMerge:false and none of these + // carry autoMerge:true, every sweep must enumerate them and skip them solely + // due to the gate — which is the regression under test. The gate is the + // first/early filter in each sweep, so candidates are dropped before any + // store.getTask / git helper is reached. + const stale = new Date(Date.now() - 600_000).toISOString(); + const seededInReviewCandidates = [ + // recoverStaleIncompleteReviewTasks + recoverGhostReviewTasks: + // idle in-review with incomplete steps, stale. + { + id: "FN-GATE-INCOMPLETE", + column: "in-review", + paused: false, + steps: [{ status: "pending" }], + log: [], + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverInterruptedMergingTasks: stale `merging` status. + { + id: "FN-GATE-MERGING", + column: "in-review", + paused: false, + status: "merging", + steps: [], + log: [], + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverMergedReviewTasks + recoverGhostReviewTasks(skip merge-confirmed): + // mergeConfirmed:true stuck in in-review. + { + id: "FN-GATE-MERGED", + column: "in-review", + paused: false, + steps: [], + log: [], + mergeDetails: { mergeConfirmed: true }, + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverStuckMergeDeadlocks + recoverAlreadyMergedReviewTasks + + // recoverOrphanOnlyScopeViolations: failed in-review, retries exhausted, + // worktree present. + { + id: "FN-GATE-FAILED", + column: "in-review", + paused: false, + status: "failed", + steps: [], + log: [], + mergeRetries: MAX_AUTO_MERGE_RETRIES, + worktree: "/tmp/test-project/.worktrees/FN-GATE-FAILED", + branch: "fn/FN-GATE-FAILED", + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverReviewTasksWithFailedPreMergeSteps: idle in-review whose merge is + // blocked specifically by a failed pre-merge workflow step, worktree set. + { + id: "FN-GATE-PREMERGE", + column: "in-review", + paused: false, + steps: [], + log: [], + worktree: "/tmp/test-project/.worktrees/FN-GATE-PREMERGE", + workflowStepResults: [{ phase: "pre-merge", status: "failed" }], + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverMissingWorktreeReviewFailures: failed by missing-worktree session + // start, with step progress. + { + id: "FN-GATE-MISSINGWT", + column: "in-review", + paused: false, + status: "failed", + error: "Refusing to start coding agent in missing worktree: /tmp/gone", + steps: [{ status: "done" }], + log: [], + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverPartialProgressNoTaskDoneFailures: failed without fn_task_done, + // partial step progress, not work-complete, retries available. + { + id: "FN-GATE-NOTASKDONE", + column: "in-review", + paused: false, + status: "failed", + error: "Agent finished without calling fn_task_done", + steps: [{ status: "done" }, { status: "pending" }], + log: [], + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverForeignOnlyContaminatedInReviewTasks: in-review with branch + + // worktree, not merge-confirmed. + { + id: "FN-GATE-FOREIGN", + column: "in-review", + paused: false, + branch: "fn/FN-GATE-FOREIGN", + worktree: "/tmp/test-project/.worktrees/FN-GATE-FOREIGN", + steps: [], + log: [], + updatedAt: stale, + columnMovedAt: stale, + }, + // recoverCompletionHandoffLimbo: idle in-review with no status/mergeDetails/ + // review, an aged "Task marked done by agent" log marker, no merge blocker. + { + id: "FN-GATE-HANDOFF", + column: "in-review", + paused: false, + steps: [], + log: [{ action: "Task marked done by agent", timestamp: stale }], + updatedAt: stale, + columnMovedAt: stale, + }, + // reclaimSelfOwnedBranchConflicts: in-review branch-conflict-unrecoverable. + // (No worktree, so even absent the gate it is skipped before any git call; + // the gate is what the assertions verify.) + { + id: "FN-GATE-RECLAIM", + column: "in-review", + paused: true, + pausedReason: "branch-conflict-unrecoverable", + branch: "fn/FN-GATE-RECLAIM", + steps: [], + log: [], + updatedAt: stale, + columnMovedAt: stale, + }, + ] as unknown as Task[]; + + // Resolve fixtures only for the in-review column the sweeps enumerate; other + // columns (todo / in-progress / triage) stay empty so the non-auto-merge- + // gated branches of reclaim/foreign-only sweeps don't reach git helpers. + (store.listTasks as ReturnType).mockImplementation( + async (opts?: { column?: string }) => + opts?.column === "in-review" ? seededInReviewCandidates : [], + ); }); afterEach(() => { @@ -8237,8 +8384,11 @@ describe("autoMerge gating for mutating in-review sweeps (FN-5147)", () => { } const result = await (manager as any)[methodName](); expect(result).toBe(0); - // The sweep may list tasks to discover per-task autoMerge overrides, - // but must not mutate anything without one (respects PR-based review flow). + // Enumeration must have happened: the sweep listed real, stale in-review + // candidates seeded above. Mutations are skipped solely because of the + // per-task auto-merge gate (respects PR-based review flow) — so these + // assertions are non-vacuous. + expect(store.listTasks).toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled(); expect(store.updateTask).not.toHaveBeenCalled(); expect(store.logEntry).not.toHaveBeenCalled(); @@ -8247,6 +8397,10 @@ describe("autoMerge gating for mutating in-review sweeps (FN-5147)", () => { it("performs no mutations when autoMerge is disabled and no per-task override exists: recoverCompletionHandoffLimbo", async () => { const result = await manager.recoverCompletionHandoffLimbo(); expect(result).toBeUndefined(); + // The seeded FN-GATE-HANDOFF candidate carries an aged "Task marked done by + // agent" marker and no merge blocker, so the sweep enumerates it and would + // requeue/fail it absent the per-task gate. + expect(store.listTasks).toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled(); expect(store.updateTask).not.toHaveBeenCalled(); expect(store.logEntry).not.toHaveBeenCalled(); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 929dbaea75..2c60cd7c81 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -2320,6 +2320,12 @@ export class SelfHealingManager { } const inReviewPausedCandidates = (await this.store.listTasks({ column: "in-review", slim: true })) .filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable"); + // Per-task auto-merge gating applies to ALL candidate columns, not just + // in-review: the FN-5704 regression contract ("short-circuits reclaim + // when autoMerge is false") deliberately keeps execution-stage reclaim + // and resume-limbo escalation inert in manual-review projects. The + // per-task override preserves that for override-less tasks while letting + // explicit autoMerge:true tasks recover. const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates] .filter((task) => allowsAutoMergeProcessing(task, settings)); @@ -6929,6 +6935,10 @@ export class SelfHealingManager { !task.userPaused && !executingIds.has(task.id), ), + // The paused in-progress contamination branch is gated per-task too: + // pre-existing behavior kept this sweep fully inert in manual-review + // projects (mirroring the FN-5704 reclaim contract), so override-less + // tasks stay untouched while explicit autoMerge:true tasks recover. ...inProgress.filter((task) => task.column === "in-progress" && allowsAutoMergeProcessing(task, settings) && From 419f688afb846e5eb6bbcec07e1d6a49910a1191 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 07:15:04 -0700 Subject: [PATCH 42/46] FN-5936: fix auto-merge board stabilization on mobile Keep the dashboard board visible when auto-merge toggles across viewport changes. - keep board stabilization subscribed to visualViewport resize events instead of removing the listener after the first resize - add regression coverage for Android, iOS, tablet, desktop, empty-column, rollback, and error-boundary auto-merge toggle paths - relax settings hydration assertions and expand app-settings tests for auto-merge rollback and coercion behavior Files changed: .changeset/fn-5936-auto-merge-mobile-fix.md | 7 + packages/dashboard/app/components/Board.tsx | 19 +- packages/dashboard/app/components/__tests__/App.test.tsx | 8 +- packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx | 418 +++++++++++++++++++++ packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts | 53 ++- 5 files changed, 489 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-5936 Fusion-Task-Lineage: 6e937d0e-dc60-4863-a884-001228dd0cd1 --- .changeset/fn-5936-auto-merge-mobile-fix.md | 7 + packages/dashboard/app/components/Board.tsx | 19 +- .../app/components/__tests__/App.test.tsx | 8 +- .../auto-merge-toggle-blank.mobile.test.tsx | 418 ++++++++++++++++++ .../hooks/__tests__/useAppSettings.test.ts | 53 ++- 5 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 .changeset/fn-5936-auto-merge-mobile-fix.md create mode 100644 packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx diff --git a/.changeset/fn-5936-auto-merge-mobile-fix.md b/.changeset/fn-5936-auto-merge-mobile-fix.md new file mode 100644 index 0000000000..83a144b12a --- /dev/null +++ b/.changeset/fn-5936-auto-merge-mobile-fix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the dashboard auto-merge toggle blanking on mobile by keeping board stabilization tied to viewport events instead of a one-shot resize listener. + +The in-review board now stays visible when auto-merge is toggled across Android mobile, iOS mobile, tablet, and desktop layouts, with regression coverage for populated and empty columns plus rollback and error-boundary paths. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 936931d017..423c39df46 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -236,25 +236,20 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask } }; + const visualViewport = window.visualViewport; + const handleViewportResize = () => { + scheduleStabilization(); + }; + scheduleStabilization(); window.addEventListener("pageshow", handlePageShow); - - const visualViewport = window.visualViewport; - let handleViewportResize: (() => void) | null = null; - if (visualViewport) { - handleViewportResize = () => { - scheduleStabilization(); - if (typeof visualViewport.removeEventListener === "function") { - visualViewport.removeEventListener("resize", handleViewportResize!); - } - handleViewportResize = null; - }; + if (typeof visualViewport?.addEventListener === "function") { visualViewport.addEventListener("resize", handleViewportResize); } return () => { window.removeEventListener("pageshow", handlePageShow); - if (handleViewportResize && typeof visualViewport?.removeEventListener === "function") { + if (typeof visualViewport?.removeEventListener === "function") { visualViewport.removeEventListener("resize", handleViewportResize); } if (rafId !== null) { diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index e8b41cbf89..45b60c9f6b 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -1652,9 +1652,11 @@ describe("App auto-open Settings on unauthenticated", () => { await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); - // The Settings modal should be open showing Authentication content - // fetchSettings is called twice: once by App useEffect, once by SettingsModal - await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2)); + // The Settings modal should be open showing Authentication content. + // App and SettingsModal both hydrate settings, and follow-up refreshes may + // legitimately add another fetch during initialization; the invariant here + // is that settings hydration happened before Authentication content renders. + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); // Authentication section should be active — auth status is fetched when section is active await waitFor(() => { diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx new file mode 100644 index 0000000000..e93dc45750 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx @@ -0,0 +1,418 @@ +import React, { useState } from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { Board } from "../Board"; +import { PageErrorBoundary } from "../ErrorBoundary"; +import type { Task } from "@fusion/core"; + +vi.mock("../../api", () => ({ + fetchWorkflowSteps: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../hooks/useBlockerFanout", () => ({ + useBlockerFanout: () => new Map(), +})); + +vi.mock("../../hooks/useConfirm", () => ({ + useConfirm: () => ({ confirm: vi.fn() }), +})); + +vi.mock("../../hooks/useFlashOnIncrease", () => ({ + useFlashOnIncrease: () => false, +})); + +vi.mock("../PluginSlot", () => ({ + PluginSlot: () => null, +})); + +vi.mock("../QuickEntryBox", () => ({ + QuickEntryBox: () => null, +})); + +vi.mock("../TaskCard", () => ({ + TaskCard: ({ task, autoMergeEnabled }: { task: Task; autoMergeEnabled?: boolean }) => { + if (task.id === "FN-ERROR" && autoMergeEnabled === false) { + throw new Error("Auto-merge render failed"); + } + return
task:{task.id}:{String(autoMergeEnabled)}
; + }, +})); + +vi.mock("../WorktreeGroup", () => ({ + WorktreeGroup: ({ label, autoMergeEnabled }: { label: string; autoMergeEnabled?: boolean }) => ( +
worktree:{String(autoMergeEnabled)}
+ ), +})); + +function ensureMatchMedia() { + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn(), + }); + } +} + +function mockViewport(width: number) { + ensureMatchMedia(); + Object.defineProperty(window, "innerWidth", { value: width, configurable: true }); + return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query === "(max-width: 768px)" ? width <= 768 : false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + +function createVisualViewport(scale = 1) { + const resizeListeners = new Set<() => void>(); + return { + scale, + addEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "resize") { + resizeListeners.add(listener); + } + }), + removeEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "resize") { + resizeListeners.delete(listener); + } + }), + dispatchResize: () => { + for (const listener of [...resizeListeners]) { + listener(); + } + }, + }; +} + +function createTask(id: string, column: Task["column"]): Task { + return { + id, + title: id, + description: `${id} description`, + column, + status: column === "in-review" ? "in-review" : undefined, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + } as Task; +} + +function BaseBoardHarness({ + tasks, + autoMerge, + onToggleAutoMerge, +}: { + tasks: Task[]; + autoMerge: boolean; + onToggleAutoMerge: () => void | Promise; +}) { + return ( + + ({} as Task))} + onOpenDetail={vi.fn()} + addToast={vi.fn()} + onQuickCreate={vi.fn(async () => undefined)} + onNewTask={vi.fn()} + autoMerge={autoMerge} + onToggleAutoMerge={onToggleAutoMerge} + globalPaused={false} + /> + + ); +} + +function BoardHarness({ tasks, initialAutoMerge = true }: { tasks: Task[]; initialAutoMerge?: boolean }) { + const [autoMerge, setAutoMerge] = useState(initialAutoMerge); + + return ( + setAutoMerge((current) => !current)} + /> + ); +} + +function RollbackBoardHarness({ tasks }: { tasks: Task[] }) { + const [autoMerge, setAutoMerge] = useState(true); + + return ( + { + const previousAutoMerge = autoMerge; + const nextAutoMerge = !previousAutoMerge; + setAutoMerge(nextAutoMerge); + + try { + await Promise.reject(new Error("network")); + } catch { + setAutoMerge(previousAutoMerge); + } + }} + /> + ); +} + +function installAnimationFrame() { + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + setTimeout(() => cb(0), 0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); +} + +function expectBoardVisible() { + expect(document.querySelector("main.board")).not.toBeNull(); + expect(screen.getByText("In Review")).toBeInTheDocument(); + expect(screen.queryByText("Something went wrong")).toBeNull(); +} + +describe("auto-merge toggle mobile blank regression", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("keeps the mobile board visible after an Android viewport resize triggered by toggling auto-merge", () => { + const viewportSpy = mockViewport(375); + const visualViewport = createVisualViewport(1); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + installAnimationFrame(); + + render(); + + const board = document.querySelector("main.board") as HTMLElement; + expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true"); + expectBoardVisible(); + + act(() => { + vi.runAllTimers(); + }); + + board.scrollLeft = 240; + act(() => { + visualViewport.dispatchResize(); + vi.runAllTimers(); + }); + expect(board.scrollLeft).toBe(0); + + board.scrollLeft = 240; + fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" })); + + expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("false"); + + board.scrollLeft = 240; + act(() => { + visualViewport.dispatchResize(); + vi.runAllTimers(); + }); + + expectBoardVisible(); + expect(board.scrollLeft).toBe(0); + viewportSpy.mockRestore(); + }); + + it("round-trips auto-merge on mobile Android with an empty in-review column without blanking", () => { + const viewportSpy = mockViewport(375); + const visualViewport = createVisualViewport(1); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + installAnimationFrame(); + + render(); + const board = document.querySelector("main.board") as HTMLElement; + + act(() => { + vi.runAllTimers(); + }); + + const toggle = screen.getByRole("checkbox", { name: "Auto-merge" }); + expect(toggle).toBeChecked(); + expectBoardVisible(); + + fireEvent.click(toggle); + expect(toggle).not.toBeChecked(); + board.scrollLeft = 180; + act(() => { + visualViewport.dispatchResize(); + vi.runAllTimers(); + }); + expectBoardVisible(); + expect(board.scrollLeft).toBe(0); + + fireEvent.click(toggle); + expect(toggle).toBeChecked(); + board.scrollLeft = 180; + act(() => { + visualViewport.dispatchResize(); + vi.runAllTimers(); + }); + expectBoardVisible(); + expect(board.scrollLeft).toBe(0); + viewportSpy.mockRestore(); + }); + + it("keeps populated task-card and worktree surfaces visible when auto-merge toggles on mobile", () => { + const viewportSpy = mockViewport(375); + const visualViewport = createVisualViewport(1); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + installAnimationFrame(); + + render( + , + ); + + act(() => { + vi.runAllTimers(); + }); + + expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true"); + expect(screen.getByTestId("worktree-group-Unassigned")).toHaveTextContent("true"); + + fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" })); + + expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("false"); + expect(screen.getByTestId("worktree-group-Unassigned")).toHaveTextContent("false"); + expectBoardVisible(); + viewportSpy.mockRestore(); + }); + + it("re-anchors on the mobile iOS pageshow path after toggling auto-merge", () => { + const viewportSpy = mockViewport(375); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: createVisualViewport(1.1), + }); + installAnimationFrame(); + + render(); + const board = document.querySelector("main.board") as HTMLElement; + + act(() => { + vi.runAllTimers(); + }); + + fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" })); + board.scrollLeft = 210; + + const pageShow = new Event("pageshow") as PageTransitionEvent; + Object.defineProperty(pageShow, "persisted", { configurable: true, value: true }); + act(() => { + window.dispatchEvent(pageShow); + vi.runAllTimers(); + }); + + expectBoardVisible(); + expect(board.scrollLeft).toBe(0); + viewportSpy.mockRestore(); + }); + + it("keeps the board visible on tablet where the mobile stabilization effect is disabled", () => { + const viewportSpy = mockViewport(900); + installAnimationFrame(); + + render(); + + const toggle = screen.getByRole("checkbox", { name: "Auto-merge" }); + expect(toggle).toBeChecked(); + expectBoardVisible(); + + fireEvent.click(toggle); + expect(toggle).not.toBeChecked(); + expect(screen.getByTestId("task-card-FN-TABLET")).toHaveTextContent("false"); + expectBoardVisible(); + viewportSpy.mockRestore(); + }); + + it("keeps the board visible on desktop after toggling auto-merge", () => { + const viewportSpy = mockViewport(1280); + installAnimationFrame(); + + render(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" })); + + expect(screen.getByTestId("task-card-FN-DESKTOP")).toHaveTextContent("false"); + expectBoardVisible(); + viewportSpy.mockRestore(); + }); + + it("keeps the mobile board visible when the toggle rolls back after an update failure", async () => { + const viewportSpy = mockViewport(375); + const visualViewport = createVisualViewport(1); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + installAnimationFrame(); + + render(); + + const toggle = screen.getByRole("checkbox", { name: "Auto-merge" }); + expect(toggle).toBeChecked(); + + await act(async () => { + fireEvent.click(toggle); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(toggle).toBeChecked(); + expect(screen.getByTestId("task-card-FN-ROLLBACK")).toHaveTextContent("true"); + expectBoardVisible(); + viewportSpy.mockRestore(); + }); + + it("shows a visible page error boundary fallback instead of a blank board when a board child throws", () => { + const viewportSpy = mockViewport(375); + const visualViewport = createVisualViewport(1); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + installAnimationFrame(); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" })); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + + consoleErrorSpy.mockRestore(); + viewportSpy.mockRestore(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts index a3c8971ed3..23fcf751db 100644 --- a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts @@ -102,7 +102,24 @@ describe("useAppSettings", () => { expect(result.current.autoMerge).toBe(false); }); - it("rolls back optimistic state when toggle update fails", async () => { + it("rolls back optimistic autoMerge state when toggle update fails", async () => { + mockUpdateSettings.mockRejectedValueOnce(new Error("network")); + + const { result } = renderHook(() => useAppSettings("proj_123")); + + await waitFor(() => { + expect(result.current.autoMerge).toBe(false); + }); + + await act(async () => { + await result.current.toggleAutoMerge(); + }); + + expect(result.current.autoMerge).toBe(false); + expect(mockUpdateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123"); + }); + + it("rolls back optimistic state when global pause update fails", async () => { mockUpdateSettings.mockRejectedValueOnce(new Error("network")); const { result } = renderHook(() => useAppSettings("proj_123")); @@ -168,6 +185,40 @@ describe("useAppSettings", () => { }); }); + it("coerces undefined autoMerge settings to false", async () => { + mockFetchSettings.mockResolvedValueOnce({ + autoMerge: undefined, + globalPause: true, + enginePaused: false, + prAuthAvailable: true, + taskStuckTimeoutMs: 600000, + showQuickChatFAB: false, + } as never); + + const { result } = renderHook(() => useAppSettings("proj_123")); + + await waitFor(() => { + expect(result.current.autoMerge).toBe(false); + }); + }); + + it("coerces truthy non-boolean autoMerge settings to true", async () => { + mockFetchSettings.mockResolvedValueOnce({ + autoMerge: "enabled", + globalPause: true, + enginePaused: false, + prAuthAvailable: true, + taskStuckTimeoutMs: 600000, + showQuickChatFAB: false, + } as never); + + const { result } = renderHook(() => useAppSettings("proj_123")); + + await waitFor(() => { + expect(result.current.autoMerge).toBe(true); + }); + }); + it("propagates capacity risk settings from fetchSettings", async () => { mockFetchSettings.mockResolvedValueOnce({ autoMerge: false, From ac92174cba6c3f98b36669c7d833e051fc8923de Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 07:50:59 -0700 Subject: [PATCH 43/46] FN-5907: harden planning task creation side effects Keep Planning Mode task creation responses successful when follow-up work fails. - catch and log planning create-task/create-tasks side-effect failures so the API can still return 201 after task creation succeeds - safely handle async task lifecycle listener failures in the core store to avoid leaking unhandled rejections during follow-up updates - add regression coverage for single-task and multi-task Planning Mode creation across live/persisted sessions, branch selection surfaces, and GitHub tracking failures Files changed: .changeset/fn-5907-planning-create-fetch.md | 5 + .../core/src/__tests__/task-creation-hook.test.ts | 25 ++ packages/core/src/store.ts | 38 ++- .../src/__tests__/routes-planning-tracking.test.ts | 108 ++++++++- .../src/__tests__/routes-planning.test.ts | 267 +++++++++++++++++++++ .../src/routes/register-planning-subtask-routes.ts | 69 +++++- 6 files changed, 496 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-5907 Fusion-Task-Lineage: d9f6574b-e19b-4459-a612-7a3d2510836f --- .changeset/fn-5907-planning-create-fetch.md | 5 + .../src/__tests__/task-creation-hook.test.ts | 25 ++ packages/core/src/store.ts | 38 ++- .../routes-planning-tracking.test.ts | 108 ++++++- .../src/__tests__/routes-planning.test.ts | 267 ++++++++++++++++++ .../register-planning-subtask-routes.ts | 69 ++++- 6 files changed, 496 insertions(+), 16 deletions(-) create mode 100644 .changeset/fn-5907-planning-create-fetch.md diff --git a/.changeset/fn-5907-planning-create-fetch.md b/.changeset/fn-5907-planning-create-fetch.md new file mode 100644 index 0000000000..03a08faecd --- /dev/null +++ b/.changeset/fn-5907-planning-create-fetch.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix a Planning Mode reliability bug where creating a single task could fail with a browser-level `Failed to fetch` error when post-create side effects threw or rejected before the dashboard finished responding. diff --git a/packages/core/src/__tests__/task-creation-hook.test.ts b/packages/core/src/__tests__/task-creation-hook.test.ts index 24c7c17178..4f0668a325 100644 --- a/packages/core/src/__tests__/task-creation-hook.test.ts +++ b/packages/core/src/__tests__/task-creation-hook.test.ts @@ -108,6 +108,31 @@ describe("task creation hook", () => { expect(created2.id).toMatch(/^FN-/); }); + it("does not leak async task:updated listener rejections during create follow-up updates", async () => { + const store = harness.store(); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + + store.on("task:updated", async (task) => { + if (task.id.startsWith("FN-")) { + throw new Error(`listener boom for ${task.id}`); + } + }); + + try { + const task = await store.createTask({ description: "planning create listener safety" }); + await store.updateTask(task.id, { size: "M" }); + await store.logEntry(task.id, "Created via Planning Mode", "Initial plan: test"); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandledRejections).toHaveLength(0); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + it("can clear hook with undefined", async () => { const store = harness.store(); const hook = vi.fn(); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index f5684fbc5c..aa291da4bf 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1224,6 +1224,34 @@ export class TaskStore extends EventEmitter { this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir); } + private emitTaskLifecycleEventSafely( + event: "task:created" | "task:updated", + args: TaskStoreEvents["task:created"] | TaskStoreEvents["task:updated"], + ): boolean { + const listeners = super.listeners(event) as Array<(...listenerArgs: typeof args) => unknown>; + if (listeners.length === 0) { + return false; + } + + const [task] = args; + const taskId = task && typeof task === "object" && "id" in task ? String(task.id) : "unknown"; + + for (const listener of listeners) { + try { + const result = listener(...args); + if (result && typeof (result as PromiseLike).then === "function") { + void Promise.resolve(result).catch((error) => { + storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`); + }); + } + } catch (error) { + storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`); + } + } + + return true; + } + /** * Get the SQLite database, initializing it on first access. * Also performs auto-migration from legacy file-based storage if needed. @@ -4001,7 +4029,7 @@ export class TaskStore extends EventEmitter { await this._maybeAutoArchiveSameAgentDuplicate(task, input); - this.emit("task:created", task); + this.emitTaskLifecycleEventSafely("task:created", [task]); if (options?.invokeTaskCreatedHook !== false) { await this.invokeTaskCreatedHook(task); } @@ -5850,7 +5878,7 @@ export class TaskStore extends EventEmitter { if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); } - this.emit("task:updated", task); + this.emitTaskLifecycleEventSafely("task:updated", [task]); return task; }); } @@ -6521,7 +6549,7 @@ export class TaskStore extends EventEmitter { if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); } - this.emit("task:updated", task); + this.emitTaskLifecycleEventSafely("task:updated", [task]); return task; }); } @@ -6772,12 +6800,12 @@ export class TaskStore extends EventEmitter { if (this.isWatching) { this.taskCache.set(id, { ...current }); } - this.emit("task:updated", current); + this.emitTaskLifecycleEventSafely("task:updated", [current]); return current; } const emittedTask = ({ id, log, updatedAt } as unknown) as Task; - this.emit("task:updated", emittedTask); + this.emitTaskLifecycleEventSafely("task:updated", [emittedTask]); return emittedTask; }); } diff --git a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts index c0e7d2b9c7..27971f6416 100644 --- a/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning-tracking.test.ts @@ -200,7 +200,39 @@ describe("planning routes github tracking background dispatch", () => { expect(response.status).toBe(201); await vi.waitFor(() => { - expect(planningWarn).toHaveBeenCalled(); + expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue")); + }); + }); + + it("POST /planning/create-task still returns 201 when createIssue throws synchronously", async () => { + createIssueSpy.mockImplementation(() => { + throw new Error("sync github crash"); + }); + + sessions.set("plan-2-sync", { + summary: { + title: "Planned task 2", + description: "Planned task description 2", + suggestedSize: "M", + priority: "normal", + suggestedDependencies: [], + keyDeliverables: [], + }, + initialPlan: "initial", + history: [], + }); + + const response = await performRequest( + app, + "POST", + "/planning/create-task", + JSON.stringify({ sessionId: "plan-2-sync" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(201); + await vi.waitFor(() => { + expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue")); }); }); @@ -250,4 +282,78 @@ describe("planning routes github tracking background dispatch", () => { expect(createIssueSpy).toHaveBeenCalledTimes(2); }); }); + + it("POST /planning/create-tasks still returns 201 when createIssue rejects asynchronously", async () => { + createIssueSpy.mockRejectedValue(new Error("github down")); + + sessions.set("plan-3-reject", { + summary: { + title: "Plan", + description: "Plan", + suggestedSize: "M", + priority: "normal", + suggestedDependencies: [], + keyDeliverables: [], + }, + initialPlan: "initial", + history: [], + }); + + const response = await performRequest( + app, + "POST", + "/planning/create-tasks", + JSON.stringify({ + planningSessionId: "plan-3-reject", + subtasks: [ + { id: "tmp-1", title: "Subtask 1", description: "D1" }, + { id: "tmp-2", title: "Subtask 2", description: "D2" }, + ], + }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(201); + await vi.waitFor(() => { + expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue")); + }); + }); + + it("POST /planning/create-tasks still returns 201 when createIssue throws synchronously", async () => { + createIssueSpy.mockImplementation(() => { + throw new Error("sync github crash"); + }); + + sessions.set("plan-3-sync", { + summary: { + title: "Plan", + description: "Plan", + suggestedSize: "M", + priority: "normal", + suggestedDependencies: [], + keyDeliverables: [], + }, + initialPlan: "initial", + history: [], + }); + + const response = await performRequest( + app, + "POST", + "/planning/create-tasks", + JSON.stringify({ + planningSessionId: "plan-3-sync", + subtasks: [ + { id: "tmp-1", title: "Subtask 1", description: "D1" }, + { id: "tmp-2", title: "Subtask 2", description: "D2" }, + ], + }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(201); + await vi.waitFor(() => { + expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue")); + }); + }); }); diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 055ff8644b..0222a6cb75 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -421,6 +421,23 @@ describe("Planning Mode Routes", () => { return app; } + async function createCompletedPlanningSession(initialPlan = "Build a user auth system"): Promise { + const startRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start", + JSON.stringify({ initialPlan }), + { "Content-Type": "application/json" } + ); + const sessionId = startRes.body.sessionId; + + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); + + return sessionId; + } + async function connectPlanningStreamUntilComplete(sessionId: string): Promise { const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); setTimeout(() => { @@ -1952,6 +1969,256 @@ describe("Planning Mode Routes", () => { }); }); + it.each([ + { + sessionSource: "live", + useSummaryOverride: false, + branchSelection: { mode: "project-default" }, + expectedBranch: undefined, + expectedBaseBranch: undefined, + }, + { + sessionSource: "live", + useSummaryOverride: true, + branchSelection: { mode: "auto-new", baseBranch: "develop" }, + expectedBranch: undefined, + expectedBaseBranch: "develop", + }, + { + sessionSource: "persisted", + useSummaryOverride: false, + branchSelection: { mode: "existing", branchName: "feature/shared-auth", baseBranch: "develop" }, + expectedBranch: "feature/shared-auth", + expectedBaseBranch: "develop", + }, + { + sessionSource: "persisted", + useSummaryOverride: true, + branchSelection: { mode: "custom-new", branchName: "feature/planned-auth", baseBranch: "main" }, + expectedBranch: "feature/planned-auth", + expectedBaseBranch: "main", + }, + ])("returns 201 for $sessionSource create-task sessions across branch selection surfaces (summary override: $useSummaryOverride)", async ({ + sessionSource, + useSummaryOverride, + branchSelection, + expectedBranch, + expectedBaseBranch, + }) => { + (store.createTask as ReturnType).mockResolvedValue({ + id: `FN-${sessionSource}-${branchSelection.mode}`, + description: "Build auth flow", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType).mockResolvedValue({}); + (store.logEntry as ReturnType).mockResolvedValue(undefined); + + let app = buildApp(); + let sessionId: string; + + if (sessionSource === "live") { + sessionId = await createCompletedPlanningSession(); + } else { + sessionId = `persisted-${branchSelection.mode}-${useSummaryOverride ? "override" : "default"}`; + const persistedSession = { + id: sessionId, + type: "planning", + status: "complete", + title: "Build persisted planning", + inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }), + conversationHistory: "[]", + currentQuestion: null, + result: JSON.stringify({ + title: "Persisted planning output", + description: "Persist planning results so users can create tasks later", + suggestedSize: "M", + priority: "normal", + suggestedDependencies: ["FN-100"], + keyDeliverables: ["Persist sessions"], + }), + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archived: 0, + }; + const mockAiSessionStore = { + get: vi.fn((id: string) => (id === sessionId ? persistedSession : null)), + listAll: vi.fn(() => []), + delete: vi.fn(), + }; + app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any })); + } + + const summary = useSummaryOverride + ? { + title: "Edited auth task", + description: "Edited description from summary view", + suggestedSize: "S", + suggestedDependencies: ["FN-500"], + keyDeliverables: ["Login flow"], + } + : undefined; + + const res = await REQUEST( + app, + "POST", + "/api/planning/create-task", + JSON.stringify({ sessionId, branchSelection, ...(summary ? { summary } : {}) }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(201); + expect(store.createTask).toHaveBeenCalledWith( + expect.objectContaining({ + title: useSummaryOverride ? "Edited auth task" : expect.any(String), + branch: expectedBranch, + baseBranch: expectedBaseBranch, + }), + ); + }); + + it.each([ + { label: "size update rejection", configure: () => (store.updateTask as ReturnType).mockRejectedValueOnce(new Error("size update failed")) }, + { label: "log entry rejection", configure: () => (store.logEntry as ReturnType).mockRejectedValueOnce(new Error("log entry failed")) }, + ])("still returns 201 when planning create-task post-create side effects fail (%s)", async ({ configure }) => { + (store.createTask as ReturnType).mockResolvedValue({ + id: "FN-250", + description: "Build a user auth system", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType).mockResolvedValue({}); + (store.logEntry as ReturnType).mockResolvedValue(undefined); + configure(); + + const sessionId = await createCompletedPlanningSession(); + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-task", + JSON.stringify({ sessionId }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(201); + }); + + it("still returns 201 when planning create-task session release throws", async () => { + (store.createTask as ReturnType).mockResolvedValue({ + id: "FN-251", + description: "Build a user auth system", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType).mockResolvedValue({}); + (store.logEntry as ReturnType).mockResolvedValue(undefined); + const releaseSessionSpy = vi.spyOn(planningModule, "releaseSession").mockImplementation(() => { + throw new Error("release exploded"); + }); + + try { + const sessionId = await createCompletedPlanningSession(); + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-task", + JSON.stringify({ sessionId }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(201); + } finally { + releaseSessionSpy.mockRestore(); + } + }); + + it("still returns 201 when planning create-tasks post-create updates fail", async () => { + (store.createTask as ReturnType) + .mockResolvedValueOnce({ + id: "FN-260", + description: "First", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }) + .mockResolvedValueOnce({ + id: "FN-261", + description: "Second", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType) + .mockRejectedValueOnce(new Error("size update failed")) + .mockResolvedValueOnce({ + id: "FN-261", + description: "Second", + column: "triage", + dependencies: ["FN-260"], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + (store.logEntry as ReturnType).mockResolvedValue(undefined); + + const planningSessionId = await createCompletedPlanningSession(); + const breakdownRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-breakdown", + JSON.stringify({ sessionId: planningSessionId }), + { "Content-Type": "application/json" } + ); + + const generatedSubtasks = breakdownRes.body.subtasks as Array<{ + id: string; + title: string; + description: string; + suggestedSize: "S" | "M" | "L"; + dependsOn: string[]; + }>; + + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-tasks", + JSON.stringify({ + planningSessionId, + subtasks: [ + { + id: generatedSubtasks[0]!.id, + title: "Auth backend", + description: "Implement backend", + suggestedSize: "L", + dependsOn: [], + }, + { + id: generatedSubtasks[1]!.id, + title: "Auth frontend", + description: "Implement frontend", + dependsOn: [generatedSubtasks[0]!.id], + }, + ], + }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(201); + expect(res.body.tasks).toHaveLength(2); + }); + it("creates task with explicit summary priority", async () => { (store.createTask as ReturnType).mockResolvedValue({ id: "FN-100", diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 8105cbe6ca..f0ab6518ec 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -983,6 +983,25 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann }; }; + const logPlanningCreateWarning = (message: string, error: unknown, metadata?: Record): void => { + planningLogger.warn(message, { + ...metadata, + error: error instanceof Error ? error.message : String(error), + }); + }; + + const runPlanningCreateSideEffect = async ( + message: string, + work: () => Promise | unknown, + metadata?: Record, + ): Promise => { + try { + await work(); + } catch (error) { + logPlanningCreateWarning(message, error, metadata); + } + }; + /** * POST /api/planning/create-task * Create a task from a completed planning session. @@ -1103,18 +1122,30 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann baseBranch: resolvedBaseBranch, }); - // Update task with suggested size if provided + // Update task with suggested size if provided. if (summary.suggestedSize) { - await scopedStore.updateTask(task.id, { size: summary.suggestedSize }); + await runPlanningCreateSideEffect( + "Planning create-task size update failed", + () => scopedStore.updateTask(task.id, { size: summary.suggestedSize }), + { taskId: task.id, sessionId }, + ); } - // Log the planning mode creation - await scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`); + // Log the planning mode creation. + await runPlanningCreateSideEffect( + "Planning create-task log entry failed", + () => scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`), + { taskId: task.id, sessionId }, + ); // Release any live in-memory planning runtime for this session, but // keep the persisted completed row so planning history can still list // and restore the summary after single-task creation. - releaseSession(sessionId); + await runPlanningCreateSideEffect( + "Planning create-task session release failed", + () => releaseSession(sessionId), + { taskId: task.id, sessionId }, + ); res.status(201).json(task); } catch (err: unknown) { @@ -1314,7 +1345,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann createdTasks.push(task); if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") { - await scopedStore.updateTask(task.id, { size: item.suggestedSize }); + await runPlanningCreateSideEffect( + "Planning create-tasks size update failed", + () => scopedStore.updateTask(task.id, { size: item.suggestedSize }), + { taskId: task.id, planningSessionId }, + ); } } @@ -1326,14 +1361,28 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann : []; if (resolvedDependencies.length > 0) { - const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies }); - createdTasks[index] = updated; + await runPlanningCreateSideEffect( + "Planning create-tasks dependency update failed", + async () => { + const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies }); + createdTasks[index] = updated; + }, + { taskId: created.id, planningSessionId }, + ); } - await scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails); + await runPlanningCreateSideEffect( + "Planning create-tasks log entry failed", + () => scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails), + { taskId: created.id, planningSessionId }, + ); } - cleanupSession(planningSessionId); + await runPlanningCreateSideEffect( + "Planning create-tasks session cleanup failed", + () => cleanupSession(planningSessionId), + { planningSessionId }, + ); res.status(201).json({ tasks: createdTasks }); } catch (err: unknown) { From 76c18efa92b7cfd6316834e33102bc6ea6591473 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 08:24:12 -0700 Subject: [PATCH 44/46] FN-5939: expose operational log retention setting Expose operational log retention as a project setting in the dashboard. - add an Operational log retention selector to the Project General settings section with supported retention options - validate operationalLogRetentionDays in the settings API and cover accepted and rejected values in tests - document the constrained retention values and assert project-scope/default parity for the setting Files changed: docs/settings-reference.md | 2 +- packages/core/src/__tests__/settings-parity.test.ts | 7 ++++ packages/dashboard/app/components/SettingsModal.tsx | 47 ++++++++++------------ packages/dashboard/app/components/__tests__/SettingsModal.test.tsx | 8 ++++ packages/dashboard/src/__tests__/routes-settings.test.ts | 21 ++++++++++ packages/dashboard/src/routes/register-settings-memory-routes.ts | 10 +++++ 6 files changed, 69 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-5939 Fusion-Task-Lineage: 2148dd88-1cef-4c6d-9696-31148fce97d3 --- docs/settings-reference.md | 2 +- .../src/__tests__/settings-parity.test.ts | 7 +++ .../app/components/SettingsModal.tsx | 47 +++++++++---------- .../__tests__/SettingsModal.test.tsx | 8 ++++ .../src/__tests__/routes-settings.test.ts | 21 +++++++++ .../routes/register-settings-memory-routes.ts | 10 ++++ 6 files changed, 69 insertions(+), 26 deletions(-) diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 08dcf82ad7..179225d0b2 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -430,7 +430,7 @@ Default notes: | `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). | | `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. | | `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. | -| `operationalLogRetentionDays` | `number` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). Periodic maintenance prunes rows older than this many days using each row's `timestamp`. Set `0` to disable pruning. | +| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes rows older than this many days using each row's `timestamp`. | | `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. | | `chatRoomRecentVerbatimMessages` | `number` | `25` | Number of newest chat-room messages kept verbatim in responder context before older entries are compacted (about 2× prior default history). | | `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). | diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 8c314969f8..a2e13b78b9 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -117,6 +117,13 @@ describe("settings key parity", () => { expect(PROJECT_SETTINGS_KEYS).toContain("mailAutoCleanupDays"); }); + it("defaults operationalLogRetentionDays to 30 and keeps it project-scoped", () => { + expect(DEFAULT_PROJECT_SETTINGS.operationalLogRetentionDays).toBe(30); + expect(isProjectSettingsKey("operationalLogRetentionDays")).toBe(true); + expect(isGlobalSettingsKey("operationalLogRetentionDays")).toBe(false); + expect(PROJECT_SETTINGS_KEYS).toContain("operationalLogRetentionDays"); + }); + it("keeps heartbeatScopeDiscipline project-scoped with strict default", () => { expect(DEFAULT_PROJECT_SETTINGS.heartbeatScopeDiscipline).toBe("strict"); expect(isProjectSettingsKey("heartbeatScopeDiscipline")).toBe(true); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index fcdd2df02b..3e891f7af7 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2451,6 +2451,28 @@ export function SettingsModal({ Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting. +
+ + + + Lowering this window means Reliability metrics/charts and the Activity feed will not show history older + than the selected range. Per-task task detail history is unaffected. Default: 30 days. + +

Chat Rooms

@@ -6108,31 +6130,6 @@ export function SettingsModal({ )}
-

Database Maintenance

-
- - - - Prune append-only operational logs (activity log, agent logs, run audit, heartbeats) older than this - many days during periodic maintenance. Keeps the database from growing without bound — large databases - are slower to checkpoint and more prone to corruption. Default: 30 days. - -
-

Memory Backups