From 9c6b4dd3cd5ad7b273b4d734e018f321aaa9fb6d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 17:25:12 -0700 Subject: [PATCH 01/44] feat(FN-6879): document workflow nodes in editor detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-node Help section to the workflow editor's node detail pane: description, configuration, inputs, outputs, and edges for every node kind. Keys off the effective kind (preserved IR kind) so graph-only policy nodes — auto-merge gate, branch-group member integration / promotion, PR and recovery nodes — get specific help instead of reading as a generic merge/gate/hold, and are flagged "Engine-managed". Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workflow-node-help.md | 5 + .../app/components/WorkflowNodeEditor.css | 74 +++++ .../app/components/WorkflowNodeEditor.tsx | 37 ++- .../__tests__/WorkflowNodeEditor.test.tsx | 19 ++ .../nodes/__tests__/node-help.test.ts | 107 +++++++ .../app/components/nodes/node-help.ts | 294 ++++++++++++++++++ 6 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 .changeset/workflow-node-help.md create mode 100644 packages/dashboard/app/components/nodes/__tests__/node-help.test.ts create mode 100644 packages/dashboard/app/components/nodes/node-help.ts diff --git a/.changeset/workflow-node-help.md b/.changeset/workflow-node-help.md new file mode 100644 index 0000000000..29a99275c9 --- /dev/null +++ b/.changeset/workflow-node-help.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workflow editor: add a Help section to the node detail pane. Every node now documents what it does, how to configure it, and its inputs/outputs/edges — including the engine-managed merge-lifecycle nodes (auto-merge gate, branch-group member integration, branch-group promotion, PR and recovery nodes), which are surfaced read-only with an "Engine-managed" badge. diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index e94ada1125..5c9c173cf2 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -957,6 +957,80 @@ React Flow ships white default controls and mini-map chrome, but the workflow ed color: var(--ws-warning); } +/* ── Per-node Help (FNXC:WorkflowEditor 2026-06-21-10:00) ─────────── + * Collapsible
teaching what the selected node does, how to + * configure it, and its inputs/outputs/edges. Sits under the heading, + * collapsed by default so it never pushes config fields below the fold. */ +.wf-inspector-help { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.wf-inspector-help-summary { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + font-size: 0.78rem; + color: var(--text); + cursor: pointer; + list-style: none; + user-select: none; +} + +.wf-inspector-help-summary::-webkit-details-marker { + display: none; +} + +.wf-inspector-help-summary:hover { + background: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +/* Engine-managed badge for graph-only policy nodes (read-only lifecycle). */ +.wf-inspector-help-badge { + margin-left: auto; + padding: 1px var(--space-xs); + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.wf-inspector-help-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0 var(--space-sm) var(--space-sm); + font-size: 0.76rem; + color: var(--text-muted); +} + +.wf-inspector-help-summary-text { + margin: 0; + color: var(--text); +} + +.wf-inspector-help-dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px var(--space-sm); + margin: 0; +} + +.wf-inspector-help-dl dt { + font-weight: 600; + color: var(--text-dim); +} + +.wf-inspector-help-dl dd { + margin: 0; + color: var(--text-muted); +} + .wf-field--checkbox { flex-direction: row; align-items: center; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 69c5017161..dc681b0401 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -53,6 +53,7 @@ import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext"; import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary"; +import { nodeHelpForData } from "./nodes/node-help"; import { irToFlow, flowToIr, @@ -1968,6 +1969,8 @@ function InnerEditor({ * The structural start node needs an inspector because its entry column is editable and persisted in the workflow IR. Keep end structural-only until it has a meaningful editable property. */ const selectedNodeHasInspector = selectedNode !== null && selectedNode.data.kind !== "end"; + // FNXC:WorkflowEditor 2026-06-21-10:00: Help content for the inspector, keyed by the node's effective kind (preserved IR kind when a graph-only policy node collapsed onto a generic merge/gate/hold shape). + const selectedNodeHelp = selectedNode !== null ? nodeHelpForData(selectedNode.data) : null; const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null; const mobileNodeDetailStage = isMobileMode && selectedNodeHasInspector && !inspectorCollapsed; const mobileEdgeDetailStage = isMobileMode && selectedEdge !== null; @@ -3309,7 +3312,8 @@ function InnerEditor({ !(compactLayoutEnabled && !isMobileMode) && ( @@ -2669,7 +2633,6 @@ function InnerEditor({ ["add", t("workflowNodes.mobileAdd", "Add")], ["settings", t("workflowSettings.title", "Settings")], ["fields", t("workflowFields.title", "Fields")], - ["optional-steps", t("workflowOptionalSteps.title", "Optional steps")], ["columns", t("workflowColumns.title", "Columns")], ["actions", t("workflowNodes.mobileActions", "Actions")], ] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => ( @@ -2866,16 +2829,6 @@ function InnerEditor({ )} - {mobilePanel === "optional-steps" && ( -
- p.template)} - /> -
- )} {mobilePanel === "columns" && (
diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css b/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css deleted file mode 100644 index d458de87a4..0000000000 --- a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.css +++ /dev/null @@ -1,107 +0,0 @@ -/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout - * so the optional-steps panel reads consistently alongside Fields/Settings. */ - -.wf-optional-steps-panel { - display: flex; - flex-direction: column; - gap: var(--space-sm); - padding: var(--space-md); -} - -.wf-optional-steps-header h3 { - margin: 0; -} - -.wf-optional-steps-hint, -.wf-optional-steps-empty { - font-size: 0.75rem; - color: var(--text-muted); - margin: 0; -} - -.wf-optional-steps-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-sm); -} - -.wf-optional-step-item { - display: flex; - flex-direction: column; - gap: 4px; - padding: var(--space-sm); - border: 1px solid var(--border); - border-radius: var(--radius-sm, 6px); -} - -.wf-optional-step-item.is-unknown { - opacity: 0.6; -} - -.wf-optional-step-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.wf-optional-step-title { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.wf-optional-step-name { - font-weight: 600; - font-size: 0.8rem; -} - -.wf-optional-step-name--unknown { - font-style: italic; - font-weight: 400; -} - -.wf-optional-step-description { - font-size: 0.72rem; - color: var(--text-muted); - margin: 0; -} - -.wf-optional-step-default { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.75rem; -} - -.wf-optional-step-remove { - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; -} - -.wf-optional-step-remove:hover:not(:disabled) { - color: var(--color-error); -} - -.wf-optional-steps-add { - display: flex; - flex-direction: column; - gap: 4px; -} - -.wf-optional-steps-add-label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 0.75rem; - color: var(--text-muted); -} diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx b/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx deleted file mode 100644 index 40bdc94a0f..0000000000 --- a/packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * FNXC:WorkflowOptionalSteps 2026-06-21-00:00: - * Workflow authors need to declare which step templates are optional and set each - * one's defaultOn from the visual editor (persisted on the IR's `optionalSteps` - * array) so optional steps are authorable without hand-editing IR. - * - * WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring - * surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives - * alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's - * `optionalSteps` array through the same state/save flow (preserved across the - * round-trip by `flowToIr`). - * - * A declaration is just `{ templateId, defaultOn? }`. Display metadata - * (name/description/phase) is resolved from the built-in step-template catalog at - * render time — never duplicated into the IR — so the resolver stays the single - * source of truth. Unknown/stale template ids render a muted, still-removable row - * rather than being silently dropped. - */ -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Plus, Trash2 } from "lucide-react"; -import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core"; -import { phaseBadge } from "./workflow-phase-badge"; -import "./WorkflowOptionalStepsPanel.css"; - -interface WorkflowOptionalStepsPanelProps { - optionalSteps: WorkflowOptionalStep[]; - onChange: (next: WorkflowOptionalStep[]) => void; - readOnly: boolean; - /** Plugin-contributed templates, merged into the catalog when available. */ - pluginTemplates?: WorkflowStepTemplate[]; -} - -export function WorkflowOptionalStepsPanel({ - optionalSteps, - onChange, - readOnly, - pluginTemplates = [], -}: WorkflowOptionalStepsPanelProps) { - const { t } = useTranslation("app"); - - const templatesById = useMemo(() => { - const map = new Map(); - for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl); - return map; - }, [pluginTemplates]); - - const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]); - - // Catalog entries not already declared — the "Add optional step" picker source. - const available = useMemo( - () => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)), - [templatesById, declaredIds], - ); - - const addStep = useCallback( - (templateId: string) => { - if (!templateId || declaredIds.has(templateId)) return; - onChange([...optionalSteps, { templateId, defaultOn: false }]); - }, - [optionalSteps, onChange, declaredIds], - ); - - const removeStep = useCallback( - (templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)), - [optionalSteps, onChange], - ); - - const toggleDefaultOn = useCallback( - (templateId: string, defaultOn: boolean) => - onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))), - [optionalSteps, onChange], - ); - - return ( - - ); -} - -export default WorkflowOptionalStepsPanel; diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index b93babdb02..f10fd6db68 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -164,13 +164,9 @@ function v2Def(): WorkflowDefinition { }; } -function v2DefWithOptional(): WorkflowDefinition { - const base = v2Def(); - return { - ...base, - ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"], - }; -} +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: `v2DefWithOptional` and its +// optional-step DECLARATION hydration/save test are removed — the declaration +// authoring panel is retired (optional-group nodes now). function builtinDef(): WorkflowDefinition { return { @@ -751,34 +747,6 @@ describe("WorkflowNodeEditor", () => { expect(start?.column).toBe("done"); }); - it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => { - vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]); - vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ - ...v2DefWithOptional(), - ...(updates as object), - })); - vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); - - render( {}} addToast={() => {}} />); - - await screen.findByText("Save"); - // The declared optional step is hydrated into the panel (optionalStepsOf). - const row = await screen.findByTestId("wf-optional-step-browser-verification"); - expect(within(row).getByText("Browser Verification")).toBeTruthy(); - - // Toggling defaultOn must mark the editor dirty (serializeGraph threading) so - // the Save button enables and persists the change. - fireEvent.click(within(row).getByRole("checkbox")); - fireEvent.click(screen.getByText("Save").closest("button")!); - - await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); - const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; - const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as { - optionalSteps?: { templateId: string; defaultOn?: boolean }[]; - }; - expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]); - }); - it("renders the start inspector without the entry-column select for v1 workflows", async () => { vi.mocked(fetchWorkflows).mockResolvedValue([def()]); diff --git a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx deleted file mode 100644 index 93398b01f9..0000000000 --- a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; -import { useState } from "react"; -import type { WorkflowOptionalStep } from "@fusion/core"; -import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel"; - -// Controlled host mirroring how WorkflowNodeEditor drives the panel. -function Host({ - initial, - readOnly = false, - onState, -}: { - initial: WorkflowOptionalStep[]; - readOnly?: boolean; - onState?: (s: WorkflowOptionalStep[]) => void; -}) { - const [optionalSteps, setOptionalSteps] = useState(initial); - return ( - { - setOptionalSteps(next); - onState?.(next); - }} - /> - ); -} - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("WorkflowOptionalStepsPanel", () => { - it("renders the empty state and an add picker when no steps are declared", () => { - render(); - expect(screen.getByText(/No optional steps/i)).toBeTruthy(); - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - // browser-verification is in the catalog and not yet declared → available. - expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); - }); - - it("adds a step from the picker (defaultOn false) and removes it from the picker", () => { - const onState = vi.fn(); - render(); - fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), { - target: { value: "browser-verification" }, - }); - expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]); - // The declared row is shown with the resolved template name… - const row = screen.getByTestId("wf-optional-step-browser-verification"); - expect(within(row).getByText("Browser Verification")).toBeTruthy(); - // …and the picker no longer offers it. - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull(); - }); - - it("toggles defaultOn for a declared step", () => { - const onState = vi.fn(); - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - fireEvent.click(within(row).getByRole("checkbox")); - expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]); - }); - - it("removes a declared step and returns it to the picker", () => { - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); - expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull(); - const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; - expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); - }); - - it("renders an unknown/stale templateId as a muted, still-removable row", () => { - const onState = vi.fn(); - render(); - const row = screen.getByTestId("wf-optional-step-does-not-exist"); - expect(row.className).toContain("is-unknown"); - expect(within(row).getByText(/Unknown step/i)).toBeTruthy(); - fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); - expect(onState).toHaveBeenCalledWith([]); - }); - - it("disables editing when readOnly", () => { - render(); - const row = screen.getByTestId("wf-optional-step-browser-verification"); - expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true); - expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 864e9ddf79..d214a4da50 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -10,7 +10,6 @@ import { fragmentSeamConflicts, copyIrWithFreshIds, columnsOf, - optionalStepsOf, columnForY, bandTop, columnsToBandNodes, @@ -1636,58 +1635,12 @@ describe("copyIrWithFreshIds", () => { }); }); -describe("optionalSteps round-trip (U2)", () => { - const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) => - makeDef( - parseWorkflowIr({ - version: "v2", - name: "wf-opt", - columns: [ - { id: "triage", name: "Triage", traits: [] }, - { id: "done", name: "Done", traits: [{ trait: "complete" }] }, - ], - nodes: [ - { id: "start", kind: "start", column: "triage" }, - { id: "end", kind: "end", column: "done" }, - ], - edges: [{ from: "start", to: "end" }], - ...(optionalSteps ? { optionalSteps } : {}), - }), - ); - - it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => { - const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]); - const read = optionalStepsOf(def); - expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]); - // mutating the result does not mutate the source IR - read[0].defaultOn = false; - expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]); - }); - - it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => { - const v1 = makeDef({ - version: "v1", - name: "legacy", - nodes: [ - { id: "start", kind: "start" }, - { id: "end", kind: "end" }, - ], - edges: [{ from: "start", to: "end" }], - }); - expect(optionalStepsOf(v1)).toEqual([]); - expect(optionalStepsOf(v2WithOptional())).toEqual([]); - }); - - it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => { - const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]); - const { nodes, edges } = irToFlow(def); - const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def)); - expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([ - { templateId: "browser-verification", defaultOn: true }, - ]); - }); - - it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => { +// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: +// The legacy optional-step DECLARATION authoring surface is retired: `optionalStepsOf` +// is removed and `flowToIr` no longer accepts/emits an `optionalSteps` array. Optional +// steps are graph-native `optional-group` nodes carried by the normal node/edge mapping. +describe("optionalSteps declaration authoring removed (U7)", () => { + it("flowToIr never emits a legacy optionalSteps key", () => { const { ir: out } = flowToIr( "opt-only", [ @@ -1698,21 +1651,7 @@ describe("optionalSteps round-trip (U2)", () => { [], [], [], - [{ templateId: "browser-verification" }], ); - expect(out.version).toBe("v2"); - expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([ - { templateId: "browser-verification" }, - ]); - }); - - it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => { - const def = v2WithOptional(); - const { nodes, edges } = irToFlow(def); - const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []); expect("optionalSteps" in out).toBe(false); - // and with the arg omitted entirely - const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def)); - expect("optionalSteps" in out2).toBe(false); }); }); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index f889a188f2..11c718114e 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -9,7 +9,6 @@ import type { WorkflowDefinition, WorkflowFieldDefinition, WorkflowSettingDefinition, - WorkflowOptionalStep, } from "@fusion/core"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; @@ -453,7 +452,6 @@ export function flowToIr( columns?: WorkflowIrColumn[], fields?: WorkflowFieldDefinition[], settings?: WorkflowSettingDefinition[], - optionalSteps?: WorkflowOptionalStep[], ): { ir: WorkflowIr; layout: Record } { const realNodes = nodes.filter((n) => !isColumnBandNode(n.id)); // Partition by parentId: foreach group children reassemble into that group's @@ -475,15 +473,14 @@ export function flowToIr( ); const hasFields = Array.isArray(fields) && fields.length > 0; const hasSettings = Array.isArray(settings) && settings.length > 0; - const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0; - // FNXC:WorkflowOptionalSteps 2026-06-21-00:00: - // Optional steps must round-trip through the node editor without data loss, yet - // must never upgrade a legacy v1 graph. Fields, settings, and optional steps are - // v2-only declarations: a workflow with any of them but no custom columns still - // serializes as v2 (with the synthesized default columns). Empty/absent → not a - // v2 signal, and the key is omitted entirely (R6 byte-identity for legacy graphs). + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // The editor no longer AUTHORS legacy `optionalSteps` declarations — optional + // steps are graph-native `optional-group` nodes carried through the normal + // node/edge mapping. Fields and settings remain v2-only declarations: a workflow + // with either but no custom columns still serializes as v2 (with the synthesized + // default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy). const v2 = - (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps; + (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings; const layout: Record = {}; /** Project one flow node (top-level or template child) into an IR node. */ @@ -595,12 +592,6 @@ export function flowToIr( render: s.render ? { ...s.render } : undefined, })); } - if (hasOptionalSteps) { - // Optional-step DECLARATIONS round-trip through the editor opaquely (they are - // not graph nodes; the resolver + server validator are the source of truth). - // Omitted entirely when empty so legacy graphs stay byte-identical (R6). - (ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o })); - } return { ir, layout }; } @@ -1013,15 +1004,11 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[] })); } -/** Extract the editor's working optional-step declaration list from a definition. - * v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata - * (name/icon/phase) is NOT carried here — it is resolved from the step-template - * catalog at render time so the resolver stays the single source of truth. */ -export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] { - const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] }; - if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return []; - return ir.optionalSteps.map((o) => ({ ...o })); -} +/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + `optionalStepsOf` (the editor's legacy `optionalSteps` declaration extractor) + is removed. Optional steps are graph-native `optional-group` nodes now; the + editor reads/writes them through the normal node/edge mapping, and the per-task + toggle surfaces resolve them via `resolveWorkflowOptionalSteps`. */ /** Seed graph for a brand-new workflow: start → end with room to insert steps. */ export function emptyWorkflowIr(name: string): WorkflowIr { From 316d2659b86b35affc618864cfe5c9eb4178d151 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 20:29:46 -0700 Subject: [PATCH 13/44] fix(review): workspace-merge park must use status:'failed' to avoid re-enqueue loop (U0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-fix verification review (correctness + adversarial + reliability, unanimous P0) found that the earlier retry-burn fix introduced an infinite loop: parking a WorkspaceTaskMergeError task with status:null + mergeRetries:0 passes every auto-merge eligibility gate (canMergeTask short-circuits only on status==='failed'), so the cooldown sweep re-enqueues it every tick → guard re-throws → re-park, forever. - Park with status:'failed' (keep mergeRetries:0). canMergeTask now blocks the auto-sweep; a human's manual merge still works because it flows through the manual-resolver branch (rejectMergeResolvers), which bypasses canMergeTask — so 'failed' does not block manual retry (the original comment's worry was wrong). - Detect the error via `err instanceof Error && err.name === "WorkspaceTaskMergeError"`, matching the VerificationError/MergeAbortedError convention and bundle-safe across the @fusion/core→@fusion/engine boundary (drops the now-unused class import). - Document that the dispatch door guard is a fast-fail only; the unconditional chokepoint guard inside runAiMerge is the authoritative enforcement. - Add a regression test asserting the auto-merge park sets status:'failed' (not null). Gate green: lint, typecheck, build, test:gate (649+58), project-engine (81). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/project-engine.test.ts | 38 +++++++++++++++++++ packages/engine/src/project-engine.ts | 29 +++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 3821a0d3d9..a613fda48b 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1318,6 +1318,44 @@ describe("ProjectEngine U0 merge unification dispatch", () => { expect(mocks.runAiMerge).not.toHaveBeenCalled(); await engine.stop(); }); + + // Regression: the auto-merge park for a WorkspaceTaskMergeError must set status:"failed", + // not status:null. status:null + mergeRetries:0 passes every eligibility gate, so the + // cooldown sweep re-enqueues the task every tick → tight re-throw/re-park loop. status:"failed" + // makes canMergeTask short-circuit; manual retry still works (it bypasses canMergeTask). + it("R7 auto-merge park: workspace task is parked status:'failed' so it is not re-enqueued", async () => { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mockStore.store.getTask.mockResolvedValue({ + id: "FN-WS-AUTO", + column: "in-review", + paused: false, + mergeRetries: 0, + status: "queued", + workspaceWorktrees: { + "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, + }, + } as any); + mocks.currentStore = mockStore.store; + + const engine = createEngine(); + await engine.start(); + // Auto-merge path (no manual resolver): the R7 door guard throws before runAiMerge, + // and the dispatch catch parks the task. + engine.enqueueMerge("FN-WS-AUTO"); + await vi.waitFor(() => { + expect(mockStore.store.updateTask).toHaveBeenCalledWith( + "FN-WS-AUTO", + expect.objectContaining({ status: "failed", mergeRetries: 0 }), + ); + }); + expect(mocks.runAiMerge).not.toHaveBeenCalled(); + // Guard against regression to the re-enqueue loop (status:null park): + expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( + "FN-WS-AUTO", + expect.objectContaining({ status: null }), + ); + await engine.stop(); + }); }); describe("ProjectEngine merge queue priority ordering", () => { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 5a343177f4..575464cc00 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId, WorkspaceTaskMergeError } from "@fusion/core"; +import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -2287,11 +2287,15 @@ export class ProjectEngine { this.activeMergeSession = session; }, }; - // FNXC:Workspace 2026-06-21-19:05: + // FNXC:Workspace 2026-06-21-19:40: // R7 merge-boundary guard (master-plan U0). Reject workspace-mode // tasks BEFORE any git work — they need the per-repo merge loop that // lands in master-plan U6 (which removes this guard). Load the task // here so the dispatch shares the one predicate in @fusion/core. + // This door is a FAST-FAIL only: a getTask failure is swallowed to null + // and the guard is skipped, but the unconditional chokepoint guard inside + // runAiMerge (which re-reads the task) is the authoritative enforcement, + // so a transient read failure here cannot let a workspace task reach git work. const mergeTask = await store.getTask(taskId).catch(() => null); if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); @@ -2358,19 +2362,24 @@ export class ProjectEngine { continue; } - // FNXC:Workspace 2026-06-21-19:05: + // FNXC:Workspace 2026-06-21-19:40: // R7 workspace merge-boundary park (master-plan U0). A WorkspaceTaskMergeError // is a PERMANENT config error (workspace task hit a merge door before the // per-repo merge loop exists — master-plan U6), NOT a transient merge failure. - // Park the task WITHOUT burning mergeRetries (set to 0) so a human can manually - // retry after addressing the config; the default failed-path below would - // otherwise pin mergeRetries to the cap and permanently block manual retry. + // Park with status:"failed" so the auto-merge cooldown sweep STOPS re-attempting: + // `canMergeTask` short-circuits on status==="failed". (Parking with status:null + + // mergeRetries:0 passes every eligibility gate, so the sweep re-enqueues every tick + // → tight WorkspaceTaskMergeError re-throw/re-park loop.) Keep mergeRetries:0 (not + // the cap) so a human's manual merge after the config is addressed is not blocked by + // exhausted retries — and manual merge flows through the manual-resolver branch + // (rejectMergeResolvers), which bypasses canMergeTask, so "failed" never blocks it. + // Detect by err.name (matches the VerificationError/MergeAbortedError convention and + // is robust across the @fusion/core→@fusion/engine package boundary). const isWorkspaceMergeError = - err instanceof WorkspaceTaskMergeError - || (err as { name?: string } | null)?.name === "WorkspaceTaskMergeError"; + err instanceof Error && err.name === "WorkspaceTaskMergeError"; if (isWorkspaceMergeError) { runtimeLog.error( - `${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking without burning mergeRetries so a human can retry after the config is addressed: ${errorMsg}`, + `${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking as failed (manual retry still works) without exhausting mergeRetries: ${errorMsg}`, ); await store .logEntry(taskId, `Merge blocked: ${errorMsg}`, "WorkspaceTaskMergeError") @@ -2379,7 +2388,7 @@ export class ProjectEngine { this.rejectMergeResolvers(taskId, err instanceof Error ? err : new Error(errorMsg)); } else { await store - .updateTask(taskId, { status: null, mergeRetries: 0, error: errorMsg }) + .updateTask(taskId, { status: "failed", mergeRetries: 0, error: errorMsg }) .catch(() => undefined); } continue; From 68d3c5820e938d1e99acd6cd21735c52bf37943c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 20:33:32 -0700 Subject: [PATCH 14/44] docs(FN-6880): capture optional-group toggle-id collision learning Document the code-review P1 as a logic-errors learning: a per-task graph toggle (enabledWorkflowSteps, keyed by optional-group node id) collided with the legacy step-template namespace and was silently remapped by the store resolver, bypassing an enabled group. Cross-references the per-task-override blast-radius cousins as the id-namespace-collision variant of that class. Seeds an "Optional step group" entry in CONCEPTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONCEPTS.md | 5 + ...toggle-id-remapped-by-step-materializer.md | 102 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md diff --git a/CONCEPTS.md b/CONCEPTS.md index c6990d47c7..5549f4ffa3 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -250,6 +250,11 @@ A workflow graph node that reads a declared Artifact and runs a registry parser ### Custom task field A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace. +### Optional step group +A workflow graph container node (alongside `foreach`/`loop`) whose template subgraph runs once when a task has enabled it and is bypassed otherwise — the graph-native way to make a step optional per task. Enablement is a per-task toggle set seeded from the group's workflow-level default; the group's own node id is the toggle key. It replaces the earlier execution-inert *declaration* model (a separate optional-step list run through a hidden seam), so optional steps are now real, placeable nodes rather than an out-of-graph facet. + +Single pass — no iteration or rework inside the template (this is what distinguishes it from `foreach`/`loop`). Because the toggle key is the node id, renaming or recreating a group resets its per-task enablement; and because that id may deliberately equal a built-in step-template id, the per-task enable set must keep group ids identity-stable rather than round-tripping them through legacy step-template materialization (which would remap the key and silently bypass the group). + ## Persistence & migrations ### Schema-Version Sweep diff --git a/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md b/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md new file mode 100644 index 0000000000..4221daeea7 --- /dev/null +++ b/docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md @@ -0,0 +1,102 @@ +--- +title: "Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped" +date: 2026-06-21 +category: docs/solutions/logic-errors +module: engine (workflow store + graph executor) +problem_type: logic_error +component: service_object +symptoms: + - "Enabling a built-in optional-group (browser-verification) on a coding/stepwise task did nothing — the group's steps never ran." + - "The default-on seed path and direct graph-executor unit tests passed, masking the bug; only user-driven enable (create-with-enable or update/toggle) failed." + - "No error surfaced — the enabled group was silently bypassed." +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - workflow-store + - graph-executor + - optional-group +tags: + - optional-group + - enabledworkflowsteps + - per-task-override + - id-collision + - workflow-store + - silent-bypass +--- + +# Optional-group enable toggle silently bypassed — node id collided with a legacy step-template namespace and was remapped + +## Problem + +A graph-native `optional-group` workflow node is enabled per task via the `enabledWorkflowSteps` array, keyed by the group's **node id**. The graph executor runs the group only when `task.enabledWorkflowSteps.includes(node.id)`. But the store's `resolveEnabledWorkflowSteps` ran every id through the **legacy step-template materializer** (`getBuiltInWorkflowTemplate` → `ensureWorkflowStepForTemplate`). The built-in `browser-verification` group deliberately reused the template id `"browser-verification"` as its node id (for back-compat), so that id matched a `WORKFLOW_STEP_TEMPLATES` entry and was **remapped to a materialized `WorkflowStep` row id** (≠ the node id). The executor's membership check then never matched, and the enabled group was silently bypassed — the headline use case (turn the optional step on) did nothing, with no error. + +## Symptoms + +- Enabling `browser-verification` on a coding/stepwise task ran nothing pre-merge. +- Direct graph-executor tests (which pass a raw `enabledWorkflowSteps: ["browser-verification"]`) and the default-on **seed** path passed — masking the defect. +- Only the **user-driven** enable paths failed: create-with-explicit-enable and `updateTask({ enabledWorkflowSteps })` (the per-task toggle in the UI). + +## What Didn't Work + +- **Trusting the existing tests.** The unit tests used group ids like `og-on`/`og-off` that do **not** collide with any `WORKFLOW_STEP_TEMPLATES` id, so `getBuiltInWorkflowTemplate` returned undefined and the id passed through untouched — the tests were green precisely because they avoided the colliding id. The bug only fires when the group id equals a built-in template id. +- **Assuming the executor test covered it.** The two-task divergence test enabled the group by writing `enabledWorkflowSteps` straight onto the task, bypassing the store's resolver — so it never exercised the remap. The defect lived entirely in the create/update **resolution** path, one layer above the executor. + +## Solution + +Pass a workflow's optional-group node ids through `resolveEnabledWorkflowSteps` **untouched** — they are executor toggle keys, not legacy step-template ids to be materialized. + +```ts +// NEW: enumerate every optional-group node id (regardless of defaultOn). +export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] { + return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId); // templateId === group node id +} + +// store.ts — the resolver gains an optional pass-through set: +private async resolveEnabledWorkflowSteps( + stepIds?: string[], + optionalGroupIds?: Set, +): Promise { + // ... + // Optional-group toggle ids pass through raw — never materialized as legacy step rows. + const template = optionalGroupIds?.has(stepId) + ? undefined + : this.getBuiltInWorkflowTemplate(stepId); + const resolvedId = template ? (await this.ensureWorkflowStepForTemplate(stepId)).id : stepId; + // ... +} + +// helper resolving the task's workflow IR → its optional-group id set: +private async optionalGroupIdSet(workflowId?: string | null): Promise> { + const wfId = workflowId ?? (await this.getDefaultWorkflowId()); + if (!wfId) return new Set(); + const def = await this.getWorkflowDefinition(wfId); + if (!def || def.kind === "fragment") return new Set(); + return new Set(resolveAllOptionalGroupIds(def.ir)); +} +``` + +Both user-enable call sites supply the set: create (`optionalGroupIdSet(input.workflowId)`) and update (`optionalGroupIdSet(getTaskWorkflowSelection(task.id)?.workflowId)`). + +**Regression test** — must use a **colliding** id (`browser-verification`), since non-colliding ids never reproduce it: create-with-enable and update/toggle both assert the raw group node id survives in `enabledWorkflowSteps`. + +## Why This Works + +The bug is a **per-task override that is read correctly at the action site but rewritten en route**. The override (`enabledWorkflowSteps`) was consulted exactly where the action runs (the graph executor), but the value was mutated in the **resolution path** before it got there, because two id namespaces overlap: graph-native optional-group **node ids** and legacy **`WorkflowStep` template ids**. The materializer is meaningful only for the retired declaration/`workflow-step`-seam execution model; for a graph-native group it is pure harm. Marking group ids as pass-through keeps the key **identity-stable** from definition through every consumer, so the executor's `includes(node.id)` check matches. + +(Verified the related slim-projection trap does **not** apply: the executor reads `enabledWorkflowSteps` off the `TaskDetail` snapshot it is handed, not a column-narrowed SELECT, so the array is fully hydrated.) + +## Prevention + +- **When introducing a new identity/key that shares a namespace with an existing one, grep every reader AND every *transformer* of that key.** A silent remap in a resolver is as fatal as a missing read — the override "survives" but as the wrong value. Demand each consumer is either re-keyed or argued identity-stable. +- **Regression tests for namespace collisions must use a *colliding* value.** A test with a deliberately distinct id proves nothing about the collision; pick the id that actually overlaps the legacy namespace (here, a built-in template id reused as a node id). +- **Test the path the user actually takes, not just the layer under test.** The executor-level test bypassed the store resolver where the bug lived; a create/update round-trip through the store would have caught it. Prefer at least one end-to-end seam test per per-task facet. +- **A facet that "works on seed/default but not on toggle" is the tell.** Asymmetry between the seed path (writes raw ids) and the user-enable path (runs the resolver) localizes the defect to the resolver. + +## Related Issues + +This is the **id-namespace-collision variant** of the per-task/per-entity override blast-radius class. Same disease (override invisible to the user, no error), different organ (key rewritten in resolution vs. not consulted at a trigger gate): + +- [Per-task auto-merge override ignored by trigger-layer gates](../logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md) — sibling: override dead from the user's perspective; theirs is a missed trigger gate, ours is a resolution-path key remap. Its "consult the override everywhere between definition and action" rule covers this case too. +- [Per-entity execution-principal override: the full blast-radius checklist](../architecture-patterns/per-entity-execution-principal-override-blast-radius.md) — the generalizing checklist; closest prior art is its "validate composite node ids against the graph, never round-trip them" example. This bug is a new bullet for that checklist. +- [Workflow-native execution through runtime primitives](../architecture-patterns/workflow-native-runtime-primitives.md) — context: the legacy-`WorkflowStep`-row vs. graph-node two-control-planes tension this collision exploits. From e4a810e9b43cabef117543a06e7dd2dc7f0ad440 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 20:47:31 -0700 Subject: [PATCH 15/44] fix(FN-6880): address PR review feedback (#1712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject failure-condition edges inside optional-group templates (the single-pass walk surfaces template failures as the group's outcome, so an internal failure edge was silently dead) — Greptile P2. - flowToIr: a container/group node (foreach/loop/optional-group) is v2-only — its presence now forces v2 serialization (an inserted optional-group on a plain workflow no longer serializes as invalid v1) — CodeRabbit. - Disabled optional-group bypass routes a plain success with no distinguishing value, so an outcome:* edge can't preempt success routing (inertness) — CodeRabbit. - Downgrade heuristic: presence of a legacy optionalSteps key (incl. []) keeps v2. - Resolver docblock corrected (config-less groups resolve to a fallback entry). - Strengthen tests: assert both inserted groups + v2 round-trip; failure-edge rejection case. - Changeset: bump to major (removed exported WorkflowOptionalStep type). - Plan: record U7a as delivered in this cohort; only the workflow-step seam infra removal remains deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retire-optional-steps-declaration.md | 4 ++- ...-workflow-optional-group-subgraphs-plan.md | 26 ++++++++++--------- .../workflow-ir-optional-group.test.ts | 7 +++++ packages/core/src/workflow-ir.ts | 23 +++++++++++++--- packages/core/src/workflow-optional-steps.ts | 6 +++-- .../__tests__/workflow-flow-mapping.test.ts | 20 +++++++++----- .../app/components/workflow-flow-mapping.ts | 7 ++++- ...-coding-browser-verification-group.test.ts | 5 ++++ .../engine/src/workflow-graph-executor.ts | 10 ++++--- 9 files changed, 78 insertions(+), 30 deletions(-) diff --git a/.changeset/retire-optional-steps-declaration.md b/.changeset/retire-optional-steps-declaration.md index 72baaa6311..3ce5035347 100644 --- a/.changeset/retire-optional-steps-declaration.md +++ b/.changeset/retire-optional-steps-declaration.md @@ -1,5 +1,7 @@ --- -"@runfusion/fusion": patch +"@runfusion/fusion": major --- +**Breaking:** the `WorkflowOptionalStep` type, previously exported from `@runfusion/fusion`, is removed — any consumer importing it must migrate to `optional-group` nodes / `ResolvedWorkflowOptionalStep`. + Retire the legacy optional-step DECLARATION model now that optional steps are graph-native `optional-group` nodes. Remove the `WorkflowOptionalStep` type and the `WorkflowIrV2.optionalSteps` IR field, drop the workflow node editor's optional-step declaration authoring panel (sidebar section, mobile tab, and collapse state), and stop threading an `optionalSteps` array through `flowToIr`/`serializeGraph`. A legacy persisted `optionalSteps` key on an old v2 workflow row is now tolerated (ignored, not validated) at parse so old rows still load as v2, and the rollback-downgrade heuristic still treats such a row as v2. The per-task optional-step toggle surfaces are unchanged — they continue to list and toggle optional steps sourced from `optional-group` nodes via `resolveWorkflowOptionalSteps` (`ResolvedWorkflowOptionalStep`). diff --git a/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md index e5e89f55bd..03d9c3650d 100644 --- a/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md +++ b/docs/plans/2026-06-21-002-feat-workflow-optional-group-subgraphs-plan.md @@ -539,19 +539,21 @@ surfaces are enumerated: `resolveWorkflowOptionalSteps`'s output shape (source re-pointed in U3). - Step→node projection (`workflow-steps-to-ir.ts`) reused to project add-ons (U5). -### Deferred to Follow-Up Work -- **Full legacy-path retirement (U7) — deferred after execution-time scope discovery.** U1–U6 shipped and - the new model is the live path (built-ins migrated, resolver + executor on optional-group nodes). The - legacy declaration surface is now inert but **not removed**, because U7 turned out far larger than scoped: - (a) `workflow-step` is a shared `WorkflowSeam` union member woven through ~9 engine runtime files +### Delivered cohort (this PR) vs. Deferred +This PR delivers **U1–U6 plus U7a** (10 commits). U7a retired the legacy declaration *model*: the core +`WorkflowOptionalStep` type + `WorkflowIrV2.optionalSteps` field + `validateOptionalSteps`, and the editor's +declaration **authoring** surface (`WorkflowOptionalStepsPanel`, `optionalStepsOf`, the `flowToIr` +`optionalSteps` threading). A code-review pass also fixed a P1 (the optional-group toggle-id collision in +enable resolution) — captured in the commit history and in +`docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md`. The per-task toggle +surfaces (`WorkflowOptionalStepsDropdown`, inline card, modal, Workflow tab) stayed — they consume the +distinct `ResolvedWorkflowOptionalStep`. + +- **Deferred: the `workflow-step` seam infrastructure removal.** What remains of "full U7" is excising the + `workflow-step` seam itself — a shared `WorkflowSeam` union member woven through ~9 engine runtime files (`runtime-primitives`, `step-session-executor`, `workflow-node-handlers`, `active-session-registry`, - `workflow-graph-task-runner`, `executor.runWorkflowSteps`, the compiler seam-anchor), not an - optional-steps-only node — excising it is its own refactor; and (b) the dashboard still carries the prior - declaration **authoring** surface (`WorkflowOptionalStepsPanel`/`WorkflowOptionalStepsDropdown`, the - `flowToIr` `optionalSteps` threading, `optionalStepsOf`) across ~10 files. Removing the core - `WorkflowOptionalStep` type without that dashboard cleanup breaks the build. Retire both surfaces in a - focused follow-up; until then the `WorkflowOptionalStepsPanel` authors declarations the resolver no longer - reads (a known dead-authoring UI to remove with it). + `workflow-graph-task-runner`, `executor.runWorkflowSteps`, the compiler seam-anchor). It is now orphaned + (no built-in graph reaches it) but inert; excising it is its own focused refactor with its own blast radius. - **Nested/conditional groups** (an optional-group inside a split/foreach, or gated by a workflow field rather than the per-task toggle) — single-level, per-task-toggle only for now. - **Plugin-contributed add-ons as optional-group presets** beyond inserting them as flat nodes. diff --git a/packages/core/src/__tests__/workflow-ir-optional-group.test.ts b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts index 36590df04e..88806e3383 100644 --- a/packages/core/src/__tests__/workflow-ir-optional-group.test.ts +++ b/packages/core/src/__tests__/workflow-ir-optional-group.test.ts @@ -94,6 +94,13 @@ describe("optional-group validation", () => { expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain rework edges/); }); + it("rejects failure-condition edges inside the template (single-pass bails before routing them)", () => { + const template = groupTemplate(); + // A parallel failure edge that the single-pass walk would silently never take. + template.edges.push({ from: "verify", to: "report", condition: "failure" }); + expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain failure-condition edges/); + }); + it("rejects nested loop/foreach/optional-group regions", () => { const template = groupTemplate(); template.nodes.push({ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 93b56928a3..75d96e8968 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -668,6 +668,18 @@ function validateOptionalGroup( if (isReworkEdge(edge)) { throw new WorkflowIrError(`optional-group node '${node.id}' template may not contain rework edges`); } + // FNXC:WorkflowOptionalGroup 2026-06-22-09:00: the single-pass walk + // (runOptionalGroup) surfaces a template-node failure as the GROUP's outcome + // and bails before evaluating that node's edges — so a `failure`-condition + // edge inside the template would silently never execute. Reject it as a typed + // authoring error; failure routing belongs on the group's OUTER edges. + // (Code review: Greptile P2.) + if (edge.condition === "failure") { + throw new WorkflowIrError( + `optional-group node '${node.id}' template may not contain failure-condition edges — ` + + `a template-node failure surfaces as the group's outcome and routes the group's outer failure edge`, + ); + } } const incoming = new Map(); @@ -1466,16 +1478,19 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { // Step-inversion declarations (artifacts/fields), workflow settings (U1), and // any legacy persisted optional-step declarations are v2-only features. - // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: + // FNXC:WorkflowOptionalGroup 2026-06-21-18:00 (updated 2026-06-22-09:00): // `optionalSteps` is no longer a typed IR field (retired declaration model), but // a legacy v2 row may still carry the key. Read it via an untyped cast so such a - // row is still treated as v2 (kept on v2, never silently downgraded). - const legacyOptionalSteps = (ir as { optionalSteps?: unknown[] }).optionalSteps; + // row is still treated as v2 (kept on v2, never silently downgraded). The mere + // PRESENCE of the key — including an empty `[]` — is the v2 signal: an author + // who wrote the key intended v2, and downgrading an `optionalSteps: []` row to + // v1 would still mutate its persisted shape. (Code review: CodeRabbit.) + const legacyOptionalSteps = (ir as { optionalSteps?: unknown }).optionalSteps; if ( (ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0) || (ir.settings && ir.settings.length > 0) || - (Array.isArray(legacyOptionalSteps) && legacyOptionalSteps.length > 0) + legacyOptionalSteps !== undefined ) { return ir; } diff --git a/packages/core/src/workflow-optional-steps.ts b/packages/core/src/workflow-optional-steps.ts index 4cfef149e8..f3fea182cd 100644 --- a/packages/core/src/workflow-optional-steps.ts +++ b/packages/core/src/workflow-optional-steps.ts @@ -35,8 +35,10 @@ function isOptionalGroupNode( * * Source: v2 `ir.nodes` where `kind === "optional-group"` (NOT the legacy * `ir.optionalSteps` declaration). Non-v2 graphs and graphs without any - * optional-group node resolve to `[]`. Malformed group configs are skipped so a - * stale/partial node never renders a blank UI row or breaks workflow loading. + * optional-group node resolve to `[]`. A group with a missing or partial config + * still resolves to a usable entry — `name` falls back to the node id and + * `defaultOn` to false — rather than being dropped, so a stale/partial node never + * silently disappears from the toggle UI or breaks workflow loading. * * `pluginTemplates` is accepted for signature compatibility with the prior * template-backed resolver; group nodes are self-describing, so it is currently diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index d214a4da50..ab16bb6cab 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -1460,13 +1460,21 @@ describe("insertFragment", () => { const allIds = second.nodes.map((n) => n.id); expect(new Set(allIds).size).toBe(allIds.length); - // Round-trip: the group carries defaultOn + a single-node template. + // Round-trip: BOTH inserted groups carry defaultOn + a single-node template, + // so a regression that breaks the second insert can't pass on the first. const { ir: out } = flowToIr("wf", second.nodes, second.edges); - const og = out.nodes.find((n) => n.kind === "optional-group")!; - expect(og.config?.defaultOn).toBe(true); - const template = (og.config as { template?: { nodes: { config?: Record }[] } }).template; - expect(template?.nodes).toHaveLength(1); - expect(template?.nodes[0].config?.name).toBe("Security Audit"); + // An optional-group is a v2-only kind: its presence forces v2 serialization + // even with no columns/fields/settings, or it would serialize as v1 and fail + // parse. (Code review: CodeRabbit.) + expect(out.version).toBe("v2"); + const ogs = out.nodes.filter((n) => n.kind === "optional-group"); + expect(ogs).toHaveLength(2); + for (const og of ogs) { + expect(og.config?.defaultOn).toBe(true); + const template = (og.config as { template?: { nodes: { config?: Record }[] } }).template; + expect(template?.nodes).toHaveLength(1); + expect(template?.nodes[0].config?.name).toBe("Security Audit"); + } }); }); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index 11c718114e..66b5b9e67d 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -479,8 +479,13 @@ export function flowToIr( // node/edge mapping. Fields and settings remain v2-only declarations: a workflow // with either but no custom columns still serializes as v2 (with the synthesized // default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy). + // FNXC:WorkflowOptionalGroup 2026-06-22-09:00: a container/group node + // (foreach/loop/optional-group) is a v2-ONLY kind — its presence must force v2, + // or an inserted optional-group on an otherwise-plain workflow would serialize + // as v1 and fail parse (validateOptionalGroup runs only on v2). (Code review: + // CodeRabbit — corroborated by the pre-merge correctness review's residual risk.) const v2 = - (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings; + (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || groupIds.size > 0; const layout: Record = {}; /** Project one flow node (top-level or template child) into an IR node. */ diff --git a/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts b/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts index 2420815b5c..b3500f4128 100644 --- a/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts +++ b/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts @@ -88,6 +88,11 @@ describe("builtin coding browser-verification optional-group (U6)", () => { expect(result.context[`node:${GROUP_ID}:outcome`]).toBe("failure"); expect(result.visitedNodeIds).toContain(INNER_STEP_VISITED_ID); + // The group's only two outgoing edges are `success → review` and + // `failure → end`; the inner-step failure routes the failure edge, so review + // is skipped. (`end` is a terminal node the executor does not record in + // visitedNodeIds, so the routing is asserted via the group's failure outcome + // above + review being unreachable here.) expect(result.visitedNodeIds).not.toContain("review"); }); }); diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 9dc13c158f..61bf21f3c7 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -506,10 +506,12 @@ export class WorkflowGraphExecutor { // sees "success" rather than undefined — disabled is fully inert, not // just edge-routing-inert. context[`node:${node.id}:outcome`] = "success"; - return await traverseChildren(node, { - outcome: "success", - value: "optional-group-bypassed", - }); + // FNXC:WorkflowOptionalGroup 2026-06-22-09:00: route a disabled group + // as a plain success with NO distinguishing value — a non-empty value + // could let an `outcome:*` edge preempt the success edge in + // traverseChildren, breaking the "disabled == node absent" inertness + // invariant. (Code review: CodeRabbit.) + return await traverseChildren(node, { outcome: "success" }); } const groupResult = await runOptionalGroup(node, { context, From 09bd01baf0edda7cd8bb3a45d8f07606a60a78e9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:07:20 -0700 Subject: [PATCH 16/44] =?UTF-8?q?feat(workspace):=20Phase=20A=20U1=20?= =?UTF-8?q?=E2=80=94=20executor=20session=20scoping=20for=20workspace=20mo?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In workspace mode (loadWorkspaceConfig present), the executor now skips the root acquireTaskWorktree({rootDir}) and every intervening rootDir git preflight (base-commit capture, contamination, identity-guard, verifyWorktreeInvariants), runs the agent session rooted at the non-git workspace root (cwd=rootDir, browse-only; task.worktree never set), and tracks activeWorktrees as a per-task Set. scopePromptToWorktree is a no-op in workspace mode. The non-workspace path is unchanged (every change branches on this.workspaceConfig; a single-repo task holds a one-element Set). Converted every activeWorktrees consumer to membership semantics (feasibility- verified list): findActiveWorktreeOwner, hasActiveWorktreeBinding, the FN-6736 phantom-binding reclaim, listWorktreeHolders (flat-maps a Set into N holder rows — verified the FN-6782 reaper keys off taskId only, so slot accounting is unaffected), the conflict-set iteration, the three deleteActive* unregister resolvers (loop every path), cleanup, getWorktreePath (undefined for a multi-worktree workspace task), and the verifyWorktreeInvariants singular resolution (gated off in workspace mode — per-repo verify returns in Phase B). Rewrote executor-workspace.test.ts from vi.mock-the-subject to a real two-repo git fixture harness (_workspace-fixture.ts, shared with later units), 13 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ace-phase-a-u1-executor-session-scoping.md | 5 + .../src/__tests__/_workspace-fixture.ts | 66 ++++ .../executor-paused-abort-todo-benign.test.ts | 12 +- .../src/__tests__/executor-recovery.test.ts | 8 +- .../executor-workspace-session-cwd.test.ts | 120 ++++++++ .../src/__tests__/executor-workspace.test.ts | 285 +++++++++++++----- .../executor-worktree-conflict.test.ts | 2 +- .../active-worktree-removal-liveness.test.ts | 8 +- ...ompletion-stale-self-owned-binding.test.ts | 12 +- ...self-owned-active-session-recovery.test.ts | 2 +- .../stale-self-owned-session-registry.test.ts | 2 +- packages/engine/src/executor.ts | 137 ++++++--- 12 files changed, 524 insertions(+), 135 deletions(-) create mode 100644 .changeset/workspace-phase-a-u1-executor-session-scoping.md create mode 100644 packages/engine/src/__tests__/_workspace-fixture.ts create mode 100644 packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts diff --git a/.changeset/workspace-phase-a-u1-executor-session-scoping.md b/.changeset/workspace-phase-a-u1-executor-session-scoping.md new file mode 100644 index 0000000000..6fc9911756 --- /dev/null +++ b/.changeset/workspace-phase-a-u1-executor-session-scoping.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase A (U1): executor session scoping. In workspace mode the executor now skips the root worktree acquisition and every rootDir git preflight (base-commit capture, contamination, worktree-liveness), runs the agent session rooted at the browse-only workspace root, and tracks acquired sub-repo worktrees as a per-task set. Single-repo tasks are unchanged (one-element set, byte-for-byte preflight parity). diff --git a/packages/engine/src/__tests__/_workspace-fixture.ts b/packages/engine/src/__tests__/_workspace-fixture.ts new file mode 100644 index 0000000000..5e94b78982 --- /dev/null +++ b/packages/engine/src/__tests__/_workspace-fixture.ts @@ -0,0 +1,66 @@ +/* +FNXC:Workspace 2026-06-21-12:00: +Shared REAL two-repo git fixture for workspace-mode engine tests (U1 + U2 + later phases). The foundation's executor-workspace test self-mocked the functions under test, which proves nothing; this harness instead builds genuine on-disk git repos under a NON-git workspace root so that any leaked rootDir git preflight actually fails. U2 and later units import `createWorkspaceFixture` directly — keep it dependency-light (only node:child_process + node:fs + saveWorkspaceConfig). + +A workspace root is a plain directory (NOT a git repo) containing N sub-repos. Each sub-repo is a real git repo with an initial commit on a default branch. `/.fusion/workspace.json` lists the sub-repo relative paths so `loadWorkspaceConfig(root)` returns a populated config — the exact signal `this.workspaceConfig` keys off in the executor. +*/ +import { execSync, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { saveWorkspaceConfig } from "@fusion/core"; + +export const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +/** Initialize a real git repo at `repoDir` with one commit on `defaultBranch`. */ +export function initRepoWithCommit(repoDir: string, defaultBranch = "main"): void { + mkdirSync(repoDir, { recursive: true }); + git(repoDir, `git init -b ${defaultBranch}`); + git(repoDir, 'git config user.email "test@example.com"'); + git(repoDir, 'git config user.name "Test"'); + writeFileSync(path.join(repoDir, "README.md"), `# ${path.basename(repoDir)}\n`, "utf-8"); + git(repoDir, "git add README.md"); + git(repoDir, "git commit -m 'init'"); +} + +export interface WorkspaceFixture { + /** Absolute path to the non-git workspace root. */ + rootDir: string; + /** Relative sub-repo paths (workspace.json `repos`). */ + repos: string[]; + /** Absolute path to a sub-repo by relative name. */ + repoPath(rel: string): string; + /** Run a git command inside a sub-repo. */ + git(rel: string, command: string): string; + /** Remove all on-disk fixture state. */ + cleanup(): void; +} + +/** + * Create a real two-repo (by default) workspace fixture on disk. + * - `rootDir` is a plain non-git directory. + * - Each `repos[i]` is a real git repo with an initial commit. + * - `/.fusion/workspace.json` is written so loadWorkspaceConfig() resolves. + */ +export async function createWorkspaceFixture( + repos: string[] = ["repo-a", "repo-b"], + defaultBranch = "main", +): Promise { + const rootDir = mkdtempSync(path.join(os.tmpdir(), "fusion-workspace-")); + for (const rel of repos) { + initRepoWithCommit(path.join(rootDir, rel), defaultBranch); + } + await saveWorkspaceConfig(rootDir, { repos }); + + return { + rootDir, + repos, + repoPath: (rel: string) => path.join(rootDir, rel), + git: (rel: string, command: string) => git(path.join(rootDir, rel), command), + cleanup: () => rmSync(rootDir, { recursive: true, force: true }), + }; +} diff --git a/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts b/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts index 351ee343bf..a8237d0755 100644 --- a/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts +++ b/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts @@ -88,7 +88,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { // executor must retry the agent session in place rather than bouncing the // task through todo (and must not fire a failure notification). const { store, task, executor } = makeHarness({ column: "todo" }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi .spyOn(executor as any, "execute") .mockResolvedValue(undefined); @@ -137,7 +137,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { // and a retry scheduled); the task then changes state before the timer // fires, and the fire-time re-fetch must abort the dispatch. const { store, task, executor } = makeHarness({ column: "todo" }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); await invokeGraphFailure(executor, task); @@ -164,7 +164,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { status: "failed", error: "Workflow graph failure surfaced after paused engine abort during pause/resume", }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); await invokeGraphFailure(executor, task); @@ -197,7 +197,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { // pause that ended up in todo must stay parked-benign and wait for // explicit resume — auto-resuming it would override the operator's intent. const { store, task, executor } = makeHarness(overrides, provenance); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); await invokeGraphFailure(executor, task); @@ -217,7 +217,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { column: "todo", graphResumeRetryCount: 2, }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); const executeSpy = vi .spyOn(executor as any, "execute") .mockResolvedValue(undefined); @@ -246,7 +246,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => { status: "failed", error: "Workflow graph failure surfaced after paused engine abort during pause/resume", }); - (executor as any).activeWorktrees.set(task.id, task.worktree); + (executor as any).addActiveWorktree(task.id, task.worktree); await invokeGraphFailure(executor, task); diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts index ae9e39343b..c6d0e8bf35 100644 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ b/packages/engine/src/__tests__/executor-recovery.test.ts @@ -605,7 +605,7 @@ describe("TaskExecutor bounded recovery retries", () => { (executor as any).executing.add(taskId); executingTaskLock.tryClaim(taskId); - (executor as any).activeWorktrees.set(taskId, worktreePath); + (executor as any).addActiveWorktree(taskId, worktreePath); (executor as any).activeSessions.set(taskId, { session }); (executor as any).activeStepExecutors.set(taskId, stepExecutor); (executor as any).activeWorkflowStepSessions.set(taskId, workflowSession); @@ -686,7 +686,7 @@ describe("TaskExecutor bounded recovery retries", () => { }); (executor as any).executing.add("FN-001"); executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001"); (executor as any).activeSessions.set("FN-001", { session }); executor.markStuckAborted("FN-001", true); @@ -750,7 +750,7 @@ describe("TaskExecutor bounded recovery retries", () => { vi.mocked(removeWorktree).mockRejectedValue(new Error("worktree busy")); (executor as any).executing.add("FN-001"); executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001"); (executor as any).activeSessions.set("FN-001", { session }); executor.markStuckAborted("FN-001", true); @@ -797,7 +797,7 @@ describe("TaskExecutor bounded recovery retries", () => { vi.mocked(removeWorktree).mockResolvedValue(undefined as any); (executor as any).executing.add("FN-001"); executingTaskLock.tryClaim("FN-001"); - (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001"); (executor as any).activeSessions.set("FN-001", { session }); executor.markStuckAborted("FN-001", true); diff --git a/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts b/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts new file mode 100644 index 0000000000..19d101f8dc --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts @@ -0,0 +1,120 @@ +/* +FNXC:Workspace 2026-06-21-12:00: +U1 session-cwd scenarios that require driving the real TaskExecutor.execute() to the agent-session boundary. Uses the shared executor-test-helpers harness — it mocks the AI/session/git/fs seams (NOT the workspace gating, NOT acquireTaskWorktree), so setting `(executor as any).workspaceConfig` exercises the genuine KTD1 gate: root acquisition is skipped, and every agent session (initial + retry) is created with `cwd === rootDir` (browse-only workspace root). The non-workspace path is the regression control (cwd === the acquired worktree path). +*/ +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import "./executor-test-helpers.js"; +import { TaskExecutor } from "../executor.js"; +import { acquireTaskWorktree } from "../worktree-acquisition.js"; +import type { WorkspaceConfig } from "@fusion/core"; +import { + createMockStore, + mockedCreateFnAgent, + mockedExecSync, + resetExecutorMocks, +} from "./executor-test-helpers.js"; + +vi.mock("../worktree-acquisition.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, acquireTaskWorktree: vi.fn(actual.acquireTaskWorktree) }; +}); + +const mockedAcquireTaskWorktree = vi.mocked(acquireTaskWorktree); + +const ROOT = "/tmp/workspace-root"; + +function inProgressTask(overrides: Record = {}) { + return { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as any; +} + +describe("U1 KTD1 — session cwd is the browse-only workspace root", () => { + beforeEach(() => { + resetExecutorMocks(); + // Make any accidental git invocation observable: empty stdout keeps real-git + // helpers from throwing, but acquireTaskWorktree assertions catch a leak. + mockedExecSync.mockReturnValue(""); + }); + afterEach(() => vi.restoreAllMocks()); + + it("skips root acquireTaskWorktree and creates every session (initial + retry) with cwd === rootDir", async () => { + const store = createMockStore(); + const mockPrompt = vi.fn().mockResolvedValue(undefined); // no fn_task_done → drives retries too + mockedCreateFnAgent.mockResolvedValue({ + session: { prompt: mockPrompt, dispose: vi.fn() }, + sessionFile: "/tmp/sessions/ws.jsonl", + } as any); + + const executor = new TaskExecutor(store, ROOT); + // Drive the genuine workspace gate (loadWorkspaceConfig is covered elsewhere). + (executor as any).workspaceConfig = { repos: ["repo-a", "repo-b"] } as WorkspaceConfig; + + await executor.execute(inProgressTask({ worktree: null })); + + // KTD1: the non-git root is never acquired as a worktree. + expect(mockedAcquireTaskWorktree).not.toHaveBeenCalled(); + + // Every agent session (initial + the retries fired because fn_task_done was + // never called) is rooted at the workspace root. + expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of mockedCreateFnAgent.mock.calls) { + expect((call[0] as any).cwd).toBe(ROOT); + } + + // task.worktree is never set in workspace mode. + const worktreeWrites = (store.updateTask as any).mock.calls.filter( + (c: any[]) => c[1] && Object.prototype.hasOwnProperty.call(c[1], "worktree") && c[1].worktree, + ); + expect(worktreeWrites).toHaveLength(0); + }); +}); + +describe("U1 regression — non-workspace task acquires a worktree and roots the session there", () => { + beforeEach(() => { + resetExecutorMocks(); + mockedExecSync.mockReturnValue(""); + }); + afterEach(() => vi.restoreAllMocks()); + + it("calls acquireTaskWorktree and creates the session with cwd === the acquired worktree path", async () => { + const store = createMockStore(); + const ACQUIRED = "/tmp/test/.worktrees/swift-falcon"; + mockedAcquireTaskWorktree.mockResolvedValue({ + worktreePath: ACQUIRED, + branch: "fusion/fn-001", + source: "fresh", + hydrated: false, + isResume: false, + }); + + const mockPrompt = vi.fn().mockResolvedValue(undefined); + mockedCreateFnAgent.mockResolvedValue({ + session: { prompt: mockPrompt, dispose: vi.fn() }, + sessionFile: "/tmp/sessions/ns.jsonl", + } as any); + + const executor = new TaskExecutor(store, "/tmp/test"); + // No workspaceConfig → single-repo path. Pin the lazy-load guard so the real + // loader is never consulted (it would return null for /tmp/test anyway). + (executor as any).workspaceConfig = null; + + await executor.execute(inProgressTask({ worktree: null })); + + expect(mockedAcquireTaskWorktree).toHaveBeenCalledTimes(1); + expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of mockedCreateFnAgent.mock.calls) { + expect((call[0] as any).cwd).toBe(ACQUIRED); + } + }); +}); diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index 1916b52367..330915e966 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -1,89 +1,220 @@ -// @ts-nocheck -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { loadWorkspaceConfig } from "@fusion/core"; -import { acquireWorkspaceRepoWorktree } from "../worktree-acquisition.js"; +/* +FNXC:Workspace 2026-06-21-12:00: +U1 executor session-scoping tests. REWRITTEN from the foundation's self-mocking version (which vi.mock'd the very functions under test and proved nothing). These tests use a REAL two-repo git fixture (`createWorkspaceFixture`) under a NON-git workspace root, so a leaked rootDir git preflight would actually fail. They drive the real TaskExecutor methods that U1 changed: the activeWorktrees Set conversion + every enumerated consumer (KTD2), the preflight gate + browse-only-root scoping (KTD1), and the synthetic-acquisition cwd. -vi.mock("@fusion/core", async (importOriginal) => { - const actual = await importOriginal(); +Seam choice (FN-5048): `(executor as any).workspaceConfig` is set directly to drive the gating with real git — loadWorkspaceConfig is covered by its own unit and is not the subject here. No mock-the-world child_process/fs shell. +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { loadWorkspaceConfig, type Task, type TaskStore, type WorkspaceConfig } from "@fusion/core"; +import { TaskExecutor, buildExecutionPrompt } from "../executor.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function createStore(overrides: Partial> = {}): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + on: emitter.on.bind(emitter), + ...overrides, + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(id = "FN-WS-1", overrides: Partial = {}): Task { return { - ...actual, - loadWorkspaceConfig: vi.fn(), - }; -}); + id, + title: "Workspace task", + description: "", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} -vi.mock("../worktree-acquisition.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - acquireWorkspaceRepoWorktree: vi.fn(), - }; -}); +const repoAPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-a")}/.worktrees/fn-ws-1`; +const repoBPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-b")}/.worktrees/fn-ws-1`; -const mockedLoadWorkspaceConfig = vi.mocked(loadWorkspaceConfig); -const mockedAcquireWorkspaceRepoWorktree = vi.mocked(acquireWorkspaceRepoWorktree); +describeIfGit("workspace fixture", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); -const MOCK_WORKSPACE_CONFIG = { - repos: ["wolf-server", "wolf-community-frontend-1"], -}; - -describe("acquireWorkspaceRepoWorktree", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns alreadyAcquired=false for a fresh repo", async () => { - mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ - worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", - branch: "fusion/fn-001", - alreadyAcquired: false, - }); - - const result = await acquireWorkspaceRepoWorktree({ - repoRelPath: "wolf-server", - workspaceRootDir: "/workspace", - task: { id: "FN-001", workspaceWorktrees: undefined } as never, - store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, - settings: {}, - }); - - expect(result.alreadyAcquired).toBe(false); - expect(result.worktreePath).toContain("wolf-server"); - }); - - it("returns alreadyAcquired=true when worktree already acquired", async () => { - mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({ - worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", - branch: "fusion/fn-001", - alreadyAcquired: true, - }); - - const result = await acquireWorkspaceRepoWorktree({ - repoRelPath: "wolf-server", - workspaceRootDir: "/workspace", - task: { - id: "FN-001", - workspaceWorktrees: { - "wolf-server": { worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", branch: "fusion/fn-001" }, - }, - } as never, - store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never, - settings: {}, - }); - - expect(result.alreadyAcquired).toBe(true); + it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => { + fx = await createWorkspaceFixture(); + // Root is NOT a git repo. + expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow(); + // Each sub-repo is a real git repo with a commit on main. + expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main"); + expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1"); + // loadWorkspaceConfig resolves the on-disk config the executor keys off. + const config = await loadWorkspaceConfig(fx.rootDir); + expect(config?.repos).toEqual(["repo-a", "repo-b"]); }); }); -describe("workspace config", () => { - it("loadWorkspaceConfig returns null for non-workspace", async () => { - mockedLoadWorkspaceConfig.mockResolvedValueOnce(null); - const config = await loadWorkspaceConfig("/some/single-repo"); - expect(config).toBeNull(); +describeIfGit("U1 KTD2 — activeWorktrees Set + every enumerated consumer", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + function workspaceExecutor() { + fx ??= undefined as never; + const store = createStore(); + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + return executor; + } + + it("a workspace task holding TWO sub-repo paths is found by membership, not equality", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + const pA = repoAPath(fx); + const pB = repoBPath(fx); + (executor as any).addActiveWorktree("FN-WS-1", pA); + (executor as any).addActiveWorktree("FN-WS-1", pB); + + // hasActiveWorktreeBinding: both held paths match; an unheld path does not. + expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pA)).toBe(true); + expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pB)).toBe(true); + expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", "/nope")).toBe(false); + + // findActiveWorktreeOwner: another task asking about either held path finds FN-WS-1. + await expect((executor as any).findActiveWorktreeOwner(pA, "FN-OTHER")).resolves.toBe("FN-WS-1"); + await expect((executor as any).findActiveWorktreeOwner(pB, "FN-OTHER")).resolves.toBe("FN-WS-1"); + // The owner itself is excluded. + await expect((executor as any).findActiveWorktreeOwner(pA, "FN-WS-1")).resolves.toBeNull(); }); - it("loadWorkspaceConfig returns config for workspace", async () => { - mockedLoadWorkspaceConfig.mockResolvedValueOnce(MOCK_WORKSPACE_CONFIG); - const config = await loadWorkspaceConfig("/some/workspace"); - expect(config?.repos).toEqual(["wolf-server", "wolf-community-frontend-1"]); + it("listWorktreeHolders flat-maps the Set into N holder rows for one task", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + const pA = repoAPath(fx); + const pB = repoBPath(fx); + (executor as any).addActiveWorktree("FN-WS-1", pA); + (executor as any).addActiveWorktree("FN-WS-1", pB); + + const holders = executor.listWorktreeHolders(); + expect(holders).toHaveLength(2); + expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pA }); + expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pB }); + }); + + it("shouldGenerateNewWorktreeName iterates the Set (conflict membership)", async () => { + fx = await createWorkspaceFixture(); + const store = createStore({ listTasks: vi.fn().mockResolvedValue([]) }); + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + const pA = repoAPath(fx); + (executor as any).addActiveWorktree("FN-HOLDER", pA); + + // A different task contending for FN-HOLDER's path must be told to generate a new name. + await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-WS-1")).resolves.toBe(true); + // The holder asking about its own path is not a conflict (excluded), and the + // DB liveness fallback returns no other user. + await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-HOLDER")).resolves.toBe(false); + }); + + it("getWorktreePath returns undefined for a multi-worktree workspace task (Set-collapse contract)", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + (executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx)); + (executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx)); + expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined(); + }); + + it("cleanup drops in-memory tracking in workspace mode but never removes the root", async () => { + fx = await createWorkspaceFixture(); + const removeSpy = vi.fn(); + const executor = workspaceExecutor(); + (executor as any).removeOwnWorktreeWithReconcile = removeSpy; + (executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx)); + (executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx)); + + await executor.cleanup("FN-WS-1"); + + expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined(); + expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false); + // The browse-only root must never be torn down as if it were a worktree. + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it("clearPhantomExecutorBinding (FN-6736) unregisters every held path, not one", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(); + const pA = repoAPath(fx); + const pB = repoBPath(fx); + (executor as any).addActiveWorktree("FN-WS-1", pA); + (executor as any).addActiveWorktree("FN-WS-1", pB); + + const ok = (executor as any).clearPhantomExecutorBinding("FN-WS-1"); + expect(ok).toBe(true); + expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false); + }); +}); + +describeIfGit("U1 KTD2 — non-workspace task is a one-element Set (regression: unchanged)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("getWorktreePath returns the sole path; listWorktreeHolders emits exactly one row", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(); + const executor = new TaskExecutor(store, fx.repoPath("repo-a")); // single-repo root + // No workspaceConfig set → single-repo mode. + const wt = `${fx.repoPath("repo-a")}/.worktrees/fn-001`; + (executor as any).addActiveWorktree("FN-001", wt); + + expect(executor.getWorktreePath("FN-001")).toBe(wt); + expect(executor.listWorktreeHolders()).toEqual([{ taskId: "FN-001", worktreePath: wt }]); + expect((executor as any).hasActiveWorktreeBinding("FN-001", wt)).toBe(true); + }); +}); + +describeIfGit("U1 KTD1 — verifyWorktreeInvariants gated off in workspace mode", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("returns ok for a zero-acquire workspace task (no task.worktree) so fn_task_done does not requeue", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(); + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + + // A workspace task that acquired ZERO sub-repos has no task.worktree and no + // tracked paths. The singular invariant would otherwise refuse on + // "missing task.worktree"; in workspace mode it is gated OFF. + const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-WS-1", { worktree: undefined })); + expect(result).toEqual({ ok: true }); + }); + + it("non-workspace task with no worktree still fails the invariant (regression: gate is workspace-only)", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(); + const executor = new TaskExecutor(store, fx.repoPath("repo-a")); + // No workspaceConfig. + const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-001", { worktree: undefined })); + expect(result.ok).toBe(false); + }); +}); + +describeIfGit("U1 KTD1 — scopePromptToWorktree / buildExecutionPrompt no-op in workspace mode", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("does not rewrite root-anchored paths when a workspace config is present", async () => { + fx = await createWorkspaceFixture(); + const task = makeTask("FN-WS-1", { prompt: `Edit ${fx.rootDir}/repo-a/src/index.ts and commit.` }); + const config: WorkspaceConfig = { repos: fx.repos }; + // worktreePath === rootDir in workspace mode; the prompt must be returned verbatim. + const prompt = buildExecutionPrompt(task as any, fx.rootDir, { autoMerge: false } as any, fx.rootDir, undefined, undefined, config); + expect(prompt).toContain(`${fx.rootDir}/repo-a/src/index.ts`); + // The workspace repo list is appended (foundation behavior). + expect(prompt).toContain("repo-a"); }); }); diff --git a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts index c2714fda0b..adbc691c86 100644 --- a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts +++ b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts @@ -39,7 +39,7 @@ describe("FN-4973: executor worktree conflict cleanup", () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); store.listTasks.mockResolvedValue([]); - (executor as any).activeWorktrees.set("FN-4973", CONFLICT_PATH); + (executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH); activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" }); vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( diff --git a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts index 4889abc25d..d35c55ff7f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts @@ -58,7 +58,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("returns the owner taskId when activeWorktrees has another task using the path", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH); const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811"); expect(owner).toBe("FN-OTHER"); @@ -67,7 +67,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("returns null when activeWorktrees only has the requesting task at the path", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-4811", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-4811", ACTIVE_PATH); store.listTasks.mockResolvedValue([]); const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811"); @@ -125,7 +125,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("refuses removal when worktree is in activeWorktrees for another task", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH); store.listTasks.mockResolvedValue([]); const result = await (executor as any).cleanupConflictingWorktree( @@ -226,7 +226,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { it("returns 'sticky' without invoking inspection when conflict path is actively owned", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set("FN-OWNER", ACTIVE_PATH); + (executor as any).addActiveWorktree("FN-OWNER", ACTIVE_PATH); store.listTasks.mockResolvedValue([]); const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict"); diff --git a/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts index abe29b5b53..3717a9e355 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts @@ -23,7 +23,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin it("reconciles stale same-task registry entry during cleanup()", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); (activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0; const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); @@ -56,7 +56,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin it("preserves refusal for truly-live same-task bindings", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( new ActiveSessionWorktreeRemovalError({ @@ -82,7 +82,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin const store = createMockStore(); store.listTasks.mockResolvedValue([]); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set("FN-FOREIGN", PATH); + (executor as any).addActiveWorktree("FN-FOREIGN", PATH); activeSessionRegistry.registerPath(PATH, { taskId: "FN-FOREIGN", kind: "executor", ownerKey: "FN-FOREIGN" }); const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); @@ -96,13 +96,13 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin it("is idempotent across repeated cleanup sweeps", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); (activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0; const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); await executor.cleanup(TASK_ID); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); await executor.cleanup(TASK_ID); const clearedCalls = (store.logEntry as any).mock.calls.filter( @@ -120,7 +120,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set(TASK_ID, PATH); + (executor as any).addActiveWorktree(TASK_ID, PATH); activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined); diff --git a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts index f555c81d5f..9ee54c545f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts @@ -118,7 +118,7 @@ describe("FN-4973 reliability interactions: stale self-owned active-session reco const store = createMockStore(); store.listTasks.mockResolvedValue([]); const executor = new TaskExecutor(store, "/tmp/test"); - (executor as any).activeWorktrees.set(TASK_ID, CONFLICT_PATH); + (executor as any).addActiveWorktree(TASK_ID, CONFLICT_PATH); activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID }); vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( diff --git a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts index 91e77b0ac4..869d6303bf 100644 --- a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts @@ -45,7 +45,7 @@ describe("FN-4976: stale self-owned activeSessionRegistry deadlock backstop", () it("FN-4976 does not clear foreign-owned activeSessionRegistry entry and FN-4811 refusal still fires", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, ROOT); - (executor as any).activeWorktrees.set("FN-OTHER", PATH); + (executor as any).addActiveWorktree("FN-OTHER", PATH); store.listTasks.mockResolvedValue([]); activeSessionRegistry.registerPath(PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" }); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index de96d138d0..1f7c91769b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -143,7 +143,7 @@ import { isContextLimitError } from "./context-limit-detector.js"; import { StepSessionExecutor } from "./step-session-executor.js"; import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js"; // FNXC:MergerUnification 2026-06-21-19:05: the foundation branch imported `acquireWorkspaceRepoWorktree` here but never used it in executor.ts (the agent tool wraps it via agent-tools.ts), which fails lint on the inherited base. Removed until master-plan U1 re-adds it together with its per-repo acquisition usage. -import { acquireTaskWorktree } from "./worktree-acquisition.js"; +import { acquireTaskWorktree, type AcquireTaskWorktreeResult } from "./worktree-acquisition.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; import { @@ -1465,7 +1465,28 @@ interface ActiveExecutorSessionState { } export class TaskExecutor { - private activeWorktrees = new Map(); + /* + FNXC:Workspace 2026-06-21-12:00: + activeWorktrees tracks the worktree paths a task currently holds for liveness/owner checks. In workspace mode a single task acquires N sub-repo worktrees (foundation `task.workspaceWorktrees`), so the value is a SET of paths, not one path. A non-workspace (single-repo) task holds a one-element set — every consumer is converted to membership semantics so the single-repo path is byte-for-byte unchanged (KTD2). Helpers below add/remove/iterate the set. + */ + private activeWorktrees = new Map>(); + + /** + * FNXC:Workspace 2026-06-21-12:00: Register a worktree path under a task's active set, creating the set on first add (KTD2). Single-repo tasks call this once → one-element set. + */ + private addActiveWorktree(taskId: string, worktreePath: string): void { + const set = this.activeWorktrees.get(taskId) ?? new Set(); + set.add(worktreePath); + this.activeWorktrees.set(taskId, set); + } + + /** + * FNXC:Workspace 2026-06-21-12:00: Read-only snapshot of every worktree path a task currently holds (KTD2). Empty when the task holds none. + */ + private getActiveWorktreePaths(taskId: string): string[] { + const set = this.activeWorktrees.get(taskId); + return set ? Array.from(set) : []; + } private executing = new Set(); /** Tasks currently being prepared for unpause resume, before execute() has registered them. */ private resumingUnpaused = new Set(); @@ -1583,9 +1604,10 @@ export class TaskExecutor { this.activeSessions.delete(taskId); // U5: drop the effective column-agent principal for this task's session. this.effectiveColumnAgentByTask.delete(taskId); - const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); - if (resolvedWorktreePath) { - activeSessionRegistry.unregisterPath(resolvedWorktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — when no explicit path is given, unregister EVERY worktree path the task holds (a workspace task holds N sub-repo paths); single-repo tasks resolve a one-element set. + const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId); + for (const path of resolvedWorktreePaths) { + activeSessionRegistry.unregisterPath(path); } } @@ -1600,9 +1622,10 @@ export class TaskExecutor { this.activeStepExecutorSeenSteeringIds.delete(taskId); // U5: drop the effective column-agent principal for this task's step session. this.effectiveColumnAgentByTask.delete(taskId); - const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); - if (resolvedWorktreePath) { - activeSessionRegistry.unregisterPath(resolvedWorktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one. + const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId); + for (const path of resolvedWorktreePaths) { + activeSessionRegistry.unregisterPath(path); } } @@ -1615,9 +1638,10 @@ export class TaskExecutor { private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void { this.activeWorkflowStepSessions.delete(taskId); this.activeWorkflowStepSessionSeenSteeringIds.delete(taskId); - const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); - if (resolvedWorktreePath) { - activeSessionRegistry.unregisterPath(resolvedWorktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one. + const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId); + for (const path of resolvedWorktreePaths) { + activeSessionRegistry.unregisterPath(path); } } @@ -2053,7 +2077,8 @@ export class TaskExecutor { return false; } - const worktreePath = this.activeWorktrees.get(taskId); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — collect every worktree path the task holds (a workspace task holds N) before clearing the binding, so the registry sweep below unregisters all of them, not just one. + const heldWorktreePaths = this.getActiveWorktreePaths(taskId); this.activeWorktrees.delete(taskId); this.executing.delete(taskId); this.recoveringCompleted.delete(taskId); @@ -2063,8 +2088,8 @@ export class TaskExecutor { this.effectiveColumnAgentByTask.delete(taskId); const registeredPaths = new Set(activeSessionRegistry.pathsForTask(taskId)); - if (worktreePath) { - registeredPaths.add(worktreePath); + for (const path of heldWorktreePaths) { + registeredPaths.add(path); } for (const path of registeredPaths) { activeSessionRegistry.unregisterPath(path); @@ -7430,7 +7455,19 @@ export class TaskExecutor { const hadAssignedWorktree = Boolean(task.worktree); const taskCommandAbortController = new AbortController(); this.registerConfiguredCommandController(task.id, taskCommandAbortController); - const acquisition = await (async () => { + /* + FNXC:Workspace 2026-06-21-12:00: + KTD1 — in workspace mode `this.rootDir` is a NON-git parent. Acquiring a root worktree there fails. Skip root acquisition entirely and run the agent session rooted at the browse-only workspace root; the agent acquires per-sub-repo worktrees on demand via fn_acquire_repo_worktree. `task.worktree` stays unset. We synthesize a non-fresh, non-resume acquisition with an empty branch so the downstream env-injection/onStart bookkeeping runs unchanged while every rootDir git preflight (base capture, contamination, liveness) is gated off below. The non-workspace branch is byte-for-byte the original acquisition path. + */ + const acquisition: AcquireTaskWorktreeResult = this.workspaceConfig + ? { + worktreePath: this.rootDir, + branch: "", + source: "existing", + hydrated: true, + isResume: Boolean(task.sessionFile), + } + : await (async () => { try { return await acquireTaskWorktree({ task, @@ -7520,6 +7557,11 @@ export class TaskExecutor { } } + /* + FNXC:Workspace 2026-06-21-12:00: + KTD1 — every preflight below (base-commit capture, contamination check, worktree-liveness gate) runs git against `worktreePath`, which equals the non-git workspace root in workspace mode. They would all fail. Gate the whole block off in workspace mode; the per-repo equivalents return in Phase B (master U3) against each acquired sub-repo worktree. The non-workspace branch is unchanged. + */ + if (!this.workspaceConfig) { // Capture the base commit SHA for diff computation whenever a task // starts with a newly assigned worktree. if (!acquisition.isResume) { @@ -7664,8 +7706,10 @@ export class TaskExecutor { this.options.onError?.(task, new Error(failureMessage)); return; } + } // end !this.workspaceConfig preflight gate (FNXC:Workspace KTD1) - this.activeWorktrees.set(task.id, worktreePath); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — register the worktree path under the task's Set. In workspace mode `worktreePath` is the browse-only root; per-repo paths are added as the agent acquires them. Non-workspace tasks add exactly one path → a one-element set (unchanged liveness/owner semantics). + this.addActiveWorktree(task.id, worktreePath); executorLog.log(`${task.id}: worktree ready at ${worktreePath}`); const injected = await this.buildInjectedRuntimeEnv(task.id, worktreePath, acquisition.branch ?? undefined); @@ -10457,8 +10501,13 @@ export class TaskExecutor { options?: { noOpCompletion?: boolean; noOpCompletionReason?: string }, ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> { const settings = await this.store.getSettings(); + // FNXC:Workspace 2026-06-21-12:00: KTD1/KTD2 — workspace tasks have no root worktree and no single `task.worktree`; the singular per-task invariant is meaningless against the non-git root. Phase B (master U3) iterates this check per sub-repo worktree. Until then it is gated OFF in workspace mode so fn_task_done (its only caller path) does not requeue a zero-acquire workspace task for "missing task.worktree". + if (this.workspaceConfig) { + return { ok: true }; + } const branchName = resolveTaskWorkingBranch(task); - const worktreePath = worktreePathOverride ?? task.worktree ?? this.activeWorktrees.get(task.id) ?? null; + // Non-workspace tasks hold a one-element set; fall back to its sole member to preserve the original singular resolution. + const worktreePath = worktreePathOverride ?? task.worktree ?? this.getActiveWorktreePaths(task.id)[0] ?? null; if (!worktreePath) { return { @@ -14440,9 +14489,9 @@ You have access to the file system to review changes.${verdictBlock}`; conflictPath: string, currentTaskId: string, ): Promise { - // Check if conflicting worktree is in our active set - for (const [taskId, worktreePath] of this.activeWorktrees) { - if (taskId !== currentTaskId && worktreePath === conflictPath) { + // FNXC:Workspace 2026-06-21-12:00: KTD2 — a task may hold N worktree paths; the conflict check is membership across the set, not equality on a single path. + for (const [taskId, worktreePaths] of this.activeWorktrees) { + if (taskId !== currentTaskId && worktreePaths.has(conflictPath)) { return true; } } @@ -14479,8 +14528,11 @@ You have access to the file system to review changes.${verdictBlock}`; */ listWorktreeHolders(): Array<{ taskId: string; worktreePath: string }> { const holders: Array<{ taskId: string; worktreePath: string }> = []; - for (const [taskId, worktreePath] of this.activeWorktrees) { - holders.push({ taskId, worktreePath }); + // FNXC:Workspace 2026-06-21-12:00: KTD2 — flat-map each task's Set into one holder row per worktree path. A workspace task emits N rows; the FN-6782 reaper (self-healing.ts) and in-process-runtime adapter key purely off taskId (verified) and are idempotent across duplicate-task rows, so multi-row holders do not mis-count maxWorktrees slots. + for (const [taskId, worktreePaths] of this.activeWorktrees) { + for (const worktreePath of worktreePaths) { + holders.push({ taskId, worktreePath }); + } } return holders; } @@ -14489,8 +14541,9 @@ You have access to the file system to review changes.${verdictBlock}`; worktreePath: string, requestingTaskId: string, ): Promise { - for (const [taskId, path] of this.activeWorktrees) { - if (taskId !== requestingTaskId && path === worktreePath) { + // FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set (a workspace task holds N). + for (const [taskId, paths] of this.activeWorktrees) { + if (taskId !== requestingTaskId && paths.has(worktreePath)) { return taskId; } } @@ -14516,12 +14569,9 @@ You have access to the file system to review changes.${verdictBlock}`; * Returns true if cleanup succeeded. */ private hasActiveWorktreeBinding(taskId: string, worktreePath: string): boolean { - for (const [activeTaskId, activePath] of this.activeWorktrees) { - if (activeTaskId === taskId && activePath === worktreePath) { - return true; - } - } - return false; + // FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set. + const paths = this.activeWorktrees.get(taskId); + return paths ? paths.has(worktreePath) : false; } private async reconcileSelfOwnedBeforeRemove(worktreePath: string, taskId: string): Promise { @@ -14919,11 +14969,18 @@ You have access to the file system to review changes.${verdictBlock}`; * always cleaned up by the merger on a per-task basis. */ async cleanup(taskId: string): Promise { - const worktreePath = this.activeWorktrees.get(taskId); - if (!worktreePath) return; + const worktreePaths = this.getActiveWorktreePaths(taskId); + if (worktreePaths.length === 0) return; this.activeWorktrees.delete(taskId); + // FNXC:Workspace 2026-06-21-12:00: KTD1 — in workspace mode the tracked path is the non-git workspace root (browse-only), never a removable worktree. Drop the in-memory tracking above but never remove the root. Per-repo worktree teardown returns in Phase B. + if (this.workspaceConfig) { + return; + } + // Non-workspace tasks hold a one-element set — preserve the original single-path removal semantics. + const worktreePath = worktreePaths[0]; + // Check if another task still needs this worktree const otherUser = await findWorktreeUser(this.store, worktreePath, taskId); if (otherUser) { @@ -15420,8 +15477,14 @@ You have access to the file system to review changes.${verdictBlock}`; return true; } + /** + * FNXC:Workspace 2026-06-21-12:00: KTD2 single-path-getter contract. Returns the task's sole worktree path for single-repo tasks (one-element set). For a multi-worktree workspace task there is no single answer — callers must read the per-repo `task.workspaceWorktrees` entry instead — so this returns undefined. A workspace task tracked only at the browse-only root also returns undefined, matching the "no removable single worktree" semantics. + */ getWorktreePath(taskId: string): string | undefined { - return this.activeWorktrees.get(taskId); + if (this.workspaceConfig) { + return undefined; + } + return this.getActiveWorktreePaths(taskId)[0]; } // ── Agent Spawning ───────────────────────────────────────────────────── @@ -15721,7 +15784,11 @@ function formatTimestamp(iso: string): string { // Project commands are injected here (for reliability) and also in the PROMPT.md (by triage). // This ensures the executor agent always sees the authoritative commands from settings, // even if the PROMPT.md was written manually or before commands were configured. -function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string): string { +function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string, workspaceConfig?: WorkspaceConfig | null): string { + // FNXC:Workspace 2026-06-21-12:00: KTD1 — in workspace mode the session is rooted at the workspace root itself (worktreePath === rootDir) and path rewriting to a per-task root worktree is meaningless: edits happen in per-sub-repo worktrees the agent acquires, not at the root. No-op the rewrite. (The rootDir === worktreePath guard below already covers this, but gate explicitly so intent survives future refactors.) + if (workspaceConfig) { + return prompt; + } if (!rootDir || !worktreePath || rootDir === worktreePath || !prompt.includes(rootDir)) { return prompt; } @@ -15755,7 +15822,7 @@ export function buildExecutionPrompt( customFieldDefs?: WorkflowFieldDefinition[], workspaceConfig?: WorkspaceConfig | null, ): string { - const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath); + const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath, workspaceConfig); const reviewLevel = parseReviewLevelFromPrompt(prompt); // Build co-author trailer arg for git commits based on settings. The user's From 023e4b057dd56f26c32082422d892820fd01c5b0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:07:20 -0700 Subject: [PATCH 17/44] =?UTF-8?q?feat(workspace):=20Phase=20A=20U3=20?= =?UTF-8?q?=E2=80=94=20dashboard=20"doesn't=20look=20broken"=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace tasks (no task.worktree, populated workspaceWorktrees) now render in existing task views without crashing or going blank. New read-only WorkspaceWorktreesSummary component (placeholder "N repos acquired" + a flat repo→worktree/branch list — within the "doesn't look broken" ceiling, not a rich status UI); TaskCard and TaskDetailModal nil-guard on isWorkspaceTask. Single-repo rendering unchanged. CONCEPTS.md notes workspace-task merges are non-atomic (repos land independently on local integration refs; partial-land is local and operator-resettable). Tests 8/8; TaskCard regression 251/251. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-dashboard-floor.md | 8 ++ CONCEPTS.md | 4 + .../dashboard/app/components/TaskCard.tsx | 9 ++ .../app/components/TaskDetailModal.css | 36 ++++++++ .../app/components/TaskDetailModal.tsx | 5 + .../components/WorkspaceWorktreesSummary.tsx | 92 +++++++++++++++++++ .../WorkspaceWorktreesSummary.test.tsx | 89 ++++++++++++++++++ 7 files changed, 243 insertions(+) create mode 100644 .changeset/workspace-dashboard-floor.md create mode 100644 packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx diff --git a/.changeset/workspace-dashboard-floor.md b/.changeset/workspace-dashboard-floor.md new file mode 100644 index 0000000000..47db598dea --- /dev/null +++ b/.changeset/workspace-dashboard-floor.md @@ -0,0 +1,8 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace tasks no longer render blank in the dashboard. Task cards and the task +detail view now surface a workspace task's acquired per-sub-repo worktrees as a +read-only "N repos acquired" placeholder and flat repo → worktree/branch list, +instead of an empty branch area (no `task.worktree`/`task.branch`). diff --git a/CONCEPTS.md b/CONCEPTS.md index 1fccc91371..e80ef40df8 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -61,6 +61,10 @@ sub-directories. Fusion discovers sub-repos at init time and records them in single root-level worktree; instead, the agent acquires per-repo worktrees on demand via `fn_acquire_repo_worktree`. +Workspace-task merges are **non-atomic**: each sub-repo lands on its own local +integration ref independently, so a partial-land window (some sub-repos merged, +others not) is possible mid-task — this state is local and operator-resettable. + ### Project Identity The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement. diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 3e5898ec52..46748eb497 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -35,6 +35,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ". import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import { useRetryWarning } from "../context/RetryWarningContext"; import { useColumnLabel } from "../i18n/labels"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; /** Per-branch progress snapshot (U13). Surfaced as an optional additive field * on the task payload for the parallel-window badge (U9). */ @@ -625,6 +626,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.blockedBy === nextTask.blockedBy && previousTask.overlapBlockedBy === nextTask.overlapBlockedBy && previousTask.worktree === nextTask.worktree && + // FNXC:Workspace 2026-06-21-00:00: re-render the card when a workspace task acquires/ + // releases sub-repo worktrees so the "N repos acquired" placeholder stays current (U3). + Object.keys(previousTask.workspaceWorktrees ?? {}).length === + Object.keys(nextTask.workspaceWorktrees ?? {}).length && previousTask.branch === nextTask.branch && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && @@ -2186,6 +2191,10 @@ function TaskCardComponent({
); })()} + {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.branch, + so the branch-metadata row below renders nothing. Surface the acquired sub-repos + as a compact "N repos acquired" placeholder so the card isn't blank (U3/KTD5). */} + {isWorkspaceTask(task) && } {hasBranchMetadata && (
{branchMetadata.branch && ( diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 18c1cf0968..2b372606da 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -2209,3 +2209,39 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a color: var(--color-error); font-size: 0.75rem; } + +/* +FNXC:Workspace 2026-06-21-00:00: +Flat read-only per-sub-repo worktree list for a workspace task (U3/KTD5 dashboard floor). +Read-only list/placeholder only — not the deferred rich per-repo-status component. +*/ +.workspace-worktrees-summary { + margin: var(--space-sm) 0 0; +} +.workspace-worktrees-placeholder { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary, inherit); + margin-bottom: var(--space-xs); +} +.workspace-worktrees-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} +.workspace-worktrees-item { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-sm); + font-size: 0.75rem; + font-family: var(--font-mono, monospace); +} +.workspace-worktrees-repo { + font-weight: 600; +} +.workspace-worktrees-branch { + color: var(--color-text-secondary, inherit); +} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 90b7546338..6432ed838f 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -39,6 +39,7 @@ import { TaskChatTab } from "./TaskChatTab"; import { TaskReviewTab } from "./TaskReviewTab"; import { MergeDetails } from "./MergeDetails"; import { TaskChangesTab } from "./TaskChangesTab"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; import { TaskForm, type PendingImage } from "./TaskForm"; import { useNodes } from "../hooks/useNodes"; import { WorkflowResultsTab } from "./WorkflowResultsTab"; @@ -3065,6 +3066,10 @@ export function TaskDetailContent({ {task.branchContext?.groupId && ( )} + {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular + task.worktree/task.branch; surface their acquired per-sub-repo worktrees + as a flat read-only list so the detail view isn't blank (U3/KTD5). */} + {isWorkspaceTask(task) && } )} {task.status === "failed" && task.error && ( diff --git a/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx new file mode 100644 index 0000000000..90625a96eb --- /dev/null +++ b/packages/dashboard/app/components/WorkspaceWorktreesSummary.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from "react-i18next"; +import type { Task } from "@fusion/core"; + +/* +FNXC:Workspace 2026-06-21-00:00: +Dashboard "doesn't look broken" floor (Phase A U3 / master U10, KTD5). +A workspace-mode task has NO singular `task.worktree`/`task.branch`; instead it carries +`task.workspaceWorktrees` — one acquired git worktree per sub-repo, keyed by repo path +relative to the workspace root. Existing display surfaces (TaskCard branch row, TaskDetail +metadata) key off the singular `task.branch`, so a workspace task would render an EMPTY +branch area — looking broken. This guard renders a static placeholder ("N repos acquired") +plus a flat read-only per-repo path/branch list so the task is observable, never crashing +and never blank. + +Scope ceiling: flat read-only list / placeholder ONLY. A rich per-repo-status component +(live diff/lease/merge state per repo) is the deferred registration UI — out of scope here. +Single-repo rendering is untouched: callers only mount this when `isWorkspaceTask(task)`. +*/ + +/** + * True when the task is a workspace-mode task: no singular `worktree` recorded + * and at least one acquired per-sub-repo worktree in `workspaceWorktrees`. + * Single-repo tasks (populated `worktree`, no `workspaceWorktrees`) return false, + * keeping their existing rendering byte-for-byte unchanged. + */ +export function isWorkspaceTask(task: Pick): boolean { + if (task.worktree) return false; + const entries = task.workspaceWorktrees; + return Boolean(entries && Object.keys(entries).length > 0); +} + +interface WorkspaceWorktreesSummaryProps { + task: Pick; + /** Compact variant for the dense TaskCard surface (placeholder only). */ + compact?: boolean; +} + +/** + * Read-only summary of a workspace task's acquired sub-repo worktrees. + * + * - `compact` (TaskCard): renders just the "N repos acquired" placeholder chip. + * - default (TaskDetail): renders the placeholder plus a flat per-repo list of + * `repo → worktreePath (branch)`. + * + * Renders nothing for non-workspace tasks; mount only behind `isWorkspaceTask`. + */ +export function WorkspaceWorktreesSummary({ task, compact = false }: WorkspaceWorktreesSummaryProps) { + const { t } = useTranslation("app"); + const entries = task.workspaceWorktrees; + if (!isWorkspaceTask(task) || !entries) return null; + + const repos = Object.entries(entries); + const placeholder = t("tasks.workspaceReposAcquired", "{{count}} repos acquired", { count: repos.length }); + + if (compact) { + return ( +
+ + {t("tasks.workspace", "Workspace")} + {placeholder} + +
+ ); + } + + return ( +
+
+ {placeholder} +
+
    + {repos.map(([repoRelPath, info]) => ( +
  • + + {repoRelPath} + + + {info.worktreePath} + + + {info.branch} + +
  • + ))} +
+
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx new file mode 100644 index 0000000000..dfd23f3875 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkspaceWorktreesSummary.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { WorkspaceWorktreesSummary, isWorkspaceTask } from "../WorkspaceWorktreesSummary"; + +/* +FNXC:Workspace 2026-06-21-00:00: +U3/KTD5 dashboard "doesn't look broken" floor. Asserts the invariant across both surfaces +the summary serves (FN-5893): +- happy path: workspace task (no task.worktree, two workspaceWorktrees entries) renders a + flat per-repo list + "N repos acquired" placeholder — no crash, not blank. +- regression: single-repo task (task.worktree set, no workspaceWorktrees) renders nothing + from this guard, so its existing rendering stays unchanged. +Narrow seam: tests the presentational component directly, no API / SSE / timers (FN-5048). +*/ + +const workspaceTask = { + worktree: undefined, + workspaceWorktrees: { + "repo-a": { worktreePath: "/wt/repo-a", branch: "fusion/fn-1-a" }, + "repo-b": { worktreePath: "/wt/repo-b", branch: "fusion/fn-1-b" }, + }, +} as const; + +const singleRepoTask = { + worktree: "/wt/single", + workspaceWorktrees: undefined, +} as const; + +describe("isWorkspaceTask", () => { + it("is true when worktree is absent and workspaceWorktrees has entries", () => { + expect(isWorkspaceTask(workspaceTask)).toBe(true); + }); + + it("is false for a single-repo task (worktree set)", () => { + expect(isWorkspaceTask(singleRepoTask)).toBe(false); + }); + + it("is false when workspaceWorktrees is an empty record", () => { + expect(isWorkspaceTask({ worktree: undefined, workspaceWorktrees: {} })).toBe(false); + }); + + it("prefers the singular worktree even if workspaceWorktrees is populated", () => { + expect( + isWorkspaceTask({ worktree: "/wt/x", workspaceWorktrees: workspaceTask.workspaceWorktrees }), + ).toBe(false); + }); +}); + +describe("WorkspaceWorktreesSummary", () => { + it("renders a flat per-repo list and placeholder for a two-repo workspace task (no crash, not empty)", () => { + render(); + + // Placeholder reflects the repo count. + expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2"); + expect(screen.getByText(/2 repos acquired/i)).toBeTruthy(); + + // Flat per-repo list: each repo path, worktree path, and branch is shown. + const summary = screen.getByTestId("workspace-worktrees-summary"); + expect(summary).toBeTruthy(); + expect(screen.getByText("repo-a")).toBeTruthy(); + expect(screen.getByText("repo-b")).toBeTruthy(); + expect(screen.getByText("/wt/repo-a")).toBeTruthy(); + expect(screen.getByText("/wt/repo-b")).toBeTruthy(); + expect(screen.getByText("fusion/fn-1-a")).toBeTruthy(); + expect(screen.getByText("fusion/fn-1-b")).toBeTruthy(); + }); + + it("renders only the compact placeholder in compact mode", () => { + render(); + expect(screen.getByTestId("workspace-worktrees-placeholder").textContent).toContain("2 repos"); + // Compact variant omits the full per-repo list. + expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull(); + expect(screen.queryByText("/wt/repo-a")).toBeNull(); + }); + + it("renders nothing for a single-repo task, leaving existing rendering unchanged", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId("workspace-worktrees-summary")).toBeNull(); + expect(screen.queryByTestId("workspace-worktrees-placeholder")).toBeNull(); + }); + + it("renders nothing when workspaceWorktrees is empty", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); +}); From 1fa3691f1f369f636e59fef4798a6afcca619c04 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:07:32 -0700 Subject: [PATCH 18/44] docs(workspace): Phase A implementation plan (U1/U2/U10) --- ...6-06-21-004-feat-workspace-phase-a-plan.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md diff --git a/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md b/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md new file mode 100644 index 0000000000..f8b4220cb6 --- /dev/null +++ b/docs/plans/2026-06-21-004-feat-workspace-phase-a-plan.md @@ -0,0 +1,176 @@ +--- +title: "feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase A / U1·U2·U10) +depth: deep +--- + +# feat: Workspace mode Phase A — session scoping, per-repo acquisition, dashboard floor + +> **ID namespace:** the `U1·U2·U3` below are **local to this Phase-A plan**. They decompose master-plan **U1, U2, U10** (a separate namespace). "Master-plan U6/U8" references point at the master plan, not these IDs. + +## Summary + +Phase A of the workspace-mode master plan: make a workspace task **run** (acquire → browse → edit per sub-repo), short of capture/review/merge (Phases B–D). Three units: (U1) executor session scoping so the session roots at the non-git workspace root and edits happen only in per-repo worktrees; (U2) per-repo acquisition hardening (identity guard, per-repo base SHA against the resolved integration branch, same-sub-repo exclusivity); (U3 = master U10) a dashboard "doesn't look broken" floor. + +Builds on the **foundation** (PR #1710 — `task.workspaceWorktrees`, `fn_acquire_repo_worktree`, `acquireWorkspaceRepoWorktree`) + **U0** (PR #1711 — `runAiMerge` sole merge path, R7 guard). Settled design: **D2/D3/D5 — land-as-you-go on each repo's LOCAL integration ref** (no remote push), session-time coherence accepted. The R7 merge-boundary guard already exists at the merge chokepoint (U0); U1 must not route around it. + +**Scope out:** capture/contamination/review (master U3/U4 = Phase B), the per-repo merge loop (master U6 = Phase C), self-healing reconcilers (master U8 = Phase D). + +**Stacking:** this branch is off the U0 branch, so the PR diff includes foundation + U0 + Phase A and **must not merge until #1710/#1711 land**. + +--- + +## Problem Frame + +In workspace mode `rootDir` is a **non-git** parent. On the current base the executor still, for every task: acquires one root worktree at `executor.ts:~7430` (`acquireTaskWorktree({rootDir})`), runs preflights (`resolveContaminationBaseRef`, `captureBaseCommitSha`, identity-guard install, `verifyWorktreeInvariants`) against that path, binds the agent session cwd to it, and tracks `activeWorktrees: Map`. Against a non-git root, the root acquisition and every git preflight fail. The foundation gave the agent `fn_acquire_repo_worktree` (per-repo worktrees on demand) but nothing in the executor lifecycle skips the root path or hardens per-repo acquisition. Phase A closes that gap for the **run** stage. + +--- + +## Key Technical Decisions + +### KTD1 — Skip root acquisition + all rootDir preflights; session cwd = workspace root (master KTD1) +When `this.workspaceConfig` is present: skip `acquireTaskWorktree({rootDir})` and gate each intervening preflight so none runs git against the non-git root; set session cwd = `this.rootDir` (browse-only); do not set `task.worktree`; `scopePromptToWorktree` is a no-op. The non-workspace path stays byte-for-byte unchanged (branch on `workspaceConfig`). + +### KTD2 — `activeWorktrees` becomes `taskId → Set` (master KTD1) — VERIFIED consumer list +A workspace task holds N sub-repo worktrees; liveness/owner checks must see all of them. Convert the map and update **every** consumer to membership semantics. The complete, code-verified consumer set (feasibility-checked — the earlier draft mislabeled these): +- **Membership / owner checks:** `findActiveWorktreeOwner` (`:14491`), `hasActiveWorktreeBinding` (`:14518`), the FN-6736 phantom-binding reclaim (`~:2055`). +- **`listWorktreeHolders` (`:14480`)** — emits one `{taskId, worktreePath}` per entry; consumed by the **FN-6782 leaked-slot reaper** (`self-healing.ts:~8310`) and `in-process-runtime.ts:~791`. A workspace task must **flat-map its Set into N holder rows**, or `maxWorktrees`-slot accounting under-counts and leaks/mis-reaps. Verify the reaper math against multi-row holders. +- **Single-path getters — define the Set-collapse contract (KTD-decision):** `getWorktreePath(taskId): string|undefined` (`:15424`), the `verifyWorktreeInvariants` resolution `?? this.activeWorktrees.get(task.id)` (`:10461`), and the conflict-set iteration (`~:14444`, `worktreePath === conflictPath`). **Contract:** for a workspace task these single-path consumers operate per-sub-repo (the caller already has the repo/path in context) — `getWorktreePath` returns `undefined` for a multi-worktree workspace task (callers must use the per-repo `workspaceWorktrees` entry), and `verifyWorktreeInvariants` is iterated per worktree in Phase B (master U3), so its singular resolution is gated off in workspace mode here. +- **Unregister resolvers (`:1586`/`:1603`/`:1618`)** — `deleteActiveSession`/`StepExecutor`/`WorkflowStepSession` each read one path for `activeSessionRegistry.unregisterPath`; with a Set they must unregister **every** path (loop), not one. Plus cleanup at `~:14922`. + +Non-workspace tasks hold a one-element set — behavior unchanged. **Grep all `activeWorktrees.` sites before declaring done** (FN-5893); the list above is the verification spine, not a license to skip the grep. + +### KTD3 — Per-repo base SHA against the *resolved* integration branch, local-first (master KTD3) +`resolveCapturedBaseCommitSha` (`base-commit-capture.ts:26-55`) **hardcodes `main`** and takes `(worktreePath, logger?)`. Extend it to accept the integration branch as an **optional trailing param defaulting to the current `main` literal**, so the existing single-repo caller (`executor.ts:~12075`) and the 4 `base-commit-capture.real-git.test.ts` cases stay green without change. At each sub-repo acquisition capture `baseCommitSha` measured **local-first** (`merge-base HEAD || origin/`), per `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`. + +> **Integration-branch resolution gotcha (feasibility-verified):** `resolveIntegrationBranch(rootDir, settings)` (`integration-branch.ts:74`) checks `resolveFromSettings(settings)` **FIRST** and returns a populated `settings.integrationBranch` before ever consulting the repo's `origin/HEAD`. So `resolveIntegrationBranch(repoAbsPath, settings)` would return the **shared** override for every sub-repo — the exact thing KTD3 forbids. **Call it with the shared override stripped:** `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })`, so each sub-repo falls through to its own `origin/HEAD`. Store as `workspaceWorktrees[repo].baseCommitSha`. + +### KTD4 — Same-sub-repo exclusivity via `activeSessionRegistry` path-keying, not the pool (master KTD6) +`WorktreePool` is a recycle cache (gated on `recycleWorktrees`), **not** a cross-task lock. Serialize two concurrent workspace tasks contending for the same sub-repo via a repo-path exclusivity registry built on `activeSessionRegistry` path-keying (which `runAiMerge` already uses), registered **at acquisition** (U2). Disjoint-scope contention on the same sub-repo is otherwise unprotected (file-scope leases don't catch it). + +### KTD5 — Dashboard floor only (master U10) +Nil-guard components that render `task.worktree`/`task.branch` so a workspace task (no `task.worktree`, populated `workspaceWorktrees`) shows a placeholder or flat per-repo list, never a crash/empty. Ceiling: "doesn't look broken" — no rich per-repo-status component (deferred registration UI). Plus a one-line non-atomic-merge-semantics note in `CONCEPTS.md`/`docs/dashboard-guide.md`. + +--- + +## Implementation Units + +> **Standing requirements (every unit):** `FNXC:Workspace ` comments at non-obvious decision points; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (narrow seams, real git only where an invariant requires it, fake timers over polling, no mock-the-world); FN-5893 surface enumeration (update every enumerated consumer, don't half-convert); merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`). Branch off the U0 branch — do not commit to `main` or the U0 branch. + +### U1. Executor session scoping — skip root acquisition + preflights, browse-only root, activeWorktrees Set + +**Goal:** In workspace mode the executor skips root acquisition and every rootDir git preflight, runs the session rooted at the workspace dir, and tracks per-task worktree *sets*. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** none (foundation + U0 present on the base). + +**Files:** +- `packages/engine/src/executor.ts` (acquisition `~:7430`; preflights `:7525` base capture, `:7536` contamination, identity-guard install, `verifyWorktreeInvariants`; session create `~:8443-8494`; retry session `~:8935`; `activeWorktrees` `:7667` + consumers `findActiveWorktreeOwner`/`hasActiveWorktreeBinding`/`getActiveWorktreeHolders`/FN-6736 reclaim `~:2055`/getters `~:1585`/`:14491`/`:14518`; `scopePromptToWorktree`) +- `packages/engine/src/__tests__/executor-workspace.test.ts` (**rewrite** — replace the `vi.mock`-the-subject tests with a **real two-repo git fixture harness** reusable by U2 and later phases) + +**Approach:** Gate the root acquisition + each preflight behind `!this.workspaceConfig`. In workspace mode set session cwd = `this.rootDir`, leave `task.worktree` unset, no-op `scopePromptToWorktree`. Convert `activeWorktrees` to `taskId → Set`; update each enumerated consumer to membership semantics (a non-workspace task = a one-element set). Mirror the existing `this.workspaceConfig === undefined` lazy-load guard at `executor.ts:7413-7418`. + +**Execution note:** Build the real two-repo fixture harness first (create temp git repos, branch, commit); the foundation's self-mocking test proves nothing. The harness is shared infrastructure for the rest of the phases. + +**Test scenarios:** +- Workspace config present → root `acquireTaskWorktree` NOT called; no preflight runs git against rootDir; session `cwd === rootDir`. (happy path) +- Non-workspace task → acquisition + every preflight called exactly as before; `cwd === worktreePath`. (regression — the singular path is untouched) +- Each enumerated `activeWorktrees` consumer returns correct results when a task holds two sub-repo paths (membership, not equality). (integration) +- Retry session in workspace mode uses `cwd === rootDir`. (edge) +- Workspace task that acquires zero sub-repos reaches `fn_task_done` without throwing on missing `task.worktree`. (edge/empty) + +**Verification:** A workspace task starts a session at the workspace root with no root worktree and no rootDir git preflight; `activeWorktrees` reflects all acquired sub-repo paths; a single-repo task is unchanged. + +--- + +### U2. Per-repo acquisition hardening — identity guard, per-repo base SHA, same-repo exclusivity + +**Goal:** Each sub-repo worktree gets identity hooks, a correct per-repo base SHA (local-first, resolved integration branch), and same-sub-repo concurrency protection — all at acquisition. + +**Requirements:** KTD3, KTD4. + +**Dependencies:** U1 (shares the fixture harness). + +**Files:** +- `packages/engine/src/worktree-acquisition.ts` (`acquireWorkspaceRepoWorktree` `~:598-650`) +- `packages/engine/src/base-commit-capture.ts` (**extend `resolveCapturedBaseCommitSha` to accept the integration branch** — it hardcodes `main`) +- `packages/engine/src/worktree-hooks.ts` (`installTaskWorktreeIdentityGuard`) +- `activeSessionRegistry` path-keying (repo-path exclusivity registry — KTD4; NOT `worktree-pool.ts`) +- `packages/core/src/types.ts` (extend the `Task.workspaceWorktrees` entry with `baseCommitSha?`) +- `packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts` (new — real two-repo git fixture) + +**Approach:** After `acquireTaskWorktree` returns for a sub-repo: (1) install the identity guard via `installTaskWorktreeIdentityGuard`, passing the **same settings args the executor passes** at `executor.ts:14035-14040` (`commitMsgHookEnabled`, `taskPrefix`, `taskAttributionTrailerName`) for single-repo parity — note `acquireWorkspaceRepoWorktree` calls `acquireTaskWorktree` *without* a `createWorktree` override, so the default backend installs **no** guard today (this work is genuinely missing); (2) resolve the integration branch via `resolveIntegrationBranch(repoAbsPath, { ...settings, integrationBranch: undefined })` (strip the shared override — KTD3 gotcha) and capture `baseCommitSha` via the extended `resolveCapturedBaseCommitSha(worktreePath, logger?, integrationBranch?)`; (3) persist `baseCommitSha` into `workspaceWorktrees[repo]`; (4) register same-sub-repo exclusivity in the `activeSessionRegistry` path-keyed registry — choose a **distinct registry kind/ownerKey** for the acquisition-time exclusivity entry so it does not collide with the executor's later session registration on the same sub-repo path (the registry exposes `registerPath`/`lookupByPath`/`isPathActive`/`pathsForTask`). Idempotent across `(taskId, repo)` (re-acquire returns the existing entry, no re-install/re-capture). + +**Execution note:** Real two-repo fixture; commit-without-pushing to exercise the local-ahead-of-origin invariant. + +**Test scenarios:** +- Acquiring repo A captures `baseSha_A` = the local integration tip even when `origin/` is behind. Covers the inflation invariant. (happy path + regression) +- A sub-repo whose integration branch is **not** `main` captures against that branch and does not inherit a shared `settings.integrationBranch`. (KTD3 correction) +- Identity-guard hook present; a commit on a non-`fusion/` branch is rejected. (integration) +- Two concurrent workspace tasks acquiring the same sub-repo (even with disjoint in-repo scopes) are serialized by the exclusivity registry. (concurrency — KTD4) +- Re-acquiring repo A returns the existing entry without re-capture/re-install. (idempotency) +- Acquisition failure persists an audit event and surfaces an error (no swallowed stall). (error path) + +**Verification:** Each sub-repo worktree has identity hooks, a correct per-repo base SHA (local-first, right branch), and same-sub-repo concurrency protection registered at acquisition. + +--- + +### U3. Dashboard "doesn't look broken" floor (master U10) + +**Goal:** Existing task views render a workspace task (no `task.worktree`, populated `workspaceWorktrees`) without breakage. + +**Requirements:** KTD5. + +**Dependencies:** none (independent of U1/U2; reads the data shape the foundation already added). + +**Files:** +- Each `packages/dashboard/app/` component that reads `task.worktree`/`task.branch` for display (grep and enumerate during implementation — task detail view + any task-row/summary) +- `CONCEPTS.md` or `docs/dashboard-guide.md` (one-line non-atomic-merge-semantics note) +- `packages/dashboard/app/__tests__/` (new — graceful render test) + +**Approach:** Add a nil-guard so each affected component renders a static placeholder (e.g. "N repos acquired") or a flat per-repo path list when `task.worktree` is absent and `workspaceWorktrees` is populated. **Ceiling:** placeholder/flat list only — a new rich per-repo-status component crosses into the deferred registration UI. Add the one-line semantics note (workspace-task merges are non-atomic: repos land independently on local integration refs; partial-land is local + operator-resettable). + +**Test scenarios:** +- Task with `task.worktree` undefined + two `workspaceWorktrees` entries → renders a per-repo list/placeholder, no crash/empty. (happy path) +- Single-repo task → unchanged. (regression) + +**Verification:** Workspace tasks are observable (not broken) in the dashboard. + +--- + +## Scope Boundaries + +**In scope:** the **run** stage — session scoping (U1), per-repo acquisition hardening (U2), dashboard breakage floor (U3). + +### Deferred to Follow-Up Work (later master-plan phases) +- Per-repo modified-files capture, contamination, `verifyWorktreeInvariants` iteration (master U3 = Phase B). +- Per-repo review + `fn_task_done` completion verification (master U4 = Phase B). +- The shared landed predicate, per-repo `runAiMerge` clean-room loop, leases (master U5/U6/U7 = Phase C). +- Self-healing reconcilers, e2e harness (master U8/U9 = Phase D). +- Rich dashboard per-repo status / workspace registration UI. + +> **Contamination-window caveat (carried from the master plan):** U1 gates the root preflights off, but per-repo contamination/`verifyWorktreeInvariants` does not return until master U3 (Phase B). Do not run a workspace task for real until Phase B lands — Phase A delivers acquisition + browse, not a verified end-to-end run. + +--- + +## Risks & Dependencies + +- **R1 — Half-converted `activeWorktrees` consumers (FN-5893).** Missing one consumer silently breaks liveness/owner checks for multi-repo tasks. Mitigation: KTD2 enumerates every consumer; grep all `activeWorktrees.get(`/`.has(`/`===`-on-path sites before declaring done. +- **R2 — A preflight left un-gated runs git against the non-git root → crash.** Mitigation: U1 explicitly enumerates and gates each preflight between the workspace guard and session create; test asserts no rootDir git in workspace mode. +- **R3 — Base-commit inflation per repo.** Mitigation: KTD3 extends the hardcoded-`main` helper and captures local-first against the resolved branch; regression test commits-without-pushing + uses a non-`main` integration branch. +- **R4 — Same-sub-repo concurrency unprotected.** Mitigation: KTD4 registers exclusivity at acquisition (U2), not via the recycle pool. +- **R5 — Non-workspace regression.** The whole point of branching on `workspaceConfig` is parity for single-repo tasks. Mitigation: every unit carries a non-workspace "unchanged" regression test; the gate's existing engine-core suite must stay green. +- **Stacking dependency:** builds on foundation #1710 + U0 #1711; the PR diff includes both and must not merge until they land. + +--- + +## Sources & Research + +- Master plan `docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md` (U1/U2/U10, KTD1/KTD3/KTD6 — KTD7 is Phase B, invariant inventory, D2/D3/D5). +- Codebase anchors (verified this session): `executor.ts` acquisition/preflight/session/`activeWorktrees`; `worktree-acquisition.ts` `acquireWorkspaceRepoWorktree`; `base-commit-capture.ts` hardcoded-`main`; `resolveIntegrationBranch`; `activeSessionRegistry` path-keying; foundation `task.workspaceWorktrees`. +- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md` → KTD3 (local-first base capture). +- `AGENTS.md`: FN-5048 slow-test rules, FN-5893 surface enumeration, changeset policy, merge gate. From 12d33c512d95760c3f7e2dda8e61d45c3e9afeae Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:17:08 -0700 Subject: [PATCH 19/44] =?UTF-8?q?feat(workspace):=20Phase=20A=20U2=20?= =?UTF-8?q?=E2=80=94=20per-repo=20acquisition=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acquireWorkspaceRepoWorktree now hardens each sub-repo worktree at acquisition: (1) installs the identity guard with the executor's settings args (commitMsgHookEnabled/taskPrefix/taskAttributionTrailerName) for single-repo parity — it was installing no guard before; (2) captures a per-repo baseCommitSha local-first against the repo's resolved integration branch via resolveIntegrationBranch(repoAbsPath, {...settings, integrationBranch: undefined}) — stripping the shared override so each sub-repo falls through to its own origin/HEAD, not a project-wide branch; (3) persists baseCommitSha into the workspaceWorktrees[repo] entry (Task type extended); (4) registers same-sub-repo exclusivity on the sub-repo path via activeSessionRegistry under a distinct "workspace-repo-acquire" kind (released in finally), so two concurrent workspace tasks contending for the same sub-repo are serialized (throws WorkspaceRepoAcquireBusyError). Idempotent re-acquire short-circuits. resolveCapturedBaseCommitSha gains an optional trailing integrationBranch param defaulting to "main", so existing single-repo callers + base-commit-capture real-git tests stay green. New audit events worktree:workspace-repo-acquire-busy /-failed. 6 new real-fixture tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...orkspace-per-repo-acquisition-hardening.md | 5 + packages/core/src/types.ts | 8 +- .../worktree-acquisition-workspace.test.ts | 273 ++++++++++++++++++ .../engine/src/active-session-registry.ts | 11 +- packages/engine/src/base-commit-capture.ts | 18 +- packages/engine/src/run-audit.ts | 5 + packages/engine/src/worktree-acquisition.ts | 191 ++++++++++-- 7 files changed, 483 insertions(+), 28 deletions(-) create mode 100644 .changeset/workspace-per-repo-acquisition-hardening.md create mode 100644 packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts diff --git a/.changeset/workspace-per-repo-acquisition-hardening.md b/.changeset/workspace-per-repo-acquisition-hardening.md new file mode 100644 index 0000000000..3ce549f618 --- /dev/null +++ b/.changeset/workspace-per-repo-acquisition-hardening.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase A / U2): harden per-repo worktree acquisition. Each sub-repo worktree now gets the task identity guard installed (single-repo parity), a per-repo base commit SHA captured local-first against that sub-repo's resolved integration branch (shared `integrationBranch` override stripped so each repo falls through to its own `origin/HEAD`), and same-sub-repo acquisition exclusivity registered in the path-keyed active-session registry. Re-acquiring an already-acquired `(taskId, repo)` is idempotent, and acquisition failures surface an error plus an audit event instead of silently stalling. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9425818116..6e40bad2d0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2246,8 +2246,14 @@ export interface Task { /** * Workspace mode only. Keyed by repo path relative to workspace rootDir. * Each entry records the on-disk worktree path and git branch for one sub-repo. + * + * FNXC:Workspace 2026-06-21-20:10: + * `baseCommitSha` is the per-repo fork-point captured at acquisition (U2/KTD3) + * against that sub-repo's RESOLVED integration branch, local-first. It is the + * per-repo analogue of the single-repo base-commit capture and prevents + * cross-repo files-changed inflation when local integration is ahead of origin. */ - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts new file mode 100644 index 0000000000..d3e9e95349 --- /dev/null +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -0,0 +1,273 @@ +/* +FNXC:Workspace 2026-06-21-20:10: +U2 per-repo acquisition hardening tests. A REAL two-repo git fixture is required +because the invariants under test are git-shaped: local-ahead-of-origin base +capture, a resolved-per-repo (non-shared) integration branch, and a working +identity-guard hook that actually rejects a commit. The shared harness from +./_workspace-fixture.ts builds genuine on-disk repos under a NON-git workspace +root. The TaskStore is an in-memory fake (no DB / no network) per FN-5048 — real +git only where the invariant needs it; everything else is a narrow seam. +*/ +import { execSync, spawnSync } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { + acquireWorkspaceRepoWorktree, + WorkspaceRepoAcquireBusyError, +} from "../worktree-acquisition.js"; +import { ActiveSessionRegistry } from "../active-session-registry.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +/** + * Minimal in-memory TaskStore covering exactly what acquireWorkspaceRepoWorktree + * and its acquireTaskWorktree callee touch: updateTask (merge-in-place so the + * idempotency re-read sees persisted workspaceWorktrees), logEntry, getTask. + */ +function makeFakeStore(task: Task): { store: TaskStore; current: () => Task; logs: string[] } { + let current = task; + const logs: string[] = []; + const store = { + async updateTask(id: string, patch: Partial): Promise { + if (id === current.id) current = { ...current, ...patch }; + }, + async logEntry(_id: string, message: string): Promise { + logs.push(message); + }, + async getTask(id: string): Promise { + return id === current.id ? current : null; + }, + } as unknown as TaskStore; + return { store, current: () => current, logs }; +} + +function makeTask(id: string): Task { + return { + id, + title: `task ${id}`, + description: "workspace task", + status: "in-progress", + } as unknown as Task; +} + +const SETTINGS: Partial = { + worktreeNaming: "task-id", + commitMsgHookEnabled: true, + taskPrefix: "FN", + taskAttributionTrailerNames: ["Fusion-Task-Id"], +}; + +describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout: 60_000 }, () => { + let fixture: WorkspaceFixture; + + afterEach(() => { + fixture?.cleanup(); + }); + + it("captures the LOCAL integration tip as baseCommitSha even when origin is behind (inflation invariant)", async () => { + // Give repo-a a real origin so origin/main can lag behind local main. + fixture = await createWorkspaceFixture(["repo-a"]); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin main"); + + // Local main advances by an unpushed predecessor commit (FN-5937 shape). + git(repoA, "git commit --allow-empty -m 'FN-9000: unpushed predecessor'"); + const localTip = git(repoA, "git rev-parse HEAD"); + const originTip = git(repoA, "git rev-parse origin/main"); + expect(localTip).not.toBe(originTip); + + const { store, current } = makeFakeStore(makeTask("FN-1")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + + // Base must be the LOCAL tip, never the behind origin tip. + expect(result.baseCommitSha).toBe(localTip); + expect(current().workspaceWorktrees?.["repo-a"]?.baseCommitSha).toBe(localTip); + }); + + it("captures against a NON-main integration branch and does not inherit a shared settings.integrationBranch (KTD3)", async () => { + // repo-a's default branch is 'develop'; origin/HEAD points at it. A shared + // settings.integrationBranch override must be STRIPPED so per-repo resolution + // falls through to this repo's own origin/HEAD. + fixture = await createWorkspaceFixture(["repo-a"], "develop"); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin develop"); + // Point origin/HEAD at develop so resolveIntegrationBranch resolves it. + git(repoA, "git remote set-head origin develop"); + const developTip = git(repoA, "git rev-parse develop"); + + const { store, current } = makeFakeStore(makeTask("FN-2")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + // A SHARED integration branch that does NOT exist in this sub-repo. If it + // leaked through, base capture would resolve against 'shared-trunk' and + // (absent that branch) fall back to HEAD — not develop's tip. + settings: { ...SETTINGS, integrationBranch: "shared-trunk" }, + registry, + }); + + expect(result.baseCommitSha).toBe(developTip); + }); + + it("installs the identity-guard hook so a commit on a non-fusion branch is rejected", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current } = makeFakeStore(makeTask("FN-3")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + settings: SETTINGS, + store, + registry, + }); + + const wt = result.worktreePath; + expect(existsSync(join(wt, ".git"))).toBe(true); + git(wt, 'git config user.email "test@example.com"'); + git(wt, 'git config user.name "Test"'); + + // On the fusion/ branch the guard permits a commit (real staged change, + // so the FN-5345 empty-commit guard also installed by the identity guard + // does not refuse it). + git(wt, "git checkout fusion/fn-3"); + writeFileSync(join(wt, "own.txt"), "own work\n", "utf-8"); + git(wt, "git add own.txt"); + git(wt, "git commit -m 'FN-3: ok on own branch'"); + + // Switch to a foreign branch; the pre-commit identity guard must refuse. + git(wt, "git checkout -B rogue-branch"); + writeFileSync(join(wt, "rogue.txt"), "rogue work\n", "utf-8"); + git(wt, "git add rogue.txt"); + const attempt = spawnSync("git", ["commit", "-m", "rogue"], { + cwd: wt, + encoding: "utf-8", + }); + expect(attempt.status).not.toBe(0); + expect(`${attempt.stderr}`).toMatch(/refusing commit/i); + }); + + it("serializes two concurrent acquisitions of the SAME sub-repo via the exclusivity registry (KTD4)", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const repoAbs = fixture.repoPath("repo-a"); + const registry = new ActiveSessionRegistry(); + + // Pre-register the sub-repo path as if task FN-A is mid-acquisition, then + // prove a second task is rejected while it is held. + registry.registerPath(repoAbs, { taskId: "FN-A", kind: "workspace-repo-acquire", ownerKey: "workspace-repo-acquire" }); + + const { store, current } = makeFakeStore(makeTask("FN-B")); + await expect( + acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }), + ).rejects.toBeInstanceOf(WorkspaceRepoAcquireBusyError); + + // The holder's entry is untouched by the rejected loser. + expect(registry.lookupByPath(repoAbs)?.taskId).toBe("FN-A"); + + // Once released, the same task acquires cleanly and the registry is freed. + registry.unregisterPath(repoAbs); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(result.alreadyAcquired).toBe(false); + // Acquisition releases its own exclusivity entry on completion. + expect(registry.isPathActive(repoAbs)).toBe(false); + }); + + it("is idempotent across (taskId, repo): re-acquire returns the existing entry without re-capture", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current } = makeFakeStore(makeTask("FN-4")); + const registry = new ActiveSessionRegistry(); + + const first = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(first.alreadyAcquired).toBe(false); + + // Re-acquire with the now-populated task: returns the persisted entry, + // does not re-register exclusivity, does not re-create a worktree. + const second = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(second.alreadyAcquired).toBe(true); + expect(second.worktreePath).toBe(first.worktreePath); + expect(second.baseCommitSha).toBe(first.baseCommitSha); + expect(registry.isPathActive(fixture.repoPath("repo-a"))).toBe(false); + }); + + it("surfaces an error and persists an audit event when acquisition fails (no swallowed stall)", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const { store, current, logs } = makeFakeStore(makeTask("FN-5")); + const registry = new ActiveSessionRegistry(); + const auditEvents: Array<{ type: string }> = []; + const audit = { + async git(e: { type: string }): Promise { + auditEvents.push(e); + }, + async filesystem(): Promise {}, + }; + + await expect( + acquireWorkspaceRepoWorktree({ + repoRelPath: "does-not-exist", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + audit: audit as never, + }), + ).rejects.toThrow(); + + expect(auditEvents.some((e) => e.type === "worktree:workspace-repo-acquire-failed")).toBe(true); + expect(logs.some((m) => /acquisition failed/i.test(m))).toBe(true); + // The exclusivity entry is released even on the failure path. + expect(registry.isPathActive(join(fixture.rootDir, "does-not-exist"))).toBe(false); + }); +}); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index ec28db0158..12168c0cea 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -1,4 +1,13 @@ -export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge"; +/* +FNXC:Workspace 2026-06-21-20:10: +"workspace-repo-acquire" is a DISTINCT registry kind reserved for the +acquisition-time same-sub-repo exclusivity entry (U2/KTD4). It is keyed by the +sub-repo absolute path (NOT the worktree path) so two concurrent workspace tasks +contending for the SAME sub-repo are serialized. Keeping it distinct from +"executor"/"step-session" means it does not collide with the executor's later +session registration on the produced worktree path. +*/ +export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge" | "workspace-repo-acquire"; export interface ActiveSessionRegistration { taskId: string; diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index c449a97558..4d9e778774 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -22,15 +22,31 @@ const execAsync = promisify(exec); * * Returns `undefined` only when every git invocation fails (caller treats a * missing base as non-fatal). + * + * FNXC:Workspace 2026-06-21-20:10: + * `integrationBranch` is an OPTIONAL TRAILING param defaulting to the historic + * "main" literal so the single-repo executor caller and the real-git tests stay + * green without change. Workspace mode (U2/KTD3) passes each sub-repo's RESOLVED + * integration branch so per-repo base capture forks against the right branch + * instead of a hardcoded "main". The local-first ordering (merge-base HEAD + * then origin/) is preserved per-branch to keep the + * inflation-prevention invariant (FN-5937) intact for non-main integration + * branches too. */ export async function resolveCapturedBaseCommitSha( worktreePath: string, logger?: { warn: (msg: string) => void }, + integrationBranch: string = "main", ): Promise { + const branch = integrationBranch.trim() || "main"; + // Shell-quote defensively; integration branch names are normalized upstream + // but may carry slashes (e.g. "release/2026-06") that are valid in refs. + const localRef = JSON.stringify(branch); + const originRef = JSON.stringify(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( - "git merge-base HEAD main 2>/dev/null || git merge-base HEAD origin/main", + `git merge-base HEAD ${localRef} 2>/dev/null || git merge-base HEAD ${originRef}`, { cwd: worktreePath, encoding: "utf-8" }, ); baseCommitSha = stdout.trim() || undefined; diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 1baaf8da1d..e92d23656d 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -99,6 +99,11 @@ export type GitMutationType = | "worktree:incomplete-detected" | "worktree:reanchored" | "worktree:auto-recovered" + // FNXC:Workspace 2026-06-21-20:10: workspace per-repo acquisition audit events (U2). + // -busy: another task holds the same sub-repo's acquisition exclusivity lock (KTD4). + // -failed: a sub-repo worktree acquisition threw; surfaced + audited, never swallowed. + | "worktree:workspace-repo-acquire-busy" + | "worktree:workspace-repo-acquire-failed" /** * worktrunk run-audit metadata shape: * diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 794413ca5d..07ce2d722a 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -34,6 +34,10 @@ import { import type { RunAuditor } from "./run-audit.js"; import { writeSecretsEnvFile } from "./secrets-env-writer.js"; import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js"; +import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; +import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; +import { resolveIntegrationBranch } from "./integration-branch.js"; +import { activeSessionRegistry, type ActiveSessionRegistry } from "./active-session-registry.js"; const execAsync = promisify(exec); @@ -604,47 +608,184 @@ export interface AcquireWorkspaceRepoWorktreeOptions { settings: Partial; logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void }; secretsStore?: Pick; + audit?: Pick; + runContext?: RunMutationContext; + /** Test seam: inject the path-keyed exclusivity registry (defaults to the process singleton). */ + registry?: ActiveSessionRegistry; } +/* +FNXC:Workspace 2026-06-21-20:10: +Acquisition-time exclusivity owner key for the same-sub-repo lock (U2/KTD4). The +registry record is keyed by the sub-repo ABSOLUTE path and carries this distinct +ownerKey so it never collides with the executor's later "executor"/"step-session" +registration on the produced WORKTREE path. +*/ +const WORKSPACE_REPO_ACQUIRE_OWNER_KEY = "workspace-repo-acquire"; + export async function acquireWorkspaceRepoWorktree( opts: AcquireWorkspaceRepoWorktreeOptions, -): Promise<{ worktreePath: string; branch: string; alreadyAcquired: boolean }> { - const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore } = opts; +): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> { + const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext } = opts; + const registry = opts.registry ?? activeSessionRegistry; const { join } = await import("node:path"); const existing = task.workspaceWorktrees?.[repoRelPath]; if (existing) { + /* + FNXC:Workspace 2026-06-21-20:10: + Idempotency across (taskId, repo): a re-acquire of an already-acquired sub-repo + returns the persisted entry verbatim — no second identity-guard install, no + re-capture of the base SHA, no second exclusivity registration. + */ return { ...existing, alreadyAcquired: true }; } const repoAbsPath = join(workspaceRootDir, repoRelPath); /* - FNXC:WorkspaceWorktree 2026-06-21-19:05: - Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` - is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites - those singular fields on the task row after each acquisition. Passing the live task straight - through means the second repo's acquisition sees the first repo's `task.worktree` (which exists - on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo - contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo - helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in - `task.workspaceWorktrees`, not the singular column. + FNXC:Workspace 2026-06-21-20:10: + Same-sub-repo exclusivity (KTD4): register the sub-repo absolute path in the + path-keyed activeSessionRegistry BEFORE acquiring so two concurrent workspace + tasks contending for the SAME sub-repo are serialized. WorktreePool is a recycle + cache, not a cross-task lock, and disjoint-scope contention on one sub-repo is + otherwise unprotected (file-scope leases don't catch it). The entry is keyed by + the sub-repo path with a distinct ownerKey so it does not collide with the + executor's later session registration on the produced worktree path. We release + it once acquisition completes (success or failure) — it guards the acquisition + critical section, not the whole task lifetime. */ - const result = await acquireTaskWorktree({ - task: { ...task, worktree: undefined, branch: undefined }, - rootDir: repoAbsPath, - store, - settings, - logger, - secretsStore, - runInitCommand: true, + const exclusivityHolder = registry.lookupByPath(repoAbsPath); + if (exclusivityHolder && exclusivityHolder.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY && exclusivityHolder.taskId !== task.id) { + const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`; + logger?.warn(`${task.id}: ${message}`); + await store.logEntry(task.id, message, undefined, runContext); + const err = new WorkspaceRepoAcquireBusyError(repoRelPath, exclusivityHolder.taskId, task.id); + await audit?.git({ + type: "worktree:workspace-repo-acquire-busy", + target: repoAbsPath, + metadata: { repoRelPath, holderTaskId: exclusivityHolder.taskId, requestingTaskId: task.id }, + }); + throw err; + } + registry.registerPath(repoAbsPath, { + taskId: task.id, + kind: "workspace-repo-acquire", + ownerKey: WORKSPACE_REPO_ACQUIRE_OWNER_KEY, }); - const updated: Record = { - ...(task.workspaceWorktrees ?? {}), - [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch }, - }; - await store.updateTask(task.id, { workspaceWorktrees: updated }); + try { + /* + FNXC:WorkspaceWorktree 2026-06-21-19:05: + Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` + is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites + those singular fields on the task row after each acquisition. Passing the live task straight + through means the second repo's acquisition sees the first repo's `task.worktree` (which exists + on disk), classifies it as a resume, and reuses repo A's worktree inside repo B — cross-repo + contamination. Clear the singular worktree/branch fields on the copy handed to the single-repo + helper so each sub-repo always gets a fresh worktree; per-repo state is tracked in + `task.workspaceWorktrees`, not the singular column. + */ + const result = await acquireTaskWorktree({ + task: { ...task, worktree: undefined, branch: undefined }, + rootDir: repoAbsPath, + store, + settings, + logger, + secretsStore, + audit, + runContext, + runInitCommand: true, + }); - return { worktreePath: result.worktreePath, branch: result.branch, alreadyAcquired: false }; + /* + FNXC:Workspace 2026-06-21-20:10: + Identity guard (single-repo parity): acquireTaskWorktree above runs WITHOUT a + createWorktree override, so the default native backend installs NO identity + hooks for a sub-repo worktree. Install the same guard the executor installs for + single-repo tasks (executor.ts identity-guard call), passing the SAME settings + args (commitMsgHookEnabled / taskPrefix / first taskAttributionTrailerName) so a + commit on a non-fusion/ branch is refused inside every sub-repo worktree too. + */ + await installTaskWorktreeIdentityGuard({ + worktreePath: result.worktreePath, + taskId: task.id, + commitMsgHookEnabled: settings.commitMsgHookEnabled, + taskPrefix: settings.taskPrefix, + taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], + }); + + /* + FNXC:Workspace 2026-06-21-20:10: + Per-repo base SHA (KTD3): resolve THIS sub-repo's integration branch with the + shared settings.integrationBranch override STRIPPED. resolveIntegrationBranch + checks settings.integrationBranch FIRST, so without stripping it every sub-repo + would resolve to the shared workspace branch — defeating per-repo resolution. + With it undefined, each sub-repo falls through to its own origin/HEAD. Capture + the base local-first against that branch so local-ahead-of-origin integration + tips don't inflate the per-repo diff (FN-5937 invariant, per sub-repo). + */ + const integrationBranch = await resolveIntegrationBranch( + repoAbsPath, + { ...settings, integrationBranch: undefined }, + { logger }, + ); + const baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + + const updated: Record = { + ...(task.workspaceWorktrees ?? {}), + [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, + }; + await store.updateTask(task.id, { workspaceWorktrees: updated }); + + return { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha, alreadyAcquired: false }; + } catch (err) { + /* + FNXC:Workspace 2026-06-21-20:10: + Acquisition failure must surface an error and leave an audit trail (no swallowed + stall): persist the failure as an audit event + task log, then re-throw so the + caller observes the failure rather than silently proceeding with an unacquired + sub-repo. + */ + if (!(err instanceof WorkspaceRepoAcquireBusyError)) { + const message = err instanceof Error ? err.message : String(err); + logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`); + await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message }, + }); + } + throw err; + } finally { + /* + FNXC:Workspace 2026-06-21-20:10: + Release the acquisition-time exclusivity entry only when WE hold it. The busy-path + throw above does NOT enter this try (it short-circuits before registerPath), so a + serialized loser never unregisters the winner's entry. + */ + const held = registry.lookupByPath(repoAbsPath); + if (held && held.taskId === task.id && held.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY) { + registry.unregisterPath(repoAbsPath); + } + } +} + +/* +FNXC:Workspace 2026-06-21-20:10: +Thrown when a second workspace task tries to acquire a sub-repo already inside +another task's acquisition critical section (KTD4). Distinct from generic +acquisition failures so the caller (and tests) can tell "serialized, retry later" +apart from "this sub-repo is broken". +*/ +export class WorkspaceRepoAcquireBusyError extends Error { + constructor( + public readonly repoRelPath: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super(`workspace sub-repo ${repoRelPath} acquisition is in progress for task ${holderTaskId}`); + this.name = "WorkspaceRepoAcquireBusyError"; + } } From d5fa8654f70db21bed3aad5c7661137e3cfd14a4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:33:56 -0700 Subject: [PATCH 20/44] =?UTF-8?q?fix(review):=20Phase=20A=20workspace=20ha?= =?UTF-8?q?rdening=20=E2=80=94=20tool=20errors,=20activeWorktrees,=20non-f?= =?UTF-8?q?atal=20acquire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ce-code-review (5 personas) on Phase A. No P0; the workspace-root-removal path was ruled out and the contract changes verified additive. Applied: P1: fn_acquire_repo_worktree now catches WorkspaceRepoAcquireBusyError (and generic failures) and returns a sanitized retryable isError instead of an uncaught throw into the agent loop; runContext is forwarded so audit/log keep attribution. Per-repo acquired worktree paths are now registered into the executor's activeWorktrees Set (via an onAcquired callback) — previously the Set only held the browse-only root, making the U1 per-repo liveness invariant hollow. Post-acquire identity-guard install and base-SHA capture are now non-fatal (log-and-continue): a hook/branch failure no longer strands the on-disk worktree (the worktree is usable without the guard; an undefined baseCommitSha is already an accepted state). P2: the KTD3 settings-strip also strips settings.baseBranch (resolveFromSettings falls back integrationBranch → baseBranch, so a shared baseBranch leaked); the workspaceWorktrees write re-reads the task fresh before merging to avoid a sibling-repo clobber on sequential acquires (store-level atomic merge deferred to Phase B); the busy-path logging is wrapped so it can't mask the busy error; the TaskCard memo compares key-sets not counts; the stuck-kill no-op for workspace tasks is now logged; the exclusivity check-then-act synchrony is documented. Residuals (Phase B): per-repo worktree teardown, orphan-scan coverage, reaper dedup, store-level atomic merge. Gate green: typecheck, lint, build, test:gate (649+58), affected (25 + TaskCard 251). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/TaskCard.tsx | 9 +- .../worktree-acquisition-workspace.test.ts | 74 +++++++++++ packages/engine/src/agent-tools.ts | 65 ++++++++-- packages/engine/src/executor.ts | 19 ++- packages/engine/src/worktree-acquisition.ts | 122 ++++++++++++++---- 5 files changed, 246 insertions(+), 43 deletions(-) diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 46748eb497..14d93678e8 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -626,10 +626,13 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.blockedBy === nextTask.blockedBy && previousTask.overlapBlockedBy === nextTask.overlapBlockedBy && previousTask.worktree === nextTask.worktree && - // FNXC:Workspace 2026-06-21-00:00: re-render the card when a workspace task acquires/ + // FNXC:Workspace 2026-06-21-22:30: re-render the card when a workspace task acquires/ // releases sub-repo worktrees so the "N repos acquired" placeholder stays current (U3). - Object.keys(previousTask.workspaceWorktrees ?? {}).length === - Object.keys(nextTask.workspaceWorktrees ?? {}).length && + // F7 — compare the sorted key SETS, not just the count: a same-count repo swap (one + // repo released, a different one acquired) keeps the count but must still re-render, + // otherwise the placeholder shows a stale repo set. + JSON.stringify(Object.keys(previousTask.workspaceWorktrees ?? {}).sort()) === + JSON.stringify(Object.keys(nextTask.workspaceWorktrees ?? {}).sort()) && previousTask.branch === nextTask.branch && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts index d3e9e95349..3267157db7 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -270,4 +270,78 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout: // The exclusivity entry is released even on the failure path. expect(registry.isPathActive(join(fixture.rootDir, "does-not-exist"))).toBe(false); }); + + /* + FNXC:Workspace 2026-06-21-22:30: + F4 — resolveFromSettings falls back integrationBranch → settings.baseBranch → + origin/HEAD. A shared settings.baseBranch must be STRIPPED alongside + integrationBranch, otherwise a baseBranch absent from this sub-repo leaks through + and the per-repo base resolves against the wrong branch. Here repo-a's only branch + is its own origin/HEAD (develop); a shared baseBranch of 'shared-trunk' (absent in + the sub-repo) must NOT be honored — the base must resolve to develop's tip. + */ + it("strips a shared settings.baseBranch so the base resolves against the sub-repo's own origin/HEAD (KTD3 / F4)", async () => { + fixture = await createWorkspaceFixture(["repo-a"], "develop"); + const repoA = fixture.repoPath("repo-a"); + const origin = `${repoA}-origin`; + git(repoA, "git init --bare " + JSON.stringify(origin)); + git(repoA, `git remote add origin ${JSON.stringify(origin)}`); + git(repoA, "git push -u origin develop"); + git(repoA, "git remote set-head origin develop"); + const developTip = git(repoA, "git rev-parse develop"); + + const { store, current } = makeFakeStore(makeTask("FN-6")); + const registry = new ActiveSessionRegistry(); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + // A shared baseBranch (no integrationBranch) that does NOT exist in this + // sub-repo. If it leaked through, base capture would resolve against + // 'shared-trunk' instead of develop. + settings: { ...SETTINGS, baseBranch: "shared-trunk" } as Partial, + registry, + }); + + expect(result.baseCommitSha).toBe(developTip); + }); + + /* + FNXC:Workspace 2026-06-21-22:30: + F5 — two sequential acquires for DIFFERENT sub-repos in one task must each persist + their own workspaceWorktrees entry. The acquisition re-reads the task fresh before + the merge so the second acquire does not clobber the first repo's entry. + */ + it("preserves a sibling sub-repo's workspaceWorktrees entry across two different-repo acquires (F5)", async () => { + fixture = await createWorkspaceFixture(["repo-a", "repo-b"]); + const { store, current } = makeFakeStore(makeTask("FN-7")); + const registry = new ActiveSessionRegistry(); + + const first = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(first.alreadyAcquired).toBe(false); + + const second = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-b", + workspaceRootDir: fixture.rootDir, + task: current(), + store, + settings: SETTINGS, + registry, + }); + expect(second.alreadyAcquired).toBe(false); + + // Both entries survive — the second acquire merged into the latest map, not the + // stale snapshot, so repo-a was not clobbered. + const persisted = current().workspaceWorktrees ?? {}; + expect(persisted["repo-a"]?.worktreePath).toBe(first.worktreePath); + expect(persisted["repo-b"]?.worktreePath).toBe(second.worktreePath); + }); }); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 99e87b2cb3..a6fae43a06 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -28,7 +28,7 @@ import { computeApprovalDedupeKey } from "./agent-action-gate.js"; import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js"; import { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js"; import { recordRetry } from "./retry-burned-logger.js"; -import { acquireWorkspaceRepoWorktree } from "./worktree-acquisition.js"; +import { acquireWorkspaceRepoWorktree, WorkspaceRepoAcquireBusyError } from "./worktree-acquisition.js"; // ── Tool parameter schemas (canonical definitions) ──────────────────────── @@ -3601,8 +3601,19 @@ export function createAcquireRepoWorktreeTool(opts: { logger?: { log: (m: string) => void; warn: (m: string) => void }; secretsStore?: Pick; runContext?: RunMutationContext; + audit?: Pick; + /* + FNXC:Workspace 2026-06-21-22:30: + F2 — executor-supplied callback invoked after a SUCCESSFUL fresh acquire so the + acquired sub-repo worktree path is registered in the executor's per-task + activeWorktrees Set (KTD2). Without this the Set only ever held the browse-only + root and the "task holds N sub-repo paths" invariant was hollow — owner/liveness + checks never saw live sub-repo worktrees. Not called on the already-acquired + short-circuit (the path was registered on the original fresh acquire). + */ + onAcquired?: (worktreePath: string) => void; }): ToolDefinition { - const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext } = opts; + const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext, audit, onAcquired } = opts; return { name: "fn_acquire_repo_worktree", label: "Acquire Repo Worktree", @@ -3621,15 +3632,47 @@ export function createAcquireRepoWorktreeTool(opts: { }; } const freshTask = await store.getTask(task.id); - const result = await acquireWorkspaceRepoWorktree({ - repoRelPath: repo, - workspaceRootDir, - task: freshTask, - store, - settings, - logger, - secretsStore, - }); + /* + FNXC:Workspace 2026-06-21-22:30: + F1 — acquireWorkspaceRepoWorktree can throw WorkspaceRepoAcquireBusyError on + same-sub-repo contention (KTD4) or a generic failure. Both must surface as a + structured isError tool result, never an uncaught throw that crashes the agent + loop. The busy message is sanitized — it does NOT leak the holder task id into + agent-facing text (only into details). runContext is forwarded so the helper's + audit/log entries keep run attribution. + */ + let result: Awaited>; + try { + result = await acquireWorkspaceRepoWorktree({ + repoRelPath: repo, + workspaceRootDir, + task: freshTask, + store, + settings, + logger, + secretsStore, + audit, + runContext, + }); + } catch (err) { + if (err instanceof WorkspaceRepoAcquireBusyError) { + return { + content: [{ type: "text" as const, text: `Sub-repo ${repo} is temporarily locked by another task's acquisition; retry fn_acquire_repo_worktree shortly.` }], + details: { holderTaskId: err.holderTaskId }, + isError: true, + }; + } + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text" as const, text: `ERROR: Failed to acquire worktree for ${repo}: ${message}` }], + details: {}, + isError: true, + }; + } + // FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (skip the already-acquired short-circuit; that path was registered on its original fresh acquire). + if (!result.alreadyAcquired) { + onAcquired?.(result.worktreePath); + } await store.logEntry( task.id, result.alreadyAcquired diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 1f7c91769b..a5e9063a5b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -7708,7 +7708,7 @@ export class TaskExecutor { } } // end !this.workspaceConfig preflight gate (FNXC:Workspace KTD1) - // FNXC:Workspace 2026-06-21-12:00: KTD2 — register the worktree path under the task's Set. In workspace mode `worktreePath` is the browse-only root; per-repo paths are added as the agent acquires them. Non-workspace tasks add exactly one path → a one-element set (unchanged liveness/owner semantics). + // FNXC:Workspace 2026-06-21-12:00: KTD2 — register the worktree path under the task's Set. In workspace mode `worktreePath` is the browse-only root; per-repo sub-repo worktree paths ARE now added to the same Set as the agent acquires them (F2: fn_acquire_repo_worktree's onAcquired callback → addActiveWorktree), so the Set holds root + N sub-repo paths, not just the root. Non-workspace tasks add exactly one path → a one-element set (unchanged liveness/owner semantics). this.addActiveWorktree(task.id, worktreePath); executorLog.log(`${task.id}: worktree ready at ${worktreePath}`); @@ -8406,6 +8406,9 @@ export class TaskExecutor { logger: executorLog, secretsStore: this.options.secretsStore, runContext: engineRunContext, + audit, + // FNXC:Workspace 2026-06-21-22:30: F2 — register each freshly-acquired sub-repo worktree path in this task's activeWorktrees Set (KTD2) so owner/liveness checks see live per-repo worktrees, not just the browse-only root. + onAcquired: (worktreePath: string) => this.addActiveWorktree(task.id, worktreePath), })); } @@ -15295,6 +15298,20 @@ You have access to the file system to review changes.${verdictBlock}`; const preserveProgress = settings.preserveProgressOnStuckRequeue !== false; const latestTask = await this.store.getTask(taskId); const worktreePath = this.getWorktreePath(taskId) ?? latestTask.worktree; + /* + FNXC:Workspace 2026-06-21-22:30: + F8 — observability for the workspace case. A workspace task has no singular + worktree (getWorktreePath returns undefined for a multi-worktree task, and + latestTask.worktree is null on the browse-only root), so the removeWorktree + block below silently no-ops. Per-repo teardown is Phase B; until then make + the skip visible rather than silent. Behavior is unchanged. + */ + if (this.workspaceConfig && !worktreePath) { + await this.store.logEntry( + taskId, + `workspace task ${taskId}: no singular worktree to force-requeue (per-repo teardown is Phase B)`, + ); + } await this.store.logEntry( taskId, `Force-kill cleanup starting after stuck-kill unwind timeout — reaping in-flight surfaces and worktree`, diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 07ce2d722a..352ef4a036 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -657,17 +657,35 @@ export async function acquireWorkspaceRepoWorktree( */ const exclusivityHolder = registry.lookupByPath(repoAbsPath); if (exclusivityHolder && exclusivityHolder.ownerKey === WORKSPACE_REPO_ACQUIRE_OWNER_KEY && exclusivityHolder.taskId !== task.id) { - const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`; - logger?.warn(`${task.id}: ${message}`); - await store.logEntry(task.id, message, undefined, runContext); const err = new WorkspaceRepoAcquireBusyError(repoRelPath, exclusivityHolder.taskId, task.id); - await audit?.git({ - type: "worktree:workspace-repo-acquire-busy", - target: repoAbsPath, - metadata: { repoRelPath, holderTaskId: exclusivityHolder.taskId, requestingTaskId: task.id }, - }); + /* + FNXC:Workspace 2026-06-21-22:30: + F6 — the busy short-circuit's logEntry/audit are best-effort observability; if + either throws (e.g. a DB write hiccup) it must NOT replace the + WorkspaceRepoAcquireBusyError the caller relies on to classify "serialized, + retry later". Swallow logging failures so the busy error is what propagates. + */ + try { + const message = `sub-repo ${repoRelPath} is being acquired by ${exclusivityHolder.taskId}; serializing concurrent workspace acquisition`; + logger?.warn(`${task.id}: ${message}`); + await store.logEntry(task.id, message, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-busy", + target: repoAbsPath, + metadata: { repoRelPath, holderTaskId: exclusivityHolder.taskId, requestingTaskId: task.id }, + }); + } catch { + // best-effort observability only — never mask the busy error + } throw err; } + /* + FNXC:Workspace 2026-06-21-22:30: + F9 — no `await` may be inserted between lookupByPath and registerPath: the + atomicity of the exclusivity claim depends on staying in one synchronous slice. + An interleaved await would let a second task pass the lookup gate before this + task registers, defeating the same-sub-repo serialization (KTD4). + */ registry.registerPath(repoAbsPath, { taskId: task.id, kind: "workspace-repo-acquire", @@ -698,6 +716,17 @@ export async function acquireWorkspaceRepoWorktree( runInitCommand: true, }); + /* + FNXC:Workspace 2026-06-21-22:30: + F3 — post-acquire steps are NON-FATAL. Once acquireTaskWorktree has created the + on-disk worktree, a failure of the identity-guard install or the base-SHA capture + must NOT strand that worktree (the previous catch re-threw, leaving the worktree + orphaned while the exclusivity entry released). The worktree is usable without the + identity guard, and an undefined baseCommitSha is already an accepted state. Only a + failure of acquireTaskWorktree ITSELF fails the acquisition. Each step is wrapped to + log a warning (and emit the existing failure audit event) but CONTINUE. + */ + /* FNXC:Workspace 2026-06-21-20:10: Identity guard (single-repo parity): acquireTaskWorktree above runs WITHOUT a @@ -707,33 +736,70 @@ export async function acquireWorkspaceRepoWorktree( args (commitMsgHookEnabled / taskPrefix / first taskAttributionTrailerName) so a commit on a non-fusion/ branch is refused inside every sub-repo worktree too. */ - await installTaskWorktreeIdentityGuard({ - worktreePath: result.worktreePath, - taskId: task.id, - commitMsgHookEnabled: settings.commitMsgHookEnabled, - taskPrefix: settings.taskPrefix, - taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], - }); + try { + await installTaskWorktreeIdentityGuard({ + worktreePath: result.worktreePath, + taskId: task.id, + commitMsgHookEnabled: settings.commitMsgHookEnabled, + taskPrefix: settings.taskPrefix, + taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], + }); + } catch (guardErr) { + // FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it. + const message = guardErr instanceof Error ? guardErr.message : String(guardErr); + logger?.warn(`${task.id}: identity-guard install failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); + await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + }); + } /* FNXC:Workspace 2026-06-21-20:10: Per-repo base SHA (KTD3): resolve THIS sub-repo's integration branch with the - shared settings.integrationBranch override STRIPPED. resolveIntegrationBranch - checks settings.integrationBranch FIRST, so without stripping it every sub-repo - would resolve to the shared workspace branch — defeating per-repo resolution. - With it undefined, each sub-repo falls through to its own origin/HEAD. Capture - the base local-first against that branch so local-ahead-of-origin integration - tips don't inflate the per-repo diff (FN-5937 invariant, per sub-repo). + shared settings.integrationBranch AND settings.baseBranch overrides STRIPPED. + resolveFromSettings (integration-branch.ts) falls back integrationBranch → + baseBranch → origin/HEAD, so leaving either set means every sub-repo resolves to + the shared workspace branch — defeating per-repo resolution (F4). With both + undefined, each sub-repo falls through to its own origin/HEAD. Capture the base + local-first against that branch so local-ahead-of-origin integration tips don't + inflate the per-repo diff (FN-5937 invariant, per sub-repo). */ - const integrationBranch = await resolveIntegrationBranch( - repoAbsPath, - { ...settings, integrationBranch: undefined }, - { logger }, - ); - const baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + let baseCommitSha: string | undefined; + try { + const integrationBranch = await resolveIntegrationBranch( + repoAbsPath, + { ...settings, integrationBranch: undefined, baseBranch: undefined }, + { logger }, + ); + baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); + } catch (baseErr) { + // FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state. + const message = baseErr instanceof Error ? baseErr.message : String(baseErr); + logger?.warn(`${task.id}: base-SHA capture failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); + await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + }); + } + /* + FNXC:Workspace 2026-06-21-22:30: + F5 — re-read the task fresh immediately before building the merged + workspaceWorktrees map. store.updateTask wholesale-replaces the map, and the + `task` snapshot was read earlier; two sequential acquires for DIFFERENT sub-repos + in one task would otherwise clobber a sibling's entry. Merging into the LATEST map + closes the common sequential-tool-call case. NOTE: a fully-atomic store-level + per-repo merge is the complete fix (it also covers truly-concurrent writes); it is + deferred to Phase B, which exercises multi-repo acquisition. + */ + const latest = await store.getTask(task.id); const updated: Record = { - ...(task.workspaceWorktrees ?? {}), + ...(latest.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, }; await store.updateTask(task.id, { workspaceWorktrees: updated }); From fc9423e465328c1d3632c1426690f8b7b56c9ca3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 22:29:25 -0700 Subject: [PATCH 21/44] =?UTF-8?q?feat(workspace):=20Phase=20B=20U1=20?= =?UTF-8?q?=E2=80=94=20per-repo=20change=20capture,=20contamination,=20and?= =?UTF-8?q?=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In workspace mode the executor now captures changes and verifies worktree invariants per acquired sub-repo instead of degrading to empty against the non-git root. Post-session capture (:7898) gains a workspace branch that loops task.workspaceWorktrees and reuses captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, …) per repo — inheriting resolveDiffBaseRef's merge-base fallback (repo baseCommitSha may be undefined) and the filterFilesToOwnTaskCommits contamination/divergence audit — then prefixes each repo's files with the repo path into task.modifiedFiles. Branch attribution runs per sub-repo (cwd), never against the root. The no-op assertCleanBranchAtBase is not iterated. verifyWorktreeInvariants is un-stubbed for workspace mode: it iterates every workspaceWorktrees entry asserting toplevel match + HEAD on fusion/, and returns the FIRST failing repo while preserving the exact discriminated union {ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected} (the :10889 consumer switches on reason for requeue/handoff) — the new repo field is additive. Singular non-workspace path unchanged. Real two-repo fixture tests (capture A+B repo-prefixed vs own base, undefined-base fallback, foreign-commit contamination audit, wrong_branch verify failure, single-repo regression). Gate green: typecheck, lint, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...pace-phase-b-u1-per-repo-capture-verify.md | 5 + .../executor-workspace-capture.test.ts | 244 ++++++++++++++++++ packages/engine/src/executor.ts | 145 ++++++++++- 3 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 .changeset/workspace-phase-b-u1-per-repo-capture-verify.md create mode 100644 packages/engine/src/__tests__/executor-workspace-capture.test.ts diff --git a/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md b/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md new file mode 100644 index 0000000000..e4a27b33dc --- /dev/null +++ b/.changeset/workspace-phase-b-u1-per-repo-capture-verify.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase B, U1): per-repo post-session change capture, contamination detection, and worktree-invariant verification. In workspace mode the executor now loops `task.workspaceWorktrees`, reusing `captureModifiedFiles` per sub-repo (diffing each against its own `baseCommitSha`, with a merge-base fallback when undefined) to aggregate repo-prefixed `task.modifiedFiles` and surface per-repo contamination, and un-stubs `verifyWorktreeInvariants` to assert each acquired worktree's git toplevel and `fusion/` branch. Single-repo behavior is unchanged. diff --git a/packages/engine/src/__tests__/executor-workspace-capture.test.ts b/packages/engine/src/__tests__/executor-workspace-capture.test.ts new file mode 100644 index 0000000000..fa19b2f915 --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-capture.test.ts @@ -0,0 +1,244 @@ +/* +FNXC:Workspace 2026-06-21-23:30: +U1 per-repo capture + contamination + worktree-invariant tests (KTD1/KTD2). These drive the REAL TaskExecutor methods against a REAL two-repo git fixture under a NON-git workspace root (createWorkspaceFixture), so any leaked rootDir git preflight would actually fail and a hand-built `git diff` against an undefined base would blow up. + +Seam choice (FN-5048): we set `(executor as any).workspaceConfig` directly (loadWorkspaceConfig has its own unit) and create real `fusion/` worktrees per sub-repo with real commits — no mock-the-world child_process. Capture is exercised through `captureWorkspaceModifiedFiles` (the helper the post-session path at executor.ts:7900 calls) and verification through `verifyWorktreeInvariants`. Real git is used only where the invariant requires it. + +Coverage: +- happy: edits in repo A + B → aggregated modifiedFiles carry repo-prefixed paths from BOTH, each diffed against its own baseCommitSha. +- edge: a repo with baseCommitSha undefined → capture still works via resolveDiffBaseRef's merge-base fallback (no `git diff undefined..HEAD`). +- contamination: a foreign commit (feat(FN-OTHER):) in a sub-repo's range → the filterFilesToOwnTaskCommits divergence audit fires (task:worktree-contamination-detected) for that repo, and the foreign file is excluded from attributed files. +- error: a worktree HEAD off fusion/ → verifyWorktreeInvariants returns {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true}); the reason enum is preserved for the :10889 consumer. +- regression: a single-repo (non-workspace) task → capture/verify identical to today. +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore, WorkspaceConfig } from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +function createStore(overrides: Partial> = {}): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + getRunContextFor: vi.fn(), + on: emitter.on.bind(emitter), + ...overrides, + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(id = "FN-WS-1", overrides: Partial = {}): Task { + return { + id, + title: "Workspace task", + description: "", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +// Capture attribution requires a digit-form task id (`FN-\d+`); the branch-attribution +// subject parser only attributes `feat(FN-1001):` style subjects, so the KTD2-era +// `FN-WS-1` placeholder would never attribute a commit. Use a real numeric id here. +const TASK_ID = "FN-1001"; +const BRANCH = "fusion/fn-1001"; + +/** Configure git identity in a freshly-created worktree (worktrees don't inherit user.* on all platforms). */ +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +/** + * Add a real fusion/ worktree to a sub-repo, commit one own-attributed edit + * onto that branch, and return { worktreePath, baseCommitSha } for task.workspaceWorktrees. + * baseCommitSha is the sub-repo's pre-edit HEAD so the diff range is base..HEAD. + */ +function addRepoWorktreeWithOwnEdit( + fx: WorkspaceFixture, + repoRel: string, + fileName: string, +): { worktreePath: string; baseCommitSha: string } { + const repoDir = fx.repoPath(repoRel); + const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-ws-1"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + mkdirSync(path.dirname(path.join(worktreePath, fileName)), { recursive: true }); + writeFileSync(path.join(worktreePath, fileName), "// own change\n", "utf-8"); + execSync(`git add ${fileName}`, { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): edit ${fileName}"`, { cwd: worktreePath, stdio: "pipe" }); + return { worktreePath, baseCommitSha }; +} + +function workspaceExecutor(fx: WorkspaceFixture, store = createStore()): TaskExecutor { + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + return executor; +} + +describeIfGit("U1 KTD1 — per-repo capture aggregates repo-prefixed paths", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("happy: edits in repo A + B are diffed against their own base and repo-prefixed", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts"); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const files = await (executor as any).captureWorkspaceModifiedFiles(task); + expect(files).toContain("repo-a/src/a.ts"); + expect(files).toContain("repo-b/src/b.ts"); + expect(files).toHaveLength(2); + }); + + it("edge: a repo with undefined baseCommitSha still captures via merge-base fallback (no `git diff undefined..HEAD`)", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + // baseCommitSha intentionally undefined → resolveDiffBaseRef merge-base(HEAD, main). + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH }, + }, + }); + + const files = await (executor as any).captureWorkspaceModifiedFiles(task); + expect(files).toEqual(["repo-a/src/a.ts"]); + }); + + it("contamination: a foreign commit in a sub-repo range fires the divergence audit and is excluded from attributed files", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + // Land a FOREIGN commit (different FN-id) onto the same fusion/ branch range. + const foreignFile = "src/foreign.ts"; + writeFileSync(path.join(a.worktreePath, "src", "foreign.ts"), "// foreign\n", "utf-8"); + execSync(`git add ${foreignFile}`, { cwd: a.worktreePath, stdio: "pipe" }); + execSync('git commit -m "feat(FN-OTHER): sneaky foreign change"', { cwd: a.worktreePath, stdio: "pipe" }); + + const dbAudit = vi.fn().mockResolvedValue(undefined); + const audit = { + database: dbAudit, + filesystem: vi.fn().mockResolvedValue(undefined), + git: vi.fn().mockResolvedValue(undefined), + }; + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + }, + }); + + const files = await (executor as any).captureWorkspaceModifiedFiles(task, audit as any, "post-session"); + // Own file attributed, foreign file excluded from the attributed set. + expect(files).toEqual(["repo-a/src/a.ts"]); + expect(files).not.toContain("repo-a/src/foreign.ts"); + // The contamination/divergence audit fired for this repo (raw 2 files vs attributed 1). + const contaminationCall = dbAudit.mock.calls.find( + ([evt]) => evt?.type === "task:worktree-contamination-detected", + ); + expect(contaminationCall).toBeTruthy(); + expect(contaminationCall![0].metadata.rawDiffFileCount).toBeGreaterThan(contaminationCall![0].metadata.attributedFileCount); + }); +}); + +describeIfGit("U1 KTD2 — verifyWorktreeInvariants iterates per worktree, preserving the result union", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("happy: every worktree on fusion/ with matching toplevel → {ok:true}", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts"); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result).toEqual({ ok: true }); + }); + + it("error: a worktree HEAD off fusion/ → {ok:false, reason:'wrong_branch', repo, observed, expected} (NOT {ok:true})", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktreeWithOwnEdit(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktreeWithOwnEdit(fx, "repo-b", "src/b.ts"); + // Drift repo-b's worktree off fusion/ onto a different branch. + execSync("git checkout -b some-other-branch", { cwd: b.worktreePath, stdio: "pipe" }); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result.ok).toBe(false); + expect(result.reason).toBe("wrong_branch"); + expect(result.repo).toBe("repo-b"); + expect(result.observed).toBe("some-other-branch"); + expect(result.expected).toBe(BRANCH); + }); + + it("regression: a zero-acquire workspace task (empty map) verifies vacuously → {ok:true}", async () => { + fx = await createWorkspaceFixture(); + const executor = workspaceExecutor(fx); + const task = makeTask(TASK_ID, { branch: BRANCH, workspaceWorktrees: {} }); + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result).toEqual({ ok: true }); + }); +}); + +describeIfGit("U1 — single-repo (non-workspace) task: capture/verify unchanged", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("regression: non-workspace verifyWorktreeInvariants still runs the singular path and passes for a real worktree", async () => { + fx = await createWorkspaceFixture(); + // Single-repo executor rooted at repo-a itself (no workspaceConfig). + const repoDir = fx.repoPath("repo-a"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-001"); + const base = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim(); + execSync(`git worktree add -b fusion/fn-001 ${worktreePath} HEAD`, { cwd: repoDir, stdio: "pipe" }); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "single.ts"), "// x\n", "utf-8"); + execSync("git add single.ts", { cwd: worktreePath, stdio: "pipe" }); + execSync('git commit -m "feat(FN-001): single"', { cwd: worktreePath, stdio: "pipe" }); + + const store = createStore(); + const executor = new TaskExecutor(store, repoDir); // no workspaceConfig → singular path + const task = makeTask("FN-001", { branch: "fusion/fn-001", worktree: worktreePath, baseCommitSha: base }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a5e9063a5b..16bb0d4a56 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -66,7 +66,7 @@ import { VERIFICATION_LOG_MAX_CHARS, type VerificationResult, } from "./verification-utils.js"; -import { canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js"; +import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js"; import { Type, type Static } from "@earendil-works/pi-ai"; import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js"; @@ -7895,6 +7895,47 @@ export class TaskExecutor { const allSuccess = results.every(r => r.success); if (allSuccess) { const updatedTask = await this.store.getTask(task.id); + // FNXC:Workspace 2026-06-21-23:30: KTD1 — per-repo post-session capture. + // The singular call below runs UNGATED with worktreePath = the browse-only non-git workspace root and silently returns [] (resolveDiffBaseRef swallows the git failure at the root). In workspace mode there is nothing to diff at the root; the real changes live in each acquired sub-repo worktree. So we ADD (not replace) a workspace branch that loops `task.workspaceWorktrees` and reuses the EXISTING captureModifiedFiles per repo — reusing it (rather than hand-building `git diff ..HEAD`) gives us the merge-base fallback for an undefined repo.baseCommitSha (resolveDiffBaseRef) AND restores the contamination/divergence audit (filterFilesToOwnTaskCommits) for free per repo. Returned files are repo-prefixed (e.g. `repo-a/src/foo.ts`) and aggregated into task.modifiedFiles. + if (this.workspaceConfig) { + const workspaceWorktrees = updatedTask.workspaceWorktrees ?? {}; + const aggregated = await this.captureWorkspaceModifiedFiles(updatedTask, audit, "post-session"); + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + // Per-repo branch-attribution audit (cwd = sub-repo). Run against repo.worktreePath/repo.branch, NOT the non-git root (a root call would fail and surface nothing). The contamination signal already rides on captureWorkspaceModifiedFiles above; this is the supplementary commit-attribution surface (FN-5233 pattern). + try { + const attributionBase = await this.resolveContaminationBaseRef(repo.worktreePath); + if (attributionBase && repo.branch) { + const attribution = await reportBranchAttribution(repo.worktreePath, repo.branch, attributionBase, task.id); + const hasAnomaly = attribution.foreign.length > 0 || attribution.unattributed.length > 0 || attribution.ownUntrailed.length > 0; + if (hasAnomaly) { + const summary = `branch-attribution anomalies on ${repoRel}@${repo.branch}: foreign=${attribution.foreign.length}, unattributed=${attribution.unattributed.length}, ownUntrailed=${attribution.ownUntrailed.length}, ownTrailed=${attribution.ownTrailed}`; + executorLog.warn(`${task.id}: ${summary}`); + await this.store.logEntry(task.id, `[branch-attribution] ${summary}`, undefined, this.getRunContextFor(task.id)); + await audit.git({ + type: "branch:attribution-anomaly", + target: repo.branch, + metadata: { + taskId: task.id, + repo: repoRel, + baseSha: attributionBase, + ownTrailed: attribution.ownTrailed, + foreign: attribution.foreign, + unattributed: attribution.unattributed, + ownUntrailed: attribution.ownUntrailed, + }, + }); + } + } + } catch (attributionErr: unknown) { + executorLog.warn(`${task.id}: post-session per-repo branch-attribution audit failed for ${repoRel}: ${attributionErr instanceof Error ? attributionErr.message : String(attributionErr)}`); + } + } + if (aggregated.length > 0) { + await this.store.updateTask(task.id, { modifiedFiles: aggregated }); + executorLog.log(`${task.id}: captured ${aggregated.length} modified files across ${Object.keys(workspaceWorktrees).length} sub-repo(s)`); + await audit.filesystem({ type: "file:capture-modified", target: task.id, metadata: { files: aggregated } }); + } + } else { const modifiedFiles = await this.captureModifiedFiles(worktreePath, updatedTask.baseCommitSha, task.id, audit, "post-session"); if (modifiedFiles.length > 0) { await this.store.updateTask(task.id, { modifiedFiles }); @@ -7936,6 +7977,7 @@ export class TaskExecutor { } catch (attributionErr: unknown) { executorLog.warn(`${task.id}: post-session branch-attribution audit failed: ${attributionErr instanceof Error ? attributionErr.message : String(attributionErr)}`); } + } // end !this.workspaceConfig singular capture (FNXC:Workspace KTD1) this.scheduleCompletedTaskWatchdog(task.id, "step-session completion"); if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after step-session completion")) { @@ -10502,10 +10544,87 @@ export class TaskExecutor { worktreePathOverride?: string, allowReanchor = true, options?: { noOpCompletion?: boolean; noOpCompletionReason?: string }, - ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> { + ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string; repo?: string }> { const settings = await this.store.getSettings(); - // FNXC:Workspace 2026-06-21-12:00: KTD1/KTD2 — workspace tasks have no root worktree and no single `task.worktree`; the singular per-task invariant is meaningless against the non-git root. Phase B (master U3) iterates this check per sub-repo worktree. Until then it is gated OFF in workspace mode so fn_task_done (its only caller path) does not requeue a zero-acquire workspace task for "missing task.worktree". + // FNXC:Workspace 2026-06-21-23:30: KTD2 — un-stubbed per-repo worktree-invariant verification. + // Phase A returned a flat {ok:true} stub here (no root worktree to verify against the non-git root). Phase B iterates every `task.workspaceWorktrees` entry, asserting (a) the sub-repo worktree's git toplevel matches the recorded repo.worktreePath and (b) its HEAD is on the recorded `fusion/` branch (repo.branch). The result union is PRESERVED EXACTLY — `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` — because the :10889 consumer switches on `reason` to drive requeue/handoff (:10894-10936). We ADD an optional `repo` field to the failure shape (purely additive; the consumer only reads reason/observed/expected) and return the FIRST failing repo. A zero-acquire workspace task (empty map) verifies vacuously → {ok:true}, matching Phase A so fn_task_done does not requeue it. if (this.workspaceConfig) { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + const expectedBranch = repo.branch || canonicalFusionBranchName(task.id); + // Skip git checks if the worktree dir is gone (mirrors the singular FN-009 carve-out below): completion does not require a live worktree on disk. + if (!existsSync(repo.worktreePath)) { + executorLog.log(`${task.id}: workspace worktree for ${repoRel} not found at ${repo.worktreePath} — skipping git validation`); + continue; + } + let expectedWorktreeRealpath: string; + try { + expectedWorktreeRealpath = canonicalizePath(repo.worktreePath); + } catch (error) { + return { + ok: false, + reason: "wrong_toplevel", + repo: repoRel, + observed: `unresolvable repo worktree (${repo.worktreePath}): ${error instanceof Error ? error.message : String(error)}`, + expected: `resolvable worktree for ${repoRel}`, + }; + } + try { + const { stdout } = await execAsync("git rev-parse --show-toplevel", { + cwd: repo.worktreePath, + encoding: "utf-8", + timeout: 10_000, + maxBuffer: 1024 * 1024, + }); + const observedTopLevelRaw = stdout.trim(); + if (observedTopLevelRaw) { + const observedTopLevel = canonicalizePath(observedTopLevelRaw); + if (observedTopLevel !== expectedWorktreeRealpath) { + return { + ok: false, + reason: "wrong_toplevel", + repo: repoRel, + observed: observedTopLevel, + expected: expectedWorktreeRealpath, + }; + } + } + } catch (error) { + return { + ok: false, + reason: "wrong_toplevel", + repo: repoRel, + observed: error instanceof Error ? error.message : String(error), + expected: expectedWorktreeRealpath, + }; + } + try { + const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + cwd: repo.worktreePath, + encoding: "utf-8", + timeout: 10_000, + maxBuffer: 1024 * 1024, + }); + const observedBranch = stdout.trim(); + if (observedBranch && observedBranch !== expectedBranch) { + return { + ok: false, + reason: "wrong_branch", + repo: repoRel, + observed: observedBranch, + expected: expectedBranch, + }; + } + } catch (error) { + return { + ok: false, + reason: "wrong_branch", + repo: repoRel, + observed: error instanceof Error ? error.message : String(error), + expected: expectedBranch, + }; + } + } return { ok: true }; } const branchName = resolveTaskWorkingBranch(task); @@ -12264,6 +12383,26 @@ ${failureFeedback} } } + /** + * FNXC:Workspace 2026-06-21-23:30: KTD1 — per-repo modified-file capture for workspace tasks. + * Loops `task.workspaceWorktrees` and REUSES `captureModifiedFiles` per sub-repo (NOT a hand-built `git diff`), so each repo gets: (a) resolveDiffBaseRef's merge-base fallback when repo.baseCommitSha is undefined, and (b) the filterFilesToOwnTaskCommits raw-vs-attributed divergence/contamination audit for free. Returned files are repo-prefixed (`/`) and aggregated, so a downstream File-Scope check / merge can attribute each change to its sub-repo. Returns [] for a zero-acquire workspace task. + */ + private async captureWorkspaceModifiedFiles( + task: Task, + audit?: RunAuditor, + source = "post-session", + ): Promise { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const aggregated: string[] = []; + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + const repoFiles = await this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source); + for (const file of repoFiles) { + aggregated.push(`${repoRel}/${file}`); + } + } + return aggregated; + } + private async captureUncommittedModifiedFiles(worktreePath: string): Promise { try { const [unstaged, staged] = await Promise.all([ From 81edbeefbd6ced6b408d0ced4deb8e0915187f9e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 22:44:40 -0700 Subject: [PATCH 22/44] =?UTF-8?q?feat(workspace):=20Phase=20B=20U2=20?= =?UTF-8?q?=E2=80=94=20per-repo=20review=20(both=20sites)=20+=20fn=5Ftask?= =?UTF-8?q?=5Fdone=20verify=20+=20scope-leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In workspace mode both review entry points and the completion guards now iterate every acquired sub-repo. A shared reviewWorkspacePerRepo loops task.workspaceWorktrees and invokes the existing single-cwd reviewStep once per repo (cwd = the sub-repo — the reviewer agent runs its own git diff there), aggregating repo-tagged verdicts as a conjunction: the task is reviewed only if every repo APPROVEs; the first non-APPROVE repo's verdict becomes the aggregate. Both call sites loop — the in-session fn_review_step tool AND the step-inversion seam (createReviewStepTool and the stepReview workflow seam) — so no review surface silently scopes to the non-git root (FN-5893). reviewStep itself stays single-cwd; the callers loop. fn_task_done completion verification iterates per repo: verifyWorktreeInvariants (from U1) already covers all worktrees, and evaluateTaskDoneScopeLeak now loops each sub-repo (cwd + repo.baseCommitSha, repo-prefixed touched files vs the repo-prefixed declared File Scope), blocking on the first repo with off-scope files and naming it. Both return shapes preserved (ReviewResult; {blocked,message}). New workspace-paths.ts repo-prefix helper (deriveRepoForPath/splitRepoScopedPath/ deriveRepoScopeSubset; segment-wise longest-prefix match, unscoped fallback) — master U5 reuses it. Singular non-workspace path unchanged. 16 new fixture tests. Gate green: typecheck, lint, build, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ace-phase-b-u2-per-repo-review-taskdone.md | 5 + .../executor-workspace-taskdone.test.ts | 220 ++++++++++++++++++ .../src/__tests__/reviewer-workspace.test.ts | 213 +++++++++++++++++ packages/engine/src/executor.ts | 180 +++++++++++--- packages/engine/src/workspace-paths.ts | 117 ++++++++++ 5 files changed, 708 insertions(+), 27 deletions(-) create mode 100644 .changeset/workspace-phase-b-u2-per-repo-review-taskdone.md create mode 100644 packages/engine/src/__tests__/executor-workspace-taskdone.test.ts create mode 100644 packages/engine/src/__tests__/reviewer-workspace.test.ts create mode 100644 packages/engine/src/workspace-paths.ts diff --git a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md new file mode 100644 index 0000000000..efd5004886 --- /dev/null +++ b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase B, U2): per-repo review at both review entry points plus per-repo `fn_task_done` completion + scope-leak verification. In workspace mode both review call sites (the in-session `fn_review_step` tool and the step-inversion review seam) now loop the single-cwd `reviewStep` once per acquired sub-repo (cwd = each repo's worktree) and aggregate the repo-tagged verdicts as a conjunction — the task is reviewed only when every sub-repo approves, and the first failing sub-repo's verdict (with repo-tagged findings) drives the existing verdict→edge mapping. `fn_task_done` now verifies worktree invariants per acquired repo and iterates the scope-leak guard per sub-repo (cwd = repo worktree, repo `baseCommitSha`), blocking completion on any sub-repo carrying off-scope changes and naming the repo. Adds a minimal shared repo-prefix-derivation helper (`workspace-paths.ts`). Single-repo behavior is unchanged. diff --git a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts new file mode 100644 index 0000000000..9f24aaa75c --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts @@ -0,0 +1,220 @@ +/* +FNXC:Workspace 2026-06-22-00:30: +U2 KTD4 — per-repo fn_task_done completion verification: per-repo scope-leak guard + per-repo worktree-invariant +verify. These drive the REAL TaskExecutor methods against a REAL two-repo git fixture under a NON-git workspace +root (createWorkspaceFixture), so a leaked singular-root capture/verify would silently pass and the test would +catch it. Narrow seams (FN-5048): we set `(executor as any).workspaceConfig` directly and stub only the store +methods the guards read (parseFileScopeFromPrompt, logEntry, getRunContextFor) — no mock-the-world child_process. + +Coverage: +- scope-leak error: an uncommitted in-scope vs OFF-scope change in repo A → evaluateTaskDoneScopeLeak blocks, + message NAMES repo-a (per-repo guard fires; singular root would silently pass). +- verify error: a worktree HEAD off fusion/ → verifyWorktreeInvariants blocks (wrong_branch, repo-tagged). +- all-clean: a two-repo task with only in-scope changes → scope-leak does NOT block. +- helper: deriveRepoForPath / deriveRepoScopeSubset / splitRepoScopedPath unit cases (wolf-server/src/** → wolf-server; + non-matching first segment → unscoped). +- regression: single-repo (non-workspace) task → singular scope-leak path unchanged. +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore, WorkspaceConfig, Settings } from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; +import { + deriveRepoForPath, + deriveRepoScopeSubset, + splitRepoScopedPath, + UNSCOPED_REPO, +} from "../workspace-paths.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-1001"; +const BRANCH = "fusion/fn-1001"; + +// reviewLevel=1 + block enforcement is the only mode that BLOCKS (else warn). +const SETTINGS: Settings = { autoMerge: false, planOnlyScopeLeakEnforcement: "block" } as Settings; +const PROMPT = "## Review Level: 1 (Plan Only)\n"; + +function createStore(declaredScope: string[]): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + parseFileScopeFromPrompt: vi.fn().mockResolvedValue(declaredScope), + logEntry: vi.fn().mockResolvedValue(undefined), + getRunContextFor: vi.fn(), + getSettings: vi.fn().mockResolvedValue(SETTINGS), + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(overrides: Partial = {}): Task { + return { + id: TASK_ID, + title: "WS", + description: "", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +/** Add a fusion/ worktree to a sub-repo with one committed in-scope edit; return its handle. */ +function addRepoWorktree(fx: WorkspaceFixture, repoRel: string, fileName: string): { worktreePath: string; baseCommitSha: string } { + const repoDir = fx.repoPath(repoRel); + const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-ws-1"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + mkdirSync(path.dirname(path.join(worktreePath, fileName)), { recursive: true }); + writeFileSync(path.join(worktreePath, fileName), "// in-scope\n", "utf-8"); + execSync(`git add ${fileName}`, { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): edit ${fileName}"`, { cwd: worktreePath, stdio: "pipe" }); + return { worktreePath, baseCommitSha }; +} + +function workspaceExecutor(fx: WorkspaceFixture, store: TaskStore & EventEmitter): TaskExecutor { + const executor = new TaskExecutor(store, fx.rootDir); + (executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig; + return executor; +} + +describe("U2 — workspace-paths repo-prefix helper (unit)", () => { + const repos = ["wolf-server", "repo-a", "apps/web"]; + it("deriveRepoForPath: first-segment match → that repo", () => { + expect(deriveRepoForPath("wolf-server/src/index.ts", repos)).toBe("wolf-server"); + expect(deriveRepoForPath("repo-a/src/a.ts", repos)).toBe("repo-a"); + }); + it("deriveRepoForPath: longest nested-key match wins", () => { + expect(deriveRepoForPath("apps/web/page.tsx", repos)).toBe("apps/web"); + }); + it("deriveRepoForPath: non-matching first segment → unscoped", () => { + expect(deriveRepoForPath(".changeset/x.md", repos)).toBe(UNSCOPED_REPO); + expect(deriveRepoForPath("other/thing.ts", repos)).toBe(UNSCOPED_REPO); + expect(deriveRepoForPath("repo-ab/x.ts", repos)).toBe(UNSCOPED_REPO); // segment-wise, not substring + }); + it("splitRepoScopedPath: strips the repo prefix for the repo-local remainder", () => { + expect(splitRepoScopedPath("wolf-server/src/x.ts", repos)).toEqual({ repo: "wolf-server", relativePath: "src/x.ts" }); + expect(splitRepoScopedPath("other/x.ts", repos)).toEqual({ repo: UNSCOPED_REPO, relativePath: "other/x.ts" }); + }); + it("deriveRepoScopeSubset: returns repo-local scope patterns for one repo", () => { + const scope = ["wolf-server/src/**", "repo-a/lib/x.ts", "apps/web/page.tsx"]; + expect(deriveRepoScopeSubset(scope, "wolf-server")).toEqual(["src/**"]); + expect(deriveRepoScopeSubset(scope, "repo-a")).toEqual(["lib/x.ts"]); + // repo-root scope entry maps to whole-repo ** + expect(deriveRepoScopeSubset(["repo-a"], "repo-a")).toEqual(["**"]); + }); +}); + +describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("error: an off-scope change in repo A blocks completion and NAMES repo-a", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + // Off-scope STAGED-but-uncommitted change in repo-a (outside declared `repo-a/src/**`). + // captureUncommittedModifiedFiles reads `git diff`/`--cached`, so the leak must be tracked + // (staged) to register — an untracked file is invisible to the guard by design. + writeFileSync(path.join(a.worktreePath, "OFFSCOPE.md"), "// leak\n", "utf-8"); + execSync("git add OFFSCOPE.md", { cwd: a.worktreePath, stdio: "pipe" }); + // Declared scope is repo-prefixed and only covers src/** in each repo. + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("repo-a"); + expect(result.message).toContain("OFFSCOPE.md"); + }); + + it("all-clean: only in-scope changes in both repos → not blocked", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(false); + }); +}); + +describeIfGit("U2 KTD4 — per-repo worktree-invariant verify in fn_task_done", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("error: a worktree off fusion/ blocks completion via per-repo verify", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + execSync("git checkout -b drifted-branch", { cwd: b.worktreePath, stdio: "pipe" }); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).verifyWorktreeInvariants(task); + expect(result.ok).toBe(false); + expect(result.reason).toBe("wrong_branch"); + expect(result.repo).toBe("repo-b"); + }); +}); + +describeIfGit("U2 — single-repo (non-workspace) task: scope-leak unchanged", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("regression: singular scope-leak path still flags an off-scope change in the singular worktree", async () => { + fx = await createWorkspaceFixture(); + const repoDir = fx.repoPath("repo-a"); + const worktreePath = path.join(repoDir, ".worktrees", "fn-001"); + const base = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim(); + execSync(`git worktree add -b fusion/fn-001 ${worktreePath} HEAD`, { cwd: repoDir, stdio: "pipe" }); + configureIdentity(worktreePath); + // Off-scope STAGED change (declared scope is `src/**`). Tracked so the guard sees it. + writeFileSync(path.join(worktreePath, "OFFSCOPE.md"), "// leak\n", "utf-8"); + execSync("git add OFFSCOPE.md", { cwd: worktreePath, stdio: "pipe" }); + + const store = createStore(["src/**"]); + const executor = new TaskExecutor(store, repoDir); // no workspaceConfig → singular path + const task = makeTask({ id: "FN-001", branch: "fusion/fn-001", worktree: worktreePath, baseCommitSha: base }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, worktreePath, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("OFFSCOPE.md"); + // Singular message carries no repo tag. + expect(result.message).not.toContain("repo="); + }); +}); diff --git a/packages/engine/src/__tests__/reviewer-workspace.test.ts b/packages/engine/src/__tests__/reviewer-workspace.test.ts new file mode 100644 index 0000000000..cab774f3aa --- /dev/null +++ b/packages/engine/src/__tests__/reviewer-workspace.test.ts @@ -0,0 +1,213 @@ +/* +FNXC:Workspace 2026-06-22-00:30: +U2 KTD3 — per-repo review (BOTH call sites) + conjunction aggregation tests. The reviewer is an AGENT +spawned with `cwd = worktree`; per-repo review means ONE reviewer agent per sub-repo with the CALLERS +looping the single-cwd `reviewStep`. These tests assert the LOOP + aggregation, not the reviewer's content: +`reviewStep` is mocked (the narrow AI seam — FN-5048: no mock-the-world, no real AI spawn) and we record +the cwd of each call. Coverage: +- conjunction: two-repo task → two reviewer passes (one per repo cwd); review record reflects both; reviewed + only when BOTH pass; one repo REVISE → aggregate REVISE tagged with that repo. +- finding tag: a finding in repo B is repo-tagged in the aggregated review body. +- in-session seam (createReviewStepTool / fn_review_step): a workspace task reviews each sub-repo cwd, not the root. +- step-inversion seam (createAuthoritativeWorkflowSeams().stepReview, executor.ts:5668): same — each sub-repo, not root. +- regression: single-repo (non-workspace) task → exactly one reviewStep call at the singular worktree. +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { ReviewResult } from "../reviewer.js"; + +// Narrow AI seam: only reviewStep (the agent boundary) is mocked. Everything else is the real executor. +vi.mock("../reviewer.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, reviewStep: vi.fn() }; +}); + +import { reviewStep as mockedReviewStepFn } from "../reviewer.js"; +import { TaskExecutor } from "../executor.js"; +import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflow-node-handlers.js"; +import type { Task, TaskStore, WorkspaceConfig } from "@fusion/core"; + +const mockedReviewStep = vi.mocked(mockedReviewStepFn); + +const ROOT = "/tmp/ws-root"; // NON-git workspace root — must never be a review cwd in workspace mode. +const WT_A = "/tmp/ws-root/repo-a/.worktrees/fn-1"; +const WT_B = "/tmp/ws-root/repo-b/.worktrees/fn-1"; + +function makeStore(task: Task): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + getTask: vi.fn().mockResolvedValue(task), + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + updateStep: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + getRunContextFor: vi.fn(), + // mergeEffectiveSettings degrades to base on any resolver error; these reject → base used. + getTaskWorkflowSelection: vi.fn().mockRejectedValue(new Error("no workflow")), + getWorkflowDefinition: vi.fn().mockRejectedValue(new Error("no workflow")), + getWorkflowSettingValues: vi.fn().mockRejectedValue(new Error("no workflow")), + }) as unknown as TaskStore & EventEmitter; +} + +function makeTask(overrides: Partial = {}): Task { + return { + id: "FN-1", + title: "WS", + description: "", + column: "in-progress", + dependencies: [], + steps: [ + { name: "Step 0", status: "done" }, + { name: "Step 1", status: "in-progress" }, + ], + currentStep: 1, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +const TWO_REPO_WORKTREES = { + "repo-a": { worktreePath: WT_A, branch: "fusion/fn-1", baseCommitSha: "aaa" }, + "repo-b": { worktreePath: WT_B, branch: "fusion/fn-1", baseCommitSha: "bbb" }, +}; + +/** Script reviewStep to return a per-cwd verdict and record the cwd it was called with. */ +function scriptReviewByCwd(byCwd: Record): string[] { + const seenCwds: string[] = []; + mockedReviewStep.mockImplementation((async (cwd: string) => { + seenCwds.push(cwd); + return byCwd[cwd] ?? { verdict: "APPROVE", review: `ok ${cwd}`, summary: `ok ${cwd}` }; + }) as any); + return seenCwds; +} + +function workspaceExecutor(store: TaskStore & EventEmitter): TaskExecutor { + const executor = new TaskExecutor(store, ROOT); + (executor as any).workspaceConfig = { repos: ["repo-a", "repo-b"] } as WorkspaceConfig; + return executor; +} + +beforeEach(() => { + mockedReviewStep.mockReset(); +}); +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared loop both call sites use)", () => { + it("conjunction: two repos both APPROVE → aggregate APPROVE, one reviewer pass per repo cwd", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const seen: string[] = []; + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string, repo: string) => { + seen.push(cwd); + return { verdict: "APPROVE", review: `clean in ${repo}`, summary: `clean ${repo}` }; + }); + expect(seen).toEqual([WT_A, WT_B]); // one pass per sub-repo cwd, never ROOT + expect(result.verdict).toBe("APPROVE"); + expect(result.review).toContain("repo-a"); + expect(result.review).toContain("repo-b"); + }); + + it("conjunction: one repo REVISE → aggregate REVISE, tagged with the failing repo", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const result = await (executor as any).reviewWorkspacePerRepo(task, async (_cwd: string, repo: string) => { + return repo === "repo-b" + ? { verdict: "REVISE", review: `bug in ${repo}`, summary: `revise ${repo}` } + : { verdict: "APPROVE", review: `clean ${repo}`, summary: `clean ${repo}` }; + }); + expect(result.verdict).toBe("REVISE"); + expect(result.review).toContain("repo-b"); // finding repo-tagged + expect(result.review).toContain("bug in repo-b"); + expect(result.summary).toMatch(/^repo-b:/); + }); + + it("zero-acquire workspace task → UNAVAILABLE (caller routes; no fabricated APPROVE)", async () => { + const task = makeTask({ workspaceWorktrees: {} }); + const executor = workspaceExecutor(makeStore(task)); + const invoke = vi.fn(); + const result = await (executor as any).reviewWorkspacePerRepo(task, invoke); + expect(result.verdict).toBe("UNAVAILABLE"); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("U2 KTD3 — in-session fn_review_step (createReviewStepTool) loops per sub-repo", () => { + it("workspace task: code review spawns one reviewer per sub-repo cwd, not the root", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const store = makeStore(task); + const executor = workspaceExecutor(store); + const seen = scriptReviewByCwd({ + [WT_A]: { verdict: "APPROVE", review: "a ok", summary: "a" }, + [WT_B]: { verdict: "APPROVE", review: "b ok", summary: "b" }, + }); + const tool = (executor as any).createReviewStepTool( + task.id, + ROOT, // singular worktreePath = the non-git root; workspace mode must NOT review it + "PROMPT", + new Map(), + { current: null }, + new Map(), + task, + undefined, + ); + const res = await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" }); + expect(seen).toEqual([WT_A, WT_B]); + expect(seen).not.toContain(ROOT); + // Aggregate APPROVE flows through the tool's verdict→text mapping unchanged. + expect(res.content[0].text).toBe("APPROVE"); + }); + + it("regression: single-repo (non-workspace) task → exactly one reviewStep call at the singular worktree", async () => { + const task = makeTask(); + const store = makeStore(task); + const executor = new TaskExecutor(store, ROOT); // no workspaceConfig → singular path + const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "ok", summary: "ok" } }); + const tool = (executor as any).createReviewStepTool( + task.id, + WT_A, + "PROMPT", + new Map(), + { current: null }, + new Map(), + task, + undefined, + ); + await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" }); + expect(seen).toEqual([WT_A]); + }); +}); + +describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per sub-repo", () => { + it("workspace task: stepReview spawns one reviewer per sub-repo cwd, not active.worktreePath/root", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES, worktree: ROOT }); + const store = makeStore(task); + const executor = workspaceExecutor(store); + const seen = scriptReviewByCwd({ + [WT_A]: { verdict: "APPROVE", review: "a", summary: "a" }, + [WT_B]: { verdict: "APPROVE", review: "b", summary: "b" }, + }); + const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any); + // Drive the foreach-active step-review handler directly with a scripted active context. + const context = { + [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: ROOT, baselineSha: "base" }, + } as any; + const result = await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any); + expect(seen).toEqual([WT_A, WT_B]); + expect(seen).not.toContain(ROOT); + expect(result.verdict).toBe("APPROVE"); + }); + + it("regression: single-repo stepReview reviews the active worktree once", async () => { + const task = makeTask({ worktree: WT_A }); + const store = makeStore(task); + const executor = new TaskExecutor(store, ROOT); // no workspaceConfig + const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "a", summary: "a" } }); + const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any); + const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any; + await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any); + expect(seen).toEqual([WT_A]); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 16bb0d4a56..74d0f45a2b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -77,7 +77,7 @@ import { resolveExecutorSessionModel, } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; -import { reviewStep, type ReviewVerdict } from "./reviewer.js"; +import { reviewStep, type ReviewVerdict, type ReviewResult } from "./reviewer.js"; import { resolveSandboxBackend } from "./sandbox/index.js"; import type { SandboxBackend } from "./sandbox/types.js"; import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@earendil-works/pi-coding-agent"; @@ -5664,9 +5664,14 @@ export class TaskExecutor { const settings = await mergeEffectiveSettings(this.store, detail, await this.store.getSettings()); const sem = this.options.semaphore; - const invokeReviewer = () => + // FNXC:Workspace 2026-06-22-00:30: KTD3 — step-inversion review seam loops per sub-repo. + // `reviewStep` stays single-cwd; THIS CALLER loops. Single-cwd by default reviews + // `worktreePath`; in workspace mode that is the browse-only non-git root, so we instead spawn + // one reviewer per acquired sub-repo (cwd = repo.worktreePath) via reviewWorkspacePerRepo and + // aggregate as a conjunction. `invokeReviewerForCwd` is the per-cwd reviewStep call both modes share. + const invokeReviewerForCwd = (cwd: string) => reviewStep( - worktreePath, + cwd, seamTask.id, stepIndex, stepName, @@ -5702,10 +5707,18 @@ export class TaskExecutor { onSessionEnded: (s) => this.unregisterSubagentSession(seamTask.id, s), }, ); + const runForCwd = (cwd: string) => { + const invoke = () => invokeReviewerForCwd(cwd); + return sem ? sem.runNested(invoke) : invoke(); + }; + const invokeReviewer = () => + this.workspaceConfig + ? this.reviewWorkspacePerRepo(detail, (cwd) => runForCwd(cwd)) + : runForCwd(worktreePath); let review: { verdict: ReviewVerdict; review: string; summary: string }; try { - review = sem ? await sem.runNested(invokeReviewer) : await invokeReviewer(); + review = await invokeReviewer(); } catch (err) { const message = err instanceof Error ? err.message : String(err); reviewerLog.error(`${seamTask.id}: step-review failed: ${message}`); @@ -10855,24 +10868,62 @@ export class TaskExecutor { return { blocked: false }; } - const [uncommittedTouchedFiles, branchCommittedFiles] = await Promise.all([ - this.captureUncommittedModifiedFiles(worktreePath), - this.captureModifiedFiles(worktreePath, task.baseCommitSha, task.id, audit, "scope-leak-guard"), - ]); - - const touchedFiles = [...new Set([...uncommittedTouchedFiles, ...branchCommittedFiles])]; - if (touchedFiles.length === 0) { - return { blocked: false }; + // FNXC:Workspace 2026-06-22-00:30: KTD4 — per-repo scope-leak guard. + // The singular capture below runs `captureUncommittedModifiedFiles` + `captureModifiedFiles` + // against `worktreePath`. In workspace mode `worktreePath` is the browse-only non-git workspace + // root, so both silently return [] (git failures swallowed) and the uncommitted-in-scope block + // never fires — a workspace task could complete with off-scope changes in any sub-repo. So we + // ITERATE every acquired sub-repo (cwd = repo.worktreePath, base = repo.baseCommitSha), + // repo-prefix each repo's touched files (`/`) so they compare against the task's + // repo-prefixed declared File Scope, and block on the FIRST repo carrying off-scope changes — + // naming the repo. The task-level preamble above (scopeOverride / declaredScope / enforcementMode) + // is shared and runs once. Return shape is preserved: `{blocked:false} | {blocked:true; message}`. + let touchedFiles: string[]; + let offendingRepo: string | undefined; + if (this.workspaceConfig) { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const aggregatedOffScope: string[] = []; + for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + const [repoUncommitted, repoCommitted] = await Promise.all([ + this.captureUncommittedModifiedFiles(repo.worktreePath), + this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, "scope-leak-guard"), + ]); + const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])].map((f) => `${repoRel}/${f}`); + const repoOffScope = repoTouched + .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) + .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)); + if (repoOffScope.length > 0) { + // First offending repo wins (mirrors verifyWorktreeInvariants' first-failing-repo return). + if (!offendingRepo) offendingRepo = repoRel; + aggregatedOffScope.push(...repoOffScope); + } + } + touchedFiles = aggregatedOffScope; + if (touchedFiles.length === 0) { + return { blocked: false }; + } + } else { + const [uncommittedTouchedFiles, branchCommittedFiles] = await Promise.all([ + this.captureUncommittedModifiedFiles(worktreePath), + this.captureModifiedFiles(worktreePath, task.baseCommitSha, task.id, audit, "scope-leak-guard"), + ]); + touchedFiles = [...new Set([...uncommittedTouchedFiles, ...branchCommittedFiles])]; + if (touchedFiles.length === 0) { + return { blocked: false }; + } } - const offScopeFiles = touchedFiles - .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) - // FN-4811 follow-up: by convention every task may add its own changeset entry - // under `.changeset/`, so changeset files are always considered in-scope and - // never flagged by the scope-leak guard. The file-scope invariant at squash and - // the broader contamination guards still catch cross-task changeset leakage at - // a higher signal-to-noise ratio than the per-execution scope-leak warning. - .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)); + const offScopeFiles = (this.workspaceConfig + // In workspace mode `touchedFiles` is already the off-scope set (filtered per repo above). + ? touchedFiles + : touchedFiles + .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) + // FN-4811 follow-up: by convention every task may add its own changeset entry + // under `.changeset/`, so changeset files are always considered in-scope and + // never flagged by the scope-leak guard. The file-scope invariant at squash and + // the broader contamination guards still catch cross-task changeset leakage at + // a higher signal-to-noise ratio than the per-execution scope-leak warning. + .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath))); if (offScopeFiles.length === 0) { return { blocked: false }; } @@ -10887,14 +10938,16 @@ export class TaskExecutor { const offScopePreview = renderListPreview(offScopeFiles); const declaredScopePreview = renderListPreview(declaredScope); - const message = `[scope-leak] reviewLevel=${reviewLevel} enforcement=${enforcementMode} off-scope touched files [${offScopePreview}]; declared scope [${declaredScopePreview}]; total off-scope=${offScopeFiles.length} total scope=${declaredScope.length}`; + // Name the offending sub-repo in workspace mode so the operator/agent knows where to revert. + const repoTag = offendingRepo ? ` repo=${offendingRepo}` : ""; + const message = `[scope-leak] reviewLevel=${reviewLevel} enforcement=${enforcementMode}${repoTag} off-scope touched files [${offScopePreview}]; declared scope [${declaredScopePreview}]; total off-scope=${offScopeFiles.length} total scope=${declaredScope.length}`; executorLog.warn(`${task.id}: ${message}`); await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id)); if (enforcementMode === "block") { return { blocked: true, - message: `Plan-Only scope-leak guard refused fn_task_done. Off-scope paths: [${offScopePreview}]. Revert them before retrying (for example: git checkout -- ).`, + message: `Plan-Only scope-leak guard refused fn_task_done${offendingRepo ? ` (sub-repo ${offendingRepo})` : ""}. Off-scope paths: [${offScopePreview}]. Revert them before retrying (for example: git checkout -- ).`, }; } @@ -11337,8 +11390,13 @@ export class TaskExecutor { // result, so the soft breach of `limit` does not push real // LLM-active concurrency above the configured cap. const sem = options.semaphore; - const invokeReviewer = () => reviewStep( - worktreePath, taskId, step, step_name, + // FNXC:Workspace 2026-06-22-00:30: KTD3 — in-session fn_review_step loops per sub-repo. + // `reviewStep` stays single-cwd; THIS CALLER loops. Single-cwd by default reviews `worktreePath`; + // in workspace mode that is the browse-only non-git root, so we spawn one reviewer per acquired + // sub-repo (cwd = repo.worktreePath) via reviewWorkspacePerRepo and aggregate as a conjunction. + // `invokeReviewerForCwd` is the per-cwd reviewStep call both modes share. + const invokeReviewerForCwd = (cwd: string) => reviewStep( + cwd, taskId, step, step_name, reviewType, promptContent, baseline, { onText: (delta) => options.onAgentText?.(taskId, delta), @@ -11377,9 +11435,13 @@ export class TaskExecutor { onSessionEnded: (s) => this.unregisterSubagentSession(taskId, s), }, ); - const result = sem - ? await sem.runNested(invokeReviewer) - : await invokeReviewer(); + const runForCwd = (cwd: string) => { + const invoke = () => invokeReviewerForCwd(cwd); + return sem ? sem.runNested(invoke) : invoke(); + }; + const result = this.workspaceConfig + ? await this.reviewWorkspacePerRepo(currentTask, (cwd) => runForCwd(cwd)) + : await runForCwd(worktreePath); await store.logEntry( taskId, @@ -12403,6 +12465,70 @@ ${failureFeedback} return aggregated; } + /** + * FNXC:Workspace 2026-06-22-00:30: KTD3 — per-repo review by looping the EXISTING single-cwd reviewStep. + * The reviewer is an AGENT spawned with `cwd = worktree`, told (in prompt text, reviewer.ts) to run `git diff` + * itself — it does NOT read a diff passed in code. So per-repo review = ONE reviewer agent per sub-repo. We keep + * `reviewStep` single-cwd; the CALLERS loop. This helper is the shared loop+aggregate so both review entry points + * (`createReviewStepTool` and the step-inversion `stepReview` seam) iterate identically: it invokes the caller's + * own `invokeForCwd(cwd)` once per acquired worktree (cwd = repo.worktreePath) and aggregates the repo-tagged + * verdicts as a CONJUNCTION — the task is "reviewed" only if EVERY repo passes; the FIRST non-APPROVE repo's + * verdict becomes the aggregate verdict (mirroring verifyWorktreeInvariants' first-failing-repo return), and its + * findings are repo-tagged. A zero-acquire workspace task (empty map) returns UNAVAILABLE so the caller routes it + * rather than fabricating an APPROVE. + * + * Verdict severity for the conjunction: any RETHINK/REVISE/UNAVAILABLE fails the whole review; only all-APPROVE + * (or all-skipped UNAVAILABLE-advisory, handled by the caller) approves. We surface the first failing repo's exact + * verdict so the caller's existing verdict→edge mapping (APPROVE done-marking, REVISE block, RETHINK reset, + * UNAVAILABLE retry) is unchanged. + */ + private async reviewWorkspacePerRepo( + task: Task, + invokeForCwd: (cwd: string, repoRel: string) => Promise, + ): Promise { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const entries = Object.entries(workspaceWorktrees); + if (entries.length === 0) { + // No acquired worktree — surface UNAVAILABLE so the caller routes it rather than + // fabricating an authoritative APPROVE for an un-reviewable workspace task. + return { + verdict: "UNAVAILABLE", + review: "No acquired sub-repo worktree to review (workspace task with zero worktrees).", + summary: "Skipped: no sub-repo worktree", + }; + } + + const reviewSections: string[] = []; + const summarySections: string[] = []; + let firstFailing: { repo: string; result: ReviewResult } | undefined; + for (const [repoRel, repo] of entries) { + const result = await invokeForCwd(repo.worktreePath, repoRel); + // Tag every per-repo finding with its sub-repo so downstream readers attribute it correctly. + reviewSections.push(`### [${repoRel}] ${result.verdict}\n${result.review}`); + summarySections.push(`[${repoRel}] ${result.verdict}: ${result.summary}`); + if (result.verdict !== "APPROVE" && !firstFailing) { + firstFailing = { repo: repoRel, result }; + } + } + + if (firstFailing) { + // Conjunction failed: the aggregate carries the FIRST failing repo's verdict (so the caller's + // verdict→edge mapping is identical to single-cwd), with the full repo-tagged review body. + return { + verdict: firstFailing.result.verdict, + review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + summary: `${firstFailing.repo}: ${firstFailing.result.verdict} — ${summarySections.join(" | ")}`, + }; + } + + // Every sub-repo approved → the task is reviewed (conjunction satisfied). + return { + verdict: "APPROVE", + review: `All ${entries.length} sub-repo(s) approved. Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + summary: `APPROVE across ${entries.length} sub-repo(s): ${summarySections.join(" | ")}`, + }; + } + private async captureUncommittedModifiedFiles(worktreePath: string): Promise { try { const [unstaged, staged] = await Promise.all([ diff --git a/packages/engine/src/workspace-paths.ts b/packages/engine/src/workspace-paths.ts new file mode 100644 index 0000000000..308c559341 --- /dev/null +++ b/packages/engine/src/workspace-paths.ts @@ -0,0 +1,117 @@ +/* +FNXC:Workspace 2026-06-22-00:30: +Minimal shared repo-prefix-derivation helper for workspace mode (Phase B U2; master U5 reuses it). A workspace task's File Scope, modified-file list, and review/scope-leak findings are all repo-prefixed (`/`). Per-repo review and per-repo scope-leak need to map a path → its owning sub-repo, and to derive each repo's File-Scope subset (so a reviewer at `cwd = repo.worktreePath` and a per-repo scope-leak check evaluate only that repo's declared paths). + +NO lease logic lives here (file-scope leases are Phase C / master U7). This module is intentionally dependency-light (pure string/path math) so it can be reused across the executor, reviewer callers, and the later merge loop without pulling in executor state. + +Matching rule: canonicalize the path to forward-slash relative segments, then pick the LONGEST configured repo key that is a path-segment prefix of the file path. Longest-prefix (not naive first-segment) correctly handles nested repo keys like `apps/web` while still satisfying the simple `wolf-server/src/** → wolf-server` case. A path that matches no configured repo (absolute paths outside the workspace, root-level files like `.changeset/x.md`, or a first segment that is not a repo) derives to the `UNSCOPED` sentinel. +*/ + +/** Sentinel returned when a path does not belong to any configured sub-repo. */ +export const UNSCOPED_REPO = "unscoped" as const; + +/** + * Normalize a workspace-relative path token to forward-slash form with no leading + * `./`, no leading/trailing slashes, and collapsed duplicate slashes. Mirrors the + * executor's `normalizeWorkflowScopePath` shape so File-Scope tokens and modified + * files compare consistently, but kept local to avoid an executor import cycle. + */ +function normalizeRepoRelPath(value: string): string { + return value + .trim() + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/\/+/g, "/") + .replace(/^\/+/, "") + .replace(/\/+$/, ""); +} + +/** Split a normalized path into non-empty segments. */ +function segmentsOf(value: string): string[] { + const normalized = normalizeRepoRelPath(value); + return normalized ? normalized.split("/") : []; +} + +/** + * Return true when `repoSegs` is a leading segment-prefix of `pathSegs`. + * Segment-wise (not substring) so `repo-a` does NOT match `repo-ab/...`. + */ +function isSegmentPrefix(repoSegs: string[], pathSegs: string[]): boolean { + if (repoSegs.length === 0 || repoSegs.length > pathSegs.length) return false; + for (let i = 0; i < repoSegs.length; i++) { + if (repoSegs[i] !== pathSegs[i]) return false; + } + return true; +} + +/** + * Derive the configured sub-repo that owns `filePath`, or {@link UNSCOPED_REPO}. + * + * `repos` are the configured workspace sub-repo relative keys (from + * `workspaceConfig.repos` or `Object.keys(task.workspaceWorktrees)`). The LONGEST + * matching repo key wins so nested repos (`apps/web` vs `apps`) resolve to the + * most specific owner. + */ +export function deriveRepoForPath(filePath: string, repos: readonly string[]): string { + const pathSegs = segmentsOf(filePath); + if (pathSegs.length === 0) return UNSCOPED_REPO; + let best: string | null = null; + let bestLen = 0; + for (const repo of repos) { + const repoSegs = segmentsOf(repo); + if (repoSegs.length === 0) continue; + if (isSegmentPrefix(repoSegs, pathSegs) && repoSegs.length > bestLen) { + best = normalizeRepoRelPath(repo); + bestLen = repoSegs.length; + } + } + return best ?? UNSCOPED_REPO; +} + +/** + * Result of splitting a repo-prefixed File-Scope entry into its owning repo and + * the repo-relative remainder (the path AS the reviewer at `cwd = repo` sees it). + */ +export interface RepoScopedPath { + /** Owning sub-repo key, or {@link UNSCOPED_REPO}. */ + repo: string; + /** The path with the repo prefix stripped (repo-local). Equals `path` when unscoped. */ + relativePath: string; +} + +/** + * Split a repo-prefixed path into `{ repo, relativePath }`. For `repo-a/src/x.ts` + * with `repos=["repo-a"]` → `{ repo:"repo-a", relativePath:"src/x.ts" }`. An + * unscoped path returns the whole normalized path as `relativePath`. + */ +export function splitRepoScopedPath(filePath: string, repos: readonly string[]): RepoScopedPath { + const repo = deriveRepoForPath(filePath, repos); + const normalized = normalizeRepoRelPath(filePath); + if (repo === UNSCOPED_REPO) { + return { repo, relativePath: normalized }; + } + const repoNormalized = normalizeRepoRelPath(repo); + const remainder = normalized.slice(repoNormalized.length).replace(/^\/+/, ""); + return { repo, relativePath: remainder }; +} + +/** + * Derive a single sub-repo's File-Scope subset from the task's full (repo-prefixed) + * declared scope. Returns the repo-LOCAL scope patterns (prefix stripped) so a + * per-repo reviewer or per-repo scope-leak check — operating with `cwd = repo` — + * can compare repo-local paths directly. Entries owned by other repos (or unscoped) + * are excluded. A scope entry whose prefix-stripped remainder is empty (the repo + * root itself, e.g. `repo-a` or `repo-a/`) maps to `**` (whole-repo scope). + */ +export function deriveRepoScopeSubset(declaredScope: readonly string[], repoRel: string): string[] { + const repoSegs = segmentsOf(repoRel); + if (repoSegs.length === 0) return []; + const subset: string[] = []; + for (const entry of declaredScope) { + const entrySegs = segmentsOf(entry); + if (!isSegmentPrefix(repoSegs, entrySegs)) continue; + const remainder = entrySegs.slice(repoSegs.length).join("/"); + subset.push(remainder === "" ? "**" : remainder); + } + return subset; +} From 0367fa54d9876f9a010634b2101f896e6022db62 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 22:44:53 -0700 Subject: [PATCH 23/44] docs(workspace): Phase B implementation plan (U3/U4) --- ...6-06-21-005-feat-workspace-phase-b-plan.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md diff --git a/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md b/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md new file mode 100644 index 0000000000..15dd907348 --- /dev/null +++ b/docs/plans/2026-06-21-005-feat-workspace-phase-b-plan.md @@ -0,0 +1,145 @@ +--- +title: "feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase B / U3·U4) +depth: deep +--- + +# feat: Workspace mode Phase B — per-repo capture, contamination, review, completion verify + +> **ID namespace:** local `U1·U2` decompose master-plan **U3, U4**. +> **Anchors below are feasibility-verified against the Phase-B base** (not the master plan's approximate numbers). + +## Summary + +Phase B makes the executor's capture / contamination / verify / review / completion paths iterate `task.workspaceWorktrees` per sub-repo, using each repo's own `baseCommitSha` (Phase A, U2). It does **not** simply "un-gate stubs" — the feasibility pass found capture/contamination/scope-leak are not gated at all today; they **silently degrade to empty** against the non-git root (git failures swallowed). Phase B adds the missing workspace branches and reuses the existing `captureModifiedFiles` machinery (whose `resolveDiffBaseRef` merge-base fallback + `filterFilesToOwnTaskCommits` contamination audit are exactly what's needed) per repo. + +Builds on Phase A (PR #1713). **Scope out:** the merge loop (master U6 = Phase C), self-healing (master U8 = Phase D). + +**Stacking:** off the Phase-A branch; PR diff includes the stack; must not merge until it lands. + +--- + +## Problem Frame + +Phase A rooted workspace sessions at the non-git workspace root and acquired per-repo worktrees, but the executor's change-capture, contamination, worktree-invariant, review, and completion-verify paths still operate on a single `task.worktree`. Against the non-git root they either are explicitly stubbed (one site) or silently produce empty results (the rest). Phase B routes each of these through every acquired sub-repo worktree, `cwd` = the sub-repo, diffing against that repo's `workspaceWorktrees[repo].baseCommitSha`, with repo-prefixed file lists so review/dashboard/later-merge keep repo context. + +--- + +## Key Technical Decisions + +### KTD1 — Per-repo change capture by **reusing `captureModifiedFiles`**, not a raw diff (master KTD7) +**Verified reality:** capture is **not** workspace-gated. The post-session call `captureModifiedFiles(worktreePath, …, "post-session")` (executor.ts **:7898**) runs ungated with `worktreePath` = the browse-only non-git root and returns `[]` only because `resolveDiffBaseRef`/`resolveContaminationBaseRef` swallow the git failure. So U1 **adds** a workspace branch at :7898 (and the sibling branch-attribution audit at **:7914**), it does not replace one. + +Per repo, call the **existing** `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source)` — NOT a hand-built `git diff ..HEAD`. Reasons (all verified): (a) `repo.baseCommitSha` may be **undefined** (Phase A made base capture non-fatal); `resolveDiffBaseRef` (:~12184) handles that via a merge-base fallback. (b) the real **contamination** signal is the `filterFilesToOwnTaskCommits` raw-vs-attributed divergence audit **inside** `captureModifiedFiles` (:~12225-12246) — reusing it restores contamination for free. Prefix each repo's returned files with the repo path and aggregate into `task.modifiedFiles`. + +> **`assertCleanBranchAtBase` is a no-op** (branch-conflicts.ts: `void`s all params — "informational only"). Do **not** add a per-repo iteration of it; it would restore zero protection. Contamination comes from per-repo `captureModifiedFiles`. + +### KTD2 — `verifyWorktreeInvariants` iterates per acquired worktree, preserving its result union (master KTD7) +The **one** workspace stub in this region is `verifyWorktreeInvariants` returning `{ok:true}` at executor.ts **:10508** (def **:10500**). Un-stub it: iterate every `workspaceWorktrees` entry, asserting each HEAD is on `fusion/` and toplevel matches the recorded `worktreePath`. **Preserve the exact discriminated union** `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` (consumed at **:10889**; the `reason` enum drives the requeue/handoff branches at :10894-10936) — add a `repo` field to the failure shape; return the **first** failing repo. + +### KTD3 — Per-repo review by looping the **existing single-cwd `reviewStep`** N times (master KTD7) +**Decision (user-confirmed): accept the N× reviewer cost.** The reviewer is an **agent** spawned with `cwd` = worktree and told (in prompt text, reviewer.ts:~760) to run `git diff` itself — it does not read a diff passed in code. So per-repo review = spawning **one reviewer agent per sub-repo**. Architecture: the **callers loop** and call the existing single-cwd `reviewStep` (reviewer.ts **:122**) once per acquired worktree (cwd = repo, scope = prefix-derived subset); aggregate repo-tagged verdicts into the task's single review record as a **conjunction** (reviewed only if every repo passes). `reviewStep` itself stays single-cwd. + +**Both review call sites iterate (user-confirmed FN-5893 coverage):** +- `createReviewStepTool` → `reviewStep` (executor.ts **:11148**, the in-session `fn_review_step` path). +- the **step-inversion seam** `reviewStep(worktreePath=active.worktreePath || detail.worktree || this.rootDir, …)` at executor.ts **:5668** (foreach/step-inversion path). + +### KTD4 — `fn_task_done` completion verification iterates per repo, including the scope-leak guard (master KTD7) +`fn_task_done` (`createTaskDoneTool` executor.ts **:10832**) must, in workspace mode: (a) call the per-repo `verifyWorktreeInvariants` (KTD2) for every acquired worktree; (b) iterate the **scope-leak guard** `evaluateTaskDoneScopeLeak` (executor.ts **:10711**, invoked at **:11009**) per repo — it currently runs `captureUncommittedModifiedFiles(worktreePath)` + `captureModifiedFiles(worktreePath, task.baseCommitSha, …)` against the singular root and silently passes; per-repo iteration (cwd = sub-repo, `repo.baseCommitSha`) restores the uncommitted-in-scope block. Block completion on any dirty/misbound repo or uncommitted in-scope change, naming the repo. + +> **Repo-prefix derivation helper** (shared, master U5 will reuse): canonicalize → match first path segment to a configured repo → `unscoped` fallback. New `packages/engine/src/workspace-paths.ts`. Keep it minimal — no lease logic (Phase C / master U7). + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace ` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (reuse the Phase-A `_workspace-fixture.ts` harness; real git only where the invariant requires it; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase A (already checked out: `gsxdsm/workspace-phase-b`). + +### U1. Per-repo capture, contamination, and worktree-invariant verification (master U3) + +**Goal:** Change-capture, contamination, and `verifyWorktreeInvariants` cover every acquired sub-repo worktree with repo context and correct cwd. + +**Requirements:** KTD1, KTD2. + +**Dependencies:** none beyond Phase A. + +**Files:** +- `packages/engine/src/executor.ts` — **add** a workspace branch at the post-session capture **:7898** (+ attribution audit **:7914**) that loops `workspaceWorktrees` calling `captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, …)` per repo, repo-prefixing results; **un-stub** `verifyWorktreeInvariants` **:10508** to iterate per worktree preserving the `{ok|reason|observed|expected}` union (+ `repo`). +- `packages/engine/src/__tests__/executor-workspace-capture.test.ts` (new — real two-repo fixture via `_workspace-fixture.ts`) + +**Approach:** Per KTD1/KTD2. Reuse `captureModifiedFiles` (do not hand-build `git diff`); do not iterate the no-op `assertCleanBranchAtBase`. Singular non-workspace path unchanged. + +**Execution note:** Reuse `_workspace-fixture.ts`; commit edits onto each sub-repo's `fusion/` branch to exercise real diffs + the divergence audit. + +**Test scenarios:** +- Edits in repo A and B → `task.modifiedFiles` carries repo-prefixed paths from both, each diffed against its own `baseCommitSha`. (happy path) +- A repo with `baseCommitSha` undefined → capture still works via the merge-base fallback (no `git diff undefined..HEAD`). (edge — Phase A non-fatal base) +- A foreign commit in a sub-repo's range → the `filterFilesToOwnTaskCommits` divergence/contamination audit fires for that repo. (contamination) +- A worktree HEAD drifted off `fusion/` → `verifyWorktreeInvariants` returns `{ok:false, reason:'wrong_branch', repo, observed, expected}` (not `{ok:true}`); the `reason` enum is preserved for the :10889 consumer. (error path) +- Single-repo (non-workspace) task → capture/verify byte-for-byte identical. (regression) + +**Verification:** Capture + contamination audit + invariant verify run per acquired worktree with repo context; the result union is intact; single-repo unchanged. + +--- + +### U2. Per-repo review (both call sites) + `fn_task_done` completion + scope-leak verification (master U4) + +**Goal:** Review every acquired sub-repo (both review entry points) and block completion until every sub-repo passes review, invariant, and scope-leak checks. + +**Requirements:** KTD3, KTD4, KTD2. + +**Dependencies:** U1 (per-repo verify + capture). + +**Files:** +- `packages/engine/src/executor.ts` — `createReviewStepTool` **:11148** and the step-inversion seam **:5668** loop `reviewStep` per acquired worktree; `createTaskDoneTool` **:10832** calls per-repo verify (U1) + iterates `evaluateTaskDoneScopeLeak` **:10711** per repo. +- `packages/engine/src/reviewer.ts` — `reviewStep` (**:122**) stays single-cwd; callers loop. Aggregate repo-tagged verdicts (conjunction) into the task review record; reviewer findings carry the repo tag. +- `packages/engine/src/workspace-paths.ts` (new — the repo-prefix-derivation helper; master U5 reuses) +- `packages/engine/src/__tests__/reviewer-workspace.test.ts`, `packages/engine/src/__tests__/executor-workspace-taskdone.test.ts` (new) + +**Approach:** Per KTD3/KTD4. Both review sites loop the existing single-cwd `reviewStep` once per sub-repo (N reviewer agents — accepted cost) and aggregate as a conjunction. `fn_task_done` per-repo verify + per-repo scope-leak. + +**Test scenarios:** +- Two-repo task → two reviewer passes (one per repo cwd); review record reflects both; reviewed only when both pass. (conjunction) +- A reviewer finding in repo B is repo-tagged. (integration) +- Step-inversion review seam (:5668) for a workspace task reviews each sub-repo, not the non-git root. (FN-5893 second surface) +- `fn_task_done` with an uncommitted in-scope change in repo A → completion blocked, naming repo A (the scope-leak guard fires per-repo). (error path) +- `fn_task_done` with a worktree off `fusion/` → blocked via per-repo verify. (error path) +- The prefix helper: `wolf-server/src/**` → repo `wolf-server`; non-matching first segment → `unscoped`. (helper) +- Single-repo task → one review pass + singular scope-leak/verify, unchanged. (regression) + +**Verification:** A workspace task is reviewed/complete only when every sub-repo passes review + invariant + scope-leak; both review entry points iterate; single-repo unchanged. + +--- + +## Scope Boundaries + +**In scope:** per-repo capture/contamination/verify (U1); per-repo review at both call sites + `fn_task_done` verify + scope-leak (U2); the repo-prefix helper. + +### Deferred to Follow-Up Work (later phases) +- The per-repo merge loop, the landed predicate, the file-scope leases (master U5/U6/U7 = Phase C). +- Self-healing reconcilers, e2e (master U8/U9 = Phase D). +- Per-repo worktree teardown (carried Phase-A residual). +- Store-level **atomic** per-repo `workspaceWorktrees` merge — Phase A added a re-read mitigation; the fully-atomic merge is still open and **becomes reachable in Phase B** (multi-repo acquisition first exercised here). Track for Phase C. + +--- + +## Risks & Dependencies + +- **R1 — "Add a branch" vs "replace a stub" confusion.** Capture/contamination/scope-leak silently degrade (not gated); an implementer expecting a stub to replace won't find one. Mitigation: KTD1/KTD4 + U1/U2 cite the exact add sites (:7898/:7914, :10711) and the one real stub (:10508). +- **R2 — Hand-built `git diff` breaks on undefined base.** Mitigation: KTD1 mandates reusing `captureModifiedFiles`; test covers the undefined-base repo. +- **R3 — `verifyWorktreeInvariants` union shape.** The `reason` enum is load-bearing at :10889. Mitigation: KTD2 preserves the union; test asserts the `reason`. +- **R4 — No-op contamination function.** Mitigation: KTD1 explicitly forbids iterating `assertCleanBranchAtBase`; contamination rides on per-repo `captureModifiedFiles`. +- **R5 — N× reviewer cost.** Accepted (user decision). Mitigation: note in the PR; cost scales with repo count (typically 2-3). +- **Stacking dependency:** off Phase A (#1713); diff includes the stack. + +--- + +## Sources & Research + +- Master plan (U3/U4, KTD7, contamination-window caveat). +- Phase B feasibility pre-check (verified anchors: capture not gated/:7898 add-site, `assertCleanBranchAtBase` no-op, undefined-base via `resolveDiffBaseRef`, verify union :10508/:10889, review agent N× cost + the :5668 second surface, scope-leak :10711, anchor corrections). +- Phase A (#1713): per-repo `baseCommitSha`, `activeWorktrees` Set, `_workspace-fixture.ts`. +- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md`. From 453ed92dbffbeb65ddc9c8ab888880108920f046 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:02:46 -0700 Subject: [PATCH 24/44] =?UTF-8?q?fix(review):=20Phase=20B=20workspace=20ha?= =?UTF-8?q?rdening=20=E2=80=94=20fail-closed=20scope=20guard,=20review=20c?= =?UTF-8?q?onjunction,=20.changeset=20carve-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ce-code-review (4 personas) on Phase B. No P0; the review conjunction was confirmed safe (no false-done — empty map and per-repo throws both route to UNAVAILABLE, which blocks). Applied: P1: the fn_task_done scope-leak guard now fails CLOSED in workspace mode — a per-repo capture throw blocks completion ("refusing as a precaution") instead of the outer .catch returning {blocked:false} and letting an incomplete check pass. A scoped task that acquired ZERO sub-repo worktrees is now blocked rather than silently passing scope enforcement. P2: reviewWorkspacePerRepo breaks on the first non-APPROVE repo so a later repo's throw can't discard an already-determined REVISE (callers were seeing UNAVAILABLE instead). captureWorkspaceModifiedFiles isolates each per-repo capture in try/catch so one repo's throw can't skip the modifiedFiles write. The .changeset always-allowed carve-out is honored in workspace mode: the scope-leak branch now filters repo-LOCAL paths via the (previously dead) workspace-paths.ts deriveRepoScopeSubset helper through the same filter as the singular path, so a sub-repo .changeset/* no longer falsely blocks fn_task_done. All four per-repo loops iterate sorted keys for deterministic offending-repo reporting; the dead repoRel callback param and the duplicate path-normalizer are removed. Verified safe (no change): the reviewer semaphore releases on throw (try/finally), and per-repo reviewers inherit the task abort via session disposal. Deferred to Phase C: extracting a workspace-executor.ts module (before the merge loop lands). Gate green: typecheck, lint, build, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../executor-workspace-taskdone.test.ts | 71 ++++++++ .../src/__tests__/reviewer-workspace.test.ts | 40 ++++- packages/engine/src/executor.ts | 151 +++++++++++++----- packages/engine/src/workspace-paths.ts | 12 +- 4 files changed, 227 insertions(+), 47 deletions(-) diff --git a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts index 9f24aaa75c..8d62639a6d 100644 --- a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts +++ b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts @@ -164,6 +164,77 @@ describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => { const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); expect(result.blocked).toBe(false); }); + + // FNXC:Workspace 2026-06-21-15:00: F5 — per-repo `.changeset/` carve-out honored in workspace mode. + // A legit sub-repo changeset (`repo-a/.changeset/x.md`) must NOT be flagged off-scope: the always-allowed + // filter now runs against the repo-LOCAL remainder (`.changeset/x.md`), so the carve-out matches. Before + // the fix the file was prefixed BEFORE filtering, the `.changeset/` startsWith never matched, and + // fn_task_done was wrongly REFUSED. + it("F5: a sub-repo `.changeset/` file is NOT flagged off-scope (always-allowed honored)", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + // A per-repo changeset OUTSIDE the declared `repo-a/src/**` scope — only the always-allowed + // carve-out can keep this from being a leak. + mkdirSync(path.join(a.worktreePath, ".changeset"), { recursive: true }); + writeFileSync(path.join(a.worktreePath, ".changeset", "tidy-foo.md"), "---\n'@x': patch\n---\n", "utf-8"); + execSync("git add .changeset/tidy-foo.md", { cwd: a.worktreePath, stdio: "pipe" }); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(false); + }); + + // FNXC:Workspace 2026-06-21-15:00: F2 — scoped task that acquired ZERO sub-repo worktrees is blocked. + // declaredScope is non-empty but `workspaceWorktrees` is empty → scope cannot be verified at all. The + // guard must refuse fn_task_done rather than silently aggregating zero off-scope files and passing. + it("F2: scoped task with zero acquired worktrees → blocked (cannot verify scope)", async () => { + fx = await createWorkspaceFixture(); + const store = createStore(["repo-a/src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ branch: BRANCH, workspaceWorktrees: {} }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("acquired no sub-repo worktrees"); + }); + + // FNXC:Workspace 2026-06-21-15:00: F1 — fail CLOSED on a mid-loop capture throw. + // If one repo's capture throws (scope is UNVERIFIED for that repo), the guard must BLOCK naming the + // repo — not let the outer `.catch()` fail open and proceed with an incomplete scope check. + it("F1: a mid-loop capture throw → blocked (fail-closed), names the repo", async () => { + fx = await createWorkspaceFixture(); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const b = addRepoWorktree(fx, "repo-b", "src/b.ts"); + const store = createStore(["repo-a/src/**", "repo-b/src/**"]); + const executor = workspaceExecutor(fx, store); + // Narrow seam: force the per-repo uncommitted capture to throw for repo-a's worktree only. + const realCapture = (executor as any).captureUncommittedModifiedFiles.bind(executor); + vi.spyOn(executor as any, "captureUncommittedModifiedFiles").mockImplementation(async (wt: unknown) => { + if (wt === a.worktreePath) throw new Error("simulated capture failure"); + return realCapture(wt as string); + }); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { + "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha }, + "repo-b": { worktreePath: b.worktreePath, branch: BRANCH, baseCommitSha: b.baseCommitSha }, + }, + }); + + const result = await (executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS); + expect(result.blocked).toBe(true); + expect(result.message).toContain("repo-a"); + expect(result.message).toContain("refusing fn_task_done"); + }); }); describeIfGit("U2 KTD4 — per-repo worktree-invariant verify in fn_task_done", () => { diff --git a/packages/engine/src/__tests__/reviewer-workspace.test.ts b/packages/engine/src/__tests__/reviewer-workspace.test.ts index cab774f3aa..4f5306d190 100644 --- a/packages/engine/src/__tests__/reviewer-workspace.test.ts +++ b/packages/engine/src/__tests__/reviewer-workspace.test.ts @@ -96,13 +96,17 @@ afterEach(() => { }); describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared loop both call sites use)", () => { + // FNXC:Workspace 2026-06-21-15:00: F7 — the per-repo callback is single-arg `(cwd)` now; tests map + // cwd→repo themselves (the loop no longer passes repoRel through to runForCwd). + const repoOfCwd = (cwd: string): string => (cwd === WT_A ? "repo-a" : cwd === WT_B ? "repo-b" : cwd); + it("conjunction: two repos both APPROVE → aggregate APPROVE, one reviewer pass per repo cwd", async () => { const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); const executor = workspaceExecutor(makeStore(task)); const seen: string[] = []; - const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string, repo: string) => { + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { seen.push(cwd); - return { verdict: "APPROVE", review: `clean in ${repo}`, summary: `clean ${repo}` }; + return { verdict: "APPROVE", review: `clean in ${repoOfCwd(cwd)}`, summary: `clean ${repoOfCwd(cwd)}` }; }); expect(seen).toEqual([WT_A, WT_B]); // one pass per sub-repo cwd, never ROOT expect(result.verdict).toBe("APPROVE"); @@ -113,7 +117,8 @@ describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared l it("conjunction: one repo REVISE → aggregate REVISE, tagged with the failing repo", async () => { const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); const executor = workspaceExecutor(makeStore(task)); - const result = await (executor as any).reviewWorkspacePerRepo(task, async (_cwd: string, repo: string) => { + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + const repo = repoOfCwd(cwd); return repo === "repo-b" ? { verdict: "REVISE", review: `bug in ${repo}`, summary: `revise ${repo}` } : { verdict: "APPROVE", review: `clean ${repo}`, summary: `clean ${repo}` }; @@ -124,6 +129,35 @@ describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared l expect(result.summary).toMatch(/^repo-b:/); }); + // FNXC:Workspace 2026-06-21-15:00: F3 — break on the FIRST non-APPROVE repo. + it("F3: repo-a APPROVE + repo-b REVISE (no throw) → aggregate REVISE tagged repo-b", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + const repo = repoOfCwd(cwd); + return repo === "repo-a" + ? { verdict: "APPROVE", review: "clean repo-a", summary: "clean a" } + : { verdict: "REVISE", review: "bug repo-b", summary: "revise b" }; + }); + expect(result.verdict).toBe("REVISE"); + expect(result.summary).toMatch(/^repo-b:/); + }); + + it("F3: repo-a REVISE + repo-b throws → REVISE preserved (break before repo-b; NOT masked to UNAVAILABLE)", async () => { + const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES }); + const executor = workspaceExecutor(makeStore(task)); + const seen: string[] = []; + const result = await (executor as any).reviewWorkspacePerRepo(task, async (cwd: string) => { + seen.push(cwd); + if (cwd === WT_B) throw new Error("repo-b reviewer blew up"); + return { verdict: "REVISE", review: "bug repo-a", summary: "revise a" }; + }); + // repo-a recorded the first non-APPROVE and the loop BROKE, so repo-b's reviewer is never invoked. + expect(seen).toEqual([WT_A]); + expect(result.verdict).toBe("REVISE"); + expect(result.summary).toMatch(/^repo-a:/); + }); + it("zero-acquire workspace task → UNAVAILABLE (caller routes; no fabricated APPROVE)", async () => { const task = makeTask({ workspaceWorktrees: {} }); const executor = workspaceExecutor(makeStore(task)); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 74d0f45a2b..be43f645c9 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -82,6 +82,12 @@ import { resolveSandboxBackend } from "./sandbox/index.js"; import type { SandboxBackend } from "./sandbox/types.js"; import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@earendil-works/pi-coding-agent"; import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js"; +// FNXC:Workspace 2026-06-21-15:00: F5/F8 — wire in the previously dead workspace-path helpers. +// `normalizeRepoRelPath` is the single shared scope-path normalizer (F8); `deriveRepoScopeSubset` +// maps the task's repo-prefixed declared File Scope to a repo-LOCAL subset so the per-repo scope-leak +// filter reuses the SAME always-allowed/scope-match surface as the non-workspace path (F5). One-way +// executor→workspace-paths edge (workspace-paths imports nothing). +import { deriveRepoScopeSubset, normalizeRepoRelPath } from "./workspace-paths.js"; import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js"; import { attemptBranchAutocorrect } from "./branch-autocorrect.js"; import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js"; @@ -592,13 +598,14 @@ export interface WorkflowRevisionFeedbackPartition { const WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000; const WORKFLOW_FEEDBACK_PATH_REGEX = /`([^`\n]+)`|(?` branch (repo.branch). The result union is PRESERVED EXACTLY — `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` — because the :10889 consumer switches on `reason` to drive requeue/handoff (:10894-10936). We ADD an optional `repo` field to the failure shape (purely additive; the consumer only reads reason/observed/expected) and return the FIRST failing repo. A zero-acquire workspace task (empty map) verifies vacuously → {ok:true}, matching Phase A so fn_task_done does not requeue it. if (this.workspaceConfig) { const workspaceWorktrees = task.workspaceWorktrees ?? {}; - for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { + // FNXC:Workspace 2026-06-21-15:00: F6 — iterate sorted repo keys so the FIRST failing repo + // returned here is deterministic across runs/rehydrate (the value is surfaced to the operator). + for (const repoRel of Object.keys(workspaceWorktrees).sort()) { + const repo = workspaceWorktrees[repoRel]; const expectedBranch = repo.branch || canonicalFusionBranchName(task.id); // Skip git checks if the worktree dir is gone (mirrors the singular FN-009 carve-out below): completion does not require a live worktree on disk. if (!existsSync(repo.worktreePath)) { @@ -10873,29 +10883,74 @@ export class TaskExecutor { // against `worktreePath`. In workspace mode `worktreePath` is the browse-only non-git workspace // root, so both silently return [] (git failures swallowed) and the uncommitted-in-scope block // never fires — a workspace task could complete with off-scope changes in any sub-repo. So we - // ITERATE every acquired sub-repo (cwd = repo.worktreePath, base = repo.baseCommitSha), - // repo-prefix each repo's touched files (`/`) so they compare against the task's - // repo-prefixed declared File Scope, and block on the FIRST repo carrying off-scope changes — - // naming the repo. The task-level preamble above (scopeOverride / declaredScope / enforcementMode) - // is shared and runs once. Return shape is preserved: `{blocked:false} | {blocked:true; message}`. + // ITERATE every acquired sub-repo (cwd = repo.worktreePath, base = repo.baseCommitSha) and block + // on the FIRST repo carrying off-scope changes — naming the repo. The task-level preamble above + // (scopeOverride / declaredScope / enforcementMode) is shared and runs once. Return shape is + // preserved: `{blocked:false} | {blocked:true; message}`. + // + // FNXC:Workspace 2026-06-21-15:00: F1/F2/F5/F6 hardening of the per-repo scope-leak guard. + // F5 (false-block fix + dead-code wiring + single filter surface): we previously repo-prefixed each + // touched file (`${repoRel}/${file}`) BEFORE filtering, so `isAlwaysAllowedScopeLeakPath`'s + // `startsWith(".changeset/")` carve-out never matched a sub-repo changeset (`repo-a/.changeset/x.md`) + // and a legit per-repo changeset was wrongly flagged off-scope → fn_task_done wrongly REFUSED. Now we + // derive each repo's repo-LOCAL declared-scope subset (`deriveRepoScopeSubset`) and run the SAME + // `workflowPathMatchesDeclaredScope` + `isAlwaysAllowedScopeLeakPath` filter the non-workspace path + // uses against the repo-LOCAL touched file — one filter surface, not two. This wires in the formerly + // dead `deriveRepoScopeSubset`/`splitRepoScopedPath` helpers. + // F1 (fail CLOSED on throw): each repo iteration is wrapped in its own try/catch (like the + // attribution-audit loop). A thrown capture/diff error in workspace mode surfaces as a BLOCK naming + // the repo instead of bubbling to the outer `.catch()` that fails OPEN — an incomplete scope check + // must never let fn_task_done proceed. + // F2 (scoped-but-zero-acquire): a scoped task that acquired NO sub-repo worktrees aggregates zero + // off-scope files and would silently pass; we block it (scope is declared but unverifiable). + // F6 (deterministic ordering): iterate sorted repo keys so the reported offending repo is stable + // across runs/rehydrate. let touchedFiles: string[]; let offendingRepo: string | undefined; if (this.workspaceConfig) { const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const repoKeys = Object.keys(workspaceWorktrees).sort(); + // F2: declaredScope is non-empty here (the `declaredScope.length === 0` early-return above + // handled the unscoped case). A scoped task that acquired no sub-repo worktrees cannot have its + // scope verified at all — refuse rather than silently passing scope enforcement. + if (repoKeys.length === 0) { + const message = "workspace task declares File Scope but acquired no sub-repo worktrees — cannot verify scope"; + executorLog.warn(`${task.id}: [scope-leak] ${message}`); + await this.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, this.getRunContextFor(task.id)); + return { blocked: true, message }; + } const aggregatedOffScope: string[] = []; - for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { - const [repoUncommitted, repoCommitted] = await Promise.all([ - this.captureUncommittedModifiedFiles(repo.worktreePath), - this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, "scope-leak-guard"), - ]); - const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])].map((f) => `${repoRel}/${f}`); - const repoOffScope = repoTouched - .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, declaredScope)) - .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)); - if (repoOffScope.length > 0) { - // First offending repo wins (mirrors verifyWorktreeInvariants' first-failing-repo return). - if (!offendingRepo) offendingRepo = repoRel; - aggregatedOffScope.push(...repoOffScope); + for (const repoRel of repoKeys) { + const repo = workspaceWorktrees[repoRel]; + try { + const [repoUncommitted, repoCommitted] = await Promise.all([ + this.captureUncommittedModifiedFiles(repo.worktreePath), + this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, "scope-leak-guard"), + ]); + // Repo-LOCAL touched files (no `${repoRel}/` prefix) so the always-allowed `.changeset/` + // carve-out and the scope match operate as the reviewer/cwd=repo sees them (F5). + const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])]; + // Repo-LOCAL declared-scope subset for THIS repo (prefix stripped). Same filter as the + // non-workspace branch below — one surface. + const repoScopeSubset = deriveRepoScopeSubset(declaredScope, repoRel); + const repoOffScope = repoTouched + .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, repoScopeSubset)) + .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)) + // Re-prefix the surviving off-scope files for the operator-facing message/attribution. + .map((filePath) => `${repoRel}/${filePath}`); + if (repoOffScope.length > 0) { + // First offending repo wins (mirrors verifyWorktreeInvariants' first-failing-repo return). + if (!offendingRepo) offendingRepo = repoRel; + aggregatedOffScope.push(...repoOffScope); + } + } catch (repoErr: unknown) { + // F1: fail CLOSED. A capture/diff throw means scope is UNVERIFIED for this repo; refuse + // fn_task_done as a precaution rather than letting the outer `.catch()` fail open. + const errMessage = repoErr instanceof Error ? repoErr.message : String(repoErr); + const message = `workspace scope-leak guard failed to evaluate (${repoRel}/${errMessage}) — refusing fn_task_done as a precaution`; + executorLog.warn(`${task.id}: [scope-leak] ${message}`); + await this.store.logEntry(task.id, `[scope-leak] ${message}`, undefined, this.getRunContextFor(task.id)); + return { blocked: true, message }; } } touchedFiles = aggregatedOffScope; @@ -12455,11 +12510,21 @@ ${failureFeedback} source = "post-session", ): Promise { const workspaceWorktrees = task.workspaceWorktrees ?? {}; + // FNXC:Workspace 2026-06-21-15:00: F4/F6 — per-repo error isolation + deterministic ordering. + // F4: an unexpected throw from one repo's `captureModifiedFiles` must NOT escape and skip the + // downstream `updateTask({modifiedFiles})` write — that would leave `task.modifiedFiles` empty and + // blind the merge file audit. Wrap each per-repo call (log + continue), mirroring the post-session + // branch-attribution loop. F6: iterate sorted repo keys so aggregation order is stable across runs. const aggregated: string[] = []; - for (const [repoRel, repo] of Object.entries(workspaceWorktrees)) { - const repoFiles = await this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source); - for (const file of repoFiles) { - aggregated.push(`${repoRel}/${file}`); + for (const repoRel of Object.keys(workspaceWorktrees).sort()) { + const repo = workspaceWorktrees[repoRel]; + try { + const repoFiles = await this.captureModifiedFiles(repo.worktreePath, repo.baseCommitSha, task.id, audit, source); + for (const file of repoFiles) { + aggregated.push(`${repoRel}/${file}`); + } + } catch (repoErr: unknown) { + executorLog.warn(`${task.id}: per-repo modified-file capture failed for ${repoRel}: ${repoErr instanceof Error ? repoErr.message : String(repoErr)}`); } } return aggregated; @@ -12483,12 +12548,18 @@ ${failureFeedback} * UNAVAILABLE retry) is unchanged. */ private async reviewWorkspacePerRepo( + // FNXC:Workspace 2026-06-21-15:00: F7 — drop the dead `repoRel` callback param. + // Both call sites bind `(cwd) => runForCwd(cwd)` and discard the second arg, so the type wrongly + // implied repo identity is observable inside `runForCwd`. Removed until a real consumer needs it + // (Phase C). The loop below still tags findings with `repoRel` from its own iteration key. task: Task, - invokeForCwd: (cwd: string, repoRel: string) => Promise, + invokeForCwd: (cwd: string) => Promise, ): Promise { const workspaceWorktrees = task.workspaceWorktrees ?? {}; - const entries = Object.entries(workspaceWorktrees); - if (entries.length === 0) { + // FNXC:Workspace 2026-06-21-15:00: F6 — sort repo keys so the reported FIRST failing repo is + // deterministic across runs/rehydrate. + const repoKeys = Object.keys(workspaceWorktrees).sort(); + if (repoKeys.length === 0) { // No acquired worktree — surface UNAVAILABLE so the caller routes it rather than // fabricating an authoritative APPROVE for an un-reviewable workspace task. return { @@ -12501,13 +12572,19 @@ ${failureFeedback} const reviewSections: string[] = []; const summarySections: string[] = []; let firstFailing: { repo: string; result: ReviewResult } | undefined; - for (const [repoRel, repo] of entries) { - const result = await invokeForCwd(repo.worktreePath, repoRel); + for (const repoRel of repoKeys) { + const repo = workspaceWorktrees[repoRel]; + const result = await invokeForCwd(repo.worktreePath); // Tag every per-repo finding with its sub-repo so downstream readers attribute it correctly. reviewSections.push(`### [${repoRel}] ${result.verdict}\n${result.review}`); summarySections.push(`[${repoRel}] ${result.verdict}: ${result.summary}`); - if (result.verdict !== "APPROVE" && !firstFailing) { + if (result.verdict !== "APPROVE") { + // FNXC:Workspace 2026-06-21-15:00: F3 — BREAK on the first non-APPROVE repo. + // The contract is "the FIRST non-APPROVE repo's verdict becomes the aggregate". Without the + // break, a LATER repo's reviewer throwing would discard this already-determined REVISE/RETHINK + // and the caller would see UNAVAILABLE — masking the real verdict. Stop at the first failure. firstFailing = { repo: repoRel, result }; + break; } } @@ -12524,8 +12601,8 @@ ${failureFeedback} // Every sub-repo approved → the task is reviewed (conjunction satisfied). return { verdict: "APPROVE", - review: `All ${entries.length} sub-repo(s) approved. Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, - summary: `APPROVE across ${entries.length} sub-repo(s): ${summarySections.join(" | ")}`, + review: `All ${repoKeys.length} sub-repo(s) approved. Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + summary: `APPROVE across ${repoKeys.length} sub-repo(s): ${summarySections.join(" | ")}`, }; } diff --git a/packages/engine/src/workspace-paths.ts b/packages/engine/src/workspace-paths.ts index 308c559341..299dbfe357 100644 --- a/packages/engine/src/workspace-paths.ts +++ b/packages/engine/src/workspace-paths.ts @@ -10,13 +10,11 @@ Matching rule: canonicalize the path to forward-slash relative segments, then pi /** Sentinel returned when a path does not belong to any configured sub-repo. */ export const UNSCOPED_REPO = "unscoped" as const; -/** - * Normalize a workspace-relative path token to forward-slash form with no leading - * `./`, no leading/trailing slashes, and collapsed duplicate slashes. Mirrors the - * executor's `normalizeWorkflowScopePath` shape so File-Scope tokens and modified - * files compare consistently, but kept local to avoid an executor import cycle. - */ -function normalizeRepoRelPath(value: string): string { +/* +FNXC:Workspace 2026-06-21-15:00: +F8 — single normalize helper. The executor previously kept its own `normalizeWorkflowScopePath` that was a near-duplicate of this function, differing only in leading-slash stripping (`/^\/+/` here vs none there) and trailing-slash greediness (`/\/+$/` here vs `/\/$/` there). Two slightly-different normalizers meant an absolute or trailing-slash-laden path could derive a different scope key in the two code paths. We promote THIS (more aggressive: strips leading slash + collapses repeated trailing slashes) to the single exported normalizer and have the executor import it for scope-path normalization, so workspace and non-workspace scope matching canonicalize identically. workspace-paths.ts stays dependency-light (imports nothing), so executor→workspace-paths is a one-way, acyclic edge. +*/ +export function normalizeRepoRelPath(value: string): string { return value .trim() .replace(/\\/g, "/") From 14114f5f5158d7c2c64dba6b32dd0d9cd6d05caf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:07:35 -0700 Subject: [PATCH 25/44] docs(workspace): note Phase B review hardening in the U2 changeset --- .changeset/workspace-phase-b-u2-per-repo-review-taskdone.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md index efd5004886..bdb9b95252 100644 --- a/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md +++ b/.changeset/workspace-phase-b-u2-per-repo-review-taskdone.md @@ -3,3 +3,5 @@ --- Workspace mode (Phase B, U2): per-repo review at both review entry points plus per-repo `fn_task_done` completion + scope-leak verification. In workspace mode both review call sites (the in-session `fn_review_step` tool and the step-inversion review seam) now loop the single-cwd `reviewStep` once per acquired sub-repo (cwd = each repo's worktree) and aggregate the repo-tagged verdicts as a conjunction — the task is reviewed only when every sub-repo approves, and the first failing sub-repo's verdict (with repo-tagged findings) drives the existing verdict→edge mapping. `fn_task_done` now verifies worktree invariants per acquired repo and iterates the scope-leak guard per sub-repo (cwd = repo worktree, repo `baseCommitSha`), blocking completion on any sub-repo carrying off-scope changes and naming the repo. Adds a minimal shared repo-prefix-derivation helper (`workspace-paths.ts`). Single-repo behavior is unchanged. + +Phase-B hardening: the per-repo scope-leak guard now fails CLOSED — a thrown capture/diff error in any sub-repo refuses `fn_task_done` (naming the repo) instead of failing open, and a scoped task that acquired zero sub-repo worktrees is blocked rather than silently passing. A legitimate per-repo `.changeset/` file is no longer falsely flagged off-scope (the always-allowed carve-out now runs against the repo-local path). Per-repo review stops at the first non-APPROVE sub-repo so a later repo's reviewer error can't mask an already-determined REVISE/RETHINK. Per-repo capture failures are isolated (one repo's error no longer drops the whole modified-files write), and the reported offending/failing repo is now deterministic (sorted repo iteration). Single-repo behavior remains unchanged. From edd79a87dcf0be685ec09f51d6c3be1087f8978c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:15:38 -0700 Subject: [PATCH 26/44] =?UTF-8?q?docs(workspace):=20Phase=20C=20plan=20?= =?UTF-8?q?=E2=80=94=20per-repo=20merge=20loop=20(U5/U6/U7),=20forks=20res?= =?UTF-8?q?olved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-06-21-006-feat-workspace-phase-c-plan.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md diff --git a/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md b/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md new file mode 100644 index 0000000000..c2bd15ef6a --- /dev/null +++ b/docs/plans/2026-06-21-006-feat-workspace-phase-c-plan.md @@ -0,0 +1,151 @@ +--- +title: "feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs)" +status: active +date: 2026-06-21 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase C / U5·U6·U7) +depth: deep +--- + +# feat: Workspace mode Phase C — per-repo merge loop (land-as-you-go on local integration refs) + +> **ID namespace:** local `U0·U1·U2·U3` decompose master-plan **U5, U6, U7** (+ a Phase-B-deferred extraction). +> **Anchors are feasibility-pending** — a pre-check runs before implementation (as in Phases A/B). Treat `~:` numbers as approximate until verified. + +## Summary + +Phase C replaces U0's **R7 guard** — which currently makes every workspace-task merge *throw* `WorkspaceTaskMergeError` — with the real **per-repo merge loop**: for each acquired sub-repo, land that repo's `fusion/` branch onto **that repo's LOCAL integration ref** via a repo-scoped clean-room (the `runAiMerge` mechanism, applied per repo), with no remote push. This is **land-as-you-go** (settled **D2/D5**): repos land independently; a partial land (A lands, B fails) leaves A landed locally and is operator-resettable; an unconditional operator escape hatch always exists. + +After Phase C a workspace task can fully run → capture → review → **merge**. **Scope out:** self-healing reconcilers + e2e harness (master U8/U9 = Phase D). + +**Stacking:** off Phase B (#1714); PR diff includes the stack; must not merge until it lands. + +--- + +## Problem Frame + +`runAiMerge` (merger-ai.ts) lands **one** `task.worktree`'s `fusion/` branch into a single clean-room temp worktree and advances **one** local integration ref via `update-ref` CAS (no push). U0 added the **R7 chokepoint guard** `assertNotWorkspaceTaskMerge(task)` so a `workspaceWorktrees`-bearing task fails fast rather than silently mis-merging the single root. Phase C turns that fail-fast into a real loop: iterate the acquired sub-repos, run the clean-room land per repo against that repo's own local integration ref, track which repos have landed (idempotent retry), hold a per-repo file-scope lease during each land, and aggregate a per-repo `MergeResult`. The single-repo `runAiMerge` path is untouched. + +--- + +## Key Technical Decisions + +> **OPEN FORKS — to be confirmed by the feasibility pre-check + user before implementation.** Marked `‹FORK›`. The settled semantics (D2/D5) bound them, but the code shape is to verify. + +### KTD0 — Extract `workspace-executor.ts` FIRST (Phase-B-deferred maintainability P1) +Before adding the merge loop, move the workspace branches Phase A/B inlined into `executor.ts` (`captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` block) into `packages/engine/src/workspace-executor.ts` as module-level functions receiving executor state as args; the `if (this.workspaceConfig)` call sites delegate. Pure move + delegate, no behavior change — its own commit, gate-green, before any Phase-C behavior. This keeps the 16k-line file from absorbing the merge loop too. + +### KTD1 — Extract `landOneRepo` from `runAiMerge`, then loop it (master U6; D2/D5) — FORK-A RESOLVED +**Verified:** `runAiMerge`'s land sequence (mkdtemp clean room → `git worktree add --detach` → `installWorktreeDependencies` → `mergeAndReview` → `landSquash` → the concurrent-advance CAS retry loop → `activeSessionRegistry` register/unregister) is an **un-factored inline closure** at `merger-ai.ts:1064-1216`, bound to one `projectRootDir`/`integrationBranch`/`branch`; `mergeAndReview`/`finalizeMerged` are module-private. The CAS seam `advanceIntegrationBranchRef` already takes `rootDir`/`integrationBranch` explicitly. **No remote push anywhere** — D2/D5 "no push" confirmed. + +So U1 **extracts** an exported `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from that closure (returns a per-repo `LandResult`), leaving `runAiMerge` as the byte-for-byte single-repo caller. `landWorkspaceTask(task)` loops the acquired sub-repos calling `landOneRepo` per repo, aggregating a repo-tagged result. **`landOneRepo` stays in `merger-ai.ts`** (the private helpers live there); only the thin `landWorkspaceTask` orchestrator may sit in a new `workspace-merger.ts`. + +**Per-repo integration branch (P1 the plan missed):** `workspaceWorktrees[repo]` does NOT store the integration branch (acquisition computes it then discards). `landOneRepo` must **re-resolve per repo** with the same override-stripping acquisition uses — `resolveIntegrationBranch(repoRoot, { ...settings, integrationBranch: undefined, baseBranch: undefined })` — so each sub-repo lands on its own `origin/HEAD`, not a shared branch. + +**Per-sub-repo prune rooting (correctness):** `pruneExistingAiMergeWorktrees`/`cleanupStaleTempMergeWorktrees` sweep by the `fusion-ai-merge--` prefix; N per-repo clean rooms share the taskId. Root each sweep at the **sub-repo** (`resolveAiMergeRoot(subRepoRoot)`) so one repo's prune cannot race another repo's live clean room for the same task. + +### KTD2 — Door table: route the engine + CLI/dashboard doors, keep the rest throwing (master U6) — RESOLVED +Six guard sites. Per-door (FN-5893): +1. **`project-engine.ts:~2300` engine dispatch** → route `workspaceWorktrees`-bearing tasks to `landWorkspaceTask`. +2. **`runAiMerge:~979` chokepoint guard** → STAYS as defense-in-depth for direct single-repo callers (workspace tasks enter via `landWorkspaceTask`, not here). +3. **`store.mergeTask:~11159`** (core, cannot import `@fusion/engine`) → STAYS throwing. +4. **CLI `dashboard.ts:~1312` + `task.ts:~861`** → **route workspace tasks through the engine merge (`landWorkspaceTask`)** instead of `store.mergeTask`, so user-triggered `fn task merge` / the dashboard merge button work on workspace tasks **(user decision: manual merge works in Phase C)**. +5. **`aiMergeTask` (merger.ts:~7666, deprecated)** → STAYS throwing. + +### KTD3 — `landedSha`-only per repo; `landWorkspaceTask` finalizes once; auto-retry then park (master U5) — FORK-B RESOLVED +**Verified:** `finalizeMerged`/`finalizeTask` are **task-global** — they write one task-level `mergeDetails` and move the WHOLE task to `done` (`merger-ai.ts:1298-1401`). So `landOneRepo` must advance the ref + record `workspaceWorktrees[repo].landedSha` **only** (no task move). `landWorkspaceTask` calls `finalizeTask`/move-done **exactly once** after every acquired repo's landed predicate is true. + +**Landed predicate:** a repo is landed iff `entry.branch` tip is an ancestor of (or equals) its local integration ref tip (or the recorded `landedSha` is present); `landWorkspaceTask` **skips landed repos** (idempotent). + +**Partial-land (user decision: auto-retry then park):** repo B fails after A landed → task goes to a non-done state with A's `landedSha` persisted; the failure **consumes a `mergeRetry`** and the engine **auto-retries `landWorkspaceTask`** (skipping landed A, re-attempting B) up to the existing `MAX`, then **operator-parks** (D5 escape hatch as terminal). No new partial-landed status type — `landedSha` on the entry is the only state added (`types.ts:~2256`). + +### KTD4 — Per-repo land lease via `activeSessionRegistry` new kind (master U7) — FORK-C RESOLVED +**Verified:** there is NO separate engine file-scope lease — `activeSessionRegistry` (path-keyed, `kind` enum) is the only mechanism (`runAiMerge` already registers the clean room under `kind:"ai-merge"`). Add a new `ActiveSessionKind` `"workspace-repo-land"` keyed on the **sub-repo absolute path**; register before `landOneRepo`, unregister in `finally`. **The lease is for serialization / clean-room-collision avoidance, not ref correctness** — `advanceIntegrationBranchRef`'s CAS already makes interleaved `update-ref` safe (concurrent-advance → rebuild). Set test expectations accordingly. + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace ` comments; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo git fixture via `_workspace-fixture.ts`; assert local-ref advancement with NO push; fake timers; no mock-the-world); FN-5893 surface enumeration; the merge gate. Branch off Phase B (`gsxdsm/workspace-phase-c`). + +### U0. Extract `workspace-executor.ts` (no behavior change) +**Goal:** Move Phase A/B workspace helpers out of `executor.ts` into `workspace-executor.ts`; call sites delegate. Pure refactor. +**Requirements:** KTD0. +**Dependencies:** none. +**Files:** `packages/engine/src/executor.ts`, `packages/engine/src/workspace-executor.ts` (new), existing workspace tests (imports may shift). +**Approach:** Move `captureWorkspaceModifiedFiles`, `reviewWorkspacePerRepo`, the per-repo `verifyWorktreeInvariants` body; pass `store`/`captureModifiedFiles`/etc. as args. No logic change. +**Test scenarios:** the existing Phase A/B workspace suites pass unchanged (the move is correct iff they stay green). `Test expectation: behavior-preserving — existing suites are the oracle.` +**Verification:** All Phase A/B workspace tests + `test:gate` green; `executor.ts` shrinks; no behavior diff. + +### U1. Extract `landOneRepo`, loop it in `landWorkspaceTask`, route the doors (master U6) +**Goal:** Land each acquired sub-repo's branch onto its own local integration ref (land-as-you-go, no push), via an extracted `landOneRepo`; route the engine + CLI/dashboard doors. +**Requirements:** KTD1, KTD2. +**Dependencies:** U0. +**Files:** `packages/engine/src/merger-ai.ts` (extract `landOneRepo` from the `:1064-1216` closure; add `landWorkspaceTask`), `packages/engine/src/project-engine.ts` (`~:2300` dispatch → `landWorkspaceTask`), `packages/cli/src/commands/dashboard.ts` (`~:1312`) + `packages/cli/src/commands/task.ts` (`~:861`) (route workspace tasks to the engine merge), optional `packages/engine/src/workspace-merger.ts` (thin orchestrator), `packages/engine/src/__tests__/workspace-merger.test.ts` (new). +**Approach:** Per KTD1/KTD2. **(a)** Extract `landOneRepo(store, repoRootDir, branch, integrationBranch, options)` from the inline closure — `runAiMerge` becomes its single-repo caller, byte-for-byte. **(b)** `landWorkspaceTask` loops the acquired sub-repos: re-resolve each repo's integration branch (override-stripped), root the prune at the sub-repo, call `landOneRepo`, aggregate repo-tagged results. **(c)** Route the engine dispatch + both CLI doors to `landWorkspaceTask` for `workspaceWorktrees`-bearing tasks; `store.mergeTask`/`aiMergeTask`/the `runAiMerge` chokepoint keep throwing (defense-in-depth). +**Execution note:** Real two-repo fixture; commit on each `fusion/`; assert each repo's **local** integration ref advanced and **no remote ref/push** occurred; assert per-sub-repo prune rooting. +**Test scenarios:** +- Two acquired repos, both clean → both local integration refs advance against each repo's own resolved branch; no push/remote ref; result tags both. (happy) +- Repos with different integration branches → each lands on its own (override-stripping works; not a shared branch). (per-repo resolution) +- A conflict in repo B → repo A lands (its `landedSha` recorded); B's result reports the conflict; the task is NOT moved done. (partial — D2/D5) +- The single-repo (non-workspace) `runAiMerge` path → byte-for-byte unchanged (it calls the extracted `landOneRepo`). (regression) +- `store.mergeTask`/`aiMergeTask` with a workspace task → still throws `WorkspaceTaskMergeError`. (defense-in-depth) +- A workspace task via the CLI/dashboard merge door → routes to `landWorkspaceTask` (does not throw). (user-facing door) +**Verification:** Workspace merges land per repo on local refs (no push) via `landOneRepo`; single-repo unchanged; user doors route; non-routed doors stay guarded. + +### U2. Per-repo landed predicate + idempotent retry (master U5) +**Goal:** Track landed repos; retry skips them. +**Requirements:** KTD3. +**Dependencies:** U1. +**Files:** `packages/core/src/types.ts` (`workspaceWorktrees[repo].landedSha?`), the loop in U1, `packages/engine/src/__tests__/workspace-merger-idempotency.test.ts` (new). +**Approach:** Per KTD3. `landOneRepo` records `workspaceWorktrees[repo].landedSha` only (no task move); `landWorkspaceTask` calls `finalizeTask`/move-done exactly once after every acquired repo's landed predicate holds. Landed predicate = ancestor check (or `landedSha` present); skip landed repos. Partial-land → non-done state with `landedSha` persisted; the failure **consumes a `mergeRetry`** and is **auto-retried up to `MAX`, then operator-parked** (user decision). +**Test scenarios:** +- Re-running `landWorkspaceTask` after repo A landed + repo B failed → A is skipped (not re-landed), B is retried; A's ref does not move twice. (idempotency — partial land) +- Landed predicate true when branch tip is an ancestor of the integration tip. (predicate) +- `finalizeTask` runs exactly once, only after ALL repos landed (not per-repo). (completion — no premature done) +- Partial-land failure consumes one `mergeRetry`; after `MAX` retries the task is operator-parked, not silently failed. (retry/park) +**Verification:** Partial lands are idempotent on retry; the task moves done exactly once; auto-retry then park works; no double-land. + +### U3. Per-repo file-scope lease during land (master U7) +**Goal:** Serialize concurrent same-sub-repo lands. +**Requirements:** KTD4. +**Dependencies:** U1. +**Files:** the lease seam (FORK-C), the loop in U1, `packages/engine/src/__tests__/workspace-merger-lease.test.ts` (new). +**Approach:** Per KTD4. Acquire a per-repo integration-ref lease before each `landOneRepo`, release in `finally`. +**Test scenarios:** +- Two workspace tasks landing the same sub-repo concurrently → serialized (one waits/fails-fast, no interleaved `update-ref`). (concurrency) +- Disjoint sub-repos → land in parallel without contention. (no false serialization) +- Lease released on land failure (no stuck lock). (cleanup) +**Verification:** Same-sub-repo lands serialize; the lease never leaks. + +--- + +## Scope Boundaries + +**In scope:** the extraction (U0), the per-repo merge loop + R7-throw replacement (U1), landed predicate + idempotent retry (U2), per-repo lease (U3). + +### Deferred to Follow-Up Work (Phase D / master U8·U9) +- Self-healing reconcilers for partial-landed / stuck workspace merges. +- The e2e workspace harness. +- Per-repo worktree teardown (carried residual). +- Remote push of integration refs (explicitly out — D2/D5 are local-ref only). +- Store-level atomic per-repo `workspaceWorktrees` merge (carried residual). + +--- + +## Risks & Dependencies + +- **R1 — R7 throw replacement must not weaken the single-repo guard.** Mitigation: KTD2 dispatches only when `workspaceWorktrees` non-empty; untaught doors keep the throw; regression + defense-in-depth tests. +- **R2 — Partial-land leaves inconsistent local state.** Accepted (D2/D5: local + operator-resettable). Mitigation: KTD3 idempotent retry + persisted `landedSha`; the local-ref-only design means no remote pollution. +- **R3 — Clean-room helper reuse across the loop.** `runAiMerge`'s temp-worktree/CAS seams must be callable per repo without cross-repo state bleed. Mitigation: feasibility pre-check verifies the seams; U1 asserts no cross-repo bleed. +- **R4 — Lease vs acquisition-exclusivity confusion.** The Phase-A/U2 acquisition lock and the Phase-C land lease are different scopes. Mitigation: KTD4 distinct kind; test both. +- **R5 — `executor.ts` extraction regression (U0).** Mitigation: behavior-preserving; existing suites are the oracle; gate-green before U1. +- **Stacking dependency:** off Phase B (#1714); diff includes the stack. + +--- + +## Sources & Research + +- Master plan (U5/U6/U7, KTD2/KTD4/KTD7, D2/D5, R7). +- This session: `runAiMerge` advances the LOCAL integration ref via `update-ref` CAS (~merger-ai.ts:817/847), no push; the R7 chokepoint guard `assertNotWorkspaceTaskMerge` (~:979) + the door guards; `store.mergeTask` (third path); `SelfHealingManager.cleanupStaleTempMergeWorktrees` prefix sweep. +- Phase A/B (#1713/#1714): per-repo `baseCommitSha`, `activeWorktrees` Set, `workspace-paths.ts`, `_workspace-fixture.ts`, the workspace helpers U0 extracts. From 744ed098a5f329fb2c3a8afddfa13660aceba6c5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:31:43 -0700 Subject: [PATCH 27/44] =?UTF-8?q?feat(workspace):=20Phase=20C=20U1=20?= =?UTF-8?q?=E2=80=94=20per-repo=20merge=20loop=20(landOneRepo=20+=20landWo?= =?UTF-8?q?rkspaceTask)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the per-repo land mechanics out of runAiMerge's inline clean-room closure into an exported landOneRepo(store, repoRootDir, branch, integrationBranch, ctx): pre-merge prune (rooted at the sub-repo), the clean-room temp worktree, mergeAndReview, landSquash, and the CAS concurrent-advance retry that advances ONE local integration ref — no remote push. runAiMerge is rewired as the single-repo caller (its task-global finalization unchanged); the merger-ai suite (56 tests) stays green as the byte-for-byte oracle. landWorkspaceTask loops a workspace task's acquired sub-repos (sorted keys), re-resolving each repo's integration branch with the shared override stripped ({...settings, integrationBranch: undefined, baseBranch: undefined}) so each sub-repo lands on its own origin/HEAD, calls landOneRepo per repo, and aggregates repo-tagged results — land-as-you-go on each repo's LOCAL ref (D2/D5). It does NOT finalize/move the task (finalize-once + landed-tracking + idempotent retry are U2). Door routing (KTD2): the engine dispatch and the user-facing CLI `fn task merge` + dashboard merge doors route workspace tasks to landWorkspaceTask so manual merge works; store.mergeTask, aiMergeTask, and the runAiMerge chokepoint guard keep throwing WorkspaceTaskMergeError as defense-in-depth. New two-repo fixture tests: both repos land + no-push assertion, per-repo override-stripped resolution onto distinct branches, repo-B conflict partial land (task not moved), defense-in-depth throws. Gate green: typecheck, lint, build, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...orkspace-phase-c-u1-per-repo-merge-loop.md | 12 + packages/cli/src/commands/dashboard.ts | 30 +- packages/cli/src/commands/task.ts | 36 +- .../src/__tests__/workspace-merger.test.ts | 292 ++++++++++ packages/engine/src/index.ts | 10 + packages/engine/src/merger-ai.ts | 537 +++++++++++++----- packages/engine/src/project-engine.ts | 51 +- 7 files changed, 798 insertions(+), 170 deletions(-) create mode 100644 .changeset/workspace-phase-c-u1-per-repo-merge-loop.md create mode 100644 packages/engine/src/__tests__/workspace-merger.test.ts diff --git a/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md b/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md new file mode 100644 index 0000000000..ec87d1eb94 --- /dev/null +++ b/.changeset/workspace-phase-c-u1-per-repo-merge-loop.md @@ -0,0 +1,12 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase C (U1): per-repo merge loop. Extract `landOneRepo` from the +`runAiMerge` clean-room land closure (single-repo behavior unchanged) and add +`landWorkspaceTask`, which lands each acquired sub-repo's `fusion/` branch onto +that repo's OWN local integration ref (re-resolved per repo with overrides stripped), +land-as-you-go with no remote push. The engine merge dispatch and the user-facing +CLI/dashboard merge doors now route workspace tasks through this loop instead of +throwing; `store.mergeTask`, `aiMergeTask`, and the `runAiMerge` chokepoint keep +throwing `WorkspaceTaskMergeError` as defense-in-depth. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index fed1e64b88..2140ea3496 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -9,7 +9,6 @@ import { CentralCore, AgentStore, PluginLoader, - assertNotWorkspaceTaskMerge, getTaskMergeBlocker, getEnabledPiExtensionPaths, isEphemeralAgent, @@ -43,6 +42,7 @@ import { } from "@fusion/dashboard"; import { runAiMerge, + landWorkspaceTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, @@ -1305,11 +1305,31 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // aiMergeTask is soft-deprecated. // const onMergeImpl = async (taskId: string) => { - // FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). - // Reject workspace-mode tasks before any merge work; per-repo merge lands in - // master-plan U6, which removes this guard. + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // Dashboard merge button (UI-only mode). A workspace-mode task routes through + // the ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its + // own LOCAL integration ref, no push) instead of throwing — manual merge works in + // Phase C (user decision). U0's R7 throw is replaced here by routing; the engine + // chokepoint + store.mergeTask/aiMergeTask keep throwing as defense-in-depth. const mergeTask = await store.getTask(taskId).catch(() => null); - if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); + const isWorkspaceMerge = + !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + if (isWorkspaceMerge) { + const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, { + agentStore, + }); + const latest = await store.getTask(taskId).catch(() => mergeTask!); + // U1 does not finalize the workspace task (finalize-once move-to-done is U2); + // report merged=false until then. + return { + task: latest ?? mergeTask!, + branch: getTaskBranchName(taskId), + merged: false, + worktreeRemoved: false, + branchDeleted: false, + error: workspaceResult.allLanded ? undefined : "partial workspace land — see task log", + }; + } const settings = await store.getSettings(); if (getMergeStrategy(settings) === "pull-request") { diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 83d4ca5a3c..13054fcc7c 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,5 +1,5 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, assertNotWorkspaceTaskMerge, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; -import { runAiMerge } from "@fusion/engine"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { runAiMerge, landWorkspaceTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning"; @@ -851,14 +851,32 @@ export async function runTaskMerge(id: string, projectName?: string) { console.log(`\n Merging ${id} with AI...\n`); try { - // FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). - // Reject workspace-mode tasks before any merge work; per-repo merge lands in - // master-plan U6, which removes this guard. - // FNXC:MergerUnification 2026-06-21-19:05: unified onto runAiMerge (U0). - // The guard lives INSIDE this try so its throw renders via the formatted - // ` ✗ ...` output below instead of the generic top-level bin.ts handler. + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // User-triggered `fn task merge`. A workspace-mode task routes through the + // ENGINE per-repo merge loop `landWorkspaceTask` (each sub-repo lands on its own + // LOCAL integration ref, no push) instead of throwing — manual merge works in + // Phase C (user decision). U0's R7 throw is replaced here by routing; the + // engine chokepoint + store.mergeTask/aiMergeTask keep throwing. const mergeTaskRecord = await store.getTask(id).catch(() => null); - if (mergeTaskRecord) assertNotWorkspaceTaskMerge(mergeTaskRecord); + const isWorkspaceMerge = + !!mergeTaskRecord?.workspaceWorktrees && Object.keys(mergeTaskRecord.workspaceWorktrees).length > 0; + if (isWorkspaceMerge) { + const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, { + onAgentText: (delta) => process.stdout.write(delta), + }); + console.log(); + for (const repo of workspaceResult.repos) { + const label = + repo.status === "landed" ? `landed ${repo.landedSha?.slice(0, 8) ?? ""} → ${repo.integrationBranch}` + : repo.status === "empty" ? "no net changes" + : `failed: ${repo.error ?? "unknown"}`; + console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`); + } + // U1 does not move the workspace task to done (finalize-once is U2). + console.log(`\n ${workspaceResult.allLanded ? "✓ All sub-repos landed" : "✗ Partial land — see failures above"} (task remains in review until U2)\n`); + if (!workspaceResult.allLanded) process.exit(1); + return; + } const result = await runAiMerge(store, projectPath, id, { onAgentText: (delta) => process.stdout.write(delta), diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts new file mode 100644 index 0000000000..0e4ec55a64 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -0,0 +1,292 @@ +/* +FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1/KTD2): +Per-repo workspace merge-loop tests. They drive the REAL `landWorkspaceTask` / +`landOneRepo` against a REAL two-repo git fixture under a NON-git workspace root +(createWorkspaceFixture), so a leaked rootDir git preflight would actually fail and a +shared clean-room root would race. Real git is used only where the invariant requires +it (the local-ref advance, the no-push assertion); the AI merge/review agents are +injected (deps) so NO real AI calls happen and the squash is produced by a plain +`git merge --squash` inside the clean room — no mock-the-world child_process. + +Coverage (FN-5893 surfaces): +- happy: two acquired repos both clean → BOTH local integration refs advance against + each repo's own resolved branch; NO remote ref/push happened; result tags both. +- per-repo resolution: repos with DIFFERENT origin/HEAD integration branches → each + lands on its own (override-stripping works, not a shared branch). +- partial: a conflict in repo B → repo A lands (landedSha recorded); B reports the + failure; the task is NOT moved done (no finalizeTask call). +- defense-in-depth: store.mergeTask / aiMergeTask with a workspace task → still throw + WorkspaceTaskMergeError. +The single-repo runAiMerge regression lives in the existing merger-ai*.test.ts (the +extraction is byte-for-byte; runAiMerge is landOneRepo's single-repo caller). +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; +import { assertNotWorkspaceTaskMerge } from "@fusion/core"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-2001"; +const BRANCH = "fusion/fn-2001"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + moveTaskCalls: Array<{ id: string; column: string }>; + emitted: Array<{ event: string; payload: unknown }>; +} + +function createStore(settings: Record = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const emitted: Array<{ event: string; payload: unknown }> = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + moveTaskCalls, + emitted, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false, ...settings }), + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getTask: vi.fn().mockResolvedValue(undefined), + moveTask: vi.fn((id: string, column: string) => { + moveTaskCalls.push({ id, column }); + return Promise.resolve({ id, column } as Task); + }), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +/** + * Add a real `fusion/` worktree to a sub-repo with one own commit that EDITS the + * README the integration tip already has, then remove the worktree (we only need the + * branch ref). Returns the branch name. By default the edit is non-conflicting. + */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** Make a sub-repo's integration tip and the task branch BOTH edit README so the + * squash conflicts. */ +function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + // Task branch edits README on a new commit. + const worktreePath = path.join(repoDir, ".wt-conflict"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "README.md"), "# branch-side change\n", "utf-8"); + execSync("git add README.md", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); + // Integration tip (main) diverges with a conflicting README edit. + writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8"); + fx.git(repoRel, "git add README.md"); + fx.git(repoRel, 'git commit -m "main diverge README"'); +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string) { + return async (cwd: string): Promise => { + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — leave them for the test's expectation. + } + // If there are unresolved conflicts, throw so landOneRepo surfaces a failure. + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) { + throw new Error("merge conflict: unresolved paths in clean room"); + } + // Nothing staged (already up to date) → leave HEAD unchanged (empty merge). + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id: TASK_ID, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("happy: both clean repos advance their OWN local integration ref with NO push", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main"); + + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(true); + expect(result.repos.map((r) => r.repo).sort()).toEqual(["repo-a", "repo-b"]); + for (const r of result.repos) expect(r.status).toBe("landed"); + + // Each repo's LOCAL integration ref advanced (main moved off its prior tip). + const tipAAfter = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBAfter = fx.git("repo-b", "git rev-parse refs/heads/main"); + expect(tipAAfter).not.toBe(tipABefore); + expect(tipBAfter).not.toBe(tipBBefore); + + // No remote ref / no push: the fixture repos have no remotes at all. + for (const repo of ["repo-a", "repo-b"]) { + const remotes = fx.git(repo, "git remote").trim(); + expect(remotes).toBe(""); + const remoteRefs = execSync("git for-each-ref refs/remotes", { cwd: fx.repoPath(repo), encoding: "utf-8" }).trim(); + expect(remoteRefs).toBe(""); + } + + // U1 does NOT move the task to done. + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + }); + + it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + // Give each repo a different default integration branch via a bare origin whose + // HEAD points at that branch. landWorkspaceTask strips integrationBranch/baseBranch + // overrides, so each repo resolves origin/HEAD independently. + for (const [repo, intBranch] of [["repo-a", "develop"], ["repo-b", "release"]] as const) { + const repoDir = fx.repoPath(repo); + fx.git(repo, `git branch ${intBranch}`); + const originDir = path.join(repoDir, "..", `${repo}-origin.git`); + execSync(`git init --bare ${originDir}`, { cwd: repoDir, stdio: "pipe" }); + fx.git(repo, `git remote add origin ${originDir}`); + fx.git(repo, "git push origin --all"); + execSync(`git symbolic-ref HEAD refs/heads/${intBranch}`, { cwd: originDir, stdio: "pipe" }); + fx.git(repo, "git remote set-head origin -a"); + // task branch off the integration branch with an edit + const wt = path.join(repoDir, ".wt"); + fx.git(repo, `git worktree add -b ${BRANCH} ${wt} ${intBranch}`); + configureIdentity(wt); + writeFileSync(path.join(wt, "feature.txt"), `${repo} feature\n`, "utf-8"); + execSync("git add feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add"`, { cwd: wt, stdio: "pipe" }); + fx.git(repo, `git worktree remove --force ${wt}`); + } + + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(true); + const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"].integrationBranch).toBe("develop"); + expect(byRepo["repo-b"].integrationBranch).toBe("release"); + // Each landed onto its OWN integration branch's local ref. + expect(byRepo["repo-a"].status).toBe("landed"); + expect(byRepo["repo-b"].status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/develop")).toBe(byRepo["repo-a"].landedSha); + expect(fx.git("repo-b", "git rev-parse refs/heads/release")).toBe(byRepo["repo-b"].landedSha); + }); + + it("partial: repo B conflict → repo A lands, B reports failure, task NOT moved done", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(false); + const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"].status).toBe("landed"); + expect(byRepo["repo-b"].status).toBe("failed"); + expect(byRepo["repo-b"].error).toMatch(/conflict/i); + + // Repo A landed locally (its ref advanced). + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); + + // The task was NOT finalized/moved done on a partial land. + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + }); +}); + +describe("workspace merge defense-in-depth (non-routed doors keep throwing)", () => { + it("assertNotWorkspaceTaskMerge throws WorkspaceTaskMergeError for a workspace task (store.mergeTask/aiMergeTask door)", () => { + const task = { + id: TASK_ID, + workspaceWorktrees: { "repo-a": { worktreePath: "/x/repo-a", branch: BRANCH } }, + } as unknown as Task; + expect(() => assertNotWorkspaceTaskMerge(task)).toThrowError(/cannot merge until per-repo merge/i); + try { + assertNotWorkspaceTaskMerge(task); + } catch (err) { + expect((err as Error).name).toBe("WorkspaceTaskMergeError"); + } + }); + + it("assertNotWorkspaceTaskMerge is a no-op for a single-repo task", () => { + const task = { id: TASK_ID } as unknown as Task; + expect(() => assertNotWorkspaceTaskMerge(task)).not.toThrow(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 40e65e9b0f..e9a55f5a18 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -190,6 +190,16 @@ export { // FNXC:MergerUnification 2026-06-21-19:05: runAiMerge is the sole merge path // (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge). export { runAiMerge } from "./merger-ai.js"; +// FNXC:Workspace 2026-06-21-23:40 (Phase C U1): per-repo workspace merge loop + +// the extracted per-repo land primitive, exported for the CLI/dashboard merge doors. +export { + landWorkspaceTask, + landOneRepo, + type WorkspaceMergeResult, + type WorkspaceRepoLandResult, + type LandOneRepoResult, + type LandRepoContext, +} from "./merger-ai.js"; export { resolveMergePolicy, type ResolvedMergePolicy, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 8e28aeed60..ab9302a1ea 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -945,6 +945,205 @@ export async function landSquash(input: { return { outcome: "advanced", localSync: "stash-ff-conflict" }; } +// --------------------------------------------------------------------------- +// Per-repo land (extracted from runAiMerge's inline clean-room closure) +// --------------------------------------------------------------------------- + +/* +FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1): +`landOneRepo` is the per-repo land mechanic extracted byte-for-byte from +`runAiMerge`'s former inline clean-room closure: pre-merge prune (rooted at THIS +repo) → mkdtemp clean room → `git worktree add --detach` → installWorktreeDependencies +→ mergeAndReview → landSquash → the concurrent-advance CAS retry loop → the +activeSessionRegistry register/unregister + cleanup-finally. It advances ONE local +integration ref (no remote push) and returns what landed. It deliberately does NOT +move the task or write task-level mergeDetails — that task-global finalization +(`finalizeMerged`/`finalizeTask`/`evaluateNoCommitsNoOpFinalize`) stays with the +caller, so the same primitive is callable per sub-repo from `landWorkspaceTask` +without finalizing the whole task per repo (KTD3). + +`runAiMerge` is the SINGLE-REPO caller: it builds the same context it always built +and calls `landOneRepo` once against the project root, then runs its existing +finalization on the result. Single-repo behavior is unchanged. +*/ + +/** Per-task context shared by every per-repo land (agents/audit/log are bound to + * the task, not the repo). The repo-varying inputs (rootDir/branch/integrationBranch) + * are explicit `landOneRepo` args. */ +export interface LandRepoContext { + taskId: string; + settings: Settings; + audit: RunAuditor; + log: (message: string) => Promise; + setStatus: (status: string | null) => Promise; + maxPasses: number; + mergeAgent: (cwd: string, prompt: string) => Promise; + reviewAgent: (cwd: string, prompt: string) => Promise; + stashResolveAgent: (cwd: string, prompt: string) => Promise; + includeTaskId: boolean; + trailers: string[]; + taskTitle?: string; + signal?: AbortSignal; + allowDirtyLocalCheckoutSync?: boolean; +} + +/** What a single repo's land produced. No task move / mergeDetails — the caller + * decides task-global finalization. */ +export type LandOneRepoResult = + | { + /** The branch had no net changes vs the integration tip — nothing landed. */ + outcome: "empty"; + tipSha: string; + integrationBranch: string; + } + | { + /** The squash landed; the local integration ref now points at `squashSha`. */ + outcome: "landed"; + squashSha: string; + localSync: LocalSyncOutcome; + tipSha: string; + integrationBranch: string; + }; + +/** + * Land `branch` onto `integrationBranch`'s LOCAL ref in `repoRootDir` via a + * repo-scoped clean room, retrying on concurrent advance. No remote push. See + * the FNXC note above for the extraction contract. + */ +export async function landOneRepo( + store: TaskStore, + repoRootDir: string, + branch: string, + integrationBranch: string, + ctx: LandRepoContext, +): Promise { + const { + taskId, settings, audit, log, setStatus, maxPasses, + mergeAgent, reviewAgent, stashResolveAgent, + includeTaskId, trailers, taskTitle, signal, + } = ctx; + + // Pre-merge prune is rooted at THIS sub-repo (KTD1): N per-repo clean rooms for + // one task share the `fusion-ai-merge--` prefix, so a prune rooted at a + // shared root could reap a sibling repo's live clean room. Rooting it at + // repoRootDir keeps each repo's prune to its own temp roots. + try { + const pruned = await pruneExistingAiMergeWorktrees(taskId, repoRootDir, audit, log, settings); + if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`); + } catch (err: unknown) { + await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`); + } + let advanceRetries = 0; + while (true) { + throwIfAborted(signal, taskId); + const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir); + + // 1. Clean-room worktree at the integration tip. + let mergeRoot: string | undefined; + let worktreeAdded = false; + const registeredMergePaths = new Set(); + const registerMergeRoot = (pathToRegister: string): void => { + if (registeredMergePaths.has(pathToRegister)) return; + activeSessionRegistry.registerPath(pathToRegister, { taskId, kind: "ai-merge", ownerKey: `ai-merge:${taskId}` }); + registeredMergePaths.add(pathToRegister); + }; + try { + mergeRoot = await mkdtemp(join(resolveAiMergeRoot(repoRootDir, settings), `fusion-ai-merge-${taskId.toLowerCase()}-`)); + /* + * FNXC:AIMerge 2026-06-14-16:36: + * The AI-merge clean-room directory must be created and registered inside the cleanup guard. Any terminal path or interrupt after `mkdtemp`, including active-session registration failure before `git worktree add`, must still unregister known paths and remove the `fusion-ai-merge-*` directory. + */ + // Register the repo-local clean-room path as soon as it exists, before + // `git worktree add`, so self-healing/pre-merge sweeps cannot reap a + // just-created clean room in the small window before canonical registration + // is available. + registerMergeRoot(mergeRoot); + await git(["worktree", "add", "--detach", mergeRoot, tipSha], repoRootDir); + worktreeAdded = true; + let canonicalMergeRoot = mergeRoot; + try { + canonicalMergeRoot = realpathSync(mergeRoot); + } catch { + canonicalMergeRoot = mergeRoot; + } + for (const pathToRegister of new Set([canonicalMergeRoot, mergeRoot])) { + registerMergeRoot(pathToRegister); + } + await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } }); + await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`); + + /* + * FNXC:AIMerge 2026-06-13-20:32: + * The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run. + */ + const depsSyncStartedAt = Date.now(); + const depsSyncResult = await installWorktreeDependencies({ + cwd: canonicalMergeRoot, + settings, + taskId, + signal, + context: "for AI merge clean room", + logger: aiMergeLog, + log, + }); + await audit.git({ + type: "merge:ai-deps-sync", + target: integrationBranch, + metadata: { + taskId, + tipSha, + mergeRoot: canonicalMergeRoot, + installCommand: depsSyncResult.installCommand, + configured: depsSyncResult.configured, + skipped: depsSyncResult.skipped, + skipReason: depsSyncResult.skipReason, + durationMs: depsSyncResult.durationMs, + }, + }); + await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`); + + // 2 + 3. Merge + review loop (corrective passes). + const squashSha = await mergeAndReview({ + mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, + maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal, + }); + + if (!squashSha) { + // Branch had no net changes vs the tip — nothing to land. The caller + // decides how to finalize the (possibly multi-repo) task. + await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } }); + return { outcome: "empty", tipSha, integrationBranch }; + } + + // 4 + 5. Land the squash on the target branch and sync the user's + // checkout (AI reconciles a conflicting restore). + await setStatus("landing"); + const landed = await landSquash({ + projectRootDir: repoRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, + resolveConflicts: stashResolveAgent, + allowDirtyLocalCheckoutSync: ctx.allowDirtyLocalCheckoutSync === true, + }); + if (landed.outcome === "concurrent") { + if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) { + advanceRetries++; + await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`); + continue; // rebuild the clean room on the new tip + } + throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`); + } + await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`); + return { outcome: "landed", squashSha, localSync: landed.localSync, tipSha, integrationBranch }; + } finally { + for (const registeredPath of registeredMergePaths) { + activeSessionRegistry.unregisterPath(registeredPath); + } + if (mergeRoot) { + await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir: repoRootDir, worktreeAdded, audit, log }); + } + } + } +} + // --------------------------------------------------------------------------- // Orchestrator // --------------------------------------------------------------------------- @@ -1055,165 +1254,215 @@ export async function runAiMerge( const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; await setStatus("merging"); - try { - const pruned = await pruneExistingAiMergeWorktrees(taskId, projectRootDir, audit, log, settings); - if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`); - } catch (err: unknown) { - await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`); - } - let advanceRetries = 0; - while (true) { - throwIfAborted(options.signal, taskId); - const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], projectRootDir); + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1): + // runAiMerge is now the SINGLE-REPO caller of the extracted `landOneRepo`. It + // builds the same per-task context it always built and lands the project root + // once; the task-global finalization below (empty no-op / no-commits demote / + // finalizeMerged) is unchanged byte-for-byte — only the inline clean-room land + // loop moved into `landOneRepo` so `landWorkspaceTask` can reuse it per sub-repo. + const landResult = await landOneRepo(store, projectRootDir, branch, integrationBranch, { + taskId, settings, audit, log, setStatus, maxPasses, + mergeAgent, reviewAgent, stashResolveAgent, + includeTaskId, trailers, taskTitle, signal: options.signal, + allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, + }); - // 1. Clean-room worktree at the integration tip. - let mergeRoot: string | undefined; - let worktreeAdded = false; - const registeredMergePaths = new Set(); - const registerMergeRoot = (pathToRegister: string): void => { - if (registeredMergePaths.has(pathToRegister)) return; - activeSessionRegistry.registerPath(pathToRegister, { taskId, kind: "ai-merge", ownerKey: `ai-merge:${taskId}` }); - registeredMergePaths.add(pathToRegister); - }; - try { - mergeRoot = await mkdtemp(join(resolveAiMergeRoot(projectRootDir, settings), `fusion-ai-merge-${taskId.toLowerCase()}-`)); + if (landResult.outcome === "empty") { + const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task); + if (noCommitsFinalize.blocked) { + const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes"; /* - * FNXC:AIMerge 2026-06-14-16:36: - * The AI-merge clean-room directory must be created and registered inside the cleanup guard. Any terminal path or interrupt after `mkdtemp`, including active-session registration failure before `git worktree add`, must still unregister known paths and remove the `fusion-ai-merge-*` directory. + * FNXC:Lifecycle 2026-06-14-20:02: + * FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done. */ - // Register the repo-local clean-room path as soon as it exists, before - // `git worktree add`, so self-healing/pre-merge sweeps cannot reap a - // just-created clean room in the small window before canonical registration - // is available. - registerMergeRoot(mergeRoot); - await git(["worktree", "add", "--detach", mergeRoot, tipSha], projectRootDir); - worktreeAdded = true; - let canonicalMergeRoot = mergeRoot; - try { - canonicalMergeRoot = realpathSync(mergeRoot); - } catch { - canonicalMergeRoot = mergeRoot; - } - for (const pathToRegister of new Set([canonicalMergeRoot, mergeRoot])) { - registerMergeRoot(pathToRegister); - } - await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } }); - await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`); - - /* - * FNXC:AIMerge 2026-06-13-20:32: - * The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run. - */ - const depsSyncStartedAt = Date.now(); - const depsSyncResult = await installWorktreeDependencies({ - cwd: canonicalMergeRoot, - settings, + await store.updateTask(taskId, { error: reason }); + await store.logEntry( taskId, - signal: options.signal, - context: "for AI merge clean room", - logger: aiMergeLog, - log, - }); - await audit.git({ - type: "merge:ai-deps-sync", - target: integrationBranch, + `Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`, + JSON.stringify({ + doneCount: noCommitsFinalize.doneCount, + incompleteCount: noCommitsFinalize.incompleteCount, + branch, + integrationBranch, + lane: "ai-empty-merge", + }, null, 2), + ); + await audit.database({ + type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters[0]["type"], + target: taskId, metadata: { - taskId, - tipSha, - mergeRoot: canonicalMergeRoot, - installCommand: depsSyncResult.installCommand, - configured: depsSyncResult.configured, - skipped: depsSyncResult.skipped, - skipReason: depsSyncResult.skipReason, - durationMs: depsSyncResult.durationMs, + reason, + doneCount: noCommitsFinalize.doneCount, + incompleteCount: noCommitsFinalize.incompleteCount, + branch, + integrationBranch, + lane: "ai-empty-merge", }, }); - await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)"}`); + await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters[2]); + return { + task, + branch, + merged: false, + noOp: false, + ok: true, + reason, + error: reason, + worktreeRemoved: false, + branchDeleted: false, + }; + } + await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`); + return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true }); + } - // 2 + 3. Merge + review loop (corrective passes). - const squashSha = await mergeAndReview({ - mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, - maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal, - }); + return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false }); +} - if (!squashSha) { - // Branch had no net changes vs the tip — nothing to land. - await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } }); - const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task); - if (noCommitsFinalize.blocked) { - const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes"; - /* - * FNXC:Lifecycle 2026-06-14-20:02: - * FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done. - */ - await store.updateTask(taskId, { error: reason }); - await store.logEntry( - taskId, - `Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`, - JSON.stringify({ - doneCount: noCommitsFinalize.doneCount, - incompleteCount: noCommitsFinalize.incompleteCount, - branch, - integrationBranch, - lane: "ai-empty-merge", - }, null, 2), - ); - await audit.database({ - type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters[0]["type"], - target: taskId, - metadata: { - reason, - doneCount: noCommitsFinalize.doneCount, - incompleteCount: noCommitsFinalize.incompleteCount, - branch, - integrationBranch, - lane: "ai-empty-merge", - }, - }); - await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters[2]); - return { - task, - branch, - merged: false, - noOp: false, - ok: true, - reason, - error: reason, - worktreeRemoved: false, - branchDeleted: false, - }; - } - await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`); - return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, tipSha, audit, log, { empty: true }); - } +// --------------------------------------------------------------------------- +// Workspace-mode per-repo merge loop (Phase C U1) +// --------------------------------------------------------------------------- - // 4 + 5. Land the squash on the target branch and sync the user's - // checkout (AI reconciles a conflicting restore). - await setStatus("landing"); - const landed = await landSquash({ - projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, - resolveConflicts: stashResolveAgent, +/** Per-repo land outcome inside a workspace task, tagged with its sub-repo. */ +export interface WorkspaceRepoLandResult { + /** The sub-repo's relative path (the `workspaceWorktrees` key). */ + repo: string; + /** Absolute path to the sub-repo's main checkout (where the ref advanced). */ + repoRootDir: string; + /** The per-repo integration branch this repo landed onto (origin/HEAD-derived). */ + integrationBranch: string; + /** The `fusion/` branch that was landed. */ + branch: string; + /** What happened: landed, empty (no net changes), or failed. */ + status: "landed" | "empty" | "failed"; + /** The squash sha when `status === "landed"`. */ + landedSha?: string; + /** How the sub-repo checkout was reconciled when landed. */ + localSync?: LocalSyncOutcome; + /** Failure message when `status === "failed"`. */ + error?: string; +} + +/** Aggregated result of a workspace task's per-repo merge loop. */ +export interface WorkspaceMergeResult { + taskId: string; + repos: WorkspaceRepoLandResult[]; + /** True iff every acquired sub-repo landed (or was empty) with no failure. */ + allLanded: boolean; +} + +/* +FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD1/KTD2): +`landWorkspaceTask` replaces U0's R7 fail-fast throw with the real per-repo merge +loop. For each acquired sub-repo (iterated by SORTED relative-path key for +determinism) it lands that repo's `fusion/` branch onto THAT repo's own LOCAL +integration ref via the extracted `landOneRepo` — no remote push, land-as-you-go +(settled D2/D5). + +Per-repo integration branch (KTD1): `workspaceWorktrees[repo]` does NOT store the +integration branch (acquisition computes then discards it), so we re-resolve it per +repo with the SAME override-stripping acquisition used — integrationBranch/baseBranch +undefined — so each sub-repo falls through to its own origin/HEAD rather than a shared +workspace branch. + +U1 scope: on a repo failure we stop the loop and return a PARTIAL result (repo A may +have landed; B reports the failure). The landed-state predicate + idempotent retry and +the finalize-task-ONCE move-to-done are U2 — `landWorkspaceTask` here deliberately does +NOT call finalizeMerged/finalizeTask or move the task. Routing the engine + CLI doors +to this loop is KTD2. +*/ +export async function landWorkspaceTask( + store: TaskStore, + task: Task, + workspaceRootDir: string, + options: MergerOptions = {}, + deps: AgentDeps = {}, +): Promise { + const taskId = task.id; + const settings = await store.getSettings(); + const audit = createRunAuditor(store, { + runId: generateSyntheticRunId("ai-merge", taskId), + agentId: "merger", + taskId, + phase: "merge", + }); + const log = async (message: string): Promise => { + await store.logEntry(taskId, message, "AiMerge").catch(() => undefined); + await store.appendAgentLog(taskId, message, "text", undefined, "merger").catch(() => undefined); + }; + const setStatus = (status: string | null): Promise => + store.updateTask(taskId, { status }).catch(() => undefined); + + const maxPasses = Math.max(0, Math.trunc(settings.merger?.maxReviewPasses ?? 3)); + const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt(settings.agentPrompts)); + const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit); + const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt()); + const includeTaskId = settings.includeTaskIdInCommit !== false; + const trailers = taskTrailers(taskId, task.lineageId); + const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; + + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + // SORTED keys for deterministic land order (KTD1). + const repoKeys = Object.keys(workspaceWorktrees).sort(); + const repos: WorkspaceRepoLandResult[] = []; + let allLanded = true; + + await setStatus("merging"); + for (const repoRel of repoKeys) { + throwIfAborted(options.signal, taskId); + const entry = workspaceWorktrees[repoRel]; + const repoRootDir = join(workspaceRootDir, repoRel); + + // Re-resolve THIS sub-repo's integration branch with the shared overrides + // stripped (KTD1) so each sub-repo lands on its OWN origin/HEAD, not a shared + // workspace branch. + let integrationBranch: string; + try { + integrationBranch = await resolveIntegrationBranch( + repoRootDir, + { ...settings, integrationBranch: undefined, baseBranch: undefined }, + ); + } catch (err: unknown) { + const message = getErrorMessage(err); + await log(`AI merge (workspace): failed to resolve integration branch for sub-repo ${repoRel}: ${message}`); + repos.push({ repo: repoRel, repoRootDir, integrationBranch: "", branch: entry.branch, status: "failed", error: message }); + allLanded = false; + break; + } + + try { + const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { + taskId, settings, audit, log, setStatus, maxPasses, + mergeAgent, reviewAgent, stashResolveAgent, + includeTaskId, trailers, taskTitle, signal: options.signal, allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, }); - if (landed.outcome === "concurrent") { - if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) { - advanceRetries++; - await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`); - continue; // rebuild the clean room on the new tip - } - throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`); - } - await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`); - return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, squashSha, audit, log, { empty: false }); - } finally { - for (const registeredPath of registeredMergePaths) { - activeSessionRegistry.unregisterPath(registeredPath); - } - if (mergeRoot) { - await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log }); + if (landResult.outcome === "landed") { + repos.push({ + repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, + status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, + }); + } else { + repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "empty" }); } + } catch (err: unknown) { + const message = getErrorMessage(err); + await log(`AI merge (workspace): sub-repo ${repoRel} land failed: ${message}`); + await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined); + repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message }); + allLanded = false; + // U1: stop on first failure and return a partial result. U2 adds the landed + // predicate + idempotent retry so a re-run skips the already-landed repos. + break; } } + + await setStatus(null); + // TODO(Phase C U2): when `allLanded` and every acquired repo landed, finalize the + // task ONCE (finalizeTask / move-done) — NEVER per repo. Until U2's landed + // predicate + idempotent retry land, this loop leaves the task in place; the + // engine dispatch (KTD2) does not move it on a partial result. + return { taskId, repos, allLanded }; } async function mergeAndReview(input: { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 575464cc00..35f87f4308 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; +import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -31,7 +31,7 @@ import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-st import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { sweepStaleAutostashes, VerificationError } from "./merger.js"; -import { runAiMerge } from "./merger-ai.js"; +import { runAiMerge, landWorkspaceTask } from "./merger-ai.js"; import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; @@ -2287,17 +2287,44 @@ export class ProjectEngine { this.activeMergeSession = session; }, }; - // FNXC:Workspace 2026-06-21-19:40: - // R7 merge-boundary guard (master-plan U0). Reject workspace-mode - // tasks BEFORE any git work — they need the per-repo merge loop that - // lands in master-plan U6 (which removes this guard). Load the task - // here so the dispatch shares the one predicate in @fusion/core. - // This door is a FAST-FAIL only: a getTask failure is swallowed to null - // and the guard is skipped, but the unconditional chokepoint guard inside - // runAiMerge (which re-reads the task) is the authoritative enforcement, - // so a transient read failure here cannot let a workspace task reach git work. + // FNXC:Workspace 2026-06-21-23:40 (Phase C U1, KTD2): + // Engine merge dispatch door. A workspace-mode task (non-empty + // `workspaceWorktrees`) routes to the per-repo merge loop + // `landWorkspaceTask` (Phase C U1) instead of the singular runAiMerge — + // each sub-repo lands on its own LOCAL integration ref, no push. The + // U0 R7 throw is REPLACED by this routing (the runAiMerge chokepoint + // + store.mergeTask/aiMergeTask keep throwing as defense-in-depth). + // FAST-FAIL note preserved: a getTask failure is swallowed to null and + // routing falls through to runAiMerge, whose chokepoint guard re-reads + // the task and is the authoritative workspace enforcement. const mergeTask = await store.getTask(taskId).catch(() => null); - if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); + const isWorkspaceMerge = + !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + if (isWorkspaceMerge) { + // U1: land each acquired sub-repo on its own local integration ref. + // Task move-to-done (finalize once after all land) + idempotent retry + // are U2 — for now the loop returns a partial/aggregate result and the + // task is left in place. + const settings = await store.getSettings().catch(() => ({}) as Settings); + const workspaceResult = await landWorkspaceTask( + store, + mergeTask!, + cwd, + { ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true }, + ); + const latest = await store.getTask(taskId).catch(() => mergeTask!); + return { + task: latest ?? mergeTask!, + branch: mergeTask!.branch ?? "", + // U1 does not finalize the task; report merged=false until U2 wires + // the finalize-once move-to-done after every repo lands. + merged: false, + noOp: !workspaceResult.repos.some((r) => r.status === "landed"), + ok: workspaceResult.allLanded, + worktreeRemoved: false, + branchDeleted: false, + } as MergeResult; + } // FNXC:MergerUnification 2026-06-21-19:05: // Master-plan U0 collapsed the merge dispatch: `runAiMerge` (the From 7544346320161808ee83c870485b86ed9d485ef3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:46:44 -0700 Subject: [PATCH 28/44] =?UTF-8?q?feat(workspace):=20Phase=20C=20U2=20?= =?UTF-8?q?=E2=80=94=20per-repo=20landed=20predicate,=20finalize-once,=20a?= =?UTF-8?q?uto-retry-then-park?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit landWorkspaceTask now tracks per-repo landing and finalizes the task exactly once. After a repo lands, its advanced integration tip is persisted as workspaceWorktrees[repo].landedSha (fresh-read merge, siblings untouched). Before landing, isRepoLanded skips a repo iff its landedSha is present AND an ancestor of (or equal to) its local integration ref — so a retry after a partial land never re-advances an already-landed ref. finalizeWorkspaceTask runs only when every acquired repo is landed: it builds an aggregate MergeResult (representative commitSha + a workspaceLandedShas map in MergeDetails) and calls the existing task-global finalizeTask once, satisfying the task:merged consumer. No premature done on the first repo. Partial lands surface as WorkspacePartialLandError; the engine consumes a mergeRetry and re-enqueues landWorkspaceTask (skipping landed repos) with the existing conflict-retry backoff up to MAX, then operator-parks (status:failed) — mirroring shouldRetryAutoMergeConflict (new exported shouldRetryWorkspacePartialLand seam). The defense-in-depth WorkspaceTaskMergeError still hard-fails without burning retries; manual merges fall through to rejectMergeResolvers. types: workspaceWorktrees entry gains landedSha?; MergeDetails gains workspaceLandedShas?. 6 new idempotency/predicate/finalize-once/retry-park tests; oracle (52) + U1 (5) stay green. Gate: build, typecheck, lint, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ase-c-u2-landed-predicate-finalize-once.md | 15 + packages/core/src/types.ts | 22 +- .../workspace-merger-idempotency.test.ts | 353 ++++++++++++++++++ .../src/__tests__/workspace-merger.test.ts | 13 +- packages/engine/src/merger-ai.ts | 168 ++++++++- packages/engine/src/project-engine.ts | 105 +++++- 6 files changed, 650 insertions(+), 26 deletions(-) create mode 100644 .changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md create mode 100644 packages/engine/src/__tests__/workspace-merger-idempotency.test.ts diff --git a/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md new file mode 100644 index 0000000000..1f7f837c80 --- /dev/null +++ b/.changeset/workspace-phase-c-u2-landed-predicate-finalize-once.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase C (U2): per-repo landed predicate, finalize-once, and idempotent +auto-retry-then-park. `landWorkspaceTask` now records each sub-repo's `landedSha` after +its branch advances that repo's local integration ref, and on a re-run SKIPS any repo +whose recorded `landedSha` is an ancestor of (or equals) its current integration tip — so +an interrupted multi-repo land retries only the un-landed repos and never re-advances an +already-landed ref. When every acquired repo's landed predicate holds, the task moves to +`done` EXACTLY ONCE via the task-global finalize path with an aggregate `mergeDetails` +(representative `commitSha` + a `workspaceLandedShas` map). A partial land (some repos +unlanded) does not move the task done; the engine merge dispatch surfaces it as a +retryable failure that consumes a `mergeRetry` and auto-retries the merge (skipping landed +repos) up to the configured max, then operator-parks the task as failed. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6e40bad2d0..98c69d02ad 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1845,6 +1845,17 @@ export interface MergeDetails { * `task.mergeRetries`, which counts in-cycle aiMergeTask retries. */ transientRecoveryCount?: number; + /** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Workspace-mode aggregate landed map: sub-repo relative path → the squash sha + * that landed on that repo's local integration ref. Set ONLY by + * `landWorkspaceTask`'s finalize-once after EVERY acquired repo's landed + * predicate holds; the task-level `commitSha` points at one representative + * landed sha (the first sorted landed repo) so the existing `task:merged` + * consumer (which reads `mergeDetails.commitSha`) is satisfied. Empty/absent + * for single-repo tasks. + */ + workspaceLandedShas?: Record; } /** Represents an agent's checkout lease on a task. */ @@ -2252,8 +2263,17 @@ export interface Task { * against that sub-repo's RESOLVED integration branch, local-first. It is the * per-repo analogue of the single-repo base-commit capture and prevents * cross-repo files-changed inflation when local integration is ahead of origin. + * + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * `landedSha` is the per-repo "this repo's branch has landed on its local + * integration ref" marker, set by `landWorkspaceTask` after a sub-repo's squash + * advances that repo's ref. It is the ONLY partial-land state added (no new + * status type): a re-run's landed predicate skips a repo whose `landedSha` is + * present AND whose recorded value is an ancestor of (or equals) the repo's + * integration tip, so an interrupted multi-repo land retries only the un-landed + * repos and never re-advances an already-landed ref (idempotent retry). */ - workspaceWorktrees?: Record; + workspaceWorktrees?: Record; steps: TaskStep[]; currentStep: number; /** diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts new file mode 100644 index 0000000000..af9ed2e1af --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -0,0 +1,353 @@ +/* +FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): +Per-repo landed-predicate + finalize-once + idempotent-retry tests. They drive the REAL +`landWorkspaceTask` against a REAL two-repo git fixture (createWorkspaceFixture) under a +NON-git workspace root, asserting LOCAL integration-ref shas directly (FN-5048: real git +only where the invariant requires it; the AI merge/review agents are injected so NO real +AI calls happen and the squash is a plain `git merge --squash`). The retry/park decision +is tested via the engine's narrow exported seam `shouldRetryWorkspacePartialLand` with +fake timers — NOT by spinning real engine retries. + +Coverage (FN-5893 surfaces): +- idempotency: re-run after repo A landed + repo B failed → A is SKIPPED (its integration + ref does NOT advance a second time — assert the ref sha is unchanged), B is retried. +- predicate: landed predicate true when branch tip is an ancestor of integration tip; + false otherwise (ref rebuilt / no landedSha). +- no premature done: finalizeTask/move-done runs EXACTLY ONCE, only after BOTH repos land + — assert the task is NOT moved done after the first repo (partial run). +- completion: all repos landed → task reaches done with aggregate mergeDetails + (workspaceLandedShas map + representative commitSha). +- retry/park: a partial-land failure consumes one mergeRetry; after MAX it parks + (shouldRetryWorkspacePartialLand boundary, fake timers). +*/ +import { afterEach, beforeEach, afterAll, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { shouldRetryWorkspacePartialLand } from "../project-engine.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-2002"; +const BRANCH = "fusion/fn-2002"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + task: Task; + moveTaskCalls: Array<{ id: string; column: string }>; + emitted: Array<{ event: string; payload: unknown }>; +} + +/** + * A store that PERSISTS workspaceWorktrees + mergeDetails updates on a single in-memory + * task and returns it from getTask, so the landed-predicate retry reads back the + * `landedSha` that landWorkspaceTask wrote (real fresh-read-then-merge behavior). + */ +function createStore(task: Task, settings: Record = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const emitted: Array<{ event: string; payload: unknown }> = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + task, + moveTaskCalls, + emitted, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false, ...settings }), + updateTask: vi.fn(async (_id: string, patch: Partial) => { + Object.assign(store.task, patch); + return undefined; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getTask: vi.fn(async () => store.task), + moveTask: vi.fn((id: string, column: string) => { + moveTaskCalls.push({ id, column }); + store.task.column = column as Task["column"]; + return Promise.resolve(store.task); + }), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +/** Add a real `fusion/` branch to a sub-repo with one own non-conflicting commit. */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** Make a sub-repo's integration tip + task branch BOTH edit README → squash conflicts. */ +function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, ".wt-conflict"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "README.md"), "# branch-side change\n", "utf-8"); + execSync("git add README.md", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); + writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8"); + fx.git(repoRel, "git add README.md"); + fx.git(repoRel, 'git commit -m "main diverge README"'); +} + +/** Resolve repo-b's conflict by replacing the conflicting README content (no markers). */ +function resolveConflictInRepo(fx: WorkspaceFixture, repoRel: string): void { + // Re-point the task branch so the squash no longer conflicts: drop the branch's + // README edit and add a clean feature file instead. + const repoDir = fx.repoPath(repoRel); + fx.git(repoRel, `git branch -D ${BRANCH}`); + const worktreePath = path.join(repoDir, ".wt-resolved"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), "resolved feature\n", "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): resolved"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string) { + return async (cwd: string): Promise => { + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — fall through to the unmerged check. + } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) { + throw new Error("merge conflict: unresolved paths in clean room"); + } + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id: TASK_ID, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("landWorkspaceTask — landed predicate + finalize-once + idempotent retry (Phase C U2)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("idempotency: re-run after A landed + B failed skips A (ref unchanged) and retries B", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore(task); + + // First run: A lands, B conflicts → partial. + const first = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(first.allLanded).toBe(false); + expect(first.finalized).toBe(false); + const tipAAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main"); + // A's landedSha was persisted onto the task entry. + expect(store.task.workspaceWorktrees!["repo-a"].landedSha).toBe(tipAAfterFirst); + // Not moved done on a partial land. + expect(store.moveTaskCalls).toHaveLength(0); + + // Operator resolves repo B's conflict, then the merge is re-run (auto-retry). + resolveConflictInRepo(fx, "repo-b"); + + const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + // A was SKIPPED (already landed): its integration ref did NOT advance a second time. + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAAfterFirst); + const repoA = second.repos.find((r) => r.repo === "repo-a")!; + expect(repoA.alreadyLanded).toBe(true); + expect(repoA.status).toBe("landed"); + // B was retried and landed this time. + const repoB = second.repos.find((r) => r.repo === "repo-b")!; + expect(repoB.status).toBe("landed"); + expect(repoB.alreadyLanded).toBeFalsy(); + expect(second.allLanded).toBe(true); + // Finalize-once ran on the completing run. + expect(second.finalized).toBe(true); + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + }); + + it("predicate: landedSha that is an ancestor of the integration tip reads as landed; a non-ancestor does not", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + const store = createStore(task); + + // Land repo-a once. + const first = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(first.allLanded).toBe(true); + const landedSha = store.task.workspaceWorktrees!["repo-a"].landedSha!; + const tip = fx.git("repo-a", "git rev-parse refs/heads/main"); + // landedSha == tip → ancestor-or-equal → landed. Advance main with an UNRELATED + // commit; the landedSha is still an ancestor, so it must STILL read as landed. + writeFileSync(path.join(fx.repoPath("repo-a"), "unrelated.txt"), "x\n", "utf-8"); + fx.git("repo-a", "git add unrelated.txt"); + fx.git("repo-a", 'git commit -m "unrelated advance"'); + expect(fx.git("repo-a", "git merge-base --is-ancestor " + landedSha + " refs/heads/main && echo yes").trim()).toBe("yes"); + + // Re-run: predicate true (ancestor) → repo skipped, no re-land. + const tipBeforeRerun = fx.git("repo-a", "git rev-parse refs/heads/main"); + const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(second.repos[0].alreadyLanded).toBe(true); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipBeforeRerun); + + // Non-ancestor: reset main to before the landedSha → landedSha no longer reachable → + // predicate false → the repo re-lands. + void tip; + fx.git("repo-a", "git reset --hard HEAD~2"); // before the squash + unrelated commit + const tipReset = fx.git("repo-a", "git rev-parse refs/heads/main"); + expect(fx.git("repo-a", `git merge-base --is-ancestor ${landedSha} refs/heads/main || echo no`).trim()).toBe("no"); + const third = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(third.repos[0].alreadyLanded).toBeFalsy(); + expect(third.repos[0].status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipReset); + }); + + it("no premature done: a partial run (one repo failed) does NOT move the task done", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore(task); + + const result = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + // repo-a landed first, but the task must NOT be done because repo-b failed. + expect(result.repos.find((r) => r.repo === "repo-a")!.status).toBe("landed"); + expect(result.finalized).toBe(false); + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + }); + + it("completion: all repos landed → task moves done ONCE with aggregate mergeDetails", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + const task = makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore(task); + + const result = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.allLanded).toBe(true); + expect(result.finalized).toBe(true); + // Moved done exactly once and emitted task:merged exactly once. + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + const mergedEvents = store.emitted.filter((e) => e.event === "task:merged"); + expect(mergedEvents).toHaveLength(1); + + // Aggregate mergeDetails: a representative commitSha + the per-repo landed map. + const md = store.task.mergeDetails!; + expect(md.mergeConfirmed).toBe(true); + const landedShaA = fx.git("repo-a", "git rev-parse refs/heads/main"); + const landedShaB = fx.git("repo-b", "git rev-parse refs/heads/main"); + expect(md.workspaceLandedShas).toEqual({ "repo-a": landedShaA, "repo-b": landedShaB }); + // commitSha is one of the landed repo shas (representative for the task:merged consumer). + expect([landedShaA, landedShaB]).toContain(md.commitSha); + }); +}); + +describe("workspace partial-land retry/park decision (engine seam, fake timers)", () => { + beforeEach(() => vi.useFakeTimers()); + afterAll(() => vi.useRealTimers()); + + it("consumes a mergeRetry up to MAX, then parks (shouldRetryWorkspacePartialLand)", () => { + // Default MAX = 3. currentRetries + 1 < MAX gates retry. + expect(shouldRetryWorkspacePartialLand(0, {})).toMatchObject({ + shouldRetry: true, + maxAutoMergeRetries: 3, + nextRetryCount: 1, + }); + expect(shouldRetryWorkspacePartialLand(1, {})).toMatchObject({ + shouldRetry: true, + maxAutoMergeRetries: 3, + nextRetryCount: 2, + }); + // Last attempt: currentRetries + 1 === MAX → park (no further retry). + expect(shouldRetryWorkspacePartialLand(2, {})).toMatchObject({ + shouldRetry: false, + maxAutoMergeRetries: 3, + nextRetryCount: 3, + }); + // Custom cap honored. + expect(shouldRetryWorkspacePartialLand(3, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(true); + expect(shouldRetryWorkspacePartialLand(4, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(false); + }); + + it("fake-timer backoff schedule does not spin real retries", () => { + // The dispatch schedules internalEnqueueMerge via setTimeout(5000 * 2^retries). + // Assert a scheduled callback exists and only fires when advanced — no real wait. + const fired: number[] = []; + setTimeout(() => fired.push(1), 5000); + expect(fired).toHaveLength(0); + vi.advanceTimersByTime(5000); + expect(fired).toHaveLength(1); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index 0e4ec55a64..fe15703435 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -10,11 +10,14 @@ injected (deps) so NO real AI calls happen and the squash is produced by a plain Coverage (FN-5893 surfaces): - happy: two acquired repos both clean → BOTH local integration refs advance against - each repo's own resolved branch; NO remote ref/push happened; result tags both. + each repo's own resolved branch; NO remote ref/push happened; result tags both. Since + Phase C U2, a fully-landed workspace task also finalizes ONCE (moves done, emits + task:merged) — asserted here; the landed-predicate/finalize-once/retry mechanics have + dedicated coverage in workspace-merger-idempotency.test.ts. - per-repo resolution: repos with DIFFERENT origin/HEAD integration branches → each lands on its own (override-stripping works, not a shared branch). - partial: a conflict in repo B → repo A lands (landedSha recorded); B reports the - failure; the task is NOT moved done (no finalizeTask call). + failure; the task is NOT moved done (no finalizeTask call) — the partial-land retry is U2. - defense-in-depth: store.mergeTask / aiMergeTask with a workspace task → still throw WorkspaceTaskMergeError. The single-repo runAiMerge regression lives in the existing merger-ai*.test.ts (the @@ -187,9 +190,9 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { expect(remoteRefs).toBe(""); } - // U1 does NOT move the task to done. - expect(store.moveTaskCalls).toHaveLength(0); - expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + // U2 finalize-once: every repo landed → the task moves to done exactly once. + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); }); it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => { diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index ab9302a1ea..b810cd59f8 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1341,6 +1341,13 @@ export interface WorkspaceRepoLandResult { localSync?: LocalSyncOutcome; /** Failure message when `status === "failed"`. */ error?: string; + /** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * True when this repo was SKIPPED by the landed predicate on a retry (its recorded + * `landedSha` is already an ancestor of the integration tip) — its ref was NOT + * re-advanced this run. + */ + alreadyLanded?: boolean; } /** Aggregated result of a workspace task's per-repo merge loop. */ @@ -1349,6 +1356,12 @@ export interface WorkspaceMergeResult { repos: WorkspaceRepoLandResult[]; /** True iff every acquired sub-repo landed (or was empty) with no failure. */ allLanded: boolean; + /** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * True iff the finalize-once move-to-done ran this call (only when `allLanded`). + * False on a partial land (the task stays put for the engine dispatch's auto-retry). + */ + finalized: boolean; } /* @@ -1366,10 +1379,30 @@ undefined — so each sub-repo falls through to its own origin/HEAD rather than workspace branch. U1 scope: on a repo failure we stop the loop and return a PARTIAL result (repo A may -have landed; B reports the failure). The landed-state predicate + idempotent retry and -the finalize-task-ONCE move-to-done are U2 — `landWorkspaceTask` here deliberately does -NOT call finalizeMerged/finalizeTask or move the task. Routing the engine + CLI doors -to this loop is KTD2. +have landed; B reports the failure). Routing the engine + CLI doors to this loop is KTD2. + +FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): +U2 adds per-repo landed tracking + finalize-once + idempotent retry on top of U1's loop: + + - Landed predicate + skip: before landing a repo, we skip it iff its `landedSha` is + recorded AND that sha is an ancestor of (or equals) the repo's CURRENT integration + tip. A skipped repo's ref is NEVER re-advanced, so re-running `landWorkspaceTask` + after a partial land (A landed, B failed) re-attempts ONLY B — A is idempotent. + - landedSha persistence: after a repo lands, we record `workspaceWorktrees[repo].landedSha` + = the advanced integration tip via a FRESH-read-then-merge `store.updateTask` (re-read + the latest task and merge only this repo's entry, so concurrent sibling-entry writes + are not clobbered — the Phase A/B per-repo persistence pattern). + - finalize-once: the task moves to `done` EXACTLY ONCE, only after EVERY acquired repo's + landed predicate holds (all landed/empty, none failed). We reuse the task-global + `finalizeTask` move-done path with an AGGREGATE mergeDetails (representative + `commitSha` = first sorted landed repo + a `workspaceLandedShas` map) so the existing + `task:merged` consumer is satisfied. On a partial land we do NOT move done — we return + `allLanded:false` with the landed repos' `landedSha` already persisted. + +The partial-land retry/park policy (consume a mergeRetry, auto-retry skipping landed +repos up to MAX, then operator-park) is wired at the engine dispatch (project-engine.ts), +NOT here: this function reports the partial via `allLanded:false` and the dispatch drives +the retry seam. */ export async function landWorkspaceTask( store: TaskStore, @@ -1430,6 +1463,19 @@ export async function landWorkspaceTask( break; } + // U2 landed predicate + skip (KTD3): a repo whose recorded `landedSha` is an + // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP + // it so a retry never re-advances the ref. This makes a re-run after a partial + // land idempotent for the already-landed repos. + if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha)) { + await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`); + repos.push({ + repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, + status: "landed", landedSha: entry.landedSha, alreadyLanded: true, + }); + continue; + } + try { const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, @@ -1438,6 +1484,10 @@ export async function landWorkspaceTask( allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, }); if (landResult.outcome === "landed") { + // Persist this repo's landedSha BEFORE moving on (fresh-read-then-merge so + // sibling entries written by a concurrent path are not clobbered). The retry + // predicate above reads this back to skip the repo on a re-run. + await persistRepoLandedSha(store, taskId, repoRel, landResult.squashSha); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, @@ -1451,18 +1501,114 @@ export async function landWorkspaceTask( await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "failed", error: message }); allLanded = false; - // U1: stop on first failure and return a partial result. U2 adds the landed - // predicate + idempotent retry so a re-run skips the already-landed repos. + // Stop on first failure and return a partial result. The already-landed repos' + // `landedSha` is persisted, so the engine dispatch's auto-retry re-runs this + // loop and the landed predicate above skips them (only the failed repo retries). break; } } await setStatus(null); - // TODO(Phase C U2): when `allLanded` and every acquired repo landed, finalize the - // task ONCE (finalizeTask / move-done) — NEVER per repo. Until U2's landed - // predicate + idempotent retry land, this loop leaves the task in place; the - // engine dispatch (KTD2) does not move it on a partial result. - return { taskId, repos, allLanded }; + + // U2 finalize-once (KTD3): move the task to `done` EXACTLY ONCE, only after EVERY + // acquired repo's landed predicate holds (all landed/empty, none failed). Reuse the + // task-global `finalizeTask` move-done path with an aggregate mergeDetails so the + // existing `task:merged` consumer is satisfied. On a partial land we do NOT move + // done (the landed repos' `landedSha` is already persisted for the retry). + if (allLanded) { + const finalized = await finalizeWorkspaceTask(store, taskId, task, repos); + return { taskId, repos, allLanded, finalized }; + } + return { taskId, repos, allLanded, finalized: false }; +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Landed predicate: a sub-repo is landed iff a `landedSha` is recorded AND that sha is + * an ancestor of (or equals) the repo's CURRENT integration tip. The ancestor check + * (not just sha presence) survives a later un-related advance of the integration ref: + * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that + * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and + * the repo re-lands. + */ +async function isRepoLanded( + repoRootDir: string, + integrationBranch: string, + landedSha: string | undefined, +): Promise { + if (!landedSha) return false; + if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir))) { + return false; + } + // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. + return await gitOk(["merge-base", "--is-ancestor", landedSha, `refs/heads/${integrationBranch}`], repoRootDir); +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Persist one sub-repo's `landedSha` with a FRESH-read-then-merge so a concurrent + * sibling-entry write is not clobbered (Phase A/B per-repo `workspaceWorktrees` + * pattern). Re-read the latest task, merge only this repo's entry, write the whole map. + */ +async function persistRepoLandedSha( + store: TaskStore, + taskId: string, + repoRel: string, + landedSha: string, +): Promise { + const latest = await store.getTask(taskId).catch(() => undefined); + const current = latest?.workspaceWorktrees ?? {}; + const entry = current[repoRel]; + if (!entry) return; // entry vanished — nothing to merge into + const next = { ...current, [repoRel]: { ...entry, landedSha } }; + await store.updateTask(taskId, { workspaceWorktrees: next }).catch(() => undefined); +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Finalize-once: build an aggregate `MergeResult` from the per-repo lands and run the + * task-global `finalizeTask` move-done path ONCE. The representative `commitSha` is the + * first sorted landed repo's sha (so `mergeDetails.commitSha` is populated for the + * `task:merged` consumer); the full per-repo map is carried in `mergeDetails.workspaceLandedShas`. + * Returns true iff the task was moved to done. + */ +async function finalizeWorkspaceTask( + store: TaskStore, + taskId: string, + task: Task, + repos: WorkspaceRepoLandResult[], +): Promise { + const landed = repos.filter((r) => r.status === "landed" && r.landedSha); + const workspaceLandedShas: Record = {}; + for (const r of landed) workspaceLandedShas[r.repo] = r.landedSha!; + const representative = landed.length > 0 ? landed[0].landedSha : undefined; + const anyLanded = landed.length > 0; + + // Pre-populate task.mergeDetails so finalizeTask's spread carries the workspace map. + const mergeDetails: MergeDetails = { + ...task.mergeDetails, + ...(representative ? { commitSha: representative } : {}), + ...(anyLanded ? { workspaceLandedShas } : {}), + mergeConfirmed: anyLanded, + }; + await store.updateTask(taskId, { mergeDetails }).catch(() => undefined); + task.mergeDetails = mergeDetails; + + const result: MergeResult = { + task, + branch: task.branch ?? "", + merged: anyLanded, + noOp: !anyLanded, + ok: true, + reason: anyLanded ? undefined : "no-net-changes", + commitSha: representative, + mergeConfirmed: anyLanded, + worktreeRemoved: false, + branchDeleted: false, + }; + await store.logEntry(taskId, `AI merge (workspace): all ${repos.length} sub-repo(s) landed — task → done`, "AiMerge").catch(() => undefined); + await finalizeTask(store, taskId, result); + return true; } async function mergeAndReview(input: { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 35f87f4308..0276d4ee00 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -137,6 +137,28 @@ export function shouldRetryAutoMergeConflict( }; } +/* +FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): +Pure retry/park decision for a workspace PARTIAL land (some sub-repos landed, one failed). +Mirrors `shouldRetryAutoMergeConflict` so the engine dispatch's partial-land catch branch +has a narrow, unit-testable seam: a partial land is RETRYABLE (the landed repos' `landedSha` +is persisted, so a re-run skips them and only the failed repo retries), so it CONSUMES a +mergeRetry and re-enqueues up to `resolveMaxAutoMergeRetries(settings)`, then OPERATOR-PARKS +(`shouldRetry:false`). `currentRetries + 1 < MAX` keeps the LAST attempt's failure parking +in the same tick rather than scheduling an Nth timer that a restart could strand. +*/ +export function shouldRetryWorkspacePartialLand( + currentRetries: number, + settings: { maxAutoMergeRetries?: unknown } | null | undefined, +): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } { + const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); + return { + shouldRetry: currentRetries + 1 < maxAutoMergeRetries, + maxAutoMergeRetries, + nextRetryCount: currentRetries + 1, + }; +} + /** * FN-5627: Defense-in-depth gate for the auto-merge "merge already confirmed" * fast-path. Verifies the task's recorded `mergeDetails.commitSha` is actually @@ -2301,10 +2323,14 @@ export class ProjectEngine { const isWorkspaceMerge = !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; if (isWorkspaceMerge) { - // U1: land each acquired sub-repo on its own local integration ref. - // Task move-to-done (finalize once after all land) + idempotent retry - // are U2 — for now the loop returns a partial/aggregate result and the - // task is left in place. + // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + // Land each acquired sub-repo on its own local integration ref; + // `landWorkspaceTask` records each landed `landedSha`, skips + // already-landed repos on a retry (idempotent), and on full success + // finalizes the task to `done` EXACTLY ONCE. On a PARTIAL land it does + // NOT finalize — it returns `allLanded:false`, which we surface as a + // WorkspacePartialLandError so the catch-block auto-retry consumes a + // mergeRetry and re-runs (skipping landed repos) up to MAX, then parks. const settings = await store.getSettings().catch(() => ({}) as Settings); const workspaceResult = await landWorkspaceTask( store, @@ -2312,15 +2338,28 @@ export class ProjectEngine { cwd, { ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true }, ); + if (!workspaceResult.allLanded) { + const failed = workspaceResult.repos.filter((r) => r.status === "failed"); + const landedCount = workspaceResult.repos.filter((r) => r.status === "landed").length; + const detail = failed.map((r) => `${r.repo}: ${r.error ?? "land failed"}`).join("; "); + const partialErr = new Error( + `Workspace partial land for ${taskId}: ${landedCount} repo(s) landed, ${failed.length} failed — ${detail}`, + ); + partialErr.name = "WorkspacePartialLandError"; + throw partialErr; + } + // Finalized to done by landWorkspaceTask; report the merge as merged so + // the success path (retry reset + branch-group promotion) runs normally. const latest = await store.getTask(taskId).catch(() => mergeTask!); + const anyLanded = workspaceResult.repos.some((r) => r.status === "landed"); return { task: latest ?? mergeTask!, branch: mergeTask!.branch ?? "", - // U1 does not finalize the task; report merged=false until U2 wires - // the finalize-once move-to-done after every repo lands. - merged: false, - noOp: !workspaceResult.repos.some((r) => r.status === "landed"), - ok: workspaceResult.allLanded, + merged: anyLanded, + noOp: !anyLanded, + ok: true, + commitSha: workspaceResult.repos.find((r) => r.status === "landed")?.landedSha, + mergeConfirmed: anyLanded, worktreeRemoved: false, branchDeleted: false, } as MergeResult; @@ -2421,6 +2460,54 @@ export class ProjectEngine { continue; } + // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + // Workspace PARTIAL-LAND auto-retry-then-park (user decision). Unlike the R7 + // WorkspaceTaskMergeError above (a permanent config error that must NOT burn + // retries), a partial land — repo A landed, repo B failed — is RETRYABLE: the + // landed repos' `landedSha` is persisted, so a re-run of `landWorkspaceTask` + // skips them and re-attempts only the failed repo (idempotent). So this CONSUMES + // a `mergeRetry` and re-enqueues the merge with exponential backoff up to the + // existing MAX (resolveMaxAutoMergeRetries), then OPERATOR-PARKS (status:"failed") + // — mirroring the conflict-retry seam below. Detect by err.name (robust across + // the package boundary). Manual merges fall through to rejectMergeResolvers at + // the hasManualResolver early-return below (no auto-retry for manual). + const isWorkspacePartialLand = + err instanceof Error && err.name === "WorkspacePartialLandError"; + if (isWorkspacePartialLand && !hasManualResolver) { + const wsSettings = await store.getSettings().catch(() => ({ maxAutoMergeRetries: undefined })); + const wsTask = await store.getTask(taskId).catch(() => null); + const wsRetries = wsTask?.mergeRetries ?? 0; + const decision = shouldRetryWorkspacePartialLand(wsRetries, wsSettings as { maxAutoMergeRetries?: unknown }); + await store + .logEntry(taskId, `Workspace partial land: ${errorMsg}`, "WorkspacePartialLand") + .catch(() => undefined); + if (decision.shouldRetry) { + await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }).catch(() => undefined); + const delayMs = 5000 * Math.pow(2, wsRetries); + runtimeLog.log( + `Workspace partial-land retry ${decision.nextRetryCount}/${decision.maxAutoMergeRetries} for ${taskId} in ${delayMs / 1000}s (re-runs skipping landed repos)`, + ); + setTimeout(() => { + if (!this.shuttingDown) this.internalEnqueueMerge(taskId); + }, delayMs); + } else { + await store + .updateTask(taskId, { status: "failed", mergeRetries: decision.maxAutoMergeRetries, error: errorMsg }) + .catch(() => undefined); + await store + .logEntry( + taskId, + `Workspace partial land exhausted ${decision.maxAutoMergeRetries} retries — parking as failed for operator intervention (landed repos remain landed locally): ${errorMsg}`, + "WorkspacePartialLand", + ) + .catch(() => undefined); + runtimeLog.error( + `Auto-merge: ${taskId} workspace partial land exhausted ${decision.maxAutoMergeRetries} retries — parked as failed`, + ); + } + continue; + } + runtimeLog.error(`${hasManualResolver ? "Manual" : "Auto"}-merge failed for ${taskId}: ${errorMsg}`); // Surface every merge failure on the task log so the dashboard shows From 64e87f9a1264e57788af1a5994fd94c78e3ed936 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 23:56:41 -0700 Subject: [PATCH 29/44] =?UTF-8?q?feat(workspace):=20Phase=20C=20U3=20?= =?UTF-8?q?=E2=80=94=20per-repo=20land=20lease=20(serialize=20same-sub-rep?= =?UTF-8?q?o=20lands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit landWorkspaceTask now holds a per-repo land lease around each landOneRepo call: a new activeSessionRegistry kind "workspace-repo-land" keyed on the sub-repo absolute path, registered synchronously before the per-repo try and released in a finally (on success and failure, only yanking our own taskId+ownerKey entry — never a foreign/different-kind entry). Two workspace tasks landing the same sub-repo serialize; the loser throws the retryable WorkspaceRepoLandBusyError, which reuses the U2 partial-land retry/park machinery (consume a mergeRetry, backoff re-enqueue up to MAX skipping landed repos, then operator-park). Disjoint sub-repos never falsely serialize. The lease is for serialization / clean-room-collision avoidance, not ref correctness — advanceIntegrationBranchRef's CAS already makes interleaved update-ref safe. Distinct from the execution-phase "workspace-repo-acquire" lease (different kind, different lifecycle phase, each ignores the other's entry). 3 new tests (serialize, independence, release-on-failure); oracle (56) + U1/U2 (idempotency) stay green. Gate: build, typecheck, lint, test:gate (649+58). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-per-repo-land-lease.md | 5 + .../__tests__/workspace-merger-lease.test.ts | 272 ++++++++++++++++++ .../engine/src/active-session-registry.ts | 15 +- packages/engine/src/merger-ai.ts | 79 +++++ packages/engine/src/project-engine.ts | 12 +- 5 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 .changeset/workspace-per-repo-land-lease.md create mode 100644 packages/engine/src/__tests__/workspace-merger-lease.test.ts diff --git a/.changeset/workspace-per-repo-land-lease.md b/.changeset/workspace-per-repo-land-lease.md new file mode 100644 index 0000000000..8d9fe58642 --- /dev/null +++ b/.changeset/workspace-per-repo-land-lease.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode (Phase C U3): serialize concurrent same-sub-repo lands with a per-repo file-scope lease. When two workspace tasks try to land onto the SAME sub-repo's local integration ref at the same time, the merge phase now registers the sub-repo's absolute path in the path-keyed active-session registry under a distinct `workspace-repo-land` kind before each land and releases it in a `finally` (on land success or failure — no stuck lock). A second task contending for the same sub-repo fast-fails with a retryable `WorkspaceRepoLandBusyError`, which the existing partial-land auto-retry-then-park dispatch handles (consume a `mergeRetry`, re-enqueue with backoff, then operator-park). Disjoint sub-repos lease different paths and never serialize against each other. The lease prevents clean-room ai-merge worktree collisions; ref correctness is already guaranteed by `advanceIntegrationBranchRef`'s CAS (concurrent-advance → rebuild). diff --git a/packages/engine/src/__tests__/workspace-merger-lease.test.ts b/packages/engine/src/__tests__/workspace-merger-lease.test.ts new file mode 100644 index 0000000000..074752aca4 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-lease.test.ts @@ -0,0 +1,272 @@ +/* +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +Per-repo LAND lease tests. They drive the REAL `landWorkspaceTask` against a REAL +two-repo git fixture (createWorkspaceFixture) and assert the lease seam directly on +the REAL module-level `activeSessionRegistry` singleton (FN-5048: narrow seam — we +assert registry state + a merge-agent spy, NO real concurrent processes, NO +mock-the-world; the AI merge/review agents are injected so no real AI calls happen +and the squash is a plain `git merge --squash`). + +The lease is keyed by the sub-repo ABSOLUTE path under kind "workspace-repo-land". +It is for SERIALIZATION / clean-room-collision avoidance only — `advanceIntegration +BranchRef`'s CAS already makes the interleaved `update-ref` correct — so we assert +serialization behavior (one wins, the other fast-fails) and that the lease never leaks. + +Coverage (FN-5893 surfaces): +- concurrency: two tasks landing the SAME sub-repo → one acquires the land lease, + the other FAST-FAILS with WorkspaceRepoLandBusyError; no interleaved update-ref on + that repo's ref (the loser advances nothing). Lease kind/path asserted while held. +- independence: disjoint sub-repos (task1→repo-a, task2→repo-b) → both proceed, no + false serialization (neither sees the other's lease path). +- cleanup: a repo land that THROWS → the lease for that path is released (not stuck), + so a subsequent land of the same repo can acquire it. +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Task, TaskStore } from "@fusion/core"; +import { landWorkspaceTask, WorkspaceRepoLandBusyError } from "../merger-ai.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const BRANCH = "fusion/fn-3003"; +const LAND_KIND = "workspace-repo-land"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + task: Task; + moveTaskCalls: Array<{ id: string; column: string }>; +} + +/** A store that persists workspaceWorktrees/mergeDetails on one in-memory task. */ +function createStore(task: Task): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const store = Object.assign(emitter, { + task, + moveTaskCalls, + getSettings: vi.fn().mockResolvedValue({ autoMerge: false }), + updateTask: vi.fn(async (_id: string, patch: Partial) => { + Object.assign(store.task, patch); + return undefined; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getTask: vi.fn(async () => store.task), + moveTask: vi.fn((id: string, column: string) => { + moveTaskCalls.push({ id, column }); + store.task.column = column as Task["column"]; + return Promise.resolve(store.task); + }), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + }) as unknown as TaskStore & RecordingStore; + return store; +} + +/** Add a real `fusion/` branch to a sub-repo with one own non-conflicting commit. */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, taskId: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const worktreePath = path.join(repoDir, `.wt-${taskId}`); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${taskId}): add feature in ${repoRel}"`, { cwd: worktreePath, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${worktreePath}`); +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string, onEnter?: (cwd: string) => void | Promise) { + return async (cwd: string): Promise => { + if (onEnter) await onEnter(cwd); + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — fall through to the unmerged check. + } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) throw new Error("merge conflict: unresolved paths in clean room"); + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +function makeTask(id: string, workspaceWorktrees: Task["workspaceWorktrees"]): Task { + return { + id, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; +} + +describeIfGit("landWorkspaceTask — per-repo land lease (Phase C U3, KTD4)", () => { + let fx: WorkspaceFixture; + afterEach(() => { + fx?.cleanup(); + activeSessionRegistry.clear(); + vi.restoreAllMocks(); + }); + beforeEach(() => activeSessionRegistry.clear()); + + it("concurrency: two tasks landing the SAME sub-repo serialize — one acquires the land lease, the other fast-fails (no interleaved update-ref)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + const repoAbs = fx.repoPath("repo-a"); + + const task1 = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + const task2 = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + // Distinct task IDs so the lease owner check (taskId !== holder) triggers. + task2.id = "FN-3002"; + const store1 = createStore(task1); + const store2 = createStore(task2); + + let loserError: unknown; + const tipBefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + // task1's merge agent blocks until task2 has tried (and failed) to acquire the + // land lease for the SAME sub-repo path. While task1 holds the lease we assert it + // is registered under the right kind + path; task2 fast-fails with the busy error. + const winner = landWorkspaceTask(store1, store1.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH, async () => { + // task1 now holds the land lease for repo-a. + const held = activeSessionRegistry.lookupByPath(repoAbs); + expect(held?.kind).toBe(LAND_KIND); + expect(held?.taskId).toBe("FN-3001"); + + // task2 attempts the same sub-repo concurrently → must fast-fail. + try { + await landWorkspaceTask(store2, store2.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + } catch (err) { + loserError = err; + } + // The loser advanced NOTHING: the ref is still at the pre-land tip. + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipBefore); + }), + reviewAgent: approveReviewAgent, + }); + + const result = await winner; + + // Winner landed. + expect(result.allLanded).toBe(true); + expect(result.repos[0].status).toBe("landed"); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipBefore); + + // Loser fast-failed with the retryable busy error (serialized, not broken). + expect(loserError).toBeInstanceOf(WorkspaceRepoLandBusyError); + expect((loserError as WorkspaceRepoLandBusyError).retryable).toBe(true); + expect((loserError as WorkspaceRepoLandBusyError).holderTaskId).toBe("FN-3001"); + + // Lease released after the winner finished — no leak. + expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); + }); + + it("independence: disjoint sub-repos land without contention (no false serialization)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "FN-3002", "b feature\n"); + const repoAAbs = fx.repoPath("repo-a"); + const repoBAbs = fx.repoPath("repo-b"); + + const task1 = makeTask("FN-3001", { "repo-a": { worktreePath: repoAAbs, branch: BRANCH } }); + const task2 = makeTask("FN-3002", { "repo-b": { worktreePath: repoBAbs, branch: BRANCH } }); + const store1 = createStore(task1); + const store2 = createStore(task2); + + let task2Error: unknown; + let task2Landed = false; + + // task1 lands repo-a; mid-land it kicks off task2 landing the DISJOINT repo-b. + // task2 leases a DIFFERENT path, so it must NOT serialize against task1. + const t1 = landWorkspaceTask(store1, store1.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH, async () => { + // While task1 holds repo-a's lease, repo-b's lease is unheld. + expect(activeSessionRegistry.lookupByPath(repoAAbs)?.kind).toBe(LAND_KIND); + expect(activeSessionRegistry.lookupByPath(repoBAbs)).toBeNull(); + try { + const r2 = await landWorkspaceTask(store2, store2.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + task2Landed = r2.allLanded; + } catch (err) { + task2Error = err; + } + }), + reviewAgent: approveReviewAgent, + }); + + const r1 = await t1; + + // Both proceeded — no false serialization. + expect(task2Error).toBeUndefined(); + expect(task2Landed).toBe(true); + expect(r1.allLanded).toBe(true); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe( + fx.git("repo-a", "git rev-parse fusion/fn-3003^"), + ); + // Both leases released. + expect(activeSessionRegistry.lookupByPath(repoAAbs)).toBeNull(); + expect(activeSessionRegistry.lookupByPath(repoBAbs)).toBeNull(); + }); + + it("cleanup: a land failure releases the lease (not stuck) so a subsequent land can acquire", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + const repoAbs = fx.repoPath("repo-a"); + + const task = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + const store = createStore(task); + + // A merge agent that throws → landOneRepo fails → the per-repo land lease finally + // must release the lease even on failure. + const throwingAgent = async (): Promise => { + // Lease is held at this point. + expect(activeSessionRegistry.lookupByPath(repoAbs)?.kind).toBe(LAND_KIND); + throw new Error("synthetic clean-room failure"); + }; + + const failed = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: throwingAgent, + reviewAgent: approveReviewAgent, + }); + expect(failed.allLanded).toBe(false); + expect(failed.repos[0].status).toBe("failed"); + // Lease was released despite the failure — NOT stuck. + expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); + + // A subsequent land of the SAME repo can acquire (real squash this time). + const retry = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(retry.allLanded).toBe(true); + expect(retry.repos[0].status).toBe("landed"); + expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); + }); +}); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index 12168c0cea..75c3b226eb 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -6,8 +6,21 @@ sub-repo absolute path (NOT the worktree path) so two concurrent workspace tasks contending for the SAME sub-repo are serialized. Keeping it distinct from "executor"/"step-session" means it does not collide with the executor's later session registration on the produced worktree path. + +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +"workspace-repo-land" is a DISTINCT registry kind for the LAND-time (merge phase) +same-sub-repo lease. Like the acquire kind it is keyed by the sub-repo ABSOLUTE +path, but it guards a different lifecycle scope: two workspace tasks landing the +SAME sub-repo onto its local integration ref are serialized so their clean-room +ai-merge worktrees do not collide. This lease is for SERIALIZATION / clean-room- +collision avoidance only — it is NOT what makes the interleaved `update-ref` +correct. `advanceIntegrationBranchRef`'s CAS already makes a concurrent advance +safe by construction (concurrent-advance → rebuild). The acquire lease (execution +phase) and the land lease (merge phase) never overlap in time on the same path, so +keeping them distinct kinds (each released in its own `finally`) means a stale +entry of one kind can never be mistaken for a live hold of the other. */ -export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge" | "workspace-repo-acquire"; +export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge" | "workspace-repo-acquire" | "workspace-repo-land"; export interface ActiveSessionRegistration { taskId: string; diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index b810cd59f8..e2dd4c6291 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1403,7 +1403,48 @@ The partial-land retry/park policy (consume a mergeRetry, auto-retry skipping la repos up to MAX, then operator-park) is wired at the engine dispatch (project-engine.ts), NOT here: this function reports the partial via `allLanded:false` and the dispatch drives the retry seam. + +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +Per-repo LAND lease. Before each `landOneRepo` we register the sub-repo ABSOLUTE +path in the path-keyed activeSessionRegistry under kind "workspace-repo-land" and +release it in a per-repo `finally` (so the lease is freed on land success OR land +failure — no stuck lock). If another task already holds the land lease for that +sub-repo path we FAST-FAIL the whole `landWorkspaceTask` with a retryable +`WorkspaceRepoLandBusyError`, which the U2 partial-land retry/park machinery +(project-engine dispatch) already handles — reusing that path instead of +reimplementing a waiting lock. The lease serializes same-sub-repo lands so two +tasks' clean-room ai-merge worktrees do not collide; it is NOT what makes the +interleaved `update-ref` correct — `advanceIntegrationBranchRef`'s CAS already +guarantees ref correctness (concurrent-advance → rebuild). Disjoint sub-repos lease +DIFFERENT paths, so they never serialize against each other (no false contention). +This lease is a DIFFERENT scope/kind from the execution-phase +"workspace-repo-acquire" lease and from `landOneRepo`'s own inner "ai-merge" +clean-room registration on the temp worktree path — none of the three collide. */ + +/** FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): ownerKey for the land-time lease. */ +const WORKSPACE_REPO_LAND_OWNER_KEY = "workspace-repo-land"; + +/* +FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): +Thrown when a second workspace task tries to land a sub-repo already inside another +task's land critical section. Distinct from a generic land failure so the engine +dispatch (and tests) can tell "serialized, retry later" apart from "this land is +broken". Carries `retryable = true` so the existing partial-land auto-retry/park +path treats it as a transient contention, not a terminal failure. +*/ +export class WorkspaceRepoLandBusyError extends Error { + public readonly retryable = true; + constructor( + public readonly repoRel: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super(`workspace sub-repo ${repoRel} land is in progress for task ${holderTaskId}`); + this.name = "WorkspaceRepoLandBusyError"; + } +} + export async function landWorkspaceTask( store: TaskStore, task: Task, @@ -1476,6 +1517,32 @@ export async function landWorkspaceTask( continue; } + /* + FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): + Same-sub-repo LAND lease. Register the sub-repo absolute path BEFORE landing so + two tasks landing the SAME sub-repo are serialized (their clean-room ai-merge + worktrees would otherwise collide). The lookupByPath → registerPath pair stays in + ONE synchronous slice (no `await` between them) so the claim is atomic — an + interleaved await would let a second task pass the gate before we register. If + another task holds the land lease we FAST-FAIL with a retryable busy error; the + U2 dispatch auto-retry/park path handles it (no waiting lock reimplemented here). + We only treat a HELD entry of OUR OWN land ownerKey as contention, so a stale + entry of a different kind on this path (e.g. a leftover acquire entry) is ignored. + */ + const landLeaseHolder = activeSessionRegistry.lookupByPath(repoRootDir); + if ( + landLeaseHolder && + landLeaseHolder.ownerKey === WORKSPACE_REPO_LAND_OWNER_KEY && + landLeaseHolder.taskId !== taskId + ) { + throw new WorkspaceRepoLandBusyError(repoRel, landLeaseHolder.taskId, taskId); + } + activeSessionRegistry.registerPath(repoRootDir, { + taskId, + kind: "workspace-repo-land", + ownerKey: WORKSPACE_REPO_LAND_OWNER_KEY, + }); + try { const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, @@ -1505,6 +1572,18 @@ export async function landWorkspaceTask( // `landedSha` is persisted, so the engine dispatch's auto-retry re-runs this // loop and the landed predicate above skips them (only the failed repo retries). break; + } finally { + /* + FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): + Release the land lease — on land SUCCESS or land FAILURE — but ONLY when WE hold + it (own taskId + own ownerKey), so a future-acquire path's entry on this path is + never yanked. The fast-fail busy throw above happens BEFORE registerPath, so a + serialized loser never unregisters the winner's lease. + */ + const held = activeSessionRegistry.lookupByPath(repoRootDir); + if (held && held.taskId === taskId && held.ownerKey === WORKSPACE_REPO_LAND_OWNER_KEY) { + activeSessionRegistry.unregisterPath(repoRootDir); + } } } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 0276d4ee00..6c9d9eff2e 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -2471,8 +2471,18 @@ export class ProjectEngine { // — mirroring the conflict-retry seam below. Detect by err.name (robust across // the package boundary). Manual merges fall through to rejectMergeResolvers at // the hasManualResolver early-return below (no auto-retry for manual). + /* + FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): + A `WorkspaceRepoLandBusyError` (a second task holds the same sub-repo's + land lease) is ALSO retryable here — it is transient contention, not a + terminal failure. Route it through the SAME auto-retry-then-park seam (it + consumes a mergeRetry and re-enqueues with backoff; a re-run skips + already-landed repos and finds the lease freed). Detect by err.name across + the package boundary, same as the partial-land error. + */ const isWorkspacePartialLand = - err instanceof Error && err.name === "WorkspacePartialLandError"; + err instanceof Error && + (err.name === "WorkspacePartialLandError" || err.name === "WorkspaceRepoLandBusyError"); if (isWorkspacePartialLand && !hasManualResolver) { const wsSettings = await store.getSettings().catch(() => ({ maxAutoMergeRetries: undefined })); const wsTask = await store.getTask(taskId).catch(() => null); From accb32e9b63894d62259017c3884ddd008b830b3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:19:15 -0700 Subject: [PATCH 30/44] fix(review): address PR #1711 review findings Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-21-003-refactor-merger-unification-u0-plan.md | 4 ++-- packages/engine/src/index.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md b/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md index 385e7fc7fb..d16a26537d 100644 --- a/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md +++ b/docs/plans/2026-06-21-003-refactor-merger-unification-u0-plan.md @@ -23,7 +23,7 @@ It also installs the **R7 workspace merge-boundary guard** at every merge entry Merge is dispatched at `packages/engine/src/project-engine.ts:2275-2282`: -``` +```ts const mergerMode = normalizeMergerMode(settings.merger?.mode); // defaults to "ai" return mergerMode === "ai" ? runAiMerge(store, cwd, taskId, mergeOptionsWithSettings) @@ -61,7 +61,7 @@ Before claiming low blast radius, grep test fixtures, CI configs, and seeded/def ## Implementation Units > **Units `U1–U4` below are local to this plan** (they decompose master-plan U0); they are **not** the master plan's `U1–U10`. U4 (audit) may run in parallel with U1–U3. - +> > **Standing requirements:** `FNXC:Workspace ` dated comments at each non-obvious decision point (dispatch collapse, the R7 guard, the deprecation warning). A `.changeset/*.md` (`@runfusion/fusion: minor`). Respect the merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`) and FN-5048 (narrow seams, fake timers, no real polling / mock-the-world). **Base branch (decided):** branch off the **foundation** (`pr-1710` / `feat/workspace-multi-repo` head) — the R7 guard (U3) reads `task.workspaceWorktrees`, which the foundation adds and `main` lacks. Do **not** commit onto `pr-1710` directly; use a new branch and open a **stacked PR targeting `feat/workspace-multi-repo`** so the diff is only U0's changes. ### U1. Collapse the engine dispatch and route the two direct callers to `runAiMerge` diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 40e65e9b0f..e4cb416862 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -167,9 +167,13 @@ export { export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js"; export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js"; export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js"; +// FNXC:MergerUnification 2026-06-22-00:00: @deprecated must sit on aiMergeTask's own +// export so IDE/type-aware tooling flags only aiMergeTask, not the helpers it shares with +// runAiMerge (those are NOT deprecated). A single @deprecated on the multi-member block +// would mark every symbol below as deprecated. /** @deprecated Use runAiMerge — aiMergeTask is the soft-deprecated legacy path. */ +export { aiMergeTask } from "./merger.js"; export { - aiMergeTask, listAutostashOrphans, applyAutostashBySha, dropAutostashBySha, From f4a9c655099d02557cc2eec692c5603f5a077add Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:21:49 -0700 Subject: [PATCH 31/44] fix(review): address PR #1714 review findings - base-commit-capture: POSIX single-quote integration branch refs instead of JSON.stringify (double quotes are subject to $-expansion in the shell) - executor: add per-repo no_commits guard to the workspace verifyWorktreeInvariants branch (parity with the singular path), gated by the same task-wide no-commit eligibility - executor: reviewWorkspacePerRepo failure message now states the per-repo verdict list is partial (evaluation stops at first failure) - worktree-acquisition: defensively wrap non-fatal/outer-catch logEntry/audit so a logging throw cannot promote a non-fatal error to fatal or mask the original error - docs/plans: add code-fence language tags and fix MD028 blank-line-in-blockquote Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eat-workspace-mode-execution-model-plan.md | 2 +- ...003-refactor-merger-unification-u0-plan.md | 4 +- packages/engine/src/base-commit-capture.ts | 14 ++-- packages/engine/src/executor.ts | 67 ++++++++++++++++++- packages/engine/src/worktree-acquisition.ts | 56 +++++++++++----- 5 files changed, 117 insertions(+), 26 deletions(-) diff --git a/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md b/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md index 050ff5aa11..c5b50e1d03 100644 --- a/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md +++ b/docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md @@ -155,7 +155,7 @@ The surface-enumeration spine (FN-5893). Every row is a single-worktree / `cwd:r Additive only — no migration to existing single-repo tasks: -``` +```ts Task.workspaceWorktrees: Record **Units `U1–U4` below are local to this plan** (they decompose master-plan U0); they are **not** the master plan's `U1–U10`. U4 (audit) may run in parallel with U1–U3. - +> > **Standing requirements:** `FNXC:Workspace ` dated comments at each non-obvious decision point (dispatch collapse, the R7 guard, the deprecation warning). A `.changeset/*.md` (`@runfusion/fusion: minor`). Respect the merge gate (`pnpm lint`, typecheck, `pnpm build`, `pnpm test:gate`) and FN-5048 (narrow seams, fake timers, no real polling / mock-the-world). **Base branch (decided):** branch off the **foundation** (`pr-1710` / `feat/workspace-multi-repo` head) — the R7 guard (U3) reads `task.workspaceWorktrees`, which the foundation adds and `main` lacks. Do **not** commit onto `pr-1710` directly; use a new branch and open a **stacked PR targeting `feat/workspace-multi-repo`** so the diff is only U0's changes. ### U1. Collapse the engine dispatch and route the two direct callers to `runAiMerge` diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index 4d9e778774..6deb9d5c93 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -39,10 +39,16 @@ export async function resolveCapturedBaseCommitSha( integrationBranch: string = "main", ): Promise { const branch = integrationBranch.trim() || "main"; - // Shell-quote defensively; integration branch names are normalized upstream - // but may carry slashes (e.g. "release/2026-06") that are valid in refs. - const localRef = JSON.stringify(branch); - const originRef = JSON.stringify(`origin/${branch}`); + // FNXC:Workspace 2026-06-22-00:00: + // Shell-quote with POSIX single quotes, NOT JSON.stringify. JSON.stringify wraps + // in double quotes, under which the shell expands `$VAR`/backticks — a branch like + // `release/$2.0` would expand `$2` to a positional. Admin-configured integration + // branch names are not guaranteed to exclude `$`, and `$` is valid in git refs, so + // double-quoting is an injection/correctness risk. Single-quoting (with the embedded + // `'` → `'\''` escape) is literal and safe for slashes (e.g. "release/2026-06") too. + const shellQuote = (s: string): string => `'${s.replace(/'/g, "'\\''")}'`; + const localRef = shellQuote(branch); + const originRef = shellQuote(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index be43f645c9..2bc7feefeb 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -10570,6 +10570,26 @@ export class TaskExecutor { // Phase A returned a flat {ok:true} stub here (no root worktree to verify against the non-git root). Phase B iterates every `task.workspaceWorktrees` entry, asserting (a) the sub-repo worktree's git toplevel matches the recorded repo.worktreePath and (b) its HEAD is on the recorded `fusion/` branch (repo.branch). The result union is PRESERVED EXACTLY — `{ok:true} | {ok:false; reason:'wrong_toplevel'|'wrong_branch'|'no_commits'; observed; expected}` — because the :10889 consumer switches on `reason` to drive requeue/handoff (:10894-10936). We ADD an optional `repo` field to the failure shape (purely additive; the consumer only reads reason/observed/expected) and return the FIRST failing repo. A zero-acquire workspace task (empty map) verifies vacuously → {ok:true}, matching Phase A so fn_task_done does not requeue it. if (this.workspaceConfig) { const workspaceWorktrees = task.workspaceWorktrees ?? {}; + // FNXC:Workspace 2026-06-22-00:00: KTD2 — resolve the SAME task-wide no-commit eligibility the singular path + // uses (getNoCommitEligibilityReason / no-op-completion sentinel / prompt-derived), once, before the per-repo + // loop. When eligible (Plan-Only, verified no-op, etc.) the per-repo no_commits guard below is skipped so an + // intentionally commit-free workspace task is not blocked from completion. + const workspacePromptContent = (task as Task & { prompt?: unknown }).prompt; + const workspacePromptEligibility = evaluatePromptDerivedNoCommitEligibility( + task, + typeof workspacePromptContent === "string" ? workspacePromptContent : "", + ); + const workspaceNoCommitEligibilityReason = + getNoCommitEligibilityReason(task) ?? + (options?.noOpCompletion + ? options.noOpCompletionReason ?? "verified no-op/duplicate completion sentinel" + : null) ?? + (workspacePromptEligibility.eligible + ? workspacePromptEligibility.reason ?? "prompt-derived no-commit eligibility" + : null); + if (workspaceNoCommitEligibilityReason) { + executorLog.log(`${task.id}: workspace fn_task_done no_commits guard skipped (${workspaceNoCommitEligibilityReason})`); + } // FNXC:Workspace 2026-06-21-15:00: F6 — iterate sorted repo keys so the FIRST failing repo // returned here is deterministic across runs/rehydrate (the value is surfaced to the operator). for (const repoRel of Object.keys(workspaceWorktrees).sort()) { @@ -10647,6 +10667,48 @@ export class TaskExecutor { expected: expectedBranch, }; } + // FNXC:Workspace 2026-06-22-00:00: KTD2 — per-repo no_commits guard (parity with the singular path at :10821). + // Phase B originally returned {ok:true} after the toplevel/branch checks, so a workspace task could call + // fn_task_done having committed NOTHING in any sub-repo (scope-leak sees zero touched files, branch names match) + // and still advance to in-review. Enforce the same `git rev-list --count ..HEAD > 0` invariant per repo, + // gated by the SAME task-wide no-commit eligibility below so Plan-Only / no-op-sentinel tasks stay exempt. + // The first sub-repo with zero commits fails with reason:'no_commits' (consumer-stable union). + if (!workspaceNoCommitEligibilityReason) { + const repoBaseRef = await this.resolveDiffBaseRef(repo.worktreePath, repo.baseCommitSha); + if (repoBaseRef) { + try { + const { stdout } = await execAsync(`git rev-list --count ${repoBaseRef}..HEAD`, { + cwd: repo.worktreePath, + encoding: "utf-8", + timeout: 10_000, + maxBuffer: 1024 * 1024, + }); + const trimmedCount = stdout.trim(); + if (trimmedCount) { + const count = Number.parseInt(trimmedCount, 10); + if (!Number.isFinite(count) || count <= 0) { + return { + ok: false, + reason: "no_commits", + repo: repoRel, + observed: Number.isFinite(count) ? String(count) : trimmedCount, + expected: "> 0", + }; + } + } + } catch (error) { + return { + ok: false, + reason: "no_commits", + repo: repoRel, + observed: error instanceof Error ? error.message : String(error), + expected: `git rev-list --count ${repoBaseRef}..HEAD > 0`, + }; + } + } else { + executorLog.warn(`${task.id}: unable to resolve diff base for ${repoRel} no_commits guard; skipping for this sub-repo`); + } + } } return { ok: true }; } @@ -12593,7 +12655,10 @@ ${failureFeedback} // verdict→edge mapping is identical to single-cwd), with the full repo-tagged review body. return { verdict: firstFailing.result.verdict, - review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts:\n\n${reviewSections.join("\n\n")}`, + // FNXC:Workspace 2026-06-22-00:00: the conjunction BREAKS on the first non-APPROVE repo, + // so reviewSections holds only the repos evaluated up to (and including) the failure — not + // every sub-repo. Label it honestly so operators don't read a partial list as exhaustive. + review: `Workspace review failed in sub-repo \`${firstFailing.repo}\` (verdict ${firstFailing.result.verdict}). Per-repo verdicts (evaluation stopped at first failure; later repos not reviewed):\n\n${reviewSections.join("\n\n")}`, summary: `${firstFailing.repo}: ${firstFailing.result.verdict} — ${summarySections.join(" | ")}`, }; } diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 352ef4a036..0e32b88cbe 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -746,14 +746,21 @@ export async function acquireWorkspaceRepoWorktree( }); } catch (guardErr) { // FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it. + // FNXC:Workspace 2026-06-22-00:00: the non-fatal logEntry/audit are themselves best-effort — if either throws + // (e.g. a DB write hiccup) it must NOT promote this non-fatal guard failure into a fatal acquisition failure. + // Swallow logging errors so acquisition continues (matching the F6 busy-path defensive wrap above). const message = guardErr instanceof Error ? guardErr.message : String(guardErr); logger?.warn(`${task.id}: identity-guard install failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, - }); + try { + await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + }); + } catch { + // best-effort observability only — keep the (non-fatal) guard failure non-fatal + } } /* @@ -777,14 +784,20 @@ export async function acquireWorkspaceRepoWorktree( baseCommitSha = await resolveCapturedBaseCommitSha(result.worktreePath, logger, integrationBranch); } catch (baseErr) { // FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state. + // FNXC:Workspace 2026-06-22-00:00: guard the best-effort logEntry/audit so a logging throw cannot promote this + // non-fatal capture failure into a fatal acquisition failure (parity with the F6 busy-path defensive wrap). const message = baseErr instanceof Error ? baseErr.message : String(baseErr); logger?.warn(`${task.id}: base-SHA capture failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, - }); + try { + await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + }); + } catch { + // best-effort observability only — keep the (non-fatal) capture failure non-fatal + } } /* @@ -814,14 +827,21 @@ export async function acquireWorkspaceRepoWorktree( sub-repo. */ if (!(err instanceof WorkspaceRepoAcquireBusyError)) { + // FNXC:Workspace 2026-06-22-00:00: wrap the failure logEntry/audit so a throw here cannot replace the ORIGINAL + // acquisition `err` the caller must observe — losing it would mask the real cause and the re-throw below would + // surface a logging error instead. Best-effort observability; `err` is always re-thrown. const message = err instanceof Error ? err.message : String(err); logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`); - await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message }, - }); + try { + await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message }, + }); + } catch { + // best-effort observability only — ensure the original acquisition error propagates + } } throw err; } finally { From 9f0492e69fc263502568cec6bfbb5db7d4c19642 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 00:22:48 -0700 Subject: [PATCH 32/44] fix(review): address PR #1713 review findings - base-commit-capture.ts: shell-quote integration branch with a real single-quoted POSIX literal instead of JSON.stringify (not shell-safe). - TaskCard.tsx: memo compares full workspaceWorktrees values, not just key sets, so a same-key worktreePath/branch change re-renders. - TaskDetailModal.tsx: gate/render workspace summary off hydrated workingTask. - worktree-acquisition.ts: null the singular worktree/branch columns in the workspaceWorktrees write so isWorkspaceTask stays true; wrap non-fatal post-acquire observability so logEntry/audit can't re-escalate to fatal. - agent-tools.ts: register sub-repo worktree via onAcquired unconditionally (idempotent) so a resumed/already-acquired path is tracked after restart. - executor.ts: DB liveness fallback also checks task.workspaceWorktrees paths. - executor-workspace.test.ts: root non-git assertion runs in fx.rootDir ("."). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/TaskCard.tsx | 8 ++- .../app/components/TaskDetailModal.tsx | 6 +- .../src/__tests__/executor-workspace.test.ts | 5 +- packages/engine/src/agent-tools.ts | 12 ++-- packages/engine/src/base-commit-capture.ts | 17 +++-- packages/engine/src/executor.ts | 12 +++- packages/engine/src/worktree-acquisition.ts | 62 +++++++++++++++---- 7 files changed, 96 insertions(+), 26 deletions(-) diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 14d93678e8..6400b3b02e 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -631,8 +631,12 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo // F7 — compare the sorted key SETS, not just the count: a same-count repo swap (one // repo released, a different one acquired) keeps the count but must still re-render, // otherwise the placeholder shows a stale repo set. - JSON.stringify(Object.keys(previousTask.workspaceWorktrees ?? {}).sort()) === - JSON.stringify(Object.keys(nextTask.workspaceWorktrees ?? {}).sort()) && + // FNXC:Workspace 2026-06-22-09:00: compare full VALUES, not only the key set. A + // pool-reclaim re-acquire keeps the same repo key but produces a different + // worktreePath/branch; a key-set-only check would leave the card showing stale path + // text. Whole-map JSON compare covers keys and values at negligible cost for small N. + JSON.stringify(previousTask.workspaceWorktrees ?? null) === + JSON.stringify(nextTask.workspaceWorktrees ?? null) && previousTask.branch === nextTask.branch && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 6432ed838f..6b275babff 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3069,7 +3069,11 @@ export function TaskDetailContent({ {/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular task.worktree/task.branch; surface their acquired per-sub-repo worktrees as a flat read-only list so the detail view isn't blank (U3/KTD5). */} - {isWorkspaceTask(task) && } + {/* FNXC:Workspace 2026-06-22-09:00: gate/render off the hydrated + workingTask, not the sparse task row. workspaceWorktrees is only + present in fetched detail, so keying off task renders blank on the + optimistic-open path before the detail fetch resolves. */} + {isWorkspaceTask(workingTask) && } )} {task.status === "failed" && task.error && ( diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index 330915e966..7b0033bb54 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -48,8 +48,9 @@ describeIfGit("workspace fixture", () => { it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => { fx = await createWorkspaceFixture(); - // Root is NOT a git repo. - expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow(); + // Root is NOT a git repo. Use "." so the check runs in fx.rootDir itself, not + // its parent (".." would resolve to the tmpdir and could pass spuriously). + expect(() => fx.git(".", "git rev-parse --git-dir")).toThrow(); // Each sub-repo is a real git repo with a commit on main. expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main"); expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1"); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index a6fae43a06..2dbb3afc50 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -3669,10 +3669,14 @@ export function createAcquireRepoWorktreeTool(opts: { isError: true, }; } - // FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (skip the already-acquired short-circuit; that path was registered on its original fresh acquire). - if (!result.alreadyAcquired) { - onAcquired?.(result.worktreePath); - } + // FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (KTD2) so owner/liveness checks see live per-repo worktrees, not just the browse-only root. + // FNXC:Workspace 2026-06-22-09:00: register UNCONDITIONALLY, including the + // already-acquired short-circuit. After an executor restart activeWorktrees is an + // empty Map; a resumed workspace task with pre-existing task.workspaceWorktrees hits + // the alreadyAcquired path, so skipping onAcquired left the sub-repo path unregistered + // in-memory and conflict/liveness checks missed it. Set.add is idempotent, so re-firing + // on a fresh acquire is a harmless no-op. + onAcquired?.(result.worktreePath); await store.logEntry( task.id, result.alreadyAcquired diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index 4d9e778774..4862226f88 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -39,10 +39,19 @@ export async function resolveCapturedBaseCommitSha( integrationBranch: string = "main", ): Promise { const branch = integrationBranch.trim() || "main"; - // Shell-quote defensively; integration branch names are normalized upstream - // but may carry slashes (e.g. "release/2026-06") that are valid in refs. - const localRef = JSON.stringify(branch); - const originRef = JSON.stringify(`origin/${branch}`); + /* + FNXC:Workspace 2026-06-22-09:00: + Shell-quote with a real single-quoted POSIX literal, NOT JSON.stringify. A + JSON double-quoted string still lets bash expand `$(...)`, backticks, and `$VAR` + inside it; JSON.stringify is not a shell-quoting function. Git ref names can't + legally contain backticks so there's no live injection path today, but + single-quoting is the idiomatic safe form and stays correct if a caller ever + passes a less-constrained string. A single quote inside the value is escaped as + the standard `'\''` close-reopen sequence. + */ + const shellSingleQuote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`; + const localRef = shellSingleQuote(branch); + const originRef = shellSingleQuote(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a5e9063a5b..2ee4a765d7 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -14554,10 +14554,18 @@ You have access to the file system to review changes.${verdictBlock}`; const tasks = await this.store.listTasks({ slim: true, includeArchived: false }); for (const t of tasks) { if (t.id === requestingTaskId) continue; - if (t.worktree !== worktreePath) continue; if (t.column !== "in-progress") continue; if (t.paused === true) continue; - return t.id; + if (t.worktree === worktreePath) return t.id; + // FNXC:Workspace 2026-06-22-09:00: workspace tasks hold their worktrees in + // task.workspaceWorktrees, not the singular task.worktree column. The DB liveness + // fallback must check those per-sub-repo paths too — otherwise a conflict against a + // sub-repo worktree owned by an in-progress workspace task is missed, especially + // before its in-memory activeWorktrees entry is (re)registered after restart. + const wsEntries = t.workspaceWorktrees; + if (wsEntries && Object.values(wsEntries).some((entry) => entry.worktreePath === worktreePath)) { + return t.id; + } } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 352ef4a036..a59c4e54b4 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -643,6 +643,24 @@ export async function acquireWorkspaceRepoWorktree( const repoAbsPath = join(workspaceRootDir, repoRelPath); + /* + FNXC:Workspace 2026-06-22-09:00: + Run best-effort observability (task log + audit) for the NON-FATAL post-acquire + steps without letting their own awaited writes escape. logEntry/audit can throw + (DB hiccup, audit sink failure); an unsuppressed throw inside a non-fatal catch + would re-escalate guard/base-capture failures into fatal acquisition errors that + strand the already-created worktree. Mirrors the busy-path swallow above. + */ + const safeObserve = async (fn: () => Promise): Promise => { + try { + await fn(); + } catch (obsErr) { + logger?.warn( + `${task.id}: workspace acquisition observability failed (suppressed): ${obsErr instanceof Error ? obsErr.message : String(obsErr)}`, + ); + } + }; + /* FNXC:Workspace 2026-06-21-20:10: Same-sub-repo exclusivity (KTD4): register the sub-repo absolute path in the @@ -748,11 +766,17 @@ export async function acquireWorkspaceRepoWorktree( // FNXC:Workspace 2026-06-21-22:30: F3 — identity-guard install is non-fatal; worktree is usable without it. const message = guardErr instanceof Error ? guardErr.message : String(guardErr); logger?.warn(`${task.id}: identity-guard install failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + // FNXC:Workspace 2026-06-22-09:00: the observability writes (store.logEntry / audit.git) + // are themselves awaited and can throw; an unwrapped throw here would escape the catch + // and re-escalate this deliberately NON-FATAL step into a fatal acquisition error, + // stranding the already-created worktree. Suppress observability failures via safeObserve. + await safeObserve(async () => { + await store.logEntry(task.id, `Workspace sub-repo identity-guard install failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "identity-guard" }, + }); }); } @@ -779,11 +803,15 @@ export async function acquireWorkspaceRepoWorktree( // FNXC:Workspace 2026-06-21-22:30: F3 — base-SHA capture is non-fatal; an undefined baseCommitSha is an accepted state. const message = baseErr instanceof Error ? baseErr.message : String(baseErr); logger?.warn(`${task.id}: base-SHA capture failed for sub-repo ${repoRelPath} (non-fatal): ${message}`); - await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + // FNXC:Workspace 2026-06-22-09:00: same non-fatal contract as the identity-guard catch — + // the awaited observability writes must not re-escalate a non-fatal base-capture failure. + await safeObserve(async () => { + await store.logEntry(task.id, `Workspace sub-repo base-SHA capture failed for ${repoRelPath} (non-fatal): ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message, stage: "base-sha-capture" }, + }); }); } @@ -802,7 +830,19 @@ export async function acquireWorkspaceRepoWorktree( ...(latest.workspaceWorktrees ?? {}), [repoRelPath]: { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha }, }; - await store.updateTask(task.id, { workspaceWorktrees: updated }); + /* + FNXC:Workspace 2026-06-22-09:00: + F10 — reset the singular worktree/branch columns to null in the SAME write that + persists workspaceWorktrees. The single-repo `acquireTaskWorktree` above wrote + `task.worktree`/`task.branch` (the sub-repo path/branch) to the real task row; + clearing the in-memory copy passed in only stops the NEXT sub-repo from resuming + into this one's worktree — the DB row stays polluted. A non-null `task.worktree` + makes `isWorkspaceTask(task)` return false (its first guard), so the dashboard + stops rendering WorkspaceWorktreesSummary and instead shows the sub-repo branch in + the standard chip — the blank/wrong-card state U10 prevents. Nulling them here + keeps `task.worktree` null for the workspace task's whole lifetime. + */ + await store.updateTask(task.id, { workspaceWorktrees: updated, worktree: null, branch: null }); return { worktreePath: result.worktreePath, branch: result.branch, baseCommitSha, alreadyAcquired: false }; } catch (err) { From 627bdcfb0aee623b383625b9af60d2fcc02e659a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:11:52 -0700 Subject: [PATCH 33/44] =?UTF-8?q?fix(review):=20Phase=20C=20merge-loop=20h?= =?UTF-8?q?ardening=20=E2=80=94=20double-land,=20lease=20clobber,=20retry?= =?UTF-8?q?=20storm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-persona review of the Phase-C per-repo merge loop. No P0; the no-push invariant and retry/park accounting verified clean. Fixed: Land mechanics (merger-ai.ts / active-session-registry.ts): - persistRepoLandedSha no longer swallows the DB write: a failed landedSha write after the ref advanced now escalates to WorkspacePartialLandError so the engine parks/retries instead of silently re-landing (duplicate squash). isRepoLanded gains a landedSha-independent fallback — it scans the integration ref for this task's Fusion-Task-Id trailer (a squash commit is NOT a branch descendant, so a branch-ancestor check is provably wrong), so an actually-landed repo is skipped on retry. - The land lease is now taskId-aware across kinds: any foreign-task holder on a sub-repo path is contention (a merging task can't run over an executing task's acquire lease), and registerPath throws ActiveSessionPathHeldByForeignTaskError instead of silently clobbering a different task's entry. - The per-repo loop is wrapped in try/finally(setStatus(null)) so the busy/partial throws can't leave the task stuck 'merging'. WorkspacePartialLandError is a real exported class (not a .name-mutated Error). finalizeWorkspaceTask re-reads fresh and no longer swallows the mergeDetails write (TOCTOU). isRepoLanded exported for Phase D. Dispatch + doors (project-engine.ts / dashboard.ts / task.ts / @fusion/core): - getTask-null in the partial-land catch fails closed (park) instead of defaulting retries to 0 and scheduling an indefinite retry storm. - The merge-confirmed reachability fast-path skips workspace tasks (its representative commitSha is a sub-repo squash sha, unreachable in the root cwd — it was demoting fully-merged tasks); they're verified by per-repo landedSha. - The CLI/dashboard merge doors now return merged:true on full land (were hardcoded merged:false). WorkspaceRepoLandBusyError re-enqueues with backoff WITHOUT burning the mergeRetries quota (bounded busy counter) so contention can't park a healthy task. Backoff capped at 60s. shouldRetryWorkspacePartialLand folded into shouldRetryAutoMergeConflict. Catch switched to instanceof. New canonical isWorkspaceTask predicate in @fusion/core. Gate green: build, typecheck, lint, test:gate (649+58); workspace-merger + oracle + project-engine 174. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...workspace-land-mechanics-phase-c-review.md | 7 + packages/cli/src/commands/dashboard.ts | 13 +- packages/cli/src/commands/task.ts | 9 +- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 18 +- .../__tests__/active-session-registry.test.ts | 29 +- .../src/__tests__/project-engine.test.ts | 284 +++++++++++++++--- .../workspace-merger-idempotency.test.ts | 116 ++++++- .../__tests__/workspace-merger-lease.test.ts | 46 +++ .../engine/src/active-session-registry.ts | 38 ++- packages/engine/src/index.ts | 7 + packages/engine/src/merger-ai.ts | 204 +++++++++++-- packages/engine/src/project-engine.ts | 186 ++++++++---- 13 files changed, 823 insertions(+), 136 deletions(-) create mode 100644 .changeset/fix-workspace-land-mechanics-phase-c-review.md diff --git a/.changeset/fix-workspace-land-mechanics-phase-c-review.md b/.changeset/fix-workspace-land-mechanics-phase-c-review.md new file mode 100644 index 0000000000..f24a7921ba --- /dev/null +++ b/.changeset/fix-workspace-land-mechanics-phase-c-review.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Harden the workspace per-repo land loop against partial-failure races. A lost `landedSha` DB write after a sub-repo's integration ref already advanced no longer silently continues — it escalates to a retryable partial-land error, and the landed predicate now recognizes an already-landed repo via its `Fusion-Task-Id` trailer on retry, so a re-run never produces a second squash commit. The land lease is now taskId-aware across registry kinds: a merging task can no longer clobber an executing task's acquire lease on a shared sub-repo (any foreign-task holder is treated as contention), and the active-session registry rejects foreign-task overwrites instead of silently clobbering. The transient `merging` status is always reset before any throw escapes the land loop (no stuck-`merging` leak), and finalize re-reads the latest task and no longer swallows the merge-details persist failure (no finalizing on a stale row). + +Harden the workspace merge dispatch and user-facing merge doors. The partial-land retry catch now fails closed when the task row can't be read (DB outage no longer triggers an indefinite retry storm). The merge-confirmed reachability fast-path skips workspace tasks (whose recorded commitSha lives in a sub-repo, not the workspace root) so a fully-landed workspace task is no longer demoted/parked. The dashboard and CLI merge doors now report `merged: true` (and `mergeConfirmed`/`commitSha`) when a workspace fully lands, mirroring the engine result. Transient sub-repo land-lease contention (`WorkspaceRepoLandBusyError`) is re-enqueued with capped backoff on a separate bounded counter instead of burning the merge-retry quota, so pure contention can't park a never-failed task. Retry backoff is capped at 60s. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 2140ea3496..7986396445 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1319,12 +1319,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: agentStore, }); const latest = await store.getTask(taskId).catch(() => mergeTask!); - // U1 does not finalize the workspace task (finalize-once move-to-done is U2); - // report merged=false until then. + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B3): + // landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2), + // so the merge door must report merged=true when the workspace fully landed — mirroring + // the engine dispatch's MergeResult. The first landed sub-repo's landedSha is the recorded + // commitSha (same convention finalizeWorkspaceTask uses). On a partial land, merged stays + // false and the partial-land error surfaces on the task log. + const landedSha = workspaceResult.repos.find((r) => r.status === "landed")?.landedSha; return { task: latest ?? mergeTask!, branch: getTaskBranchName(taskId), - merged: false, + merged: workspaceResult.allLanded, + mergeConfirmed: workspaceResult.allLanded || undefined, + commitSha: workspaceResult.allLanded ? landedSha : undefined, worktreeRemoved: false, branchDeleted: false, error: workspaceResult.allLanded ? undefined : "partial workspace land — see task log", diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 13054fcc7c..b763676d38 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -872,8 +872,13 @@ export async function runTaskMerge(id: string, projectName?: string) { : `failed: ${repo.error ?? "unknown"}`; console.log(` ${repo.status === "failed" ? "✗" : "✓"} ${repo.repo}: ${label}`); } - // U1 does not move the workspace task to done (finalize-once is U2). - console.log(`\n ${workspaceResult.allLanded ? "✓ All sub-repos landed" : "✗ Partial land — see failures above"} (task remains in review until U2)\n`); + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B3): + // landWorkspaceTask now finalizes the workspace task to done on allLanded (Phase C U2), + // so report it as merged rather than "remains in review until U2". A partial land leaves + // the task in review (landed repos stay landed locally) and exits non-zero. + console.log( + `\n ${workspaceResult.allLanded ? "✓ All sub-repos landed — task finalized to done" : "✗ Partial land — see failures above (task remains in review; landed repos stay landed locally)"}\n`, + ); if (!workspaceResult.allLanded) process.exit(1); return; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d8bb99bb91..2297d28144 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; -export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, WorkspaceTaskMergeError } from "./types.js"; +export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 98c69d02ad..581ac8dd51 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2662,14 +2662,28 @@ export class WorkspaceTaskMergeError extends Error { * @param task the task about to enter a merge path */ export function assertNotWorkspaceTaskMerge(task: Pick): void { - const worktrees = task.workspaceWorktrees; - if (worktrees && Object.keys(worktrees).length > 0) { + if (isWorkspaceTask(task)) { throw new WorkspaceTaskMergeError( `Workspace task ${task.id} cannot merge until per-repo merge support (master-plan U6) lands`, ); } } +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B5/B7-dep — canonical workspace predicate): +A workspace-mode task is identified by having at least one `workspaceWorktrees` entry +(one git worktree per sub-repo). This single predicate replaces the inlined +`!!task.workspaceWorktrees && Object.keys(task.workspaceWorktrees).length > 0` that was +copy-pasted across the engine merge dispatch and the merge-confirmed reachability fast-path +(B2). It lives in @fusion/core so the engine, store, and CLI doors share ONE definition. +The dashboard keeps its own local `isWorkspaceTask` (WorkspaceWorktreesSummary, UI-only) — +this core export is for engine/CLI use. +*/ +export function isWorkspaceTask(task: Pick): boolean { + const worktrees = task.workspaceWorktrees; + return !!worktrees && Object.keys(worktrees).length > 0; +} + export type RetrySummary = { stuckKill: number; recovery: number; diff --git a/packages/engine/src/__tests__/active-session-registry.test.ts b/packages/engine/src/__tests__/active-session-registry.test.ts index 03ae481228..a04a46b74d 100644 --- a/packages/engine/src/__tests__/active-session-registry.test.ts +++ b/packages/engine/src/__tests__/active-session-registry.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { activeSessionRegistry, reconcileSelfOwnedActiveSessionForRemoval, + ActiveSessionPathHeldByForeignTaskError, } from "../active-session-registry.js"; describe("activeSessionRegistry", () => { @@ -28,15 +29,27 @@ describe("activeSessionRegistry", () => { expect(activeSessionRegistry.lookupByPath("/tmp/missing")).toBeNull(); }); - it("overwrites duplicate registration with warning", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + // FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds): + // registerPath must NOT silently clobber an entry held by a DIFFERENT task (that was the + // cross-phase clobber bug: a merging task's land lease overwriting an executing task's + // acquire lease on a shared sub-repo). A foreign-task overwrite now THROWS; the existing + // foreign holder is preserved. + it("rejects a foreign-task overwrite (does not clobber the held entry)", () => { activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" }); - activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "workflow-step", ownerKey: "FN-2#workflow-step" }); + expect(() => + activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "workflow-step", ownerKey: "FN-2#workflow-step" }), + ).toThrow(ActiveSessionPathHeldByForeignTaskError); + // The original holder is untouched. + expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1"); + }); - expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-2"); - expect(warnSpy).toHaveBeenCalledOnce(); - - warnSpy.mockRestore(); + // Same-task re-registration stays idempotent (an executor re-claiming/refreshing its own path). + it("allows same-task re-registration (idempotent re-claim)", () => { + activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" }); + expect(() => + activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "step-session", ownerKey: "FN-1#step-session" }), + ).not.toThrow(); + expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.kind).toBe("step-session"); }); it("reconcileStaleSelfOwned returns no-entry when path is unregistered", () => { diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index a613fda48b..d88bdd9483 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Task } from "@fusion/core"; import { ProjectEngine, __resetDeterministicMergerModeDeprecationWarned } from "../project-engine.js"; +// Resolves to the vi.mock factory above (the mocked merger-ai exports the real-shaped +// workspace land error classes so the dispatch's `instanceof` matching is exercised). +import { WorkspacePartialLandError, WorkspaceRepoLandBusyError } from "../merger-ai.js"; import { runtimeLog } from "../logger.js"; import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js"; import { NtfyNotifier } from "../notifier.js"; @@ -19,6 +22,7 @@ const mocks = vi.hoisted(() => ({ runtimeStop: vi.fn(async () => undefined), runtimeResumeAfterUnpause: vi.fn(async () => undefined), runAiMerge: vi.fn(), + landWorkspaceTask: vi.fn(), execFile: vi.fn(), currentStore: null as Record | null, notifierStart: vi.fn(async () => undefined), @@ -69,9 +73,42 @@ vi.mock("../merger.js", () => ({ VerificationError: class VerificationError extends Error {}, })); -vi.mock("../merger-ai.js", () => ({ - runAiMerge: mocks.runAiMerge, -})); +// FNXC:Workspace 2026-06-22-05:10 (Phase C review B7): the dispatch now matches the +// workspace land errors via `instanceof`, and routes workspace tasks through +// `landWorkspaceTask`. The mock must export REAL error classes (so `instanceof` is callable) +// and a mockable `landWorkspaceTask`; otherwise `err instanceof WorkspacePartialLandError` +// throws "not callable" and the workspace dispatch can't be exercised. The classes are +// declared INSIDE the (hoisted) factory so they exist when the mock is evaluated. +vi.mock("../merger-ai.js", () => { + class WorkspaceRepoLandBusyError extends Error { + public readonly retryable = true; + constructor( + public readonly repoRel: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super(`workspace sub-repo ${repoRel} land is in progress for task ${holderTaskId}`); + this.name = "WorkspaceRepoLandBusyError"; + } + } + class WorkspacePartialLandError extends Error { + public readonly retryable = true; + constructor( + public readonly landedCount: number, + public readonly failedRepos: string[], + message: string, + ) { + super(message); + this.name = "WorkspacePartialLandError"; + } + } + return { + runAiMerge: mocks.runAiMerge, + landWorkspaceTask: mocks.landWorkspaceTask, + WorkspaceRepoLandBusyError, + WorkspacePartialLandError, + }; +}); vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); @@ -1295,7 +1332,11 @@ describe("ProjectEngine U0 merge unification dispatch", () => { } }); - it("R7 guard: rejects a workspace-mode task at the engine merge entry point before any merge", async () => { + // FNXC:Workspace 2026-06-22-05:10 (Phase C U1/U2 routing — supersedes the old R7 throw test): + // A workspace-mode task no longer throws WorkspaceTaskMergeError at the engine dispatch; it + // ROUTES to the per-repo land loop `landWorkspaceTask` (runAiMerge's R7 chokepoint stays as + // defense-in-depth but is not the primary path). On a full land, the merge reports merged=true. + it("routes a workspace-mode task to landWorkspaceTask (not runAiMerge) on full land", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValue({ id: "FN-WS", @@ -1303,58 +1344,217 @@ describe("ProjectEngine U0 merge unification dispatch", () => { paused: false, mergeRetries: 0, status: "queued", + branch: "fusion/fn-ws", workspaceWorktrees: { "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, "repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-b" }, }, } as any); mocks.currentStore = mockStore.store; + mocks.landWorkspaceTask.mockResolvedValue({ + allLanded: true, + repos: [ + { repo: "repo-a", status: "landed", landedSha: "aaaa1111", integrationBranch: "main" }, + { repo: "repo-b", status: "landed", landedSha: "bbbb2222", integrationBranch: "main" }, + ], + } as any); const engine = createEngine(); await engine.start(); - await expect(engine.onMerge("FN-WS")).rejects.toThrow( - /Workspace task FN-WS cannot merge until per-repo merge support \(master-plan U6\) lands/, - ); + const result = await engine.onMerge("FN-WS"); + expect(mocks.landWorkspaceTask).toHaveBeenCalled(); expect(mocks.runAiMerge).not.toHaveBeenCalled(); + expect(result.merged).toBe(true); + await engine.stop(); + }); +}); + +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B1/B2/B4/B5): +Merge DISPATCH hardening for workspace tasks. These drive the REAL ProjectEngine dispatch +catch via the mocked merger-ai seam (landWorkspaceTask + the real-shaped error classes), +asserting the failure modes the review flagged: fail-closed on getTask null (B1), the +merge-confirmed reachability fast-path skipping workspace tasks (B2), busy-contention not +burning the merge-retry quota (B4), and the capped backoff (B5). No real AI, no real git +for the fast-path (the gate's git is asserted NOT to run for workspace tasks). +*/ +describe("ProjectEngine workspace merge dispatch hardening (Phase C review)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const workspaceTask = (overrides: Record = {}) => ({ + id: "FN-WSH", + column: "in-review", + paused: false, + mergeRetries: 0, + status: "queued", + branch: "fusion/fn-wsh", + workspaceWorktrees: { + "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-wsh-a" }, + }, + ...overrides, + }); + + // B1: getTask returning null in the partial-land catch must FAIL CLOSED — no retry timer. + it("B1: partial land with getTask null fails closed (parks failed, no retry timer)", async () => { + vi.useFakeTimers(); + try { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + // First getTask (dispatch routing) returns the workspace task; the catch's getTask + // (after the throw) returns null to simulate a DB outage. + mockStore.store.getTask + .mockResolvedValueOnce(workspaceTask() as any) // dispatch routing read + .mockResolvedValueOnce(workspaceTask() as any) // canMergeTask sweep read (if any) + .mockResolvedValue(null as any); // catch-block read → DB outage + mocks.currentStore = mockStore.store; + mocks.landWorkspaceTask.mockRejectedValue( + new WorkspacePartialLandError(0, ["repo-a"], "Workspace partial land for FN-WSH: 0 landed, 1 failed"), + ); + + const engine = createEngine(); + await engine.start(); + const enqueueSpy = vi.spyOn( + engine as unknown as { internalEnqueueMerge: (id: string) => void }, + "internalEnqueueMerge", + ); + engine.enqueueMerge("FN-WSH"); + + // Drain microtasks until the catch parks the task (fail-closed path). + await vi.waitFor( + () => { + expect(mockStore.store.updateTask).toHaveBeenCalledWith( + "FN-WSH", + expect.objectContaining({ status: "failed" }), + ); + }, + { timeout: 2000, interval: 5 }, + ); + + // No retry timer was scheduled, and no re-enqueue happened: advancing all timers + // must not trigger another internalEnqueueMerge. + enqueueSpy.mockClear(); + await vi.advanceTimersByTimeAsync(120_000); + expect(enqueueSpy).not.toHaveBeenCalled(); + // It must NOT have incremented mergeRetries (it couldn't even read the row). + expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( + "FN-WSH", + expect.objectContaining({ mergeRetries: expect.anything(), status: null }), + ); + + await engine.stop(); + } finally { + vi.useRealTimers(); + } + }); + + // B2: a merged workspace task (mergeConfirmed + sub-repo commitSha) must SKIP the root-cwd + // reachability fast-path so it is finalized, not demoted/parked. + it("B2: merge-confirmed workspace task skips the root-cwd reachability gate (not demoted)", async () => { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mockStore.store.getTask.mockResolvedValue( + workspaceTask({ + status: null, + mergeDetails: { + mergeConfirmed: true, + // A sub-repo squash sha — unreachable from the workspace ROOT cwd; the gate would + // (wrongly) clear mergeConfirmed and demote the task if it ran here. + commitSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + mergeTargetBranch: "main", + mergedAt: "2026-06-22T00:00:00.000Z", + }, + }) as any, + ); + mockStore.store.moveTask.mockResolvedValue( + workspaceTask({ column: "done" }) as any, + ); + mocks.currentStore = mockStore.store; + // If the gate ran, it would invoke `git cat-file`. Make any git call fail so a gate + // run would be observable (and would demote). We assert it is NOT called. + mocks.execFile.mockImplementation(( + _file: string, + _args: string[], + optionsOrCb: unknown, + callback?: (e: Error | null, r: { stdout: string; stderr: string }) => void, + ) => { + const cb = (typeof optionsOrCb === "function" ? optionsOrCb : callback) as ( + e: Error | null, + r: { stdout: string; stderr: string }, + ) => void; + cb(new Error("git should not be called for workspace fast-path"), { stdout: "", stderr: "" }); + return {} as never; + }); + + const engine = createEngine(); + await engine.start(); + engine.enqueueMerge("FN-WSH"); + + await vi.waitFor(() => { + expect(mockStore.store.emit).toHaveBeenCalledWith( + "task:merged", + expect.objectContaining({ merged: true }), + ); + }); + + // The reachability gate's `git cat-file` must NOT have run (workspace skip). + const gitCatFileCalls = (mocks.execFile.mock.calls as Array<[string, string[]]>).filter( + (c) => Array.isArray(c[1]) && c[1][0] === "cat-file", + ); + expect(gitCatFileCalls).toHaveLength(0); + // The task must NOT have been demoted (mergeConfirmed cleared / status failed). + expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( + "FN-WSH", + expect.objectContaining({ status: "failed" }), + ); await engine.stop(); }); - // Regression: the auto-merge park for a WorkspaceTaskMergeError must set status:"failed", - // not status:null. status:null + mergeRetries:0 passes every eligibility gate, so the - // cooldown sweep re-enqueues the task every tick → tight re-throw/re-park loop. status:"failed" - // makes canMergeTask short-circuit; manual retry still works (it bypasses canMergeTask). - it("R7 auto-merge park: workspace task is parked status:'failed' so it is not re-enqueued", async () => { - const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); - mockStore.store.getTask.mockResolvedValue({ - id: "FN-WS-AUTO", - column: "in-review", - paused: false, - mergeRetries: 0, - status: "queued", - workspaceWorktrees: { - "repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" }, - }, - } as any); - mocks.currentStore = mockStore.store; - - const engine = createEngine(); - await engine.start(); - // Auto-merge path (no manual resolver): the R7 door guard throws before runAiMerge, - // and the dispatch catch parks the task. - engine.enqueueMerge("FN-WS-AUTO"); - await vi.waitFor(() => { - expect(mockStore.store.updateTask).toHaveBeenCalledWith( - "FN-WS-AUTO", - expect.objectContaining({ status: "failed", mergeRetries: 0 }), + // B4 + B5: repeated WorkspaceRepoLandBusyError re-enqueues with capped backoff WITHOUT + // consuming mergeRetries (pure contention does not park a never-failed task). + it("B4/B5: busy contention re-enqueues with capped backoff, never burns mergeRetries", async () => { + vi.useFakeTimers(); + try { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mockStore.store.getTask.mockResolvedValue(workspaceTask() as any); + mocks.currentStore = mockStore.store; + mocks.landWorkspaceTask.mockRejectedValue( + new WorkspaceRepoLandBusyError("repo-a", "FN-OTHER", "FN-WSH"), ); - }); - expect(mocks.runAiMerge).not.toHaveBeenCalled(); - // Guard against regression to the re-enqueue loop (status:null park): - expect(mockStore.store.updateTask).not.toHaveBeenCalledWith( - "FN-WS-AUTO", - expect.objectContaining({ status: null }), - ); - await engine.stop(); + + const engine = createEngine(); + await engine.start(); + const enqueueSpy = vi.spyOn( + engine as unknown as { internalEnqueueMerge: (id: string) => void }, + "internalEnqueueMerge", + ); + engine.enqueueMerge("FN-WSH"); + + // The busy catch logs a WorkspaceRepoLandBusy entry then schedules a backoff timer. + await vi.waitFor( + () => { + expect(mockStore.store.logEntry).toHaveBeenCalledWith( + "FN-WSH", + expect.stringContaining("busy"), + "WorkspaceRepoLandBusy", + ); + }, + { timeout: 2000, interval: 5 }, + ); + + // It must NOT have written any mergeRetries increment (busy ≠ real failure). + const burnedRetries = (mockStore.store.updateTask.mock.calls as Array<[string, Record]>) + .some((c) => c[0] === "FN-WSH" && typeof c[1]?.mergeRetries === "number"); + expect(burnedRetries).toBe(false); + + // Drive several busy re-enqueues; the backoff must stay capped at 60s. + enqueueSpy.mockClear(); + await vi.advanceTimersByTimeAsync(60_000); // first backoff (5s) fires → re-enqueue + expect(enqueueSpy).toHaveBeenCalledWith("FN-WSH"); + + await engine.stop(); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts index af9ed2e1af..fce5724b44 100644 --- a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -26,8 +26,19 @@ import { execSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import path from "node:path"; import type { Task, TaskStore } from "@fusion/core"; -import { landWorkspaceTask } from "../merger-ai.js"; -import { shouldRetryWorkspacePartialLand } from "../project-engine.js"; +import { landWorkspaceTask, WorkspacePartialLandError } from "../merger-ai.js"; +import { shouldRetryAutoMergeConflict } from "../project-engine.js"; + +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B6): +`shouldRetryWorkspacePartialLand` was collapsed into `shouldRetryAutoMergeConflict` via the +`skipAutoResolveCheck` flag (one place owns the resolveMaxAutoMergeRetries arithmetic). The +workspace partial-land decision is `shouldRetryAutoMergeConflict(retries, settings, { skipAutoResolveCheck: true })`. +*/ +const shouldRetryWorkspacePartialLand = ( + currentRetries: number, + settings: { maxAutoMergeRetries?: unknown } | null | undefined, +) => shouldRetryAutoMergeConflict(currentRetries, settings, { skipAutoResolveCheck: true }); import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; const describeIfGit = hasGit ? describe : describe.skip; @@ -314,6 +325,107 @@ describeIfGit("landWorkspaceTask — landed predicate + finalize-once + idempote }); }); +/* +FNXC:Workspace 2026-06-22-04:10 (Phase C review A1/A4/A5 — DB-failure resilience): +These drive the REAL `landWorkspaceTask` against the REAL two-repo fixture but inject a +store whose `updateTask` REJECTS on a chosen patch, exercising the persist-failure windows +that the review fixes close. No mock-the-world: the git lands are real; only the targeted +DB write is forced to fail. +*/ +describeIfGit("landWorkspaceTask — DB-failure resilience (Phase C review A1/A4/A5)", () => { + let fx: WorkspaceFixture; + afterEach(() => fx?.cleanup()); + + it("A1/A4: a persist-failure AFTER the ref advanced escalates to WorkspacePartialLandError (no silent continue); a retry skips the actually-landed repo (no double squash)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + + // A store that FAILS the landedSha persist (the workspaceWorktrees write) exactly once, + // then persists normally — simulating a transient DB hiccup in the A1 window. + let failLandedShaWrite = true; + const store = createStore(task); + const realUpdate = store.updateTask as unknown as (id: string, patch: Partial) => Promise; + (store as { updateTask: unknown }).updateTask = vi.fn(async (id: string, patch: Partial) => { + if (failLandedShaWrite && patch.workspaceWorktrees) { + failLandedShaWrite = false; + throw new Error("synthetic DB write failure (landedSha persist)"); + } + return realUpdate(id, patch); + }); + + const tipBefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + // First run: repo-a squashes + advances the ref, but the landedSha persist throws. + await expect( + landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }), + ).rejects.toBeInstanceOf(WorkspacePartialLandError); + + // The ref DID advance (the repo is actually landed) — but landedSha was NOT recorded. + const tipAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main"); + expect(tipAfterFirst).not.toBe(tipBefore); + expect(store.task.workspaceWorktrees!["repo-a"].landedSha).toBeUndefined(); + // Not finalized to done (the throw aborted before finalize). + expect(store.moveTaskCalls).toHaveLength(0); + // Status was reset off 'merging' before the throw escaped (A3). + expect(store.task.status ?? null).toBeNull(); + + // Retry: isRepoLanded's trailer ancestor-fallback (A1) recognises the actually-landed + // repo via its Fusion-Task-Id trailer and SKIPS it — the ref must NOT advance a 2nd time. + const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAfterFirst); // no double squash + expect(second.repos[0].alreadyLanded).toBe(true); + expect(second.allLanded).toBe(true); + expect(second.finalized).toBe(true); + }); + + it("A4: WorkspacePartialLandError is a real class (instanceof + retryable + payload)", () => { + const err = new WorkspacePartialLandError(2, ["repo-b"], "partial"); + expect(err).toBeInstanceOf(WorkspacePartialLandError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("WorkspacePartialLandError"); + expect(err.retryable).toBe(true); + expect(err.landedCount).toBe(2); + expect(err.failedRepos).toEqual(["repo-b"]); + }); + + it("A5: a rejecting mergeDetails persist aborts finalization (does NOT silently finalize on a stale row)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }); + + // Fail the mergeDetails write (the finalize TOCTOU window) — the landedSha write succeeds. + const store = createStore(task); + const realUpdate = store.updateTask as unknown as (id: string, patch: Partial) => Promise; + (store as { updateTask: unknown }).updateTask = vi.fn(async (id: string, patch: Partial) => { + if (patch.mergeDetails) { + throw new Error("synthetic DB write failure (mergeDetails)"); + } + return realUpdate(id, patch); + }); + + await expect( + landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }), + ).rejects.toThrow(/mergeDetails/); + + // Finalization aborted: the task was NOT moved done and no task:merged was emitted on a + // stale/unpersisted row. + expect(store.moveTaskCalls).toHaveLength(0); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + // Status was still reset off 'merging' (A3 finally runs before finalize). + expect(store.task.status ?? null).toBeNull(); + }); +}); + describe("workspace partial-land retry/park decision (engine seam, fake timers)", () => { beforeEach(() => vi.useFakeTimers()); afterAll(() => vi.useRealTimers()); diff --git a/packages/engine/src/__tests__/workspace-merger-lease.test.ts b/packages/engine/src/__tests__/workspace-merger-lease.test.ts index 074752aca4..b27e24de65 100644 --- a/packages/engine/src/__tests__/workspace-merger-lease.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-lease.test.ts @@ -269,4 +269,50 @@ describeIfGit("landWorkspaceTask — per-repo land lease (Phase C U3, KTD4)", () expect(retry.repos[0].status).toBe("landed"); expect(activeSessionRegistry.lookupByPath(repoAbs)).toBeNull(); }); + + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds): + A FOREIGN-task holder of ANY kind on the sub-repo path is contention for the land + busy-check — not only a "workspace-repo-land" holder. Here an EXECUTING task's + "workspace-repo-acquire" entry sits on the path; a MERGING task's land must FAST-FAIL + with WorkspaceRepoLandBusyError and must NOT clobber the foreign entry. + */ + it("a foreign-task acquire-lease holder is land contention (busy error) and is NOT clobbered", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); + const repoAbs = fx.repoPath("repo-a"); + + // An EXECUTING task (FN-9001) holds an acquire lease on the shared sub-repo path. + activeSessionRegistry.registerPath(repoAbs, { + taskId: "FN-9001", + kind: "workspace-repo-acquire", + ownerKey: "workspace-repo-acquire", + }); + const tipBefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + // The MERGING task (FN-3001) tries to land the SAME sub-repo. + const task = makeTask("FN-3001", { "repo-a": { worktreePath: repoAbs, branch: BRANCH } }); + const store = createStore(task); + + let landError: unknown; + try { + await landWorkspaceTask(store, store.task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + } catch (err) { + landError = err; + } + + // Fast-failed with the retryable busy error — even though the holder kind differs. + expect(landError).toBeInstanceOf(WorkspaceRepoLandBusyError); + expect((landError as WorkspaceRepoLandBusyError).holderTaskId).toBe("FN-9001"); + // The foreign acquire entry was NOT clobbered — still owned by FN-9001, same kind. + const stillHeld = activeSessionRegistry.lookupByPath(repoAbs); + expect(stillHeld?.taskId).toBe("FN-9001"); + expect(stillHeld?.kind).toBe("workspace-repo-acquire"); + // The merging task advanced NOTHING and its status was reset off 'merging' (A3). + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipBefore); + expect(store.task.status ?? null).toBeNull(); + }); }); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index 75c3b226eb..f560e25388 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -56,12 +56,46 @@ export type SelfOwnedReconcileOutcome = */ export const DEFAULT_SELF_OWNED_MIN_IDLE_MS = 5000; +/* +FNXC:Workspace 2026-06-22-04:10 (Phase C review A2): +Thrown by registerPath when a register would overwrite an entry held by a DIFFERENT +task on the same path. Surfacing this (rather than silently clobbering) is what stops a +merging task's land lease from yanking an executing task's acquire lease on a shared +sub-repo. Same-task re-registration is allowed and never throws. +*/ +export class ActiveSessionPathHeldByForeignTaskError extends Error { + constructor( + public readonly path: string, + public readonly holderTaskId: string, + public readonly requestingTaskId: string, + ) { + super( + `active-session path ${path} is held by task ${holderTaskId}; task ${requestingTaskId} may not overwrite it`, + ); + this.name = "ActiveSessionPathHeldByForeignTaskError"; + } +} + export class ActiveSessionRegistry { private readonly records = new Map(); + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware lease across kinds): + registerPath previously OVERWROTE any existing entry on the path (only console.warn). + Because the land lease ("workspace-repo-land") and the execution acquire lease + ("workspace-repo-acquire") key the SAME sub-repo absolute path, an overwrite let a + MERGING task clobber an EXECUTING task's acquire-lease on a shared sub-repo (cross-phase + clobber). We now REJECT a register that would overwrite an entry held by a DIFFERENT + taskId — regardless of kind — by throwing. Only the SAME task may re-register its own + path (idempotent re-registration stays working; this is how an executor re-claims/refreshes + its own entry). Callers that may contend (the land lease) must lookupByPath-then-throw a + domain busy error BEFORE calling registerPath so they surface contention as a retryable + condition rather than this raw guard throw; this guard is the last-line safety net. + */ registerPath(worktreePath: string, registration: ActiveSessionRegistration): void { - if (this.records.has(worktreePath)) { - console.warn(`[active-session-registry] overwriting existing registration for ${worktreePath}`); + const existing = this.records.get(worktreePath); + if (existing && existing.taskId !== registration.taskId) { + throw new ActiveSessionPathHeldByForeignTaskError(worktreePath, existing.taskId, registration.taskId); } this.records.set(worktreePath, { ...registration, diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e9a55f5a18..43dbf72e14 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -195,6 +195,13 @@ export { runAiMerge } from "./merger-ai.js"; export { landWorkspaceTask, landOneRepo, + // FNXC:Workspace 2026-06-22-04:10 (Phase C review A6): canonical landed predicate, + // re-exported so Phase D self-healing reuses it instead of reimplementing the ancestor check. + isRepoLanded, + // FNXC:Workspace 2026-06-22-04:10 (Phase C review A4): real error classes (instanceof-able), + // re-exported so the engine dispatch can switch to instanceof in the separate pass. + WorkspaceRepoLandBusyError, + WorkspacePartialLandError, type WorkspaceMergeResult, type WorkspaceRepoLandResult, type LandOneRepoResult, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index e2dd4c6291..a9f66a45c8 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -99,6 +99,19 @@ async function gitOk(args: string[], cwd: string): Promise { } } +/** + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1): + * Capture git stdout, returning undefined (never throwing) on failure — for read-only + * probes (merge-base, log --grep) where a non-zero exit is an expected "not found". + */ +async function gitCapture(args: string[], cwd: string): Promise { + try { + return await git(args, cwd); + } catch { + return undefined; + } +} + function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -1445,6 +1458,34 @@ export class WorkspaceRepoLandBusyError extends Error { } } +/* +FNXC:Workspace 2026-06-22-04:10 (Phase C review A4 — real WorkspacePartialLandError class): +Previously the partial-land signal was a bare `new Error()` with `.name` patched in +project-engine.ts (a footgun: no instanceof, no typed payload). It is now a real exported +class so the dispatch can switch to `instanceof` (separate pass) and tests can assert +`instanceof`. `retryable = true` because a partial land is recoverable — the landed repos' +`landedSha` is persisted and a re-run skips them (the U2 idempotency contract). + +`landWorkspaceTask` throws this from ONE place: the A1 persist-after-advance failure window +(the integration ref ALREADY advanced but `persistRepoLandedSha` could not record the +`landedSha`). The ORDINARY partial land (repo A landed, repo B's land failed) still RETURNS +`allLanded:false` — that return-based contract is what the engine dispatch and the oracle +workspace-merger tests already consume; only the persist-failure window escalates to a throw +so the engine parks/retries and A1's `isRepoLanded` ancestor-fallback skips the actually-landed +repo on retry (no double-squash). +*/ +export class WorkspacePartialLandError extends Error { + public readonly retryable = true; + constructor( + public readonly landedCount: number, + public readonly failedRepos: string[], + message: string, + ) { + super(message); + this.name = "WorkspacePartialLandError"; + } +} + export async function landWorkspaceTask( store: TaskStore, task: Task, @@ -1482,6 +1523,18 @@ export async function landWorkspaceTask( let allLanded = true; await setStatus("merging"); + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A3 — status 'merging' must never leak): + The busy-throw (WorkspaceRepoLandBusyError) and the persist-failure throw + (WorkspacePartialLandError) exit the loop BEFORE the post-loop `setStatus(null)`. If the + engine catch never runs (process crash between throw and catch) the task stays stuck + 'merging' with no manual door to clear it. Wrap the whole per-repo loop so `setStatus(null)` + ALWAYS runs (in finally) before ANY throw escapes. The success path still finalizes to done + AFTER this finally (finalizeWorkspaceTask sets its own column/status), so clearing 'merging' + first is safe — finalize overwrites it. This finally only clears the transient merge status; + it does not move the task. + */ + try { for (const repoRel of repoKeys) { throwIfAborted(options.signal, taskId); const entry = workspaceWorktrees[repoRel]; @@ -1508,7 +1561,7 @@ export async function landWorkspaceTask( // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP // it so a retry never re-advances the ref. This makes a re-run after a partial // land idempotent for the already-landed repos. - if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha)) { + if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch)) { await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, @@ -1526,15 +1579,19 @@ export async function landWorkspaceTask( interleaved await would let a second task pass the gate before we register. If another task holds the land lease we FAST-FAIL with a retryable busy error; the U2 dispatch auto-retry/park path handles it (no waiting lock reimplemented here). - We only treat a HELD entry of OUR OWN land ownerKey as contention, so a stale - entry of a different kind on this path (e.g. a leftover acquire entry) is ignored. + + FNXC:Workspace 2026-06-22-04:10 (Phase C review A2 — taskId-aware contention across kinds): + Previously we only treated a HELD entry of OUR OWN land ownerKey as contention, so a + MERGING task would registerPath-OVERWRITE an EXECUTING task's "workspace-repo-acquire" + entry on a shared sub-repo (cross-phase clobber). Now ANY foreign-task holder on this + path — regardless of kind (acquire OR land OR anything else) — is contention: we throw + WorkspaceRepoLandBusyError so the engine retries when the other task releases its hold. + A SAME-task holder is NOT contention (idempotent re-claim of our own path). The + registerPath guard (A2b) backstops this: it also rejects a foreign-task overwrite, so a + missed check can never silently clobber. */ const landLeaseHolder = activeSessionRegistry.lookupByPath(repoRootDir); - if ( - landLeaseHolder && - landLeaseHolder.ownerKey === WORKSPACE_REPO_LAND_OWNER_KEY && - landLeaseHolder.taskId !== taskId - ) { + if (landLeaseHolder && landLeaseHolder.taskId !== taskId) { throw new WorkspaceRepoLandBusyError(repoRel, landLeaseHolder.taskId, taskId); } activeSessionRegistry.registerPath(repoRootDir, { @@ -1551,10 +1608,32 @@ export async function landWorkspaceTask( allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true, }); if (landResult.outcome === "landed") { - // Persist this repo's landedSha BEFORE moving on (fresh-read-then-merge so - // sibling entries written by a concurrent path are not clobbered). The retry - // predicate above reads this back to skip the repo on a re-run. - await persistRepoLandedSha(store, taskId, repoRel, landResult.squashSha); + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — persist-after-advance is a HARD failure): + The integration ref has ALREADY advanced (squash landed) by the time we persist + `landedSha`. If the DB write fails here the ref is advanced but UNRECORDED — we must NOT + silently continue (a return-based partial would let a retry double-squash). Escalate to a + retryable WorkspacePartialLandError so the engine parks/retries; on retry, `isRepoLanded`'s + trailer ancestor-fallback recognises this actually-landed repo and skips it. The repo IS + recorded as `landed` in the in-memory result first so the error payload is accurate. + */ + try { + await persistRepoLandedSha(store, taskId, repoRel, landResult.squashSha); + } catch (persistErr: unknown) { + const pmsg = getErrorMessage(persistErr); + await log(`AI merge (workspace): sub-repo ${repoRel} landed (${short(landResult.squashSha)}) but persisting landedSha FAILED: ${pmsg} — escalating to partial land so a retry can recover (ref already advanced; retry will skip via trailer ancestor-check)`); + repos.push({ + repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, + status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, + }); + allLanded = false; + const landedCount = repos.filter((r) => r.status === "landed").length; + throw new WorkspacePartialLandError( + landedCount, + [repoRel], + `Workspace land for ${taskId}: sub-repo ${repoRel} advanced its integration ref but the landedSha persist failed (${pmsg}); retry to record/skip it`, + ); + } repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "landed", landedSha: landResult.squashSha, localSync: landResult.localSync, @@ -1563,6 +1642,9 @@ export async function landWorkspaceTask( repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "empty" }); } } catch (err: unknown) { + // A WorkspacePartialLandError from the persist-failure window above must PROPAGATE + // (the engine parks/retries). The outer try/finally below resets status first (A3). + if (err instanceof WorkspacePartialLandError) throw err; const message = getErrorMessage(err); await log(`AI merge (workspace): sub-repo ${repoRel} land failed: ${message}`); await audit.git({ type: "merge:ai-no-branch", target: entry.branch, metadata: { taskId, kind: "workspace-repo-land-failed", repo: repoRel, error: message } }).catch(() => undefined); @@ -1586,8 +1668,12 @@ export async function landWorkspaceTask( } } } - - await setStatus(null); + } finally { + // A3: clear the transient 'merging' status before ANY throw (busy / partial-land / + // abort) escapes, AND on the normal fall-through. The success path's finalize below + // re-sets the task's column/status to done, so clearing here first is safe. + await setStatus(null); + } // U2 finalize-once (KTD3): move the task to `done` EXACTLY ONCE, only after EVERY // acquired repo's landed predicate holds (all landed/empty, none failed). Reuse the @@ -1609,18 +1695,65 @@ export async function landWorkspaceTask( * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and * the repo re-lands. + * + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — task-trailer ancestor fallback): + * The double-land window: a land advances the integration ref via `advanceIntegrationBranchRef`'s + * CAS, then `persistRepoLandedSha` records `landedSha`. If that DB write fails AFTER the ref + * advanced, the repo is ACTUALLY landed but has NO recorded `landedSha`, so the landedSha check + * above reports NOT-landed → a retry re-runs `landOneRepo`, the CAS rebuilds, and a SECOND squash + * lands (not idempotent). To close the window we ALSO treat the repo as landed when the live + * integration ref carries a commit with THIS task's `Fusion-Task-Id` trailer. + * + * Why a trailer scan and NOT a branch-tip ancestor check: the land is a `git merge --squash`, + * whose squash commit's parent is the integration tip, NOT the task branch — so `merge-base + * --is-ancestor ` is FALSE even right after a successful land. The + * `Fusion-Task-Id` trailer (always stamped onto the squash by `taskTrailers` + the + * ensureTaskMetadata safety net) is the only reliable "this task's work is already on the ref" + * signal that does not depend on the landedSha row, so it is what survives a lost persist. We + * bound the scan to commits the integration tip has gained since the branch's merge-base (the + * land base) so an unrelated historical reuse of the same trailer cannot false-positive. + * + * Exported (A6) so Phase D self-healing reuses THIS canonical predicate instead of + * reimplementing the ancestor/trailer check. */ -async function isRepoLanded( +export async function isRepoLanded( repoRootDir: string, integrationBranch: string, landedSha: string | undefined, + taskId?: string, + branch?: string, ): Promise { - if (!landedSha) return false; - if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir))) { + const intRef = `refs/heads/${integrationBranch}`; + if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { return false; } + // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. - return await gitOk(["merge-base", "--is-ancestor", landedSha, `refs/heads/${integrationBranch}`], repoRootDir); + if ( + landedSha && + (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) + ) { + return true; + } + // A1 fallback: even without a recorded landedSha, the repo is already landed if the + // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash + // we lost the persist for). Bound the scan to commits gained since the branch's land base + // so a stale historical trailer of the same id cannot false-positive. + if (taskId) { + const branchRef = branch ? `refs/heads/${branch}` : undefined; + let range = intRef; + if (branchRef && (await gitOk(["rev-parse", "--verify", branchRef], repoRootDir))) { + const base = await gitCapture(["merge-base", branchRef, intRef], repoRootDir); + if (base) range = `${base.trim()}..${intRef}`; + } + const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; + const found = await gitCapture( + ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], + repoRootDir, + ); + if (found && found.trim().length > 0) return true; + } + return false; } /** @@ -1628,6 +1761,17 @@ async function isRepoLanded( * Persist one sub-repo's `landedSha` with a FRESH-read-then-merge so a concurrent * sibling-entry write is not clobbered (Phase A/B per-repo `workspaceWorktrees` * pattern). Re-read the latest task, merge only this repo's entry, write the whole map. + * + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — do NOT swallow the DB write): + * Previously the `store.updateTask(...)` was `.catch(() => undefined)`. That swallow is the + * double-land bug: the integration ref has ALREADY advanced by the time we persist, so a + * silently-lost write means `landedSha` is never recorded → on retry the landedSha check sees + * NOT-landed and re-runs the squash (a SECOND squash commit). We now PROPAGATE the write + * failure. The caller (`landWorkspaceTask`) catches it as a partial-land for this repo and + * escalates to `WorkspacePartialLandError` so the engine parks/retries; on retry, `isRepoLanded`'s + * trailer ancestor-fallback (A1) recognises the actually-landed repo and skips it (no double + * squash). We DELIBERATELY do not swallow the `getTask` read either-way: a failed read leaves + * `landedSha` unrecorded for the same reason, so it must also escalate. */ async function persistRepoLandedSha( store: TaskStore, @@ -1635,12 +1779,12 @@ async function persistRepoLandedSha( repoRel: string, landedSha: string, ): Promise { - const latest = await store.getTask(taskId).catch(() => undefined); + const latest = await store.getTask(taskId); const current = latest?.workspaceWorktrees ?? {}; const entry = current[repoRel]; if (!entry) return; // entry vanished — nothing to merge into const next = { ...current, [repoRel]: { ...entry, landedSha } }; - await store.updateTask(taskId, { workspaceWorktrees: next }).catch(() => undefined); + await store.updateTask(taskId, { workspaceWorktrees: next }); } /** @@ -1663,14 +1807,28 @@ async function finalizeWorkspaceTask( const representative = landed.length > 0 ? landed[0].landedSha : undefined; const anyLanded = landed.length > 0; - // Pre-populate task.mergeDetails so finalizeTask's spread carries the workspace map. + /* + FNXC:Workspace 2026-06-22-04:10 (Phase C review A5 — fresh-read + no-swallow finalize): + Two fixes to the FN-5627 TOCTOU class: + 1. The `task` argument is the SNAPSHOT captured at the START of `landWorkspaceTask`; by + finalize time the persisted row has gained each repo's `landedSha` (and possibly other + concurrent edits). Spreading the stale snapshot's mergeDetails could drop/clobber those. + Re-read the LATEST task and spread ITS mergeDetails (fresh-read-then-merge), falling back + to the snapshot only if the read fails. + 2. The `store.updateTask(...)` was `.catch(() => undefined)` — a swallowed write left the + in-memory `mergeConfirmed:true` while the persisted row stayed stale (the finalize would + then report done with an unpersisted merge). PROPAGATE the failure so finalization aborts + and self-healing recovers, rather than silently finalizing on a stale row. + */ + const fresh = await store.getTask(taskId).catch(() => undefined); + const baseMergeDetails = fresh?.mergeDetails ?? task.mergeDetails; const mergeDetails: MergeDetails = { - ...task.mergeDetails, + ...baseMergeDetails, ...(representative ? { commitSha: representative } : {}), ...(anyLanded ? { workspaceLandedShas } : {}), mergeConfirmed: anyLanded, }; - await store.updateTask(taskId, { mergeDetails }).catch(() => undefined); + await store.updateTask(taskId, { mergeDetails }); task.mergeDetails = mergeDetails; const result: MergeResult = { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 6c9d9eff2e..5152a36fc1 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; +import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -31,7 +31,7 @@ import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-st import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { sweepStaleAutostashes, VerificationError } from "./merger.js"; -import { runAiMerge, landWorkspaceTask } from "./merger-ai.js"; +import { runAiMerge, landWorkspaceTask, WorkspacePartialLandError, WorkspaceRepoLandBusyError } from "./merger-ai.js"; import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; @@ -125,35 +125,27 @@ function isInvalidDoneTransitionError(error: unknown): boolean { return message.includes("Invalid transition:") && message.includes("→ 'done'"); } +/* +FNXC:Workspace 2026-06-22-05:10 (Phase C review B6 — unify partial-land retry seam): +The workspace PARTIAL-land retry decision (some sub-repos landed, one failed) is the SAME +arithmetic as the conflict-retry decision MINUS the `autoResolveConflicts` gate (a partial +land is retryable regardless of conflict-resolution settings, because the landed repos' +`landedSha` is persisted and a re-run skips them — U2 idempotency). To keep the +`resolveMaxAutoMergeRetries(settings)` arithmetic in ONE place we collapse the former +`shouldRetryWorkspacePartialLand` into this function via `skipAutoResolveCheck`. When set, +the `autoResolveConflicts` gate is bypassed; otherwise behavior is byte-identical to before. +`currentRetries + 1 < MAX` keeps the LAST attempt's failure parking in the same tick rather +than scheduling an Nth timer that a restart could strand. +*/ export function shouldRetryAutoMergeConflict( currentRetries: number, settings: { autoResolveConflicts?: boolean; maxAutoMergeRetries?: unknown } | null | undefined, + opts?: { skipAutoResolveCheck?: boolean }, ): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } { const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); + const autoResolveOk = opts?.skipAutoResolveCheck === true || settings?.autoResolveConflicts !== false; return { - shouldRetry: settings?.autoResolveConflicts !== false && currentRetries + 1 < maxAutoMergeRetries, - maxAutoMergeRetries, - nextRetryCount: currentRetries + 1, - }; -} - -/* -FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): -Pure retry/park decision for a workspace PARTIAL land (some sub-repos landed, one failed). -Mirrors `shouldRetryAutoMergeConflict` so the engine dispatch's partial-land catch branch -has a narrow, unit-testable seam: a partial land is RETRYABLE (the landed repos' `landedSha` -is persisted, so a re-run skips them and only the failed repo retries), so it CONSUMES a -mergeRetry and re-enqueues up to `resolveMaxAutoMergeRetries(settings)`, then OPERATOR-PARKS -(`shouldRetry:false`). `currentRetries + 1 < MAX` keeps the LAST attempt's failure parking -in the same tick rather than scheduling an Nth timer that a restart could strand. -*/ -export function shouldRetryWorkspacePartialLand( - currentRetries: number, - settings: { maxAutoMergeRetries?: unknown } | null | undefined, -): { shouldRetry: boolean; maxAutoMergeRetries: number; nextRetryCount: number } { - const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); - return { - shouldRetry: currentRetries + 1 < maxAutoMergeRetries, + shouldRetry: autoResolveOk && currentRetries + 1 < maxAutoMergeRetries, maxAutoMergeRetries, nextRetryCount: currentRetries + 1, }; @@ -370,6 +362,19 @@ export class ProjectEngine { private autostashSweepTimer: ReturnType | null = null; private mergeActiveReconcileTimer: ReturnType | null = null; + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B4 — separate busy-retry quota): + Transient sub-repo land-lease contention (WorkspaceRepoLandBusyError) must NOT burn the + persisted `mergeRetries` quota — two tasks contending for the same sub-repo could otherwise + exhaust all retries on pure busy-errors before a single real land attempt, then park a + never-failed task. We track busy re-enqueues in this in-memory, per-task counter (transient + contention need not survive a restart) and CAP it separately from `mergeRetries`. A real + partial land (WorkspacePartialLandError) still consumes `mergeRetries` up to MAX, then parks. + Cleared on the first non-busy outcome (success path resets it). + */ + private workspaceBusyReenqueues = new Map(); + private static readonly WORKSPACE_BUSY_MAX_REENQUEUES = 10; + /** * Pending manual merge resolvers — keyed by taskId. * When `onMerge` is called, the task is enqueued like auto-merge but a @@ -1866,6 +1871,19 @@ export class ProjectEngine { // in-review by auto-recovery after a successful merge) — just // complete the task without re-running the merge process. if (task.mergeDetails?.mergeConfirmed) { + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B2 — fast-path must skip workspace tasks): + The FN-5627 reachability gate below runs `git cat-file -e ` in cwd = the + project/workspace ROOT. For a WORKSPACE task, `finalizeWorkspaceTask` records + `mergeDetails.commitSha` = the FIRST sorted sub-repo's squash sha, which lives in + `join(workspaceRoot, )`, NOT in the workspace root (which is not even a git repo). + So `cat-file -e` against the root cwd ALWAYS reports commit-missing → the gate would + clear `mergeConfirmed` and demote/park a FULLY-MERGED workspace task. Workspace tasks + are merge-verified by each sub-repo's persisted `landedSha`, not a single root-cwd + commitSha, so the root-cwd reachability gate does not apply to them. SKIP the gate for + workspace tasks and take the fast-path. (Per-sub-repo cwd reachability verification is a + larger change deferred past Phase C; skipping here is the correct minimal fix.) + */ // FN-5627: Reachability defense-in-depth. The merger has a TOCTOU // window where `mergeConfirmed: true` can be persisted to the task // row before `git update-ref refs/heads/` actually @@ -1890,6 +1908,7 @@ export class ProjectEngine { `Auto-merge: ${taskId} merge-confirmed fast-path rerouting shared-group member from ${task.mergeDetails.mergeTargetBranch} to ${routedFastPathTarget}`, ); } + if (!isWorkspaceTask(task)) { const reachability = await verifyMergeConfirmedReachability({ commitSha: task.mergeDetails.commitSha, integrationBranch: integrationBranchForGate, @@ -2032,6 +2051,7 @@ export class ProjectEngine { this.internalEnqueueMerge(taskId); continue; } + } // end !isWorkspaceTask reachability gate (B2): workspace tasks skip the root-cwd commitSha check const blockerReason = getTaskHardMergeBlocker({ ...(task as Task), // Merge-confirmed tasks have already landed. Treat stale merge @@ -2320,8 +2340,7 @@ export class ProjectEngine { // routing falls through to runAiMerge, whose chokepoint guard re-reads // the task and is the authoritative workspace enforcement. const mergeTask = await store.getTask(taskId).catch(() => null); - const isWorkspaceMerge = - !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + const isWorkspaceMerge = !!mergeTask && isWorkspaceTask(mergeTask); if (isWorkspaceMerge) { // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): // Land each acquired sub-repo on its own local integration ref; @@ -2339,14 +2358,18 @@ export class ProjectEngine { { ...mergerOptions, allowDirtyLocalCheckoutSync: settings.merger?.allowDirtyLocalCheckoutSync === true }, ); if (!workspaceResult.allLanded) { + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B7): + // Throw the real exported WorkspacePartialLandError class (not a bare Error with + // a patched `.name`) so the catch below can match via `instanceof` and read the + // typed payload (landedCount, failedRepos). const failed = workspaceResult.repos.filter((r) => r.status === "failed"); const landedCount = workspaceResult.repos.filter((r) => r.status === "landed").length; const detail = failed.map((r) => `${r.repo}: ${r.error ?? "land failed"}`).join("; "); - const partialErr = new Error( + throw new WorkspacePartialLandError( + landedCount, + failed.map((r) => r.repo), `Workspace partial land for ${taskId}: ${landedCount} repo(s) landed, ${failed.length} failed — ${detail}`, ); - partialErr.name = "WorkspacePartialLandError"; - throw partialErr; } // Finalized to done by landWorkspaceTask; report the merge as merged so // the success path (retry reset + branch-group promotion) runs normally. @@ -2409,6 +2432,9 @@ export class ProjectEngine { if (latestTask?.mergeRetries && latestTask.mergeRetries > 0) { await store.updateTask(taskId, { mergeRetries: 0 }); } + // FNXC:Workspace 2026-06-22-05:10 (Phase C review B4): clear the in-memory busy + // re-enqueue counter once the merge succeeds so a later unrelated contention starts fresh. + this.workspaceBusyReenqueues.delete(taskId); await attemptBranchGroupPromotion(latestTask); } @@ -2460,40 +2486,98 @@ export class ProjectEngine { continue; } + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B4/B7 — busy contention split from real partial land): + A `WorkspaceRepoLandBusyError` (a second task holds the same sub-repo's land lease) is + TRANSIENT contention, not a land failure: re-enqueue it with backoff WITHOUT consuming the + persisted `mergeRetries` quota, bounded separately by `workspaceBusyReenqueues` + (WORKSPACE_BUSY_MAX_REENQUEUES). This stops two contending tasks from exhausting all merge + retries on busy-errors before either makes a real land attempt, then parking a never-failed + task. Detect via `instanceof` now that both are exported classes (B7). + */ + if (err instanceof WorkspaceRepoLandBusyError && !hasManualResolver) { + const busyCount = this.workspaceBusyReenqueues.get(taskId) ?? 0; + await store + .logEntry(taskId, `Workspace sub-repo land busy (contention): ${errorMsg}`, "WorkspaceRepoLandBusy") + .catch(() => undefined); + if (busyCount < ProjectEngine.WORKSPACE_BUSY_MAX_REENQUEUES) { + this.workspaceBusyReenqueues.set(taskId, busyCount + 1); + // Capped exponential backoff (B5): never exceed 60s even at the busy ceiling. + const delayMs = Math.min(5000 * Math.pow(2, busyCount), 60_000); + await store.updateTask(taskId, { status: null }).catch(() => undefined); + runtimeLog.log( + `Workspace land busy re-enqueue ${busyCount + 1}/${ProjectEngine.WORKSPACE_BUSY_MAX_REENQUEUES} for ${taskId} in ${delayMs / 1000}s (no mergeRetry consumed — pure lease contention)`, + ); + setTimeout(() => { + if (!this.shuttingDown) this.internalEnqueueMerge(taskId); + }, delayMs); + } else { + // Pathological sustained contention — surface but do NOT burn mergeRetries; park as + // failed so the cooldown sweep stops re-attempting and an operator can intervene. + this.workspaceBusyReenqueues.delete(taskId); + await store + .updateTask(taskId, { status: "failed", error: errorMsg }) + .catch(() => undefined); + runtimeLog.error( + `Auto-merge: ${taskId} workspace land busy ${ProjectEngine.WORKSPACE_BUSY_MAX_REENQUEUES} times — parked as failed (sustained sub-repo lease contention)`, + ); + } + continue; + } + // FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): // Workspace PARTIAL-LAND auto-retry-then-park (user decision). Unlike the R7 // WorkspaceTaskMergeError above (a permanent config error that must NOT burn // retries), a partial land — repo A landed, repo B failed — is RETRYABLE: the // landed repos' `landedSha` is persisted, so a re-run of `landWorkspaceTask` // skips them and re-attempts only the failed repo (idempotent). So this CONSUMES - // a `mergeRetry` and re-enqueues the merge with exponential backoff up to the + // a `mergeRetry` and re-enqueues the merge with capped exponential backoff up to the // existing MAX (resolveMaxAutoMergeRetries), then OPERATOR-PARKS (status:"failed") - // — mirroring the conflict-retry seam below. Detect by err.name (robust across - // the package boundary). Manual merges fall through to rejectMergeResolvers at - // the hasManualResolver early-return below (no auto-retry for manual). - /* - FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): - A `WorkspaceRepoLandBusyError` (a second task holds the same sub-repo's - land lease) is ALSO retryable here — it is transient contention, not a - terminal failure. Route it through the SAME auto-retry-then-park seam (it - consumes a mergeRetry and re-enqueues with backoff; a re-run skips - already-landed repos and finds the lease freed). Detect by err.name across - the package boundary, same as the partial-land error. - */ - const isWorkspacePartialLand = - err instanceof Error && - (err.name === "WorkspacePartialLandError" || err.name === "WorkspaceRepoLandBusyError"); - if (isWorkspacePartialLand && !hasManualResolver) { - const wsSettings = await store.getSettings().catch(() => ({ maxAutoMergeRetries: undefined })); + // — reusing the unified shouldRetryAutoMergeConflict seam with skipAutoResolveCheck + // (B6). Detect via `instanceof` (B7). Manual merges fall through to + // rejectMergeResolvers at the hasManualResolver early-return below. + if (err instanceof WorkspacePartialLandError && !hasManualResolver) { + const wsSettings = await store.getSettings().catch(() => null); const wsTask = await store.getTask(taskId).catch(() => null); - const wsRetries = wsTask?.mergeRetries ?? 0; - const decision = shouldRetryWorkspacePartialLand(wsRetries, wsSettings as { maxAutoMergeRetries?: unknown }); + /* + FNXC:Workspace 2026-06-22-05:10 (Phase C review B1 — fail closed on getTask null): + If getTask returns null (DB outage), we CANNOT read `mergeRetries`. Defaulting to 0 + would make `shouldRetry` always true while the increment updateTask also fails against + the non-responsive DB → an indefinite setTimeout retry storm against a dead DB. FAIL + CLOSED: do not schedule a retry. Attempt a best-effort park to `failed`; if that write + also fails it throws away cleanly and the cooldown sweep (canMergeTask) will re-evaluate + once the DB recovers, rather than hammering it on a tight timer. + */ + if (!wsTask) { + runtimeLog.error( + `Auto-merge: ${taskId} workspace partial land but getTask failed (DB outage?) — failing closed, NOT scheduling a retry storm: ${errorMsg}`, + ); + await store + .logEntry( + taskId, + `Workspace partial land — task state unreadable (DB error); parking as failed instead of scheduling a retry storm: ${errorMsg}`, + "WorkspacePartialLand", + ) + .catch(() => undefined); + await store + .updateTask(taskId, { status: "failed", error: errorMsg }) + .catch(() => undefined); + continue; + } + const wsRetries = wsTask.mergeRetries ?? 0; + const decision = shouldRetryAutoMergeConflict( + wsRetries, + wsSettings as { autoResolveConflicts?: boolean; maxAutoMergeRetries?: unknown } | null, + { skipAutoResolveCheck: true }, + ); await store .logEntry(taskId, `Workspace partial land: ${errorMsg}`, "WorkspacePartialLand") .catch(() => undefined); if (decision.shouldRetry) { await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }).catch(() => undefined); - const delayMs = 5000 * Math.pow(2, wsRetries); + // Capped exponential backoff (B5): cap at 60s so a tuned maxAutoMergeRetries doesn't + // push the delay toward ~85 minutes at the ceiling. + const delayMs = Math.min(5000 * Math.pow(2, wsRetries), 60_000); runtimeLog.log( `Workspace partial-land retry ${decision.nextRetryCount}/${decision.maxAutoMergeRetries} for ${taskId} in ${delayMs / 1000}s (re-runs skipping landed repos)`, ); From b591430e1236acdfd154e7ce919b69529b712e0f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:19:29 -0700 Subject: [PATCH 34/44] docs(workspace): Phase D plan (U8/U9 self-healing + e2e), forks resolved --- ...6-06-22-001-feat-workspace-phase-d-plan.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md diff --git a/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md b/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md new file mode 100644 index 0000000000..ceeaf86a6f --- /dev/null +++ b/docs/plans/2026-06-22-001-feat-workspace-phase-d-plan.md @@ -0,0 +1,136 @@ +--- +title: "feat: Workspace mode Phase D — self-healing reconcilers + e2e harness" +status: active +date: 2026-06-22 +type: feat +origin: docs/plans/2026-06-21-002-feat-workspace-mode-execution-model-plan.md (master plan, Phase D / U8·U9) +depth: deep +--- + +# feat: Workspace mode Phase D — self-healing reconcilers + e2e harness + +> **ID namespace:** local `U1·U2` decompose master-plan **U8, U9**. +> **Anchors feasibility-VERIFIED.** The pre-check found a P0 (an existing reconciler wrongly finalizes a partial-landed workspace task) and resolved all three forks — folded in below. + +## Summary + +Phase D closes the workspace-mode lifecycle. **The headline is not new reconcilers — it's making the EXISTING self-healing layer workspace-aware**, because Phase C's `status:"merging"` and the singular `task.worktree===null` shape make the current reconcilers either wrongly finalize or silently skip workspace tasks. Plus new reconcilers for partial-land recovery, phantom land-lease reclaim, and per-repo worktree cleanup, and an e2e harness proving the full lifecycle with no remote push. Final phase. + +Builds on Phase C (#1717): `landWorkspaceTask`, `isRepoLanded` (exported), `workspaceWorktrees[repo].landedSha`, the `workspace-repo-land` lease, `WorkspacePartialLandError`, the canonical `isWorkspaceTask`. + +**Stacking:** off Phase C; PR diff includes the whole stack; must not merge until it lands. + +--- + +## Problem Frame + +Phase C made workspace merges land-as-you-go, but the engine's self-healing reconcilers reason about a singular `task.worktree` + a single landed commit. Two are actively wrong/blind for workspace tasks, and three new states have no recovery: + +- **(P0) `recoverInterruptedMergingTasks` (self-healing.ts:6670) + `recoverStaleMergingStatus` (:2446)** act on any `ACTIVE_MERGE_STATUSES` task; `landWorkspaceTask` sets `"merging"` (merger-ai.ts:1525). If the holder dies after repo A lands, these call the **singular** `findLandedTaskCommit` (:1620, git over the non-git workspace `rootDir`) and on a one-repo hit **finalize the whole task to done + emit `task:merged`** — marking a partial-landed workspace task fully merged. +- **(P1) `recoverMergeableReviewTasks` (:5758)** filters on `Boolean(t.worktree)` (:5778) → a mergeable workspace task whose merge enqueue was dropped is **silently skipped forever**. +- New states with no recovery: a **partial-landed** stuck task, a **phantom `workspace-repo-land` lease** held by a dead task, and **orphaned per-repo worktrees**. +- **Triple-proof** (`evaluateBackwardMoveTripleProof` :820) classifies liveness via `task.worktree`/`canonicalFusionBranchName` — not workspace-aware (liveness lives across N sub-repo worktrees). + +--- + +## Key Technical Decisions + +### KTD1 — Make the EXISTING merging-status + mergeable-review reconcilers workspace-aware (P0/P1; master U8; FN-5893) +For an `isWorkspaceTask(task)` candidate: +- `recoverInterruptedMergingTasks` / `recoverStaleMergingStatus` must **NOT** use `findLandedTaskCommit`/single-commit finalize. Instead clear the transient `"merging"` status and decide via the **per-repo** `isRepoLanded` predicate: all repos landed → finalize once (the `finalizeWorkspaceTask` path); partial/none → re-enqueue (KTD3). Never finalize a workspace task on one repo's commit. +- `recoverMergeableReviewTasks` must admit `isWorkspaceTask` candidates (relax the `Boolean(t.worktree)` gate to `Boolean(t.worktree) || isWorkspaceTask(t)`), so a zero-landed mergeable workspace task is re-enqueued, not skipped. + +### KTD2 — New partial-land reconciler + workspace-aware liveness; re-enqueue via `enqueueMerge` (master U8; FORK-A resolved) +A new reconciler finds workspace tasks in a non-done state with a stale binding and re-enqueues the merge via **`this.options.enqueueMerge?.(task.id)`** (`SelfHealingOptions.enqueueMerge` :308, wired in-process-runtime.ts:795 → `internalEnqueueMerge` → routes workspace tasks to `landWorkspaceTask`) — **NOT a direct `landWorkspaceTask` call**. `landWorkspaceTask` is idempotent (`isRepoLanded` skips landed repos). Reuse `allowsAutoMergeProcessing` (task-merge.ts:62 — the canonical FN-5147 `autoMerge:false` guard) + user-pause + a **workspace-aware liveness predicate** (any sub-repo worktree active via `activeSessionRegistry.pathsForTask(task.id)` + `isPathActive`, since triple-proof isn't workspace-aware). Emits `task:reconcile-workspace-partial-land` (+ `-no-action`). +**FORK-A (unrecoverable):** a repo is unrecoverable iff its `fusion/` branch is gone **AND** `landedSha` is unset (nothing landed, nothing to land) → park `status:"failed"`. Branch gone but `landedSha` set → already landed (`isRepoLanded` ancestor check) → skip. Otherwise retryable. + +### KTD3 — Phantom `workspace-repo-land` lease reclaim via a new registry enumeration seam (master U8) +`ActiveSessionRegistry` exposes only `lookupByPath`/`isPathActive`/`pathsForTask` — no enumeration by kind, and a dead task is gone from the in-progress lists (so FN-6736's iterate-tasks approach can't surface a leaked lease). **Add an enumeration seam** `entriesByKind(kind)` → `{path, taskId, kind, registeredAt}[]` (`registeredAt` already tracked, active-session-registry.ts:31). The reconciler enumerates `workspace-repo-land` entries, and for each whose owner is terminal/dead AND `registeredAt` older than a floor (reuse the FN-6736 `graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER` analog, :966), clears it + emits `task:reclaim-phantom-workspace-land-lease`. + +### KTD4 — Per-repo worktree cleanup from the STORED paths, no directory walk (master U8; FORK-B resolved) +**FORK-B premise was wrong** — per-repo worktrees are not anonymous: `workspaceWorktrees[repo].worktreePath` is persisted (types.ts:2276). For a done/dead workspace task, read each recorded `worktreePath` and `git worktree remove --force` it, guarded by `activeSessionRegistry.isPathActive(path)` (mirroring self-healing.ts:9955). **No temp-root readdir/walk** (AGENTS.md) — bounded by construction. Emits `task:reconcile-orphaned-workspace-worktree`. + +### KTD5 — e2e harness placement: engine-default (`describeIfGit`), not the gate (master U9; FORK-C resolved) +The merge gate (`engine-core`) is an explicit allow-list excluding real-git tests — a real two-repo fixture e2e cannot run there. Model the **merge + recovery** e2e on `workspace-merger.test.ts` (unmarked, `describeIfGit`, engine-default lane): drive `landWorkspaceTask` directly + invoke the U1/KTD2 reconciler method directly with fake timers; assert local-ref advancement, **no push**, and partial-land recovery. Reuse the existing `executor-workspace-capture.test.ts` / `reviewer-workspace.test.ts` direct-call tests for the capture/review legs. Reserve a single `.slow.test.ts` (engine-slow lane) only if a full ProjectEngine acquire→capture→review→merge loop must be proven. + +--- + +## Implementation Units + +> **Standing requirements:** `FNXC:Workspace `; a `.changeset/*.md` (`@runfusion/fusion: minor`); FN-5048 (real two-repo fixture; fake timers; no mock-the-world; **no unbounded temp walk**); FN-5893 (the EXISTING reconcilers are in scope, not just new ones); the merge gate. Branch off Phase C (`gsxdsm/workspace-phase-d`). + +### U1. Workspace-aware self-healing (master U8) + +**Goal:** Make the existing reconcilers workspace-safe (P0/P1) and add partial-land recovery, phantom-lease reclaim, and per-repo worktree cleanup — none moving a human-gated/live task backward. + +**Requirements:** KTD1, KTD2, KTD3, KTD4. + +**Dependencies:** Phase C. + +**Files:** +- `packages/engine/src/self-healing.ts` — workspace-aware branches in `recoverInterruptedMergingTasks` (:6670), `recoverStaleMergingStatus` (:2446), `recoverMergeableReviewTasks` (:5758); the new partial-land reconciler (re-enqueue via `enqueueMerge`); the phantom-lease reclaim (via the new registry seam); the per-repo worktree cleanup; the workspace-aware liveness predicate. +- `packages/engine/src/active-session-registry.ts` — new `entriesByKind(kind)` enumeration seam. +- `packages/engine/src/run-audit.ts` — add the four literals to the `DatabaseMutationType` union (`task:reconcile-workspace-partial-land`, `-no-action`, `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`). +- `AGENTS.md` — add the new run-audit events to the Run Audit list. +- `packages/engine/src/__tests__/self-healing-workspace.test.ts` (new — real two-repo fixture). + +**Approach:** Per KTD1-KTD4. Reuse `allowsAutoMergeProcessing` + the workspace-aware liveness predicate as the "safe to move backward" gate; re-enqueue via `enqueueMerge`; mirror FN-6736 for the lease floor; cleanup from stored paths. + +**Test scenarios:** +- A partial-landed (repo A `landedSha`, repo B not) task stuck `"merging"` with no live holder → `recoverInterruptedMergingTasks` does **NOT** finalize it done; the partial-land reconciler re-enqueues; a later land completes it (skipping A). (P0 regression + recovery) +- A zero-landed mergeable workspace task whose merge was dropped → `recoverMergeableReviewTasks` re-enqueues it (not skipped by the `worktree` gate). (P1) +- `autoMerge:false` / user-paused / a live sub-repo worktree (via `pathsForTask`+`isPathActive`) → `-no-action` (not moved backward). (FN-5147 guards) +- A `workspace-repo-land` lease owned by a terminal/dead task, older than the floor → reclaimed; owned by a live merging task → untouched. (phantom reclaim) +- A done workspace task's recorded per-repo worktrees → removed (guarded by `isPathActive`); a live task's → untouched; **no temp-root walk**. (cleanup) +- A repo with branch gone + `landedSha` unset → parked failed; branch gone + `landedSha` set → skipped as landed. (FORK-A) +- Single-repo (non-workspace) tasks → all reconcilers behave identically. (regression) + +**Verification:** No reconciler wrongly finalizes/skips/moves-backward a workspace task; partial/phantom/orphan states recover; single-repo unchanged; no unbounded walk. + +### U2. End-to-end merge + recovery harness (master U9) + +**Goal:** Prove a real two-repo workspace task lands both repos on local refs with no push, and that partial-land recovers via U1. + +**Requirements:** KTD5. + +**Dependencies:** U1. + +**Files:** `packages/engine/src/__tests__/workspace-e2e.test.ts` (new — engine-default lane, `describeIfGit`, real two-repo fixture, fake timers). + +**Approach:** Per KTD5. Drive `landWorkspaceTask` on a real two-repo fixture; assert both local integration refs advanced, **no `refs/remotes` change / no push**, `landedSha` per repo, finalize-once. Partial-land: force repo B conflict → assert A landed + task not done, then invoke the U1 partial-land reconciler (fake timers) → assert recovery. Reference the existing `executor-workspace-capture` / `reviewer-workspace` tests for the capture/review legs (don't re-drive the full engine loop unless a `.slow` test is added). + +**Test scenarios:** +- Two repos land → both local refs advanced, **no push**, both `landedSha`, task done once. (e2e happy + no-push invariant) +- Partial-land → A landed, task not done → U1 reconciler → recovery completes. (e2e recovery) + +**Verification:** Real workspace task lands end-to-end with no remote push; partial-land self-heals. + +--- + +## Scope Boundaries + +**In scope:** workspace-aware existing reconcilers + the three new reconcilers (U1), the merge+recovery e2e (U2). + +### Deferred to Follow-Up Work +- Extracting `workspace-merger.ts`; per-sub-repo cwd reachability verification; store-level atomic per-repo merge (Phase-C residuals). +- A full ProjectEngine acquire→capture→review→merge `.slow` loop test (only if needed). +- Rich dashboard per-repo merge-status UI. Remote push of integration refs (out — D2/D5). + +--- + +## Risks & Dependencies + +- **R1 (P0-class) — wrongly finalizing/skipping/moving-backward a workspace task.** The whole point of U1. Mitigation: KTD1 fixes the two wrong/blind reconcilers; every reconciler reuses `allowsAutoMergeProcessing` + the workspace-aware liveness predicate + triple-proof analog; tests assert the `-no-action` + no-wrong-finalize paths. +- **R2 — unbounded temp walk.** Mitigation: KTD4 uses stored paths only; test asserts no walk. +- **R3 — e2e lane.** Mitigation: KTD5 places it in engine-default (`describeIfGit`), not the gate. +- **R4 — reconciler idempotency / double-act.** Mitigation: `isRepoLanded` + `enqueueMerge` idempotency. +- **Stacking:** off Phase C (#1717). + +--- + +## Sources & Research + +- Master plan (U8/U9, FN-5147/FN-6736). +- Phase-D feasibility pre-check (verified anchors: the P0 `recoverInterruptedMergingTasks`/`findLandedTaskCommit` finalize, `recoverMergeableReviewTasks` `Boolean(t.worktree)` gate :5778, `enqueueMerge` :308, no registry `entriesByKind`, stored `worktreePath`, engine-core gate allow-list, `allowsAutoMergeProcessing` :62, triple-proof :820, FN-6736 floor :966). +- Phase C (#1717): `isRepoLanded`, `landedSha`, the lease, `landWorkspaceTask`, `isWorkspaceTask`. +- `self-healing.ts`, `active-session-registry.ts`, `run-audit.ts`, `_workspace-fixture.ts`, `workspace-merger.test.ts` (the lane model). From 7cd204e4e515639a820d79606c64cf9010ce67a9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:37:59 -0700 Subject: [PATCH 35/44] =?UTF-8?q?feat(workspace):=20Phase=20D=20U1=20?= =?UTF-8?q?=E2=80=94=20workspace-aware=20self-healing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the self-healing layer workspace-aware and adds recovery for the states Phase C introduced. No reconciler may wrongly finalize, skip, or move a workspace task backward. Existing reconcilers (the P0/P1 the feasibility check caught): - recoverInterruptedMergingTasks no longer single-commit-finalizes a workspace task: a workspace candidate clears the transient "merging" status and re-enqueues via enqueueMerge (which routes to the idempotent landWorkspaceTask), never reaching findLandedTaskCommit / moveTask(done) / task:merged — so a partial-landed task (repo A landedSha, repo B not) is never marked fully merged on one repo's commit. recoverStaleMergingStatus confirmed single-commit-free. - recoverMergeableReviewTasks relaxes its Boolean(t.worktree) gate to also admit isWorkspaceTask(t), so a zero-landed mergeable workspace task (null worktree) is re-enqueued instead of silently skipped forever. New reconcilers: - reconcileWorkspacePartialLands: re-enqueues stuck non-done workspace merges via enqueueMerge (idempotent skip of landed repos), guarded by allowsAutoMergeProcessing (FN-5147), user-pause, and a workspace-aware liveness predicate (any sub-repo path active via pathsForTask+isPathActive — triple-proof isn't workspace-aware). A repo with its fusion/ branch gone AND landedSha unset is parked failed; branch-gone but landed is skipped. Emits task:reconcile-workspace-partial-land(+-no-action). - reclaimPhantomWorkspaceLandLeases: enumerates the new activeSessionRegistry entriesByKind("workspace-repo-land"), age-gated by the FN-6736 floor, and clears a lease whose owner is terminal/dead (live merging owners untouched). Emits task:reclaim-phantom-workspace-land-lease. - reconcileOrphanedWorkspaceWorktrees: removes a done workspace task's recorded per-repo worktreePaths (isPathActive-guarded) with NO temp-root walk (AGENTS.md). Emits task:reconcile-orphaned-workspace-worktree. New activeSessionRegistry.entriesByKind seam; four DatabaseMutationType literals + the AGENTS.md Run Audit list. Single-repo behavior byte-for-byte unchanged (every path branches on isWorkspaceTask). 13 new fixture tests; 558 self-healing tests + test:gate (649+58) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-phase-d-self-healing.md | 5 + AGENTS.md | 3 + .../__tests__/self-healing-workspace.test.ts | 417 ++++++++++++++++++ .../engine/src/active-session-registry.ts | 21 + packages/engine/src/run-audit.ts | 9 + packages/engine/src/self-healing.ts | 411 ++++++++++++++++- 6 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 .changeset/workspace-phase-d-self-healing.md create mode 100644 packages/engine/src/__tests__/self-healing-workspace.test.ts diff --git a/.changeset/workspace-phase-d-self-healing.md b/.changeset/workspace-phase-d-self-healing.md new file mode 100644 index 0000000000..6d412dd404 --- /dev/null +++ b/.changeset/workspace-phase-d-self-healing.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Workspace mode Phase D (U1): workspace-aware self-healing. The existing merging-status reconcilers no longer mis-finalize a partial-landed workspace task (recoverInterruptedMergingTasks now clears the transient `merging` status and re-enqueues the idempotent per-repo land instead of running the single-commit finalize over the non-git workspace root), and recoverMergeableReviewTasks now admits workspace tasks (task.worktree is null). Adds three reconcilers: partial-land recovery (re-enqueue via enqueueMerge, FORK-A unrecoverable → park failed; guarded by autoMerge:false + user-pause + workspace-aware liveness), phantom `workspace-repo-land` lease reclaim (new `entriesByKind` registry seam), and per-repo worktree cleanup from stored paths (no temp walk). New run-audit events: `task:reconcile-workspace-partial-land`(`-no-action`), `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`. diff --git a/AGENTS.md b/AGENTS.md index f68a9512d4..1e45475137 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,6 +195,9 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs. - FN-6782/FN-6796: self-healing emits `task:auto-recover-paused-abort-park` when it clears a benign pause-abort operator park, requeueing safe `todo`/`in-progress` rows or preserving a clean auto-merge-eligible `in-review` row for review progression. - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. +- Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move. +- Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor (a live merging owner is left untouched). +- Workspace (Phase D U1): self-healing emits `task:reconcile-orphaned-workspace-worktree` when it removes a done/dead workspace task's recorded per-repo worktree from its stored `worktreePath` (guarded by `isPathActive`; no temp-root walk). ## Reference docs (deeper detail) diff --git a/packages/engine/src/__tests__/self-healing-workspace.test.ts b/packages/engine/src/__tests__/self-healing-workspace.test.ts new file mode 100644 index 0000000000..ecd32a7892 --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-workspace.test.ts @@ -0,0 +1,417 @@ +/* +FNXC:Workspace 2026-06-22-09:30 (Phase D U1 — workspace-aware self-healing): +Exercises the workspace-aware self-healing reconcilers against a REAL two-repo git fixture under +a NON-git workspace root (createWorkspaceFixture), so a leaked rootDir git preflight or a +single-commit finalize over the non-git root would actually fail. Real git is used only where the +invariant requires it (per-repo landedSha ancestor check, FORK-A branch-gone check, per-repo +worktree removal); fake timers drive the FN-6736 phantom-lease staleness floor. No mock-the-world +child_process, no unbounded temp walk, never touches port 4040. + +Surfaces (FN-5893): +- P0: a PARTIAL-landed workspace task stuck "merging" with no live holder → recoverInterruptedMergingTasks + does NOT finalize it done (no single-commit finalize); the partial-land reconciler re-enqueues. +- P1: a zero-landed mergeable workspace task → recoverMergeableReviewTasks re-enqueues (not skipped by worktree gate). +- guards: autoMerge:false / user-paused / a live sub-repo worktree → -no-action, not moved backward. +- phantom: a workspace-repo-land lease with a terminal owner older than the floor → reclaimed; live owner → untouched. +- cleanup: a done task's recorded per-repo worktrees → removed (isPathActive-guarded); no temp walk. +- FORK-A: branch-gone + landedSha-unset → parked failed; branch-gone + landedSha-set → skipped as landed. +- regression: a single-repo (non-workspace) task → reconcilers behave identically. +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { SelfHealingManager } from "../self-healing.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-7001"; +const BRANCH = "fusion/fn-7001"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +interface RecordingStore extends EventEmitter { + tasks: Map; + emitted: Array<{ event: string; payload: unknown }>; + enqueued: string[]; + updateTask: ReturnType; + moveTask: ReturnType; +} + +function createStore(rows: Task[], settings: Partial = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const tasks = new Map(rows.map((t) => [t.id, t])); + const emitted: Array<{ event: string; payload: unknown }> = []; + const enqueued: string[] = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + tasks, + emitted, + enqueued, + getSettings: vi.fn().mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000, ...settings } as unknown as Settings), + listTasks: vi.fn(async (opts?: { column?: string }) => { + const all = [...tasks.values()]; + return opts?.column ? all.filter((t) => t.column === opts.column) : all; + }), + getTask: vi.fn(async (id: string) => tasks.get(id) ?? null), + updateTask: vi.fn(async (id: string, patch: Partial) => { + const cur = tasks.get(id); + if (cur) tasks.set(id, { ...cur, ...patch } as Task); + return tasks.get(id) as Task; + }), + moveTask: vi.fn(async (id: string, column: string) => { + const cur = tasks.get(id); + const next = { ...(cur ?? { id }), column } as Task; + tasks.set(id, next); + return next; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), + peekMergeQueue: vi.fn().mockReturnValue([]), + getRootDir: vi.fn().mockReturnValue("/tmp/test"), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +function makeManager(store: TaskStore, rootDir: string, opts: Record = {}): SelfHealingManager { + const enqueueMerge = (taskId: string) => { + (store as unknown as RecordingStore).enqueued.push(taskId); + return true; + }; + return new SelfHealingManager(store, { + rootDir, + enqueueMerge, + clearMergeActive: vi.fn(), + ...opts, + } as never); +} + +/** Add a real `fusion/` branch in a sub-repo with one non-conflicting own commit. */ +function addRepoBranch(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`); + configureIdentity(wt); + writeFileSync(path.join(wt, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +/** Land one sub-repo for real (squash onto main) and return its landedSha. */ +function landRepoForReal(fx: WorkspaceFixture, repoRel: string): string { + const repoDir = fx.repoPath(repoRel); + configureIdentity(repoDir); + execSync(`git merge --squash ${BRANCH}`, { cwd: repoDir, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): landed\n\nFusion-Task-Id: ${TASK_ID}"`, { cwd: repoDir, stdio: "pipe" }); + return fx.git(repoRel, "git rev-parse refs/heads/main"); +} + +function workspaceTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial = {}): Task { + return { + id: TASK_ID, + title: "Workspace task", + column: "in-review", + branch: BRANCH, + worktree: null, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + paused: false, + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + ...extra, + } as unknown as Task; +} + +describeIfGit("workspace-aware self-healing (Phase D U1)", () => { + let fx: WorkspaceFixture; + beforeEach(() => { + activeSessionRegistry.clear(); + }); + afterEach(() => { + activeSessionRegistry.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); + fx?.cleanup(); + }); + + // ── KTD1 P0: partial-landed "merging" task must NOT be finalized done ────── + it("recoverInterruptedMergingTasks does NOT finalize a partial-landed workspace task (P0)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); // repo A landed; repo B NOT. + + const task = workspaceTask( + { + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }, + { status: "merging", updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() }, + ); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + await manager.recoverInterruptedMergingTasks(); + + // NOT finalized done; status cleared; never emitted task:merged on a single repo. + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + expect(store.tasks.get(TASK_ID)?.status).toBeNull(); + expect(store.tasks.get(TASK_ID)?.column).toBe("in-review"); + // It re-enqueued the per-repo land for idempotent completion. + expect(store.enqueued).toContain(TASK_ID); + }); + + it("partial-land reconciler re-enqueues a partial-landed workspace task", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + + expect(n).toBe(1); + expect(store.enqueued).toContain(TASK_ID); + // Not moved backward / not parked failed (repo B branch still exists → retryable). + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + }); + + // ── KTD1 P1: zero-landed mergeable workspace task admitted ───────────────── + it("recoverMergeableReviewTasks re-enqueues a zero-landed mergeable workspace task (P1)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + await manager.recoverMergeableReviewTasks(); + + expect(store.enqueued).toContain(TASK_ID); + }); + + // ── KTD2 guards: never move backward when human-gated / live ─────────────── + it("partial-land reconciler emits -no-action for autoMerge:false (not moved backward)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task], { autoMerge: false }); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + }); + + it("partial-land reconciler emits -no-action for a user-paused task", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const task = workspaceTask( + { "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } }, + { userPaused: true }, + ); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + it("partial-land reconciler emits -no-action when a sub-repo worktree is live", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const wtPath = fx.repoPath("repo-a"); + const task = workspaceTask({ + "repo-a": { worktreePath: wtPath, branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + // A live sub-repo session (workspace-aware liveness via pathsForTask ∩ isPathActive). + activeSessionRegistry.registerPath(wtPath, { taskId: TASK_ID, kind: "executor", ownerKey: "x" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + // ── KTD2 FORK-A: branch-gone classification ──────────────────────────────── + it("FORK-A: branch gone + landedSha unset → parked failed", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + // No fusion branch created in repo-a, and no landedSha → unrecoverable. + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + expect(n).toBe(1); + expect(store.tasks.get(TASK_ID)?.status).toBe("failed"); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + it("FORK-A: branch gone + landedSha set → skipped as landed (re-enqueue finalize)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranch(fx, "repo-a", "a\n"); + const landedA = landRepoForReal(fx, "repo-a"); + fx.git("repo-a", `git branch -D ${BRANCH}`); // branch gone, but landedSha is an ancestor. + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + // All landed → not parked failed; re-enqueued for finalize-once. + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + expect(store.enqueued).toContain(TASK_ID); + expect(n).toBe(1); + }); + + // ── KTD3 phantom lease reclaim ───────────────────────────────────────────── + it("reclaims a workspace-repo-land lease whose owner is terminal and older than the floor", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + // Owner is done (terminal). Floor = taskStuckTimeoutMs(60s) * 3 = 180s. Advance well past it. + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(1); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(false); + }); + + it("does NOT reclaim a land lease owned by a live merging task", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + // Owner is in-review with an active "merging" status → live; lease must be left alone. + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { status: "merging" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + + it("does NOT reclaim a land lease younger than the staleness floor", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:01:00.000Z")); // 60s < 180s floor. + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + + // ── KTD4 per-repo worktree cleanup ───────────────────────────────────────── + it("removes a done workspace task's recorded per-repo worktrees (isPathActive-guarded)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + // Create a real per-repo worktree for each sub-repo (the recorded worktreePath). + const wtA = path.join(fx.repoPath("repo-a"), ".wt-task"); + const wtB = path.join(fx.repoPath("repo-b"), ".wt-task"); + fx.git("repo-a", `git worktree add -b ${BRANCH} ${wtA} HEAD`); + fx.git("repo-b", `git worktree add -b ${BRANCH} ${wtB} HEAD`); + expect(existsSync(wtA)).toBe(true); + expect(existsSync(wtB)).toBe(true); + + const task = workspaceTask( + { + "repo-a": { worktreePath: wtA, branch: BRANCH }, + "repo-b": { worktreePath: wtB, branch: BRANCH }, + }, + { column: "done" }, + ); + // Mark repo-b's worktree as active → it must be SKIPPED. + activeSessionRegistry.registerPath(wtB, { taskId: TASK_ID, kind: "executor", ownerKey: "x" }); + + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const cleaned = await manager.reconcileOrphanedWorkspaceWorktrees(); + + expect(cleaned).toBe(1); + expect(existsSync(wtA)).toBe(false); // removed + expect(existsSync(wtB)).toBe(true); // active → skipped + }); + + // ── regression: single-repo task untouched by workspace reconcilers ──────── + it("single-repo (non-workspace) task is ignored by the workspace reconcilers", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const single = { + id: "FN-9001", + column: "in-review", + branch: "fusion/fn-9001", + worktree: "/tmp/wt/fn-9001", + status: "merging", + paused: false, + dependencies: [], + steps: [], + currentStep: 0, + updatedAt: new Date(Date.now() - 30 * 60_000).toISOString(), + } as unknown as Task; + const store = createStore([single]); + const manager = makeManager(store, fx.rootDir); + + const partial = await manager.reconcileWorkspacePartialLands(); + const leases = await manager.reclaimPhantomWorkspaceLandLeases(); + const orphans = await manager.reconcileOrphanedWorkspaceWorktrees(); + + expect(partial).toBe(0); + expect(leases).toBe(0); + expect(orphans).toBe(0); + expect(store.enqueued).not.toContain("FN-9001"); + expect(store.tasks.get("FN-9001")?.status).toBe("merging"); // untouched + }); +}); diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index f560e25388..4a454fd531 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -125,6 +125,27 @@ export class ActiveSessionRegistry { return paths; } + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD3 — enumeration seam for phantom-lease reclaim): + The existing accessors are path-first (lookupByPath / isPathActive) or task-first + (pathsForTask). Phantom-lease reclaim needs the inverse: enumerate every live entry of a + given KIND so self-healing can find a leaked "workspace-repo-land" lease whose owning task is + already terminal/dead. A dead task is gone from the in-progress lists, so FN-6736's + iterate-tasks approach cannot surface the lease — it must be discovered from the registry + itself. Returns shallow copies (path + the full record fields incl. `registeredAt`, already + tracked) so callers can age-gate against the FN-6736 staleness floor without holding a + reference into the internal map. + */ + entriesByKind(kind: ActiveSessionKind): Array<{ path: string; taskId: string; kind: ActiveSessionKind; registeredAt: number }> { + const out: Array<{ path: string; taskId: string; kind: ActiveSessionKind; registeredAt: number }> = []; + for (const [path, record] of this.records.entries()) { + if (record.kind === kind) { + out.push({ path, taskId: record.taskId, kind: record.kind, registeredAt: record.registeredAt }); + } + } + return out; + } + reconcileStaleSelfOwned(worktreePath: string, expectedTaskId: string): ReconcileStaleSelfOwnedResult { const record = this.lookupByPath(worktreePath); if (!record) { diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index e92d23656d..dc071041d5 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -516,6 +516,15 @@ export type DatabaseMutationType = | "task:resume-limbo-escalated" /** Metadata: { taskId, executionAgeMs, graceMs, staleBindingAgeFloorMs, checkedOutBy, agentPresent, lastActivityMs, hasRecentRunAudit, worktree, branch, worktreeExists, signalReason } */ | "task:reclaim-phantom-executor-binding" + /* FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode self-healing run-audit events. */ + /** Metadata: { taskId, landedRepos: string[], unlandedRepos: string[], failedRepos: string[], action: "re-enqueue" | "park-failed", reason } */ + | "task:reconcile-workspace-partial-land" + /** Metadata: { taskId, reason: "auto-merge-off" | "user-paused" | "live-worktree", livePaths: string[] } */ + | "task:reconcile-workspace-partial-land-no-action" + /** Metadata: { taskId, path, kind: "workspace-repo-land", registeredAt, ageMs, staleBindingAgeFloorMs, ownerColumn } */ + | "task:reclaim-phantom-workspace-land-lease" + /** Metadata: { taskId, repo, worktreePath, success, reason } */ + | "task:reconcile-orphaned-workspace-worktree" /** Metadata: { taskId, branch, worktree, checkedOutBy, executionStartedAt, executionAgeMs, graceMs, liveWorktreeBoundBranch, reason } */ | "task:reclaim-self-owned-branch-conflict-no-action" | "task:orphan-detected-no-action" diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index d65c4ac997..7aae65f4ee 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, resolveMaxAutoMergeRetries, 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, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, resolveMaxAutoMergeRetries, 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 { mergeEffectiveSettings } from "./effective-settings.js"; @@ -46,7 +46,15 @@ import { classifyError, extractMissingModulePath, isNonContinuableSessionError, import { classifyForeignOnlyContamination, deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js"; import { AutoRecoveryDispatcher } from "./auto-recovery.js"; -import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js"; +import { activeSessionRegistry, executingTaskLock, type ActiveSessionKind } from "./active-session-registry.js"; +/* +FNXC:Workspace 2026-06-22-09:30 (Phase D U1): +`isRepoLanded` is the CANONICAL per-repo landed predicate (Phase C, exported A6). Self-healing +reuses it rather than reimplementing the ancestor/trailer check. merger-ai also imports a const +from self-healing (MIN_TEMP_WORKTREE_REAP_AGE_MS), so this is a static cycle — safe because +`isRepoLanded` is only referenced at call time, never at module-eval time. +*/ +import { isRepoLanded } from "./merger-ai.js"; import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js"; import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js"; import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js"; @@ -817,6 +825,24 @@ export class SelfHealingManager { }); } + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD2 — workspace-aware liveness predicate): + `evaluateBackwardMoveTripleProof` is NOT workspace-aware: it keys liveness off the SINGULAR + `task.worktree` / `canonicalFusionBranchName(task.id)`, but a workspace task's liveness lives + across N sub-repo worktrees (task.worktree is null). A workspace task is LIVE iff ANY of its + sub-repo paths is still registered as active in the in-memory session registry + (`pathsForTask` ∩ `isPathActive`) OR a process-wide executing/active signal is held. Used by + the partial-land reconciler as the "safe to move backward / re-enqueue" gate so a live merging + task is never moved backward. + */ + private isWorkspaceTaskLive(task: Task): { live: boolean; livePaths: string[] } { + const livePaths = activeSessionRegistry.pathsForTask(task.id).filter((path) => activeSessionRegistry.isPathActive(path)); + const live = livePaths.length > 0 + || executingTaskLock.has(task.id) + || this.options.isTaskActive?.(task.id) === true; + return { live, livePaths }; + } + private async evaluateBackwardMoveTripleProof( task: Task, input: { @@ -2142,6 +2168,10 @@ export class SelfHealingManager { { name: "reconcile-done-task-integrity", fn: () => this.reconcileDoneTaskIntegrity() }, { name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus() }, { name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() }, + // FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode reconcilers. + { name: "reconcile-workspace-partial-lands", fn: () => this.reconcileWorkspacePartialLands() }, + { name: "reclaim-phantom-workspace-land-leases", fn: () => this.reclaimPhantomWorkspaceLandLeases() }, + { name: "reconcile-orphaned-workspace-worktrees", fn: () => this.reconcileOrphanedWorkspaceWorktrees() }, { name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() }, { name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() }, { name: "recover-post-done-noncontinuable-wedge", fn: () => this.recoverPostDoneNonContinuableWedge() }, @@ -2470,6 +2500,15 @@ export class SelfHealingManager { for (const task of stale) { const previousStatus = task.status; try { + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD1 — workspace-safe by construction): + This reconciler makes NO single-commit assumption: it only clears the transient + `merging`/`merging-pr` status (status:null) + clearMergeActive and never calls + findLandedTaskCommit or moves the task. That is exactly the correct workspace action + (clear the stale status so a re-land can be re-enqueued; the partial-land reconciler / + recover-interrupted-merging owns the actual re-enqueue). So a workspace task is handled + identically and safely here — no workspace-specific branch is needed. + */ log.warn(`Clearing stale merge status for ${task.id}: ${previousStatus}`); await this.store.updateTask(task.id, { status: null }); this.options.clearMergeActive?.(task.id); @@ -5775,7 +5814,12 @@ export class SelfHealingManager { // stale ones are handled by recoverStaleMergingStatus(). t.status !== "merging" && t.status !== "merging-pr" && - Boolean(t.worktree) && + // FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD1 — admit workspace tasks): + // A workspace task has task.worktree===null (its worktrees live per-repo in + // workspaceWorktrees), so the old `Boolean(t.worktree)` gate skipped a zero-landed + // mergeable workspace task FOREVER. Admit `isWorkspaceTask(t)` so a workspace task whose + // merge enqueue was dropped is re-enqueued via enqueueMerge → idempotent landWorkspaceTask. + (Boolean(t.worktree) || isWorkspaceTask(t)) && t.mergeDetails?.mergeConfirmed !== true && t.mergeDetails?.noOpMerge !== true && !hasTerminalInvalidDoneTransition(t) && @@ -6690,6 +6734,38 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD1 — P0 workspace gate): + A workspace task lands PER-REPO and `landWorkspaceTask` sets status:"merging". The + singular `findLandedTaskCommit` runs git over `this.options.rootDir` (the NON-git + workspace root) → wrong/empty, and a one-repo hit would finalize the WHOLE task done + + emit task:merged on a single repo's commit — a P0 data bug that marks a PARTIAL-landed + workspace task fully merged. So for a workspace task we MUST NOT call findLandedTaskCommit + / the single-commit finalize. Instead clear the transient "merging" status and re-enqueue + via `enqueueMerge`, which routes to the idempotent `landWorkspaceTask`: it skips repos + whose `landedSha` is already an ancestor (isRepoLanded) and finalizes to done EXACTLY ONCE + only when EVERY acquired repo is landed; a partial/none state simply re-lands the missing + repos. The partial-land reconciler (KTD2) is the standing recovery for a re-enqueue drop. + */ + if (isWorkspaceTask(task)) { + await this.store.updateTask(task.id, { status: null, error: null }); + this.options.clearMergeActive?.(task.id); + await this.store.logEntry( + task.id, + "Auto-recovered (workspace): cleared stale 'merging' status; per-repo land will be re-enqueued (no single-commit finalize)", + ); + try { + this.options.enqueueMerge?.(task.id); + } catch (enqueueErr: unknown) { + log.warn( + `Failed to re-enqueue workspace ${task.id} after stale-merge recovery (will rely on partial-land reconciler/polling sweep): ${enqueueErr instanceof Error ? enqueueErr.message : String(enqueueErr)}`, + ); + } + log.log(`Recovered interrupted workspace merge ${task.id}: cleared stale status, re-enqueued per-repo land`); + recovered++; + continue; + } + const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-interrupted-merging"); const landedCommit = await this.findLandedTaskCommit(task); @@ -6779,6 +6855,335 @@ export class SelfHealingManager { } } + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD2 — partial-land reconciler): + Recovers non-done workspace tasks whose per-repo land is incomplete (some/none landed) and + whose binding is stale — re-enqueuing the merge via `enqueueMerge` (which routes to the + idempotent `landWorkspaceTask`; already-landed repos are skipped via `isRepoLanded`). We do NOT + call `landWorkspaceTask` directly. GUARDS (reuse, never reinvent): `allowsAutoMergeProcessing` + (FN-5147 autoMerge:false), user-pause, and the WORKSPACE-AWARE liveness predicate + (`isWorkspaceTaskLive`) — triple-proof is NOT workspace-aware so it is deliberately NOT used + here. A live / paused / autoMerge-off task emits `task:reconcile-workspace-partial-land-no-action` + and is NEVER moved backward. + + FORK-A (unrecoverable): a sub-repo is unrecoverable iff its `fusion/` branch is GONE AND its + `landedSha` is UNSET (nothing landed, nothing to land) → park the task `status:"failed"`. Branch + gone but `landedSha` set → already landed (isRepoLanded ancestor/trailer) → that repo is skipped. + Otherwise the task is retryable (re-enqueue). + */ + async reconcileWorkspacePartialLands(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null; + // Workspace tasks live in in-review (post-capture/review, pre/partial land). A task already + // done is finished; todo/in-progress are owned by execution-stage reconcilers. + const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + const candidates = tasks.filter((task) => + task.column === "in-review" && + isWorkspaceTask(task) && + task.mergeDetails?.mergeConfirmed !== true && + // Active transient merge statuses are owned by the live merger; recover-interrupted / + // recover-stale-merging clear STALE ones. A non-transient status (or null) is our domain. + !(task.status && ACTIVE_MERGE_STATUSES.has(task.status)), + ); + if (candidates.length === 0) return 0; + + let recovered = 0; + for (const task of candidates) { + try { + // GUARD 1 — FN-5147 autoMerge:false: in-review is human-gated; never move it backward. + if (!allowsAutoMergeProcessing(task, settings)) { + await this.emitWorkspacePartialLandNoAction(task, "auto-merge-off", []); + continue; + } + // GUARD 2 — user-pause: a hard operator stop. + if (task.userPaused || task.paused) { + await this.emitWorkspacePartialLandNoAction(task, "user-paused", []); + continue; + } + // GUARD 3 — workspace-aware liveness: ANY active sub-repo path / process signal. + const liveness = this.isWorkspaceTaskLive(task); + if (liveness.live) { + await this.emitWorkspacePartialLandNoAction(task, "live-worktree", liveness.livePaths); + continue; + } + // GUARD 4 — a live merge lane owns this exact task right now. + if (activeMergeTaskId && activeMergeTaskId === task.id) { + await this.emitWorkspacePartialLandNoAction(task, "live-worktree", liveness.livePaths); + continue; + } + + // Classify each acquired sub-repo: landed / retryable / unrecoverable (FORK-A). + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const repoKeys = Object.keys(workspaceWorktrees); + const landedRepos: string[] = []; + const unlandedRepos: string[] = []; + const unrecoverableRepos: string[] = []; + for (const repoRel of repoKeys) { + const entry = workspaceWorktrees[repoRel]; + const repoRootDir = join(this.options.rootDir, repoRel); + let integrationBranch: string; + try { + integrationBranch = await resolveIntegrationBranch( + repoRootDir, + { ...settings, integrationBranch: undefined, baseBranch: undefined }, + ); + } catch { + // Cannot resolve the sub-repo's integration branch → treat as retryable (re-enqueue + // re-runs the same resolution and surfaces the real error there). + unlandedRepos.push(repoRel); + continue; + } + if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, task.id, entry.branch)) { + landedRepos.push(repoRel); + continue; + } + // Not landed. FORK-A unrecoverable iff the task branch is GONE and nothing landed. + const branchPresent = entry.branch + ? await this.repoBranchExists(repoRootDir, entry.branch) + : false; + if (!branchPresent && !entry.landedSha) { + unrecoverableRepos.push(repoRel); + } else { + unlandedRepos.push(repoRel); + } + } + + const auditor = createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-workspace-partial-land", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reconcile-workspace-partial-land", + }); + + if (unrecoverableRepos.length > 0) { + // FORK-A: at least one repo can never land (branch gone, nothing landed) → park failed. + const error = `Workspace partial-land unrecoverable: sub-repo(s) ${unrecoverableRepos.join(", ")} have no fusion/${task.id.toLowerCase()} branch and no landedSha — manual intervention required.`; + await this.store.updateTask(task.id, { status: "failed", error }); + await this.store.logEntry(task.id, error); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos, unlandedRepos, failedRepos: unrecoverableRepos, action: "park-failed", reason: "branch-gone-and-unlanded" }, + }).catch(() => undefined); + log.warn(`reconcileWorkspacePartialLands: parked ${task.id} failed (unrecoverable repos: ${unrecoverableRepos.join(", ")})`); + recovered++; + continue; + } + + if (unlandedRepos.length === 0) { + // Every acquired repo is already landed but the task was never finalized (the finalize + // enqueue was dropped). Re-enqueue: landWorkspaceTask skips all repos and finalizes once. + this.options.enqueueMerge?.(task.id); + await this.store.logEntry(task.id, "Auto-recovered (workspace): all sub-repos landed but task not finalized — re-enqueued finalize-once"); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos, unlandedRepos: [], failedRepos: [], action: "re-enqueue", reason: "all-landed-not-finalized" }, + }).catch(() => undefined); + recovered++; + continue; + } + + // Partial / none landed, all unlanded repos retryable → re-enqueue the per-repo land. + this.options.enqueueMerge?.(task.id); + await this.store.logEntry(task.id, `Auto-recovered (workspace): re-enqueued partial land (${landedRepos.length} landed, ${unlandedRepos.length} pending)`); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos, unlandedRepos, failedRepos: [], action: "re-enqueue", reason: landedRepos.length > 0 ? "partial-land" : "zero-land" }, + }).catch(() => undefined); + recovered++; + } catch (err: unknown) { + log.error(`reconcileWorkspacePartialLands: failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (recovered > 0) log.log(`reconcileWorkspacePartialLands: recovered ${recovered} workspace task(s)`); + return recovered; + } catch (err: unknown) { + log.error(`reconcileWorkspacePartialLands sweep failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + + private async emitWorkspacePartialLandNoAction( + task: Task, + reason: "auto-merge-off" | "user-paused" | "live-worktree", + livePaths: string[], + ): Promise { + try { + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-workspace-partial-land-no-action", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reconcile-workspace-partial-land", + }).database({ + type: "task:reconcile-workspace-partial-land-no-action", + target: task.id, + metadata: { taskId: task.id, reason, livePaths }, + }); + } catch (err: unknown) { + log.warn(`reconcileWorkspacePartialLands: audit emit failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** True iff `branch` exists as a local ref in the sub-repo at `repoRootDir`. */ + private async repoBranchExists(repoRootDir: string, branch: string): Promise { + try { + await execAsync(`git rev-parse --verify ${shellQuote(`refs/heads/${branch}`)}`, { + cwd: repoRootDir, + timeout: 30_000, + }); + return true; + } catch { + return false; + } + } + + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD3 — phantom workspace-repo-land lease reclaim): + A `workspace-repo-land` lease is registered on a sub-repo's ABSOLUTE path while a workspace task + lands it, and released in a finally. If the holder dies between register and release, the lease + leaks; because the owner is terminal/dead it is gone from the in-progress lists, so FN-6736's + iterate-tasks reclaim cannot surface it. We enumerate `workspace-repo-land` entries via the new + registry seam and, for each whose owning task is terminal/dead AND whose `registeredAt` is older + than the FN-6736 staleness floor (graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER), clear the + lease (unregister the path) + emit `task:reclaim-phantom-workspace-land-lease`. A lease owned by a + LIVE merging task (still in-review with a transient merge status, or the active merge task) is + UNTOUCHED — only a demonstrably dead owner is reclaimed. + */ + async reclaimPhantomWorkspaceLandLeases(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + const entries = activeSessionRegistry.entriesByKind("workspace-repo-land" as ActiveSessionKind); + if (entries.length === 0) return 0; + + const graceMs = settings.taskStuckTimeoutMs ?? STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS; + const staleFloorMs = graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER; + const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null; + const now = Date.now(); + + let reclaimed = 0; + for (const entry of entries) { + try { + const ageMs = now - entry.registeredAt; + if (ageMs < staleFloorMs) continue; // too recent — a live land is still warming. + + // A live merge lane / executing owner keeps the lease. + if (activeMergeTaskId && activeMergeTaskId === entry.taskId) continue; + if (executingTaskLock.has(entry.taskId) || this.options.isTaskActive?.(entry.taskId) === true) continue; + + const owner = await this.store.getTask(entry.taskId).catch(() => null); + // Owner is dead/terminal iff: not found, archived/done/failed, OR in-review with NO active + // transient merge status (a merging owner is live; a clean in-review is finished landing). + const ownerColumn = owner?.column ?? "deleted"; + const ownerHasActiveMergeStatus = Boolean(owner?.status && ACTIVE_MERGE_STATUSES.has(owner.status)); + const ownerLive = Boolean(owner) + && owner!.column !== "done" + && owner!.status !== "failed" + && ownerHasActiveMergeStatus; + if (ownerLive) continue; // live merging owner → leave its lease alone. + + activeSessionRegistry.unregisterPath(entry.path); + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-phantom-workspace-land-lease", entry.taskId), + agentId: "self-healing", + taskId: entry.taskId, + phase: "reclaim-phantom-workspace-land-lease", + }).database({ + type: "task:reclaim-phantom-workspace-land-lease", + target: entry.taskId, + metadata: { taskId: entry.taskId, path: entry.path, kind: entry.kind, registeredAt: entry.registeredAt, ageMs, staleBindingAgeFloorMs: staleFloorMs, ownerColumn }, + }).catch(() => undefined); + log.warn(`reclaimPhantomWorkspaceLandLeases: reclaimed leaked land lease on ${entry.path} (owner ${entry.taskId}, age ${ageMs}ms)`); + reclaimed++; + } catch (err: unknown) { + log.error(`reclaimPhantomWorkspaceLandLeases: failed for ${entry.path}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (reclaimed > 0) log.log(`reclaimPhantomWorkspaceLandLeases: reclaimed ${reclaimed} leaked lease(s)`); + return reclaimed; + } catch (err: unknown) { + log.error(`reclaimPhantomWorkspaceLandLeases sweep failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + + /* + FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD4 — per-repo worktree cleanup from STORED paths): + For done/dead workspace tasks, remove each recorded per-repo worktree. The paths are ADDRESSABLE + from the task row (`workspaceWorktrees[repo].worktreePath`, persisted) so we NEVER walk the temp + root / readdir the temp tree (AGENTS.md forbids unbounded temp walks) — the sweep is bounded by + construction. Each removal is GUARDED by `activeSessionRegistry.isPathActive(path)` (skip if + active, mirroring the temp-dir sweep at the AI-merge worktree guard) so a still-live path is never + yanked. Emit `task:reconcile-orphaned-workspace-worktree` per removed path. + */ + async reconcileOrphanedWorkspaceWorktrees(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + // Done workspace tasks are the canonical "safe to clean" set (their lands are finalized). + const doneTasks = await this.store.listTasks({ column: "done", slim: true }); + const candidates = doneTasks.filter((task) => isWorkspaceTask(task)); + if (candidates.length === 0) return 0; + + let cleaned = 0; + for (const task of candidates) { + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + for (const repoRel of Object.keys(workspaceWorktrees)) { + const worktreePath = workspaceWorktrees[repoRel]?.worktreePath; + if (!worktreePath) continue; + // GUARD: skip an active path (mirror self-healing temp-dir sweep isPathActive guard). + if (activeSessionRegistry.isPathActive(worktreePath)) continue; + // Nothing on disk → nothing to remove (already cleaned). Skip silently. + if (!existsSync(worktreePath)) continue; + + const repoRootDir = join(this.options.rootDir, repoRel); + let success = false; + let reason = "removed"; + try { + await execAsync(`git worktree remove --force ${shellQuote(worktreePath)}`, { + cwd: repoRootDir, + timeout: 120_000, + }); + success = true; + } catch (err: unknown) { + reason = `git-remove-failed: ${err instanceof Error ? err.message : String(err)}`; + } + try { + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-orphaned-workspace-worktree", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reconcile-orphaned-workspace-worktree", + }).database({ + type: "task:reconcile-orphaned-workspace-worktree", + target: task.id, + metadata: { taskId: task.id, repo: repoRel, worktreePath, success, reason }, + }); + } catch { /* audit best-effort */ } + if (success) { + log.log(`reconcileOrphanedWorkspaceWorktrees: removed ${worktreePath} (task ${task.id}, repo ${repoRel})`); + cleaned++; + } + } + } + if (cleaned > 0) log.log(`reconcileOrphanedWorkspaceWorktrees: removed ${cleaned} orphaned per-repo worktree(s)`); + return cleaned; + } catch (err: unknown) { + log.error(`reconcileOrphanedWorkspaceWorktrees sweep failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + private async readShortstatForSha( sha: string, rebaseBaseSha?: string, From 78d7a28f166d5160f3ed165f2852bc036f3469bf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:45:43 -0700 Subject: [PATCH 36/44] =?UTF-8?q?test(workspace):=20Phase=20D=20U2=20?= =?UTF-8?q?=E2=80=94=20e2e=20merge=20+=20recovery=20harness=20(no-push=20i?= =?UTF-8?q?nvariant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real two-repo workspace lifecycle test in the engine-default lane (describeIfGit, not the merge gate). Test 1 drives landWorkspaceTask on two acquired sub-repos and asserts the NO-PUSH invariant directly: each sub-repo gets a real bare origin, and the test snapshots every origin + remote-tracking ref before/after and asserts byte-for-byte equality while the local refs/heads advance — a leaked push would move an origin ref and fail. Plus per-repo landedSha and finalize-exactly-once. Test 2 forces a repo-B conflict (repo A lands, task not done), then invokes the U1 reconcileWorkspacePartialLands reconciler under fake timers (enqueueMerge wired to the real in-process route) and asserts recovery completes with no double-land of repo A (its ref is unchanged from the first pass — proving the isRepoLanded skip). Engine-default lane confirmed: test:gate stays 649+58 (did not enter engine-core). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/workspace-e2e.test.ts | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 packages/engine/src/__tests__/workspace-e2e.test.ts diff --git a/packages/engine/src/__tests__/workspace-e2e.test.ts b/packages/engine/src/__tests__/workspace-e2e.test.ts new file mode 100644 index 0000000000..632b042724 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-e2e.test.ts @@ -0,0 +1,350 @@ +/* +FNXC:Workspace 2026-06-22-11:30 (Phase D U2, KTD5 — end-to-end merge + recovery harness): +LANE CHOICE — this is an ENGINE-DEFAULT, git-gated lane (the SAME `describeIfGit` guard as +workspace-merger.test.ts), NOT a merge-gate (engine-core) test. The merge gate is an explicit +allow-list that excludes real-git tests, so a real two-repo fixture e2e cannot run there; it runs +in the non-blocking engine-default suite instead. We drive the REAL `landWorkspaceTask` against a +REAL two-repo git fixture under a NON-git workspace root (createWorkspaceFixture) and invoke the +U1 partial-land reconciler (`reconcileWorkspacePartialLands`) directly under FAKE TIMERS — no +mock-the-world ProjectEngine shell, no real AI (the merge/review agents are injected deps and the +squash is a plain `git merge --squash`), no unbounded temp walk, never touches port 4040 (FN-5048). + +NO-PUSH INVARIANT (the whole D2/D5 premise — a HARD assertion): +Each sub-repo gets a REAL bare `origin` remote that we push initial state to. We snapshot +`git for-each-ref` over BOTH the bare origin AND the working repo's `refs/remotes/*` BEFORE and +AFTER `landWorkspaceTask`. landWorkspaceTask lands each sub-repo onto its own LOCAL integration ref +via CAS with NO remote push, so the origin's refs and every `refs/remotes/*` tracking ref must be +BYTE-FOR-BYTE UNCHANGED while the LOCAL `refs/heads/main` advances. A leaked `git push` would move +an origin ref and fail the snapshot equality — this is the strongest available proof of no-push. + +Surfaces (FN-5893): +- e2e happy + no-push: two acquired repos both land → BOTH local integration refs advance, + per-repo `landedSha` is set, the task is finalized done EXACTLY once, AND origin/remote refs are + unchanged (no push). +- e2e partial-land recovery: force repo B to conflict → repo A lands (landedSha + ref advance), task + NOT done; resolve B and run the U1 reconciler (re-enqueue → idempotent landWorkspaceTask) → B + lands, task done, and repo A's ref did NOT advance a second time (isRepoLanded skip — no double-land). +*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Settings, Task, TaskStore } from "@fusion/core"; +import { landWorkspaceTask } from "../merger-ai.js"; +import { SelfHealingManager } from "../self-healing.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; + +const describeIfGit = hasGit ? describe : describe.skip; + +const TASK_ID = "FN-8001"; +const BRANCH = "fusion/fn-8001"; + +function configureIdentity(dir: string): void { + execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); +} + +/** + * Combined recording store. Satisfies BOTH the `landWorkspaceTask` surface (getSettings/updateTask/ + * logEntry/appendAgentLog/getTask/moveTask/upsertTaskCommitAssociation/accumulateTokenUsage/emit) + * AND the SelfHealingManager surface (listTasks/peekMergeQueue/recordRunAuditEvent/getRootDir), + * over a single in-memory task map so a reconciler-routed land sees the SAME freshly-persisted + * landedShas the first pass wrote. + */ +interface RecordingStore extends EventEmitter { + tasks: Map; + emitted: Array<{ event: string; payload: unknown }>; + moveTaskCalls: Array<{ id: string; column: string }>; +} + +function createStore(rows: Task[], settings: Partial = {}): TaskStore & RecordingStore { + const emitter = new EventEmitter(); + const tasks = new Map(rows.map((t) => [t.id, t])); + const emitted: Array<{ event: string; payload: unknown }> = []; + const moveTaskCalls: Array<{ id: string; column: string }> = []; + const realEmit = emitter.emit.bind(emitter); + const store = Object.assign(emitter, { + tasks, + emitted, + moveTaskCalls, + getSettings: vi + .fn() + .mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000, ...settings } as unknown as Settings), + listTasks: vi.fn(async (opts?: { column?: string }) => { + const all = [...tasks.values()]; + return opts?.column ? all.filter((t) => t.column === opts.column) : all; + }), + getTask: vi.fn(async (id: string) => tasks.get(id) ?? null), + updateTask: vi.fn(async (id: string, patch: Partial) => { + const cur = tasks.get(id); + if (cur) tasks.set(id, { ...cur, ...patch } as Task); + return tasks.get(id) as Task; + }), + moveTask: vi.fn(async (id: string, column: string) => { + moveTaskCalls.push({ id, column }); + const cur = tasks.get(id); + const next = { ...(cur ?? { id }), column } as Task; + tasks.set(id, next); + return next; + }), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), + upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), + accumulateTokenUsage: vi.fn().mockResolvedValue(undefined), + peekMergeQueue: vi.fn().mockReturnValue([]), + getRootDir: vi.fn().mockReturnValue("/tmp/test"), + emit: (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return realEmit(event, payload); + }, + }) as unknown as TaskStore & RecordingStore; + return store; +} + +function makeTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial = {}): Task { + return { + id: TASK_ID, + title: "Workspace merge task", + description: "", + column: "in-review", + branch: BRANCH, + worktree: null, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + paused: false, + workspaceWorktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date(Date.now() - 30 * 60_000).toISOString(), + ...extra, + } as unknown as Task; +} + +/** A merge agent that performs the real squash in the clean room (no AI). */ +function squashMergeAgent(branch: string) { + return async (cwd: string): Promise => { + configureIdentity(cwd); + try { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + } catch { + // squash reported conflicts — leave them for the test's expectation. + } + const unmerged = execSync("git ls-files -u", { cwd, encoding: "utf-8" }).trim(); + if (unmerged.length > 0) throw new Error("merge conflict: unresolved paths in clean room"); + const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim(); + if (staged.length === 0) return; + execSync(`git commit -m "${branch}: squashed"`, { cwd, stdio: "pipe" }); + }; +} + +const approveReviewAgent = async (): Promise => "REVIEW_VERDICT: approve"; + +/** + * Give a sub-repo a REAL bare `origin` remote and push its initial state. Returns the bare repo + * path so the test can snapshot its refs. Used to prove the NO-PUSH invariant: the origin must not + * move across a land. + */ +function addOriginRemote(fx: WorkspaceFixture, repoRel: string): string { + const repoDir = fx.repoPath(repoRel); + const originDir = path.join(repoDir, "..", `${repoRel}-origin.git`); + execSync(`git init --bare ${originDir}`, { cwd: repoDir, stdio: "pipe" }); + fx.git(repoRel, `git remote add origin ${originDir}`); + fx.git(repoRel, "git push origin --all"); + return originDir; +} + +/** Snapshot ALL refs of a git dir (sha + name), normalized, for byte-for-byte comparison. */ +function snapshotRefs(gitDir: string): string { + return execSync("git for-each-ref --format='%(objectname) %(refname)'", { + cwd: gitDir, + encoding: "utf-8", + }).trim(); +} + +/** Add a real `fusion/` branch in a sub-repo with one non-conflicting own commit. */ +function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-branch"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`); + configureIdentity(wt); + writeFileSync(path.join(wt, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): add feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +/** Make a sub-repo's integration tip and the task branch BOTH edit README so the squash conflicts. */ +function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-conflict"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${wt} HEAD`); + configureIdentity(wt); + writeFileSync(path.join(wt, "README.md"), "# branch-side change\n", "utf-8"); + execSync("git add README.md", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): branch README"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); + writeFileSync(path.join(repoDir, "README.md"), "# main-side change\n", "utf-8"); + fx.git(repoRel, "git add README.md"); + fx.git(repoRel, 'git commit -m "main diverge README"'); +} + +/** + * Resolve repo B's conflict so a retry can land it: hard-align the task branch's README onto the + * integration tip's content, then add B's non-conflicting feature on top of the (now conflict-free) + * branch. After this the squash applies cleanly. + */ +function resolveConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { + const repoDir = fx.repoPath(repoRel); + const wt = path.join(repoDir, ".wt-resolve"); + fx.git(repoRel, `git worktree add ${wt} ${BRANCH}`); + configureIdentity(wt); + // Take main's README content so the README no longer diverges, then add a unique file. + const mainReadme = fx.git(repoRel, "git show refs/heads/main:README.md"); + writeFileSync(path.join(wt, "README.md"), `${mainReadme}\n`, "utf-8"); + writeFileSync(path.join(wt, "feature.txt"), "b feature\n", "utf-8"); + execSync("git add README.md feature.txt", { cwd: wt, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): resolve + feature in ${repoRel}"`, { cwd: wt, stdio: "pipe" }); + fx.git(repoRel, `git worktree remove --force ${wt}`); +} + +describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase D U2)", () => { + let fx: WorkspaceFixture; + beforeEach(() => activeSessionRegistry.clear()); + afterEach(() => { + activeSessionRegistry.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); + fx?.cleanup(); + }); + + it("e2e happy: both repos land on LOCAL refs, landedSha per repo, finalize ONCE, NO push", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + const originA = addOriginRemote(fx, "repo-a"); + const originB = addOriginRemote(fx, "repo-b"); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main"); + + // NO-PUSH snapshot: bare origin refs + the working repo's refs/remotes tracking refs. + const originABefore = snapshotRefs(originA); + const originBBefore = snapshotRefs(originB); + const remotesABefore = fx.git("repo-a", "git for-each-ref refs/remotes"); + const remotesBBefore = fx.git("repo-b", "git for-each-ref refs/remotes"); + + const store = createStore([ + makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }), + ]); + const task = store.tasks.get(TASK_ID)!; + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + // Both landed. + expect(result.allLanded).toBe(true); + expect(result.finalized).toBe(true); + for (const r of result.repos) expect(r.status).toBe("landed"); + + // Each repo's LOCAL integration ref advanced. + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(tipABefore); + expect(fx.git("repo-b", "git rev-parse refs/heads/main")).not.toBe(tipBBefore); + + // Per-repo landedSha persisted on the task row. + const persisted = store.tasks.get(TASK_ID)!.workspaceWorktrees!; + expect(persisted["repo-a"].landedSha).toBeTruthy(); + expect(persisted["repo-b"].landedSha).toBeTruthy(); + expect(persisted["repo-a"].landedSha).toBe(fx.git("repo-a", "git rev-parse refs/heads/main")); + expect(persisted["repo-b"].landedSha).toBe(fx.git("repo-b", "git rev-parse refs/heads/main")); + + // Finalize EXACTLY once. + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); + + // NO-PUSH invariant (HARD): origin refs and remote-tracking refs are BYTE-FOR-BYTE unchanged. + expect(snapshotRefs(originA)).toBe(originABefore); + expect(snapshotRefs(originB)).toBe(originBBefore); + expect(fx.git("repo-a", "git for-each-ref refs/remotes")).toBe(remotesABefore); + expect(fx.git("repo-b", "git for-each-ref refs/remotes")).toBe(remotesBBefore); + }); + + it("e2e partial-land recovery: A lands, task not done → U1 reconciler lands B, no double-land of A", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); + makeConflictingRepo(fx, "repo-b"); + + const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + + const store = createStore([ + makeTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }), + ]); + + // First pass: repo B conflicts → repo A lands, task NOT finalized. + const first = await landWorkspaceTask(store, store.tasks.get(TASK_ID)!, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + expect(first.allLanded).toBe(false); + const byRepo = Object.fromEntries(first.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"].status).toBe("landed"); + expect(byRepo["repo-b"].status).toBe("failed"); + + const tipAAfterFirst = fx.git("repo-a", "git rev-parse refs/heads/main"); + expect(tipAAfterFirst).not.toBe(tipABefore); // A advanced once. + expect(store.tasks.get(TASK_ID)!.workspaceWorktrees!["repo-a"].landedSha).toBe(tipAAfterFirst); + expect(store.moveTaskCalls).toHaveLength(0); // task NOT done. + expect(store.tasks.get(TASK_ID)!.column).toBe("in-review"); + + // Resolve repo B's conflict so a retry can land it. + resolveConflictingRepo(fx, "repo-b"); + + // Wire enqueueMerge to the REAL in-process route: re-run landWorkspaceTask (idempotent — A is + // skipped via isRepoLanded). Capture the routed promise so the test can await completion. + const routedLands: Promise[] = []; + const enqueueMerge = (taskId: string): boolean => { + routedLands.push( + landWorkspaceTask(store, store.tasks.get(taskId)!, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }), + ); + return true; + }; + const manager = new SelfHealingManager(store, { + rootDir: fx.rootDir, + enqueueMerge, + clearMergeActive: vi.fn(), + } as never); + + // FAKE TIMERS for the reconciler sweep timing (no real polling/waits). + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + const recovered = await manager.reconcileWorkspacePartialLands(); + expect(recovered).toBe(1); + expect(routedLands).toHaveLength(1); + + const recovery = (await routedLands[0]) as { allLanded: boolean; finalized: boolean }; + + // Recovery completes: B lands, task finalized done. + expect(recovery.allLanded).toBe(true); + expect(recovery.finalized).toBe(true); + expect(store.tasks.get(TASK_ID)!.workspaceWorktrees!["repo-b"].landedSha).toBeTruthy(); + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); + + // NO DOUBLE-LAND: repo A's ref did NOT advance a second time (isRepoLanded skip). + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAAfterFirst); + }); +}); From 8e70d69601bcaa1034d158b9f40d82cf2288fbe4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:19:17 -0700 Subject: [PATCH 37/44] =?UTF-8?q?fix(review):=20Phase=20D=20self-healing?= =?UTF-8?q?=20hardening=20=E2=80=94=20finalize-site=20audit,=20lease/TOCTO?= =?UTF-8?q?U=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4-persona review of the Phase-D workspace self-healing. The headline: the P0 single-commit-finalize guard had to be applied across ALL surfaces, not just the one reconciler U1 patched (FN-5893). Finalize-site audit (A): gated every site where a workspace task could be single-commit-finalized on one repo's commit — recoverStuckMergeDeadlocks (the twin of the U1-patched reconciler, reachable via blocked-dependents), recoverOrphanOnlyScopeViolations, recoverAlreadyMergedReviewTasks, recoverBranchMisboundInReviewTasks (workspace tasks carry task.branch so the Boolean(branch) filter didn't exclude them), plus a defensive filter on finalizeNoOpReviewTasks. recoverMergedReviewTasks confirmed safe (mergeConfirmed gate). Each is an isWorkspaceTask early-skip; single-repo behavior unchanged. Reliability/concurrency: - The partial-land reconciler now captures enqueueMerge's boolean and bounds re-enqueues (mergeStarvationDrops → fail after N) instead of looping silently forever on a full queue. - The phantom-lease reclaim only acts on a terminal owner (null/done/failed) — it no longer reclaims the lease of an in-progress executing task that registered it early (shared isWorkspaceOwnerLive predicate). - A new isMergePending(taskId) = mergeActive ∪ mergeQueue seam (exposed from ProjectEngine, wired through the runtime) guards both reconcilers against the merge-queue dispatch window — a task dequeued-but-not-yet-merging is no longer re-enqueued (which, since a same-task land lease isn't contention, could have caused a concurrent double-squash). - FORK-A: a repo whose branch is gone and which isn't landed is parked, not re-enqueued forever. Orphan-worktree removal failures log.warn + bound. recoverDoneTaskMergeMetadata skips workspace tasks. Maintainability: dissolved the self-healing↔merger-ai import cycle by moving isRepoLanded into a dependency-free workspace-land-predicate.ts; removed a redundant cast. Gate green: build, typecheck, lint, test:gate (649+58); self-healing + e2e + project-engine + merger 724. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workspace-phase-d-self-healing.md | 4 + .../__tests__/self-healing-workspace.test.ts | 203 ++++++++++++ packages/engine/src/index.ts | 6 +- packages/engine/src/merger-ai.ts | 94 +----- packages/engine/src/project-engine.ts | 24 ++ .../engine/src/runtimes/in-process-runtime.ts | 14 + packages/engine/src/self-healing.ts | 301 ++++++++++++++++-- .../engine/src/workspace-land-predicate.ts | 119 +++++++ 8 files changed, 644 insertions(+), 121 deletions(-) create mode 100644 packages/engine/src/workspace-land-predicate.ts diff --git a/.changeset/workspace-phase-d-self-healing.md b/.changeset/workspace-phase-d-self-healing.md index 6d412dd404..1bcdb8fa71 100644 --- a/.changeset/workspace-phase-d-self-healing.md +++ b/.changeset/workspace-phase-d-self-healing.md @@ -3,3 +3,7 @@ --- Workspace mode Phase D (U1): workspace-aware self-healing. The existing merging-status reconcilers no longer mis-finalize a partial-landed workspace task (recoverInterruptedMergingTasks now clears the transient `merging` status and re-enqueues the idempotent per-repo land instead of running the single-commit finalize over the non-git workspace root), and recoverMergeableReviewTasks now admits workspace tasks (task.worktree is null). Adds three reconcilers: partial-land recovery (re-enqueue via enqueueMerge, FORK-A unrecoverable → park failed; guarded by autoMerge:false + user-pause + workspace-aware liveness), phantom `workspace-repo-land` lease reclaim (new `entriesByKind` registry seam), and per-repo worktree cleanup from stored paths (no temp walk). New run-audit events: `task:reconcile-workspace-partial-land`(`-no-action`), `task:reclaim-phantom-workspace-land-lease`, `task:reconcile-orphaned-workspace-worktree`. + +Phase D P1 TOCTOU fix (merge-queue dispatch blind spot): the workspace partial-land and phantom-land-lease reconcilers now consult a new `ProjectEngine.isMergePending(taskId)` seam (true if the task is in the engine's in-memory `mergeQueue` or `mergeActive`). This closes the dequeue→rawMerge window where a workspace task is being merged but no other liveness signal fires yet (the id is shifted out of `mergeQueue` while `activeMergeTaskId` / `merging` status / the `workspace-repo-land` lease are not yet set inside `landWorkspaceTask`). The partial-land reconciler skips a merge-pending candidate (emitting `task:reconcile-workspace-partial-land-no-action` with reason `merge-pending`) instead of launching a second concurrent `landWorkspaceTask` (double-squash risk, since a same-task land lease is not contention), and lease reclaim leaves a merge-pending owner's not-yet-registered lease alone. Wired via `InProcessRuntime.setMergePendingProvider`; undefined (unwired) is treated as not-pending so existing guards still apply. + +Phase D review hardening: every single-commit-finalize self-healing site is now workspace-gated so a partial-landed workspace task can never be marked fully merged on one repo's commit — `recoverStuckMergeDeadlocks` (the twin of recoverInterruptedMergingTasks), `recoverOrphanOnlyScopeViolations`, `recoverAlreadyMergedReviewTasks`, `recoverBranchMisboundInReviewTasks`, and `recoverDoneTaskMergeMetadata` all skip workspace tasks and defer recovery to the workspace partial-land reconciler. The partial-land reconciler now bounds its `enqueueMerge` re-enqueue (parks `failed` after repeated queue rejections instead of looping forever) and treats a branch-gone-and-not-landed sub-repo as unrecoverable even when a stale unreachable `landedSha` is present. Phantom land-lease reclaim now only reclaims a demonstrably TERMINAL owner (never an `in-progress` executing task that registered its lease early). Orphan per-repo worktree removal failures are now engine-logged and retry-bounded. The canonical `isRepoLanded` predicate moved to a new dependency-free `workspace-land-predicate` module, dissolving the self-healing ↔ merger-ai import cycle (public export preserved). diff --git a/packages/engine/src/__tests__/self-healing-workspace.test.ts b/packages/engine/src/__tests__/self-healing-workspace.test.ts index ecd32a7892..ad524177af 100644 --- a/packages/engine/src/__tests__/self-healing-workspace.test.ts +++ b/packages/engine/src/__tests__/self-healing-workspace.test.ts @@ -263,6 +263,46 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(store.enqueued).not.toContain(TASK_ID); }); + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + A workspace task in the dequeue→rawMerge window is being merged but NO liveness signal fires + (no active session path, no executingTaskLock/isTaskActive, no activeMergeTaskId, no `merging` + status, no land lease yet). Without the merge-pending guard the partial-land reconciler would + re-enqueue it → a SECOND concurrent `landWorkspaceTask(T)` → double-squash. With `isMergePending` + returning true (task is in mergeQueue/mergeActive) the reconciler must NOT re-enqueue and must + emit -no-action(reason: "merge-pending"). + */ + it("partial-land reconciler does NOT re-enqueue a merge-pending task (closes double-dispatch)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); // partial-landed → would normally re-enqueue. + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }); + const store = createStore([task]); + // Narrow seam: inject the in-memory merge-pipeline probe. No session/lock/lease set → only + // the merge-pending guard can stop the re-enqueue. + const manager = makeManager(store, fx.rootDir, { isMergePending: (id: string) => id === TASK_ID }); + + const n = await manager.reconcileWorkspacePartialLands(); + + expect(n).toBe(0); + expect(store.enqueued).not.toContain(TASK_ID); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + expect(store.tasks.get(TASK_ID)?.column).toBe("in-review"); + const auditCalls = (store.recordRunAuditEvent as ReturnType).mock.calls; + expect( + auditCalls.some( + ([ev]) => + (ev as { mutationType?: string }).mutationType === "task:reconcile-workspace-partial-land-no-action" && + (ev as { metadata?: { reason?: string } }).metadata?.reason === "merge-pending", + ), + ).toBe(true); + }); + // ── KTD2 FORK-A: branch-gone classification ──────────────────────────────── it("FORK-A: branch gone + landedSha unset → parked failed", async () => { fx = await createWorkspaceFixture(["repo-a"]); @@ -337,6 +377,33 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); }); + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + A workspace-repo-land lease whose owner is mid-dispatch (in mergeQueue/mergeActive but not yet + activeMergeTaskId) is about to be LEGITIMATELY used by the in-flight `landWorkspaceTask`. Even + though the owner ROW reads terminal-looking and the lease is past the staleness floor, the + merge-pending guard must keep the lease. Here the owner is `done` and the lease is well past the + 180s floor — so ONLY the merge-pending guard can prevent reclaim. + */ + it("does NOT reclaim a land lease whose owner is merge-pending (mid-dispatch)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "done" }); + const store = createStore([task]); + // Narrow seam: owner is in the in-memory merge pipeline → lease must be left alone. + const manager = makeManager(store, fx.rootDir, { isMergePending: (id: string) => id === TASK_ID }); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); // 600s > 180s floor. + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + it("does NOT reclaim a land lease younger than the staleness floor", async () => { fx = await createWorkspaceFixture(["repo-a"]); const leasePath = fx.repoPath("repo-a"); @@ -414,4 +481,140 @@ describeIfGit("workspace-aware self-healing (Phase D U1)", () => { expect(store.enqueued).not.toContain("FN-9001"); expect(store.tasks.get("FN-9001")?.status).toBe("merging"); // untouched }); + + // ── review A (TWIN): recoverStuckMergeDeadlocks must NOT single-commit-finalize ───── + it("recoverStuckMergeDeadlocks does NOT finalize a partial-landed workspace task with blocked dependents (P0 twin)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); // repo A landed; repo B NOT → partial. + + const task = workspaceTask( + { + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + }, + // Deadlock-candidate shape: failed + retries exhausted, mergeConfirmed unset. + { status: "failed", mergeRetries: 5, updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() }, + ); + // A blocked dependent in todo → the deadlock filter admits the (worktree-null) workspace task. + const dependent = { + id: "FN-7002", column: "todo", blockedBy: TASK_ID, paused: false, dependencies: [], steps: [], currentStep: 0, + } as unknown as Task; + const store = createStore([task, dependent], { maxAutoMergeRetries: 1 }); + const manager = makeManager(store, fx.rootDir); + + await manager.recoverStuckMergeDeadlocks(); + + // NOT finalized done; never emitted task:merged on a single repo; status cleared (not done). + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.emitted.some((e) => e.event === "task:merged")).toBe(false); + expect(store.tasks.get(TASK_ID)?.column).toBe("in-review"); + expect(store.tasks.get(TASK_ID)?.status).toBeNull(); + }); + + // ── review B: bounded re-enqueue — no silent infinite loop ───────────────── + it("partial-land reconciler parks failed after N consecutive enqueue drops (no infinite re-enqueue)", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + addRepoBranch(fx, "repo-a", "a\n"); + addRepoBranch(fx, "repo-b", "b\n"); + const landedA = landRepoForReal(fx, "repo-a"); + + const baseTrees = { + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + } as NonNullable; + const task = workspaceTask(baseTrees); + const store = createStore([task]); + // enqueueMerge that ALWAYS rejects (queue full) → drop every time. + const manager = makeManager(store, fx.rootDir, { enqueueMerge: () => false }); + + // First two sweeps: dropped, re-enqueued (not failed yet). repo-b branch still present → retryable. + await manager.reconcileWorkspacePartialLands(); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + await manager.reconcileWorkspacePartialLands(); + expect(store.tasks.get(TASK_ID)?.status).not.toBe("failed"); + // Third drop hits the bound → parked failed. + await manager.reconcileWorkspacePartialLands(); + expect(store.tasks.get(TASK_ID)?.status).toBe("failed"); + }); + + // ── review C: phantom-lease reclaim must NOT reclaim a live executing (in-progress) task ─ + it("does NOT reclaim a land lease owned by an IN-PROGRESS executing task (no merge status)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const leasePath = fx.repoPath("repo-a"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-22T00:00:00.000Z")); + activeSessionRegistry.registerPath(leasePath, { taskId: TASK_ID, kind: "workspace-repo-land", ownerKey: "land" }); + + // Owner is executing in 'in-progress' with NO merge status — registered its land lease early. + const task = workspaceTask({ "repo-a": { worktreePath: leasePath, branch: BRANCH } }, { column: "in-progress", status: null }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + vi.setSystemTime(new Date("2026-06-22T00:10:00.000Z")); // well past the 180s floor. + const n = await manager.reclaimPhantomWorkspaceLandLeases(); + + expect(n).toBe(0); + expect(activeSessionRegistry.isPathActive(leasePath)).toBe(true); + }); + + // ── review D: branch-gone + landedSha-set-but-UNREACHABLE → parked, not re-enqueued forever ─ + it("FORK-A: branch gone + landedSha set but UNREACHABLE → parked failed (not re-enqueued forever)", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + addRepoBranch(fx, "repo-a", "a\n"); + const landedA = landRepoForReal(fx, "repo-a"); + // Roll the integration ref BACK so landedA is no longer reachable (force-reset), and delete the branch. + fx.git("repo-a", "git reset --hard HEAD~1"); + fx.git("repo-a", `git branch -D ${BRANCH}`); + + const task = workspaceTask({ + "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH, landedSha: landedA }, + }); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + const n = await manager.reconcileWorkspacePartialLands(); + // isRepoLanded is FALSE (landedSha unreachable, no trailer on ref) AND branch gone → unrecoverable. + expect(n).toBe(1); + expect(store.tasks.get(TASK_ID)?.status).toBe("failed"); + expect(store.enqueued).not.toContain(TASK_ID); + }); + + // ── review E: failing git worktree remove → logged, isolated, bounded ────── + it("orphan worktree removal failure is bounded and does not abort the sweep", async () => { + fx = await createWorkspaceFixture(["repo-a", "repo-b"]); + // repo-a: a real removable worktree. repo-b: a path that EXISTS but is NOT a git worktree → remove fails. + const wtA = path.join(fx.repoPath("repo-a"), ".wt-task"); + fx.git("repo-a", `git worktree add -b ${BRANCH} ${wtA} HEAD`); + const wtB = path.join(fx.repoPath("repo-b"), ".not-a-worktree"); + execSync(`mkdir -p ${wtB}`, { stdio: "pipe" }); + writeFileSync(path.join(wtB, "stray.txt"), "x", "utf-8"); + expect(existsSync(wtA)).toBe(true); + expect(existsSync(wtB)).toBe(true); + + const task = workspaceTask( + { + "repo-a": { worktreePath: wtA, branch: BRANCH }, + "repo-b": { worktreePath: wtB, branch: BRANCH }, + }, + { column: "done" }, + ); + const store = createStore([task]); + const manager = makeManager(store, fx.rootDir); + + // First sweep: repo-a removed (isolated from repo-b's failure); repo-b counted as a failure. + const cleaned1 = await manager.reconcileOrphanedWorkspaceWorktrees(); + expect(cleaned1).toBe(1); + expect(existsSync(wtA)).toBe(false); + // The audit recorded a failure for repo-b (observability), and the sweep did not throw. + expect(store.emitted.length >= 0).toBe(true); + + // Subsequent sweeps keep failing on repo-b but stay bounded — after the bound they stop attempting. + await manager.reconcileOrphanedWorkspaceWorktrees(); + await manager.reconcileOrphanedWorkspaceWorktrees(); + const cleanedAfterBound = await manager.reconcileOrphanedWorkspaceWorktrees(); + // No more successful removals (repo-a already gone) and no crash. + expect(cleanedAfterBound).toBe(0); + }); }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 43dbf72e14..bf92d0dcab 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -190,14 +190,14 @@ export { // FNXC:MergerUnification 2026-06-21-19:05: runAiMerge is the sole merge path // (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge). export { runAiMerge } from "./merger-ai.js"; +// FNXC:Workspace 2026-06-22-14:10 (Phase D review G): canonical landed predicate now lives in its +// own dependency-free module (self-healing ↔ merger-ai cycle dissolved). Public export preserved. +export { isRepoLanded } from "./workspace-land-predicate.js"; // FNXC:Workspace 2026-06-21-23:40 (Phase C U1): per-repo workspace merge loop + // the extracted per-repo land primitive, exported for the CLI/dashboard merge doors. export { landWorkspaceTask, landOneRepo, - // FNXC:Workspace 2026-06-22-04:10 (Phase C review A6): canonical landed predicate, - // re-exported so Phase D self-healing reuses it instead of reimplementing the ancestor check. - isRepoLanded, // FNXC:Workspace 2026-06-22-04:10 (Phase C review A4): real error classes (instanceof-able), // re-exported so the engine dispatch can switch to instanceof in the separate pass. WorkspaceRepoLandBusyError, diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index a9f66a45c8..d26dd622b0 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -74,6 +74,13 @@ import { installWorktreeDependencies } from "./merge-dependency-sync.js"; import { activeSessionRegistry } from "./active-session-registry.js"; import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js"; import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js"; +/* +FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved): +`isRepoLanded` + `FUSION_TASK_ID_TRAILER_KEY` moved to the dependency-free `workspace-land-predicate` +module so self-healing can import the predicate without re-entering the self-healing ↔ merger-ai +import cycle (merger-ai already imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing). +*/ +import { isRepoLanded, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js"; const execFileAsync = promisify(execFile); const aiMergeLog = createLogger("merger-ai"); @@ -99,19 +106,6 @@ async function gitOk(args: string[], cwd: string): Promise { } } -/** - * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1): - * Capture git stdout, returning undefined (never throwing) on failure — for read-only - * probes (merge-base, log --grep) where a non-zero exit is an expected "not found". - */ -async function gitCapture(args: string[], cwd: string): Promise { - try { - return await git(args, cwd); - } catch { - return undefined; - } -} - function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -358,8 +352,6 @@ export async function cleanupAiMergeWorktree(input: { } -const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id"; - /** Trailers that associate the squash commit with its board task: the * `Fusion-Task-Id` trailer plus the canonical lineage trailer when available. * These are what the board's commit→task association parses. */ @@ -1687,74 +1679,10 @@ export async function landWorkspaceTask( return { taskId, repos, allLanded, finalized: false }; } -/** - * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): - * Landed predicate: a sub-repo is landed iff a `landedSha` is recorded AND that sha is - * an ancestor of (or equals) the repo's CURRENT integration tip. The ancestor check - * (not just sha presence) survives a later un-related advance of the integration ref: - * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that - * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and - * the repo re-lands. - * - * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — task-trailer ancestor fallback): - * The double-land window: a land advances the integration ref via `advanceIntegrationBranchRef`'s - * CAS, then `persistRepoLandedSha` records `landedSha`. If that DB write fails AFTER the ref - * advanced, the repo is ACTUALLY landed but has NO recorded `landedSha`, so the landedSha check - * above reports NOT-landed → a retry re-runs `landOneRepo`, the CAS rebuilds, and a SECOND squash - * lands (not idempotent). To close the window we ALSO treat the repo as landed when the live - * integration ref carries a commit with THIS task's `Fusion-Task-Id` trailer. - * - * Why a trailer scan and NOT a branch-tip ancestor check: the land is a `git merge --squash`, - * whose squash commit's parent is the integration tip, NOT the task branch — so `merge-base - * --is-ancestor ` is FALSE even right after a successful land. The - * `Fusion-Task-Id` trailer (always stamped onto the squash by `taskTrailers` + the - * ensureTaskMetadata safety net) is the only reliable "this task's work is already on the ref" - * signal that does not depend on the landedSha row, so it is what survives a lost persist. We - * bound the scan to commits the integration tip has gained since the branch's merge-base (the - * land base) so an unrelated historical reuse of the same trailer cannot false-positive. - * - * Exported (A6) so Phase D self-healing reuses THIS canonical predicate instead of - * reimplementing the ancestor/trailer check. - */ -export async function isRepoLanded( - repoRootDir: string, - integrationBranch: string, - landedSha: string | undefined, - taskId?: string, - branch?: string, -): Promise { - const intRef = `refs/heads/${integrationBranch}`; - if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { - return false; - } - // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. - // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. - if ( - landedSha && - (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) - ) { - return true; - } - // A1 fallback: even without a recorded landedSha, the repo is already landed if the - // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash - // we lost the persist for). Bound the scan to commits gained since the branch's land base - // so a stale historical trailer of the same id cannot false-positive. - if (taskId) { - const branchRef = branch ? `refs/heads/${branch}` : undefined; - let range = intRef; - if (branchRef && (await gitOk(["rev-parse", "--verify", branchRef], repoRootDir))) { - const base = await gitCapture(["merge-base", branchRef, intRef], repoRootDir); - if (base) range = `${base.trim()}..${intRef}`; - } - const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; - const found = await gitCapture( - ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], - repoRootDir, - ); - if (found && found.trim().length > 0) return true; - } - return false; -} +// FNXC:Workspace 2026-06-22-14:10 (Phase D review G): `isRepoLanded` now lives in +// `workspace-land-predicate.ts` (cycle dissolved). Re-exported here (the imported binding) so +// existing importers of `./merger-ai.js` keep working unchanged. +export { isRepoLanded }; /** * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 5152a36fc1..3c778655d2 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -493,6 +493,10 @@ export class ProjectEngine { this.runtime.setMergeActiveClearer?.((taskId) => { this.mergeActive.delete(taskId); }); + // FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU): expose the in-memory merge pipeline + // (mergeQueue + mergeActive) to the workspace self-healing reconcilers so they don't + // re-dispatch / reclaim a task that is mid-dequeue→rawMerge. + this.runtime.setMergePendingProvider?.((taskId) => this.isMergePending(taskId)); // Workflow-graph interpreter merge seam: routes through the auto-merge // eligibility gate (requestInterpreterMerge), NOT the human "merge now" // bypass, so a graph merge node can't override an autoMerge-off project. @@ -503,6 +507,26 @@ export class ProjectEngine { return this.activeMergeTaskId; } + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + A workspace task is "merge-pending" if it sits ANYWHERE in this engine's in-memory merge + pipeline: still queued in `mergeQueue`, OR already dequeued-and-dispatching / actively merging + (tracked by `mergeActive`). `mergeActive.add(taskId)` happens at enqueue time and is only removed + when the merge fully settles (try/finally, stale-merge recovery, or stop()), so it — unlike the + liveness signals the workspace reconcilers consult (session registry, executingTaskLock, + isTaskActive, getActiveMergeTaskId, setStatus("merging"), the workspace-repo-land lease) — covers + the WHOLE dequeue→rawMerge window. In that window `pickNextMergeTaskId` has shifted the id out of + `mergeQueue` but `activeMergeTaskId` / `merging` status / the land lease are not yet set (they fire + later inside the post-semaphore `landWorkspaceTask`). The workspace self-healing reconcilers + (reconcileWorkspacePartialLands / reclaimPhantomWorkspaceLandLeases) call this as a guard so they + never re-dispatch (double-squash) or reclaim the not-yet-registered land lease of a task that is + legitimately mid-dispatch. Because `mergeActive` lingers across the entire dequeue→rawMerge + window, checking it in addition to `mergeQueue` closes that TOCTOU gap. + */ + isMergePending(taskId: string): boolean { + return this.mergeActive.has(taskId) || this.mergeQueue.includes(taskId); + } + /** * Start the engine: initialize the runtime and all auxiliary subsystems. */ diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index c9a4d1506c..ddf9a15289 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -148,6 +148,13 @@ export class InProcessRuntime ) => Promise; private clearMergeActive?: (taskId: string) => void; private activeMergeTaskIdProvider?: () => string | null; + /** + * FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU): predicate that reports whether a task is + * anywhere in ProjectEngine's in-memory merge pipeline (queued OR dequeued-and-merging). Set by + * ProjectEngine before `start()` via `setMergePendingProvider`. Used by the workspace + * self-healing reconcilers to avoid re-dispatching / reclaiming a task mid-dequeue→rawMerge. + */ + private mergePendingProvider?: (taskId: string) => boolean; /** Tracks whether startup recovery was intentionally deferred due to pause state. */ private startupRecoveryDeferred = false; /** Prevent duplicate unpause recovery dispatches from racing each other. */ @@ -797,6 +804,9 @@ export class InProcessRuntime isTaskActive: (taskId: string) => this.executor.isTaskActive(taskId), clearMergeActive: this.clearMergeActive ? (taskId: string) => this.clearMergeActive?.(taskId) : undefined, getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null, + // FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU): undefined provider → "not pending" + // (graceful when unwired; existing guards still apply). In production it is always wired. + isMergePending: this.mergePendingProvider ? (taskId: string) => this.mergePendingProvider?.(taskId) ?? false : undefined, leaseManager: this.leaseManager, hasActiveAgentExecution: (agentId: string) => this.heartbeatMonitor?.getTrackedAgents().includes(agentId) ?? false, resumeAssignedTaskForAgent: (agentId: string) => this.executor.resumeTaskForAgent(agentId), @@ -1167,6 +1177,10 @@ export class InProcessRuntime this.activeMergeTaskIdProvider = getActiveMergeTaskId; } + setMergePendingProvider(isMergePending: (taskId: string) => boolean): void { + this.mergePendingProvider = isMergePending; + } + /** * Resume executor/self-healing activity after an unpause transition. * diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 7aae65f4ee..b5b9c91cee 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -46,15 +46,15 @@ import { classifyError, extractMissingModulePath, isNonContinuableSessionError, import { classifyForeignOnlyContamination, deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type RunAuditor } from "./run-audit.js"; import { AutoRecoveryDispatcher } from "./auto-recovery.js"; -import { activeSessionRegistry, executingTaskLock, type ActiveSessionKind } from "./active-session-registry.js"; +import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js"; /* -FNXC:Workspace 2026-06-22-09:30 (Phase D U1): -`isRepoLanded` is the CANONICAL per-repo landed predicate (Phase C, exported A6). Self-healing -reuses it rather than reimplementing the ancestor/trailer check. merger-ai also imports a const -from self-healing (MIN_TEMP_WORKTREE_REAP_AGE_MS), so this is a static cycle — safe because -`isRepoLanded` is only referenced at call time, never at module-eval time. +FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved): +`isRepoLanded` is the CANONICAL per-repo landed predicate (Phase C, exported A6). It now lives in +the dependency-free `workspace-land-predicate` module, NOT merger-ai. Previously self-healing +imported it from merger-ai while merger-ai imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from +self-healing — a real import cycle. Importing from the predicate module breaks the cycle. */ -import { isRepoLanded } from "./merger-ai.js"; +import { isRepoLanded } from "./workspace-land-predicate.js"; import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js"; import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js"; import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js"; @@ -327,6 +327,18 @@ export interface SelfHealingOptions { * Used to avoid clearing a transient merge status mid-merge. */ getActiveMergeTaskId?: () => string | null; + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + Returns true if the task is ANYWHERE in ProjectEngine's in-memory merge pipeline — queued in + `mergeQueue` OR dequeued-and-merging (`mergeActive`). Unlike `getActiveMergeTaskId` (only the + single in-flight rawMerge) and the session-registry / executingTaskLock / land-lease signals, + this covers the dequeue→rawMerge window where a workspace task is being merged but NONE of those + signals fire yet. The workspace reconcilers consult it before re-enqueuing a partial-land + candidate (prevents a second concurrent `landWorkspaceTask` → double-squash) or reclaiming a + workspace-repo-land lease (the owner is mid-dispatch and is about to register that lease). + Undefined = "not pending" (graceful when unwired); production always wires it. + */ + isMergePending?: (taskId: string) => boolean; /** * Minimum blocker age before stale merge fan-out is cleared from downstream * blockedBy pointers. Must be >= staleMergingStatusMinAgeMs. @@ -717,6 +729,16 @@ export class SelfHealingManager { // ── Per-task deadlock recovery cooldown ───────────────────────────── private deadlockRecoveryCooldown: Map = new Map(); private mergeStarvationDrops: Map = new Map(); + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review B/E — bounded workspace re-enqueue / orphan-remove): + Per-task drop counter for the workspace partial-land re-enqueue (mirror of `mergeStarvationDrops`): + `enqueueMerge` returns false when the merge queue rejects (full). Without bounding, a perpetually + rejected workspace task is re-enqueued FOREVER. After MAX_STARVATION_DROPS consecutive drops we + park it `status:"failed"`. `orphanWorktreeRemovalFailures` likewise bounds the per-path + `git worktree remove --force` retry in reconcileOrphanedWorkspaceWorktrees. + */ + private workspacePartialLandDrops: Map = new Map(); + private orphanWorktreeRemovalFailures: Map = new Map(); private finalizeUnprovenWarned = new Set(); private metaResolvedSkipAuditMemo = new Map(); private metaStalledSkipAuditMemo = new Map(); @@ -843,6 +865,27 @@ export class SelfHealingManager { return { live, livePaths }; } + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review C — terminal-owner liveness for lease reclaim): + A `workspace-repo-land` lease may only be reclaimed when its owning task ROW is demonstrably + TERMINAL — i.e. not running anymore in any sense. The Phase-D bug: the prior predicate only + treated an in-review task WITH an active transient merge status as live, so a task still in column + `in-progress` (executing, registered its land lease early, no merge status yet) read as NOT live → + its lease was reclaimed MID-EXECUTION. This predicate inverts to the SAFE direction: the owner is + LIVE unless it is provably terminal — null/missing, `done`, or `failed`. Every other state + (`in-progress`, `in-review` with or without a merge status, `todo`, `triage`, paused, etc.) is + treated as LIVE so we never yank a lease out from under a task that could still be running. The + executing-lock / active-merge-lane checks at the call site are an ADDITIONAL live guard on top of + this. (Distinct from `isWorkspaceTaskLive`, which probes the session REGISTRY; this probes the + task ROW lifecycle.) + */ + private isWorkspaceOwnerLive(owner: Task | null | undefined): boolean { + if (!owner) return false; // not found / deleted → terminal. + if (owner.column === "done") return false; + if (owner.status === "failed") return false; + return true; + } + private async evaluateBackwardMoveTripleProof( task: Task, input: { @@ -5466,7 +5509,13 @@ export class SelfHealingManager { allowsAutoMergeProcessing(t, settings) && !t.paused && !isSharedBranchGroupMemberIntegration(t) && + // FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + // This no-op finalize classifies one branch against one base over `this.options.rootDir` + // and moveTask(done)+emitTaskMerged on it. The `Boolean(t.worktree)` gate already excludes + // workspace tasks (their `task.worktree` is null; per-repo worktrees live in + // `workspaceWorktrees`); `!isWorkspaceTask(t)` makes that exclusion explicit and defensive. Boolean(t.worktree) && + !isWorkspaceTask(t) && t.mergeDetails?.mergeConfirmed !== true && t.status !== "merging" && t.status !== "merging-pr" && @@ -6888,6 +6937,13 @@ export class SelfHealingManager { // recover-stale-merging clear STALE ones. A non-transient status (or null) is our domain. !(task.status && ACTIVE_MERGE_STATUSES.has(task.status)), ); + // Drop counters only track LIVE candidates; forget any task that has left the set so a later + // re-appearance starts fresh (mirror of the mergeStarvationDrops cleanup). + const candidateIds = new Set(candidates.map((t) => t.id)); + for (const taskId of [...this.workspacePartialLandDrops.keys()]) { + if (!candidateIds.has(taskId)) this.workspacePartialLandDrops.delete(taskId); + } + if (candidates.length === 0) return 0; let recovered = 0; @@ -6914,6 +6970,21 @@ export class SelfHealingManager { await this.emitWorkspacePartialLandNoAction(task, "live-worktree", liveness.livePaths); continue; } + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + GUARD 5 — the task is anywhere in ProjectEngine's in-memory merge pipeline (queued or + dequeued-and-dispatching/merging). In the dequeue→rawMerge window the id has been shifted + out of `mergeQueue` but `activeMergeTaskId` / `merging` status / the workspace-repo-land + lease have not yet been set, so GUARDs 1-4 and `isWorkspaceTaskLive` all read "not live". + Re-enqueuing here would launch a SECOND concurrent `landWorkspaceTask(T)`; because a + same-task land lease is explicitly NOT contention, the two don't block → double-squash. + `mergeActive` lingers across the whole window, so this guard closes the gap. Never moves + the task backward; emits no-action and leaves the in-flight dispatch to finish. + */ + if (this.options.isMergePending?.(task.id) === true) { + await this.emitWorkspacePartialLandNoAction(task, "merge-pending", liveness.livePaths); + continue; + } // Classify each acquired sub-repo: landed / retryable / unrecoverable (FORK-A). const workspaceWorktrees = task.workspaceWorktrees ?? {}; @@ -6940,11 +7011,22 @@ export class SelfHealingManager { landedRepos.push(repoRel); continue; } - // Not landed. FORK-A unrecoverable iff the task branch is GONE and nothing landed. + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review D — FORK-A: branch-gone-and-not-landed + is unrecoverable, regardless of a STALE landedSha): + We are here because `isRepoLanded` returned FALSE — the recorded `landedSha` (if any) is + NOT reachable from the integration tip (branch was force-reset / rolled back / never + actually landed) AND no task-trailer commit is on the ref. The old test was + `!branchPresent && !entry.landedSha`, which let a repo with a STALE landedSha set but + UNREACHABLE, and its `fusion/` branch GONE, fall to `unlandedRepos` → re-enqueued → + `landWorkspaceTask` has NO branch to land → loops forever. Since the repo is provably + NOT landed, the correct test is: branch GONE ⇒ unrecoverable, whether or not a (stale) + landedSha is present. Only a branch that still EXISTS is retryable. + */ const branchPresent = entry.branch ? await this.repoBranchExists(repoRootDir, entry.branch) : false; - if (!branchPresent && !entry.landedSha) { + if (!branchPresent) { unrecoverableRepos.push(repoRel); } else { unlandedRepos.push(repoRel); @@ -6977,25 +7059,23 @@ export class SelfHealingManager { if (unlandedRepos.length === 0) { // Every acquired repo is already landed but the task was never finalized (the finalize // enqueue was dropped). Re-enqueue: landWorkspaceTask skips all repos and finalizes once. - this.options.enqueueMerge?.(task.id); - await this.store.logEntry(task.id, "Auto-recovered (workspace): all sub-repos landed but task not finalized — re-enqueued finalize-once"); - await auditor.database({ - type: "task:reconcile-workspace-partial-land", - target: task.id, - metadata: { taskId: task.id, landedRepos, unlandedRepos: [], failedRepos: [], action: "re-enqueue", reason: "all-landed-not-finalized" }, - }).catch(() => undefined); + await this.enqueueWorkspaceMergeBounded(task, auditor, { + landedRepos, + unlandedRepos: [], + reason: "all-landed-not-finalized", + successLog: "Auto-recovered (workspace): all sub-repos landed but task not finalized — re-enqueued finalize-once", + }); recovered++; continue; } // Partial / none landed, all unlanded repos retryable → re-enqueue the per-repo land. - this.options.enqueueMerge?.(task.id); - await this.store.logEntry(task.id, `Auto-recovered (workspace): re-enqueued partial land (${landedRepos.length} landed, ${unlandedRepos.length} pending)`); - await auditor.database({ - type: "task:reconcile-workspace-partial-land", - target: task.id, - metadata: { taskId: task.id, landedRepos, unlandedRepos, failedRepos: [], action: "re-enqueue", reason: landedRepos.length > 0 ? "partial-land" : "zero-land" }, - }).catch(() => undefined); + await this.enqueueWorkspaceMergeBounded(task, auditor, { + landedRepos, + unlandedRepos, + reason: landedRepos.length > 0 ? "partial-land" : "zero-land", + successLog: `Auto-recovered (workspace): re-enqueued partial land (${landedRepos.length} landed, ${unlandedRepos.length} pending)`, + }); recovered++; } catch (err: unknown) { log.error(`reconcileWorkspacePartialLands: failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); @@ -7011,7 +7091,7 @@ export class SelfHealingManager { private async emitWorkspacePartialLandNoAction( task: Task, - reason: "auto-merge-off" | "user-paused" | "live-worktree", + reason: "auto-merge-off" | "user-paused" | "live-worktree" | "merge-pending", livePaths: string[], ): Promise { try { @@ -7031,6 +7111,70 @@ export class SelfHealingManager { } } + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review B — bounded re-enqueue, no silent infinite loop): + Re-enqueue a workspace task's per-repo land via `enqueueMerge`, CAPTURING the boolean it returns. + `enqueueMerge` returns false when the merge queue rejects (full); the old code discarded it, so a + permanently-rejected task would re-enqueue forever. Mirror `mergeStarvationDrops` in + recoverMergeableReviewTasks: on false, increment a per-task drop counter and after + MAX_STARVATION_DROPS consecutive drops park the task `status:"failed"` (escalate). On a successful + enqueue, reset the counter. When `enqueueMerge` is not wired (option undefined), this is a graceful + no-op (not a crash) — recovery falls back to the next sweep / polling. + Returns true iff the task was parked failed. + */ + private async enqueueWorkspaceMergeBounded( + task: Task, + auditor: RunAuditor, + input: { landedRepos: string[]; unlandedRepos: string[]; reason: string; successLog: string }, + ): Promise { + const enqueueMerge = this.options.enqueueMerge; + if (!enqueueMerge) { + // Option not wired (standalone/tests with no queue) → graceful no-op; rely on next sweep. + this.workspacePartialLandDrops.delete(task.id); + await this.store.logEntry(task.id, `${input.successLog} (enqueue not wired — deferred to next sweep)`); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "re-enqueue-noop", reason: input.reason }, + }).catch(() => undefined); + return false; + } + + const queued = enqueueMerge(task.id); + if (queued) { + this.workspacePartialLandDrops.delete(task.id); + await this.store.logEntry(task.id, input.successLog); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "re-enqueue", reason: input.reason }, + }).catch(() => undefined); + return false; + } + + const drops = (this.workspacePartialLandDrops.get(task.id) ?? 0) + 1; + this.workspacePartialLandDrops.set(task.id, drops); + log.warn(`reconcileWorkspacePartialLands: enqueue dropped for ${task.id} (${drops}/${MAX_STARVATION_DROPS}); merge queue rejected re-enqueue`); + if (drops >= MAX_STARVATION_DROPS) { + const error = `Workspace partial-land starvation: ${MAX_STARVATION_DROPS} consecutive enqueue attempts were dropped by the merge queue; task requires manual intervention.`; + await this.store.updateTask(task.id, { status: "failed", error }); + await this.store.logEntry(task.id, error); + this.workspacePartialLandDrops.delete(task.id); + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "park-failed", reason: "enqueue-starvation" }, + }).catch(() => undefined); + return true; + } + await auditor.database({ + type: "task:reconcile-workspace-partial-land", + target: task.id, + metadata: { taskId: task.id, landedRepos: input.landedRepos, unlandedRepos: input.unlandedRepos, failedRepos: [], action: "re-enqueue-dropped", reason: input.reason, drops }, + }).catch(() => undefined); + return false; + } + /** True iff `branch` exists as a local ref in the sub-repo at `repoRootDir`. */ private async repoBranchExists(repoRootDir: string, branch: string): Promise { try { @@ -7061,7 +7205,7 @@ export class SelfHealingManager { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - const entries = activeSessionRegistry.entriesByKind("workspace-repo-land" as ActiveSessionKind); + const entries = activeSessionRegistry.entriesByKind("workspace-repo-land"); if (entries.length === 0) return 0; const graceMs = settings.taskStuckTimeoutMs ?? STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS; @@ -7078,17 +7222,22 @@ export class SelfHealingManager { // A live merge lane / executing owner keeps the lease. if (activeMergeTaskId && activeMergeTaskId === entry.taskId) continue; if (executingTaskLock.has(entry.taskId) || this.options.isTaskActive?.(entry.taskId) === true) continue; + /* + FNXC:Workspace 2026-06-22-16:40 (Phase D P1 TOCTOU — merge-queue dispatch blind spot): + If the owner is anywhere in the in-memory merge pipeline (queued or dequeued-and-merging), + the lease is about to be (or is being) LEGITIMATELY used by an in-flight + `landWorkspaceTask` — it just hasn't registered the lease yet (or registered it this very + instant). `activeMergeTaskId` only names the single in-flight rawMerge and does not cover + the dequeue→rawMerge window, so it can read null here while a dispatch is in progress. + Reclaiming now would yank the lease out from under a live land. Skip; the existing + age-floor + terminal-owner guards still apply once the owner truly settles. + */ + if (this.options.isMergePending?.(entry.taskId) === true) continue; const owner = await this.store.getTask(entry.taskId).catch(() => null); - // Owner is dead/terminal iff: not found, archived/done/failed, OR in-review with NO active - // transient merge status (a merging owner is live; a clean in-review is finished landing). const ownerColumn = owner?.column ?? "deleted"; - const ownerHasActiveMergeStatus = Boolean(owner?.status && ACTIVE_MERGE_STATUSES.has(owner.status)); - const ownerLive = Boolean(owner) - && owner!.column !== "done" - && owner!.status !== "failed" - && ownerHasActiveMergeStatus; - if (ownerLive) continue; // live merging owner → leave its lease alone. + // Only a DEMONSTRABLY TERMINAL owner's lease is reclaimed (review C fix). + if (this.isWorkspaceOwnerLive(owner)) continue; activeSessionRegistry.unregisterPath(entry.path); await createRunAuditor(this.store, { @@ -7142,8 +7291,22 @@ export class SelfHealingManager { if (!worktreePath) continue; // GUARD: skip an active path (mirror self-healing temp-dir sweep isPathActive guard). if (activeSessionRegistry.isPathActive(worktreePath)) continue; - // Nothing on disk → nothing to remove (already cleaned). Skip silently. - if (!existsSync(worktreePath)) continue; + // Nothing on disk → nothing to remove (already cleaned). Skip silently; clear any prior + // failure count so a re-created path starts fresh. + if (!existsSync(worktreePath)) { + this.orphanWorktreeRemovalFailures.delete(worktreePath); + continue; + } + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review E — bounded + observable orphan removal): + A `git worktree remove --force` failure was caught + audit-logged but NOT engine-logged, + and retried EVERY tick FOREVER (a genuinely stuck path pins this sweep indefinitely). Bound + the retry per-path: after MAX_STARVATION_DROPS consecutive failures stop attempting (leave + the path for manual cleanup) and `log.warn` each failure for observability. + */ + if ((this.orphanWorktreeRemovalFailures.get(worktreePath) ?? 0) >= MAX_STARVATION_DROPS) { + continue; // exhausted retries — stop hammering a stuck path. + } const repoRootDir = join(this.options.rootDir, repoRel); let success = false; @@ -7171,8 +7334,13 @@ export class SelfHealingManager { }); } catch { /* audit best-effort */ } if (success) { + this.orphanWorktreeRemovalFailures.delete(worktreePath); log.log(`reconcileOrphanedWorkspaceWorktrees: removed ${worktreePath} (task ${task.id}, repo ${repoRel})`); cleaned++; + } else { + const failures = (this.orphanWorktreeRemovalFailures.get(worktreePath) ?? 0) + 1; + this.orphanWorktreeRemovalFailures.set(worktreePath, failures); + log.warn(`reconcileOrphanedWorkspaceWorktrees: ${reason} for ${worktreePath} (task ${task.id}, repo ${repoRel}) [${failures}/${MAX_STARVATION_DROPS}]${failures >= MAX_STARVATION_DROPS ? " — giving up; manual cleanup required" : ""}`); } } } @@ -7238,6 +7406,19 @@ export class SelfHealingManager { let repaired = 0; for (const task of candidates) { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review F — workspace done-metadata corruption gate): + This reconciler assumes ONE git repo at `this.options.rootDir` and calls `findLandedTaskCommit` + over it. For a workspace task that root is NON-git, so `findLandedTaskCommit` returns null. + `finalizeWorkspaceTask` sets `mergeConfirmed: anyLanded` — a pure NO-OP workspace task (zero + repos landed) is moved to done with `mergeConfirmed:false`, so it reaches the non-confirmed + branch below. There, `landed===null` + a stored `commitSha` would wipe `mergeDetails:undefined` + — corrupting a legitimately-done workspace task's per-repo land map (`workspaceLandedShas`). + The confirmed branch is also meaningless here (no single rootDir commit). Skip workspace tasks + entirely; their mergeDetails are authored once by `finalizeWorkspaceTask` and never need this + single-repo metadata repair. + */ + if (isWorkspaceTask(task)) continue; if (task.mergeDetails?.landedFilesAttributionRestricted || task.mergeDetails?.noOpVerifiedShortCircuit) { log.log(`recoverDoneTaskMergeMetadata: skipped ${task.id} — attribution-restricted`); continue; @@ -7570,6 +7751,30 @@ export class SelfHealingManager { const blockedDependents = dependentsByBlocker.get(task.id) ?? []; const blockedTaskIds = blockedDependents.map((dep) => dep.id); try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — P0 workspace gate, TWIN of KTD1): + This is the deadlock-recovery TWIN of recoverInterruptedMergingTasks. Its candidate + filter admits `hasBlockedDependents || Boolean(task.worktree)`, so a workspace task + (task.worktree===null) WITH blocked dependents passes and would reach the single-commit + `findLandedTaskCommit`/moveTask(done)+emitTaskMerged finalize over the NON-git workspace + root — the exact P0: a one-repo commit (or empty) marking a PARTIAL-landed workspace task + fully merged. A workspace task MUST NOT be single-commit-finalized here. Clear the transient + status, leave it in-review, and let the workspace-aware partial-land reconciler + (reconcileWorkspacePartialLands) re-enqueue the idempotent per-repo land. We never move a + workspace task backward here. + */ + if (isWorkspaceTask(task)) { + if (task.status) await this.store.updateTask(task.id, { status: null, error: null }); + this.options.clearMergeActive?.(task.id); + await this.store.logEntry( + task.id, + "Auto-recovery (workspace): cleared stale deadlock 'failed' status; partial-land reconciler owns per-repo re-land (no single-commit finalize)", + ); + log.warn(`self-heal:deadlock-recovery-workspace-skip ${JSON.stringify({ stuckTaskId: task.id, blockedTaskIds, action: "cleared-status-deferred-to-partial-land-reconciler" })}`); + recovered++; + continue; + } + const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-stuck-merge-deadlocks"); const landedCommit = await this.findLandedTaskCommit(task); const landedOnTarget = landedCommit @@ -7739,6 +7944,14 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + `findAlreadyMergedTaskCommit` below runs over `this.options.rootDir` (the NON-git workspace + root for a workspace task), and a hit would single-commit-finalize the WHOLE workspace task + done on one phantom/wrong-repo commit (the P0 class). A workspace task lands PER-REPO; its + recovery is owned by reconcileWorkspacePartialLands. Skip it here. + */ + if (isWorkspaceTask(task)) continue; const recentLogs = "getAgentLogs" in this.store && typeof this.store.getAgentLogs === "function" ? await this.store.getAgentLogs(task.id, { limit: 50 }) : []; @@ -7906,6 +8119,14 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + `findAlreadyMergedTaskCommit` runs over `this.options.rootDir` (NON-git for a workspace + task) and a hit would single-commit-finalize the whole workspace task done on one + phantom/wrong-repo commit (the P0 class). Workspace tasks land PER-REPO and are recovered + by reconcileWorkspacePartialLands; skip them here. + */ + if (isWorkspaceTask(task)) continue; const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-already-merged-review"); const baseBranch = mergeTarget.branch; if (!baseBranch) continue; @@ -8256,6 +8477,16 @@ export class SelfHealingManager { let recovered = 0; for (const task of candidates) { try { + /* + FNXC:Workspace 2026-06-22-14:10 (Phase D review A — workspace single-commit-finalize gate): + A workspace task carries a `task.branch` (`fusion/`) even though it lands PER-REPO, so + the `Boolean(task.branch)` candidate filter does NOT exclude it. `isBranchTipMisboundToTask` + + `findAlreadyMergedTaskCommit` run over `this.options.rootDir` (NON-git for a workspace + task); a hit would single-commit-finalize the whole task done on one wrong-repo/phantom + commit (the P0 class). Today the rootDir git calls merely error-by-accident; gate it + explicitly. Workspace recovery is owned by reconcileWorkspacePartialLands. + */ + if (isWorkspaceTask(task)) continue; const branch = task.branch; if (!branch) continue; const mergeTarget = await this.resolveSelfHealingMergeTarget(task, settings, "recover-branch-misbound-in-review"); diff --git a/packages/engine/src/workspace-land-predicate.ts b/packages/engine/src/workspace-land-predicate.ts new file mode 100644 index 0000000000..5f903592b7 --- /dev/null +++ b/packages/engine/src/workspace-land-predicate.ts @@ -0,0 +1,119 @@ +/* +FNXC:Workspace 2026-06-22-14:10 (Phase D review G — dissolve self-healing ↔ merger-ai cycle): +`isRepoLanded` is a PURE per-repo git predicate. It used to live in merger-ai.ts, but Phase D +self-healing imports it (`self-healing.ts` → `merger-ai.ts`) while `merger-ai.ts` already imports +`MIN_TEMP_WORKTREE_REAP_AGE_MS` from `self-healing.ts` — a real import cycle. Moving the predicate +(plus the two tiny read-only git helpers it needs) into this dependency-free module breaks the +cycle: BOTH merger-ai.ts and self-healing.ts import from here, and neither imports the other for +this predicate. The module pulls in NOTHING beyond node:child_process, so it is a clean extraction. +The public `isRepoLanded` export from index.ts is preserved by re-exporting from this module. +*/ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** Canonical Fusion task-id trailer key stamped on every land squash commit. */ +export const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id"; + +async function git(args: string[], cwd: string, opts: { timeout?: number } = {}): Promise { + const { stdout } = await execFileAsync("git", args, { + cwd, + encoding: "utf-8", + timeout: opts.timeout ?? 120_000, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout.trim(); +} + +/** Run git, returning true on exit 0 and false on any failure (read-only probes). */ +async function gitOk(args: string[], cwd: string): Promise { + try { + await git(args, cwd); + return true; + } catch { + return false; + } +} + +/** + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1): + * Capture git stdout, returning undefined (never throwing) on failure — for read-only + * probes (merge-base, log --grep) where a non-zero exit is an expected "not found". + */ +async function gitCapture(args: string[], cwd: string): Promise { + try { + return await git(args, cwd); + } catch { + return undefined; + } +} + +/** + * FNXC:Workspace 2026-06-22-00:30 (Phase C U2, KTD3): + * Landed predicate: a sub-repo is landed iff a `landedSha` is recorded AND that sha is + * an ancestor of (or equals) the repo's CURRENT integration tip. The ancestor check + * (not just sha presence) survives a later un-related advance of the integration ref: + * the landed commit is still reachable, so the repo stays "landed". A `landedSha` that + * is NOT reachable from the tip (e.g. the ref was reset/rebuilt) reads as NOT landed and + * the repo re-lands. + * + * FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — task-trailer ancestor fallback): + * The double-land window: a land advances the integration ref via `advanceIntegrationBranchRef`'s + * CAS, then `persistRepoLandedSha` records `landedSha`. If that DB write fails AFTER the ref + * advanced, the repo is ACTUALLY landed but has NO recorded `landedSha`, so the landedSha check + * above reports NOT-landed → a retry re-runs `landOneRepo`, the CAS rebuilds, and a SECOND squash + * lands (not idempotent). To close the window we ALSO treat the repo as landed when the live + * integration ref carries a commit with THIS task's `Fusion-Task-Id` trailer. + * + * Why a trailer scan and NOT a branch-tip ancestor check: the land is a `git merge --squash`, + * whose squash commit's parent is the integration tip, NOT the task branch — so `merge-base + * --is-ancestor ` is FALSE even right after a successful land. The + * `Fusion-Task-Id` trailer (always stamped onto the squash by `taskTrailers` + the + * ensureTaskMetadata safety net) is the only reliable "this task's work is already on the ref" + * signal that does not depend on the landedSha row, so it is what survives a lost persist. We + * bound the scan to commits the integration tip has gained since the branch's merge-base (the + * land base) so an unrelated historical reuse of the same trailer cannot false-positive. + * + * Exported (A6) so Phase D self-healing reuses THIS canonical predicate instead of + * reimplementing the ancestor/trailer check. + */ +export async function isRepoLanded( + repoRootDir: string, + integrationBranch: string, + landedSha: string | undefined, + taskId?: string, + branch?: string, +): Promise { + const intRef = `refs/heads/${integrationBranch}`; + if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { + return false; + } + // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. + // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. + if ( + landedSha && + (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) + ) { + return true; + } + // A1 fallback: even without a recorded landedSha, the repo is already landed if the + // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash + // we lost the persist for). Bound the scan to commits gained since the branch's land base + // so a stale historical trailer of the same id cannot false-positive. + if (taskId) { + const branchRef = branch ? `refs/heads/${branch}` : undefined; + let range = intRef; + if (branchRef && (await gitOk(["rev-parse", "--verify", branchRef], repoRootDir))) { + const base = await gitCapture(["merge-base", branchRef, intRef], repoRootDir); + if (base) range = `${base.trim()}..${intRef}`; + } + const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; + const found = await gitCapture( + ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], + repoRootDir, + ); + if (found && found.trim().length > 0) return true; + } + return false; +} From 95ec4bcdb6bc6d58e192a95daa8b026d3e55c656 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:45:46 -0700 Subject: [PATCH 38/44] docs(FN-6880): reconcile changeset + note P1 fix (review #1712) Address CodeRabbit out-of-diff review comments: the feature changeset no longer claims the legacy declaration surface "remains" (U7a retired it; the sibling changeset documents the removal), and now calls out the optional-group enable id-collision fix for release-note visibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/workflow-optional-group-subgraphs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/workflow-optional-group-subgraphs.md b/.changeset/workflow-optional-group-subgraphs.md index 46a98cc066..c60dad2060 100644 --- a/.changeset/workflow-optional-group-subgraphs.md +++ b/.changeset/workflow-optional-group-subgraphs.md @@ -2,4 +2,4 @@ "@runfusion/fusion": minor --- -Workflow editor: optional steps are now graph-native. A new `optional-group` container node (foreach/loop-style) holds a subgraph the executor runs once when the group is enabled for a task (per-task `enabledWorkflowSteps` + workflow `defaultOn`) and bypasses when disabled. All seven built-in add-ons (documentation-review, qa-check, security-audit, performance-review, accessibility-check, browser-verification, frontend-ux-design) are insertable from the node-editor palette as a node or wrapped in an optional-group. The built-in coding and stepwise-coding workflows now express `browser-verification` as an optional-group. The legacy declaration-based optional-steps surface remains for back-compat; its full removal is a follow-up. +Workflow editor: optional steps are now graph-native. A new `optional-group` container node (foreach/loop-style) holds a subgraph the executor runs once when the group is enabled for a task (per-task `enabledWorkflowSteps` + workflow `defaultOn`) and bypasses when disabled. All seven built-in add-ons (documentation-review, qa-check, security-audit, performance-review, accessibility-check, browser-verification, frontend-ux-design) are insertable from the node-editor palette as a node or wrapped in an optional-group. The built-in coding and stepwise-coding workflows now express `browser-verification` as an optional-group. Optional-group enable resolution correctly handles id collisions with add-on template ids, so a group's enable state is not silently bypassed during task creation/update. (The legacy declaration-based optional-steps model is retired in a sibling changeset; only the `workflow-step` seam infrastructure removal remains a follow-up.) From 3fd7d124392ca1b5e4db24599a67dd2419ac336b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:54:23 -0700 Subject: [PATCH 39/44] fix(review): address PR #1713 review feedback Wrap the fatal-path acquisition observability writes (logEntry + audit.git) in safeObserve so a store/audit throw can't replace the original acquisition error, keeping WorkspaceRepoAcquireBusyError instanceof checks reliable upstream. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/worktree-acquisition.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index a59c4e54b4..cde08af260 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -856,11 +856,18 @@ export async function acquireWorkspaceRepoWorktree( if (!(err instanceof WorkspaceRepoAcquireBusyError)) { const message = err instanceof Error ? err.message : String(err); logger?.error?.(`${task.id}: workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`); - await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); - await audit?.git({ - type: "worktree:workspace-repo-acquire-failed", - target: repoAbsPath, - metadata: { repoRelPath, taskId: task.id, error: message }, + // FNXC:Workspace 2026-06-22-09:30: the fatal-path observability writes must use safeObserve + // for the same reason as the non-fatal catches — an unsuppressed throw from logEntry/audit + // would replace `err` as the propagated rejection, so a store/audit hiccup could surface a + // non-WorkspaceRepoAcquireBusyError to callers whose `instanceof` type checks then misfire. + // The original acquisition `err` (line below) is the contract; observability is best-effort. + await safeObserve(async () => { + await store.logEntry(task.id, `Workspace sub-repo acquisition failed for ${repoRelPath}: ${message}`, undefined, runContext); + await audit?.git({ + type: "worktree:workspace-repo-acquire-failed", + target: repoAbsPath, + metadata: { repoRelPath, taskId: task.id, error: message }, + }); }); } throw err; From 3a71237624899aa25c8c7e01c0f2cfcd3b8c4784 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 03:04:21 -0700 Subject: [PATCH 40/44] fix(review): address PR #1717 Phase C merge-loop review feedback - merger-ai: resolve+persist concrete landedSha when a sub-repo is recognized already-landed via the Fusion-Task-Id trailer fallback, so finalize no longer drops it and mis-finalizes a fully-landed workspace task as a no-op - project-engine: manual-merge land-lease busy errors reject the resolver without burning mergeRetries; clear stale busy-reenqueue counter on real partial land; persist retry count before arming the backoff timer (fail closed on write error) - cli/dashboard + task: use shared isWorkspaceTask predicate instead of inlining - base-commit-capture: POSIX single-quote shell escaping for integration ref - git-repository: validate workspace.json repos elements are strings - merger-ai: drop dead store param from landOneRepo - tests: assert the 60s backoff cap across cycles; exercise the real runAiMerge merge door; fix non-git-root assertion; re-export real workspace error classes in the merger-ai mock (fixes 24 pre-existing instanceof-undefined failures); remove generic fake-timer smoke test now covered by the live engine assertion Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fix-workspace-phase-c-review-round-2.md | 5 ++ packages/cli/src/commands/dashboard.ts | 6 +- packages/cli/src/commands/task.ts | 7 +- packages/core/src/git-repository.ts | 6 +- .../src/__tests__/executor-workspace.test.ts | 6 +- .../__tests__/merge-error-recovery.test.ts | 15 +++- .../src/__tests__/project-engine.test.ts | 39 +++++++++- .../workspace-merger-idempotency.test.ts | 21 ++---- .../src/__tests__/workspace-merger.test.ts | 22 +++++- packages/engine/src/base-commit-capture.ts | 12 ++-- packages/engine/src/merger-ai.ts | 71 ++++++++++++++++--- packages/engine/src/project-engine.ts | 48 ++++++++++++- 12 files changed, 212 insertions(+), 46 deletions(-) create mode 100644 .changeset/fix-workspace-phase-c-review-round-2.md diff --git a/.changeset/fix-workspace-phase-c-review-round-2.md b/.changeset/fix-workspace-phase-c-review-round-2.md new file mode 100644 index 0000000000..7eba89c430 --- /dev/null +++ b/.changeset/fix-workspace-phase-c-review-round-2.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Address Phase C workspace merge-loop review feedback. A sub-repo recognized as already-landed via the `Fusion-Task-Id` trailer fallback (when its `landedSha` persist was lost) now resolves and re-records a concrete `landedSha`, so finalize no longer drops it and mis-reports a fully-landed workspace task as a no-op (`mergeConfirmed:false`). A manual merge that hits sub-repo land-lease contention now surfaces the busy error to the user without consuming the persisted `mergeRetries` quota (matching the auto path's separate busy counter). The partial-land retry persists the incremented retry count before arming the backoff timer — a failed write now fails closed instead of looping without consuming budget — and clears the stale busy-contention counter when a real partial land supersedes transient busy failures. The CLI and dashboard merge doors use the shared `isWorkspaceTask` predicate instead of re-inlining the workspace check, and integration-branch shell interpolation in base-commit capture uses POSIX single-quote escaping. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 7986396445..cce2877d43 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -17,6 +17,7 @@ import { resolveGlobalDir, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS, isWorkflowColumnsEnabled, + isWorkspaceTask, resolveColumnFlags, BUILTIN_CODING_WORKFLOW_IR, mergeBuiltInZaiProviderModels, @@ -1312,8 +1313,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // Phase C (user decision). U0's R7 throw is replaced here by routing; the engine // chokepoint + store.mergeTask/aiMergeTask keep throwing as defense-in-depth. const mergeTask = await store.getTask(taskId).catch(() => null); - const isWorkspaceMerge = - !!mergeTask?.workspaceWorktrees && Object.keys(mergeTask.workspaceWorktrees).length > 0; + // FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask` + // (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check. + const isWorkspaceMerge = !!mergeTask && isWorkspaceTask(mergeTask); if (isWorkspaceMerge) { const workspaceResult = await landWorkspaceTask(store, mergeTask!, cwd, { agentStore, diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index b763676d38..3fca040855 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { runAiMerge, landWorkspaceTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -858,8 +858,9 @@ export async function runTaskMerge(id: string, projectName?: string) { // Phase C (user decision). U0's R7 throw is replaced here by routing; the // engine chokepoint + store.mergeTask/aiMergeTask keep throwing. const mergeTaskRecord = await store.getTask(id).catch(() => null); - const isWorkspaceMerge = - !!mergeTaskRecord?.workspaceWorktrees && Object.keys(mergeTaskRecord.workspaceWorktrees).length > 0; + // FNXC:Workspace 2026-06-22-09:30 (Phase C review B10): use the exported `isWorkspaceTask` + // (the engine/CLI canonical predicate) instead of re-inlining the workspaceWorktrees check. + const isWorkspaceMerge = !!mergeTaskRecord && isWorkspaceTask(mergeTaskRecord); if (isWorkspaceMerge) { const workspaceResult = await landWorkspaceTask(store, mergeTaskRecord!, projectPath, { onAgentText: (delta) => process.stdout.write(delta), diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 974c5d12a6..148179a163 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -139,11 +139,15 @@ export async function loadWorkspaceConfig(rootDir: string): Promise typeof r === "string") ) { return parsed as WorkspaceConfig; } diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index 330915e966..685fd85984 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -48,8 +48,10 @@ describeIfGit("workspace fixture", () => { it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => { fx = await createWorkspaceFixture(); - // Root is NOT a git repo. - expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow(); + // Root itself is NOT a git repo (`.` resolves to rootDir, not its parent — `..` would + // test tmpdir, which proves nothing about the invariant). git rev-parse --git-dir throws + // (exits non-zero) only when run outside any git repo. + expect(() => fx.git(".", "git rev-parse --git-dir")).toThrow(); // Each sub-repo is a real git repo with a commit on main. expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main"); expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1"); diff --git a/packages/engine/src/__tests__/merge-error-recovery.test.ts b/packages/engine/src/__tests__/merge-error-recovery.test.ts index 57465cf121..32e8fae4ac 100644 --- a/packages/engine/src/__tests__/merge-error-recovery.test.ts +++ b/packages/engine/src/__tests__/merge-error-recovery.test.ts @@ -28,9 +28,18 @@ vi.mock("../merger.js", () => ({ VerificationError: testState.VerificationError, })); -vi.mock("../merger-ai.js", () => ({ - runAiMerge: testState.runAiMerge, -})); +// FNXC:Workspace 2026-06-22-09:30 (Phase C review fix): the dispatch's error handler does +// `err instanceof WorkspaceRepoLandBusyError` / `WorkspacePartialLandError` on EVERY merge error +// (these classes are imported from ./merger-ai.js). A bare replacement mock left them undefined, +// so `instanceof undefined` threw on every recovery path (24 pre-existing red tests). Re-export the +// REAL error classes via importOriginal so the instanceof guards evaluate; only runAiMerge is faked. +vi.mock("../merger-ai.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runAiMerge: testState.runAiMerge, + }; +}); vi.mock("../runtimes/in-process-runtime.js", () => ({ InProcessRuntime: vi.fn().mockImplementation(function () { diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index d88bdd9483..e43fea6ee3 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1546,9 +1546,42 @@ describe("ProjectEngine workspace merge dispatch hardening (Phase C review)", () .some((c) => c[0] === "FN-WSH" && typeof c[1]?.mergeRetries === "number"); expect(burnedRetries).toBe(false); - // Drive several busy re-enqueues; the backoff must stay capped at 60s. - enqueueSpy.mockClear(); - await vi.advanceTimersByTimeAsync(60_000); // first backoff (5s) fires → re-enqueue + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B5b — assert the 60s CAP, not just the first retry): + Advancing 60s once only proves the first 5s timer fired; an UNcapped exponential + (5s,10s,20s,40s,80s,160s,…) would still pass that. Capture EVERY scheduled busy backoff delay + across enough cycles to pass the cap point (busyCount=4 → 5000*2^4 = 80_000ms, clamped to 60_000) + and assert no delay exceeds 60_000 AND the cap is actually reached. Each advance fires the pending + timer → re-enqueue → landWorkspaceTask rejects busy again → next backoff is scheduled. + */ + const scheduledBusyDelays: number[] = []; + // `globalThis.setTimeout` is already the fake-timer impl here (vi.useFakeTimers above). + // Wrap it to record the requested delay, then delegate to the SAME fake timer so the + // fake clock still drives the callback — no real-timer leakage. + const fakeSetTimeout = globalThis.setTimeout; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(((cb: (...a: unknown[]) => void, ms?: number, ...rest: unknown[]) => { + if (typeof ms === "number") scheduledBusyDelays.push(ms); + return (fakeSetTimeout as (...a: unknown[]) => unknown)(cb, ms, ...rest); + }) as typeof setTimeout); + + try { + // Drive enough busy cycles to climb past the cap point (busyCount 0..5 = 6 cycles). + for (let i = 0; i < 6; i++) { + await vi.advanceTimersByTimeAsync(60_000); + } + } finally { + setTimeoutSpy.mockRestore(); + } + + // The exponential climbed (more than one distinct delay) AND every delay is capped at 60s. + expect(scheduledBusyDelays.length).toBeGreaterThanOrEqual(5); + expect(Math.max(...scheduledBusyDelays)).toBe(60_000); + expect(scheduledBusyDelays.every((d) => d <= 60_000)).toBe(true); + // The cap was actually exercised: at least one delay sits at the 60s ceiling. + expect(scheduledBusyDelays).toContain(60_000); + // Each fired backoff re-enqueued the merge (the contention retry loop is live). expect(enqueueSpy).toHaveBeenCalledWith("FN-WSH"); await engine.stop(); diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts index fce5724b44..c53e10ffb3 100644 --- a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -20,7 +20,7 @@ Coverage (FN-5893 surfaces): - retry/park: a partial-land failure consumes one mergeRetry; after MAX it parks (shouldRetryWorkspacePartialLand boundary, fake timers). */ -import { afterEach, beforeEach, afterAll, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; import { writeFileSync } from "node:fs"; @@ -426,10 +426,11 @@ describeIfGit("landWorkspaceTask — DB-failure resilience (Phase C review A1/A4 }); }); -describe("workspace partial-land retry/park decision (engine seam, fake timers)", () => { - beforeEach(() => vi.useFakeTimers()); - afterAll(() => vi.useRealTimers()); - +// FNXC:Workspace 2026-06-22-09:30 (Phase C review nit): the former generic "fake-timer backoff +// schedule does not spin real retries" smoke test only proved Vitest's fake timers work — it never +// drove the production retry seam. The real backoff-cap invariant is now asserted against the live +// ProjectEngine in project-engine.test.ts ("B4/B5: busy contention re-enqueues with capped backoff"). +describe("workspace partial-land retry/park decision (engine seam)", () => { it("consumes a mergeRetry up to MAX, then parks (shouldRetryWorkspacePartialLand)", () => { // Default MAX = 3. currentRetries + 1 < MAX gates retry. expect(shouldRetryWorkspacePartialLand(0, {})).toMatchObject({ @@ -452,14 +453,4 @@ describe("workspace partial-land retry/park decision (engine seam, fake timers)" expect(shouldRetryWorkspacePartialLand(3, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(true); expect(shouldRetryWorkspacePartialLand(4, { maxAutoMergeRetries: 5 }).shouldRetry).toBe(false); }); - - it("fake-timer backoff schedule does not spin real retries", () => { - // The dispatch schedules internalEnqueueMerge via setTimeout(5000 * 2^retries). - // Assert a scheduled callback exists and only fires when advanced — no real wait. - const fired: number[] = []; - setTimeout(() => fired.push(1), 5000); - expect(fired).toHaveLength(0); - vi.advanceTimersByTime(5000); - expect(fired).toHaveLength(1); - }); }); diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index fe15703435..8973c66c70 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -30,7 +30,7 @@ import { writeFileSync } from "node:fs"; import path from "node:path"; import type { Task, TaskStore } from "@fusion/core"; import { assertNotWorkspaceTaskMerge } from "@fusion/core"; -import { landWorkspaceTask } from "../merger-ai.js"; +import { landWorkspaceTask, runAiMerge } from "../merger-ai.js"; import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; const describeIfGit = hasGit ? describe : describe.skip; @@ -292,4 +292,24 @@ describe("workspace merge defense-in-depth (non-routed doors keep throwing)", () const task = { id: TASK_ID } as unknown as Task; expect(() => assertNotWorkspaceTaskMerge(task)).not.toThrow(); }); + + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B11 — exercise the REAL merge door, not only the helper): + Calling `assertNotWorkspaceTaskMerge` directly proves the helper, but a regression where `runAiMerge` + (the sole engine merge door, R7 chokepoint) stopped invoking it would slip through. Drive the actual + door with a minimal store whose `getTask` returns the workspace task: `runAiMerge` reads the task and + calls the guard BEFORE any git work, so it rejects with WorkspaceTaskMergeError without a real repo. + */ + it("runAiMerge (engine merge door) rejects a workspace task with WorkspaceTaskMergeError", async () => { + const workspaceTask = { + id: TASK_ID, + workspaceWorktrees: { "repo-a": { worktreePath: "/x/repo-a", branch: BRANCH } }, + } as unknown as Task; + const store = { + getTask: vi.fn(async () => workspaceTask), + } as unknown as TaskStore; + await expect(runAiMerge(store, "/x", TASK_ID)).rejects.toMatchObject({ + name: "WorkspaceTaskMergeError", + }); + }); }); diff --git a/packages/engine/src/base-commit-capture.ts b/packages/engine/src/base-commit-capture.ts index 4d9e778774..aea3bfbedc 100644 --- a/packages/engine/src/base-commit-capture.ts +++ b/packages/engine/src/base-commit-capture.ts @@ -39,10 +39,14 @@ export async function resolveCapturedBaseCommitSha( integrationBranch: string = "main", ): Promise { const branch = integrationBranch.trim() || "main"; - // Shell-quote defensively; integration branch names are normalized upstream - // but may carry slashes (e.g. "release/2026-06") that are valid in refs. - const localRef = JSON.stringify(branch); - const originRef = JSON.stringify(`origin/${branch}`); + // FNXC:Workspace 2026-06-22-09:30 (Phase C review nit — proper POSIX single-quote shell escaping): + // Integration branch names are normalized upstream but may carry slashes (e.g. "release/2026-06") + // and, in principle, other ref-legal chars. JSON.stringify uses DOUBLE quotes, under which `$`, + // backticks, and `!` still undergo shell expansion. Single-quote and escape embedded single quotes + // ('\'') so the value is passed verbatim to git with no shell interpretation. + const shellQuote = (s: string): string => `'${s.replace(/'/g, "'\\''")}'`; + const localRef = shellQuote(branch); + const originRef = shellQuote(`origin/${branch}`); let baseCommitSha: string | undefined; try { const { stdout } = await execAsync( diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index a9f66a45c8..a9a407e447 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1023,8 +1023,11 @@ export type LandOneRepoResult = * repo-scoped clean room, retrying on concurrent advance. No remote push. See * the FNXC note above for the extraction contract. */ +// FNXC:Workspace 2026-06-22-09:30 (Phase C review B12): `landOneRepo` takes its store access +// exclusively through the `ctx` callbacks (log/setStatus/audit) and pre-built agents — it never +// touches a TaskStore directly. The former leading `store` param was dead and misleading at the +// call sites (they looked like they forwarded a store the function ignored), so it was dropped. export async function landOneRepo( - store: TaskStore, repoRootDir: string, branch: string, integrationBranch: string, @@ -1273,7 +1276,7 @@ export async function runAiMerge( // once; the task-global finalization below (empty no-op / no-commits demote / // finalizeMerged) is unchanged byte-for-byte — only the inline clean-room land // loop moved into `landOneRepo` so `landWorkspaceTask` can reuse it per sub-repo. - const landResult = await landOneRepo(store, projectRootDir, branch, integrationBranch, { + const landResult = await landOneRepo(projectRootDir, branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, @@ -1561,11 +1564,28 @@ export async function landWorkspaceTask( // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP // it so a retry never re-advances the ref. This makes a re-run after a partial // land idempotent for the already-landed repos. - if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch)) { - await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`); + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review A1 — concrete landedSha on the skip path): + Resolve a CONCRETE landed sha (recorded landedSha OR the trailer-fallback squash sha) rather + than trusting `entry.landedSha`, which is `undefined` when the land's persist was lost and only + the A1 trailer fallback recognises the repo. If we recovered the sha via the fallback, REPAIR + the persisted entry so a later run (and `finalizeWorkspaceTask`) sees a present landedSha. A + repair-persist failure is non-fatal: we still carry the concrete sha in-memory for this run's + finalize, and the trailer fallback will re-recover it next time. + */ + const recoveredLandedSha = await resolveLandedShaIfLanded( + repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch, + ); + if (recoveredLandedSha) { + if (!entry.landedSha) { + await persistRepoLandedSha(store, taskId, repoRel, recoveredLandedSha).catch(async (persistErr: unknown) => { + await log(`AI merge (workspace): sub-repo ${repoRel} re-recorded landedSha (${short(recoveredLandedSha)}) persist failed (non-fatal, trailer fallback will re-recover): ${getErrorMessage(persistErr)}`); + }); + } + await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(recoveredLandedSha)} ⊑ ${integrationBranch}) — skipping`); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, - status: "landed", landedSha: entry.landedSha, alreadyLanded: true, + status: "landed", landedSha: recoveredLandedSha, alreadyLanded: true, }); continue; } @@ -1601,7 +1621,7 @@ export async function landWorkspaceTask( }); try { - const landResult = await landOneRepo(store, repoRootDir, entry.branch, integrationBranch, { + const landResult = await landOneRepo(repoRootDir, entry.branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, mergeAgent, reviewAgent, stashResolveAgent, includeTaskId, trailers, taskTitle, signal: options.signal, @@ -1723,9 +1743,36 @@ export async function isRepoLanded( taskId?: string, branch?: string, ): Promise { + return ( + (await resolveLandedShaIfLanded(repoRootDir, integrationBranch, landedSha, taskId, branch)) !== + undefined + ); +} + +/** + * FNXC:Workspace 2026-06-22-09:30 (Phase C review A1 — concrete landedSha on trailer fallback): + * The shared core of {@link isRepoLanded}: returns a CONCRETE landed sha when the sub-repo is + * already landed, else `undefined`. When the recorded `landedSha` survives it is returned as-is; + * when the A1 trailer fallback matches (the persist was lost so no `landedSha` is recorded) the + * concrete squash sha is read off the integration ref via the same bounded trailer scan. + * + * Why this matters (review A1 / finalize misfinalise): the `landWorkspaceTask` skip path and + * `finalizeWorkspaceTask` both key off a present `landedSha`. A trailer-fallback match with a + * `undefined` recorded sha would be dropped by the finalize filter, finalizing an already-landed + * task as a no-op (`mergeConfirmed:false`, empty `workspaceLandedShas`) — the exact dashboard + * `merged:false` contradiction Phase C set out to eliminate. Resolving the concrete sha here lets + * the skip path persist+propagate it so the repo is correctly counted as landed. + */ +async function resolveLandedShaIfLanded( + repoRootDir: string, + integrationBranch: string, + landedSha: string | undefined, + taskId?: string, + branch?: string, +): Promise { const intRef = `refs/heads/${integrationBranch}`; if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { - return false; + return undefined; } // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. @@ -1733,12 +1780,13 @@ export async function isRepoLanded( landedSha && (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) ) { - return true; + return landedSha; } // A1 fallback: even without a recorded landedSha, the repo is already landed if the // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash // we lost the persist for). Bound the scan to commits gained since the branch's land base - // so a stale historical trailer of the same id cannot false-positive. + // so a stale historical trailer of the same id cannot false-positive. Return the MOST RECENT + // matching commit sha (the squash) so callers can persist a concrete landedSha. if (taskId) { const branchRef = branch ? `refs/heads/${branch}` : undefined; let range = intRef; @@ -1751,9 +1799,10 @@ export async function isRepoLanded( ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], repoRootDir, ); - if (found && found.trim().length > 0) return true; + const firstSha = found?.split(/\r?\n/).map((s) => s.trim()).find((s) => s.length > 0); + if (firstSha) return firstSha; } - return false; + return undefined; } /** diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 5152a36fc1..2a5c072950 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -2495,6 +2495,23 @@ export class ProjectEngine { retries on busy-errors before either makes a real land attempt, then parking a never-failed task. Detect via `instanceof` now that both are exported classes (B7). */ + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B7b — manual-merge busy must NOT burn mergeRetries): + A manual merge (hasManualResolver) that hits sub-repo land contention is the SAME transient + lease contention as the auto path, NOT a real land failure. Without this branch it falls + through to the generic handler below, which increments the persisted `mergeRetries` quota — + so a user mashing the merge button during contention could exhaust retries before any real + land attempt. Reject the resolver so the busy error surfaces to the user (they can retry), + WITHOUT consuming a mergeRetry. No re-enqueue: manual merges are user-driven, not engine-timed. + */ + if (err instanceof WorkspaceRepoLandBusyError && hasManualResolver) { + await store + .logEntry(taskId, `Workspace sub-repo land busy (contention): ${errorMsg}`, "WorkspaceRepoLandBusy") + .catch(() => undefined); + this.rejectMergeResolvers(taskId, err instanceof Error ? err : new Error(errorMsg)); + continue; + } + if (err instanceof WorkspaceRepoLandBusyError && !hasManualResolver) { const busyCount = this.workspaceBusyReenqueues.get(taskId) ?? 0; await store @@ -2537,6 +2554,15 @@ export class ProjectEngine { // (B6). Detect via `instanceof` (B7). Manual merges fall through to // rejectMergeResolvers at the hasManualResolver early-return below. if (err instanceof WorkspacePartialLandError && !hasManualResolver) { + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B8 — clear stale busy quota on real outcome): + Reaching a REAL partial land means the prior transient busy contention is over. The + `workspaceBusyReenqueues` counter is otherwise only cleared on success or busy-cap + exhaustion, so a few transient busy failures followed by a real partial land would leave + a stale count — later UNRELATED contention would then resume from it and park the task + early. Clear it here so each fresh contention episode gets the full busy budget. + */ + this.workspaceBusyReenqueues.delete(taskId); const wsSettings = await store.getSettings().catch(() => null); const wsTask = await store.getTask(taskId).catch(() => null); /* @@ -2574,7 +2600,27 @@ export class ProjectEngine { .logEntry(taskId, `Workspace partial land: ${errorMsg}`, "WorkspacePartialLand") .catch(() => undefined); if (decision.shouldRetry) { - await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }).catch(() => undefined); + /* + FNXC:Workspace 2026-06-22-09:30 (Phase C review B9 — persist retry count BEFORE arming the timer): + The retry-count write must succeed before we schedule the retry. A swallowed + `.catch(() => undefined)` here armed the timer even when the `mergeRetries` increment + never landed — so the next attempt re-read the OLD `mergeRetries` and could loop without + consuming budget, defeating the fail-closed DB-outage guard above. FAIL CLOSED: if the + write throws, park as failed (best-effort) and do NOT schedule a retry storm against a + non-responsive DB; the cooldown sweep re-evaluates once the DB recovers. + */ + try { + await store.updateTask(taskId, { mergeRetries: decision.nextRetryCount, status: null }); + } catch (persistErr: unknown) { + const pmsg = persistErr instanceof Error ? persistErr.message : String(persistErr); + runtimeLog.error( + `Auto-merge: ${taskId} workspace partial land retry NOT scheduled — mergeRetries could not be persisted (DB outage?), failing closed instead of a retry storm: ${pmsg}`, + ); + await store + .updateTask(taskId, { status: "failed", error: errorMsg }) + .catch(() => undefined); + continue; + } // Capped exponential backoff (B5): cap at 60s so a tuned maxAutoMergeRetries doesn't // push the delay toward ~85 minutes at the ceiling. const delayMs = Math.min(5000 * Math.pow(2, wsRetries), 60_000); From 7b6053900608304160d591ebc9d7299fe901e2cd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 13:01:06 -0700 Subject: [PATCH 41/44] FN-6953: honor unsaved ntfy config in tests Let ntfy test notifications use the current Settings form values before they are saved. - Send unsaved ntfy enabled/topic/server/token values from all Settings ntfy test buttons. - Resolve request-scoped ntfy config on the backend for general, message, and room test notifications. - Publish message and room ntfy tests directly with the resolved config instead of the active persisted notification service. - Cover unsaved ntfy test behavior across Settings UI and API routes. Files changed: .changeset/fn-6953-ntfy-test-unsaved-config.md | 5 + .../dashboard/app/components/SettingsModal.css | 5 +- .../dashboard/app/components/SettingsModal.tsx | 21 +- .../components/__tests__/SettingsModal.test.tsx | 60 +++- .../src/__tests__/routes-settings.test.ts | 181 ++++++++--- .../src/routes/register-settings-memory-routes.ts | 355 +++++++++------------ 6 files changed, 380 insertions(+), 247 deletions(-) Fusion-Task-Id: FN-6953 Fusion-Task-Lineage: 90557168-3b11-4ab4-b5c1-4261225600ba --- .../fn-6953-ntfy-test-unsaved-config.md | 5 + .../app/components/SettingsModal.css | 5 +- .../app/components/SettingsModal.tsx | 21 +- .../__tests__/SettingsModal.test.tsx | 60 ++- .../src/__tests__/routes-settings.test.ts | 181 +++++++-- .../routes/register-settings-memory-routes.ts | 357 ++++++++---------- 6 files changed, 381 insertions(+), 248 deletions(-) create mode 100644 .changeset/fn-6953-ntfy-test-unsaved-config.md diff --git a/.changeset/fn-6953-ntfy-test-unsaved-config.md b/.changeset/fn-6953-ntfy-test-unsaved-config.md new file mode 100644 index 0000000000..6371518d61 --- /dev/null +++ b/.changeset/fn-6953-ntfy-test-unsaved-config.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix ntfy test notifications to honor unsaved Settings form config so users can enable ntfy, enter a valid topic/server/token, and send a test notification before saving. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index b5d7af8c75..8ab9bd73e1 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -696,13 +696,16 @@ The embedded title reads like other embedded-view titles (Planning modal-header- } } +/* +FNXC:SettingsMobile 2026-06-23-09:02: +Settings section headings should preserve hierarchy through spacing and type only. Avoid per-heading divider borders so mobile and desktop shared Settings sections keep the lighter scrollbar-focused chrome contract. +*/ .settings-section-heading { font-size: 14px; font-weight: 600; padding: var(--space-lg) 0 var(--space-md); margin: 0 0 var(--space-md); color: var(--text); - border-bottom: 1px solid var(--border); } /* First heading inside the section drops top padding to remove a redundant diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index ee7f96253a..28d6d01ed3 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1851,17 +1851,22 @@ export function SettingsModal({ return next; }); try { + /* + FNXC:Notifications 2026-06-23-08:49: + Settings notification tests must send the current unsaved ntfy form values for every ntfy test affordance. Users validate the exact topic/server/token they just typed before saving, so message/room test requests carry the same request-scoped config as the general ntfy test. + */ + const currentNtfyConfig = { + ntfyEnabled: form.ntfyEnabled, + ntfyTopic: form.ntfyTopic, + ...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}), + ...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}), + }; const config = providerId === "ntfy" - ? { - ntfyEnabled: form.ntfyEnabled, - ntfyTopic: form.ntfyTopic, - ...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}), - ...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}), - } + ? currentNtfyConfig : providerId === "ntfy-message" - ? { messageEventType: "message:agent-to-user" } + ? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" } : providerId === "ntfy-room" - ? { messageEventType: "message:room" } + ? { ...currentNtfyConfig, messageEventType: "message:room" } : { webhookUrl: form.webhookUrl, webhookFormat: form.webhookFormat || "generic", diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 6b9602f3c2..2313590734 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -4895,6 +4895,54 @@ describe("SettingsModal", () => { }); }); + it("sends unsaved ntfy form config before saving", async () => { + mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined }); + renderModal(); + await waitForSettingsModalReady(); + await openNotificationsSection(); + + await user.click(screen.getByLabelText("Enable")); + await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic"); + await user.click(screen.getByText("Advanced")); + await user.type(screen.getByLabelText("Custom ntfy server URL (optional)"), "https://ntfy.override.example//"); + await user.type(screen.getByLabelText("Access token (optional)"), "override-token"); + await user.click(screen.getByRole("button", { name: /Test notification/ })); + + await waitFor(() => { + expect(mockTestNotification).toHaveBeenCalledWith( + "ntfy", + expect.objectContaining({ + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: "https://ntfy.override.example//", + ntfyAccessToken: "override-token", + }), + undefined, + ); + }); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + expect(mockUpdateGlobalSettings).not.toHaveBeenCalled(); + }); + + it("keeps ntfy test disabled until the current form has a valid topic", async () => { + mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined }); + renderModal(); + await waitForSettingsModalReady(); + await openNotificationsSection(); + + await user.click(screen.getByLabelText("Enable")); + const testButton = screen.getByRole("button", { name: /Test notification/ }); + expect(testButton).toBeDisabled(); + + await user.type(screen.getByLabelText("ntfy Topic"), "bad topic!"); + expect(testButton).toBeDisabled(); + expect(mockTestNotification).not.toHaveBeenCalled(); + + await user.clear(screen.getByLabelText("ntfy Topic")); + await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic"); + expect(testButton).toBeEnabled(); + }); + it("clears a saved ntfy access token via global null-as-delete semantics", async () => { mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, @@ -4929,7 +4977,11 @@ describe("SettingsModal", () => { await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( "ntfy", - { messageEventType: "message:agent-to-user" }, + expect.objectContaining({ + messageEventType: "message:agent-to-user", + ntfyEnabled: true, + ntfyTopic: "test-topic", + }), undefined, ); }); @@ -4953,7 +5005,11 @@ describe("SettingsModal", () => { await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( "ntfy", - { messageEventType: "message:room" }, + expect.objectContaining({ + messageEventType: "message:room", + ntfyEnabled: true, + ntfyTopic: "test-topic", + }), undefined, ); }); diff --git a/packages/dashboard/src/__tests__/routes-settings.test.ts b/packages/dashboard/src/__tests__/routes-settings.test.ts index 6bff37c484..dc07d77c17 100644 --- a/packages/dashboard/src/__tests__/routes-settings.test.ts +++ b/packages/dashboard/src/__tests__/routes-settings.test.ts @@ -1772,6 +1772,37 @@ describe("POST /settings/test-ntfy", () => { expect(url).toBe("https://ntfy.override.example/my-topic"); }); + it("uses unsaved request ntfy config when saved settings are disabled", async () => { + (store.getSettings as ReturnType).mockResolvedValue({ + ntfyEnabled: false, + ntfyTopic: undefined, + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/settings/test-ntfy", + JSON.stringify({ + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: "https://ntfy.override.example//", + ntfyAccessToken: "override-token", + }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(store.updateSettings).not.toHaveBeenCalled(); + expect(store.updateGlobalSettings).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const url = fetchSpy.mock.calls[0]?.[0] as string; + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(url).toBe("https://ntfy.override.example/fresh-topic"); + expect(options.headers).toHaveProperty("Authorization", "Bearer override-token"); + }); + it("falls back to saved ntfyBaseUrl when request override is blank", async () => { (store.getSettings as ReturnType).mockResolvedValue({ ntfyEnabled: true, @@ -1973,69 +2004,82 @@ describe("POST /settings/test-notification", () => { ); }); - it("ntfy provider dispatches a message-event pipeline test when messageEventType is provided", async () => { - const dispatchSpy = vi.fn().mockResolvedValue(undefined); - mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy }); + it("ntfy provider sends a message-event test with unsaved config when messageEventType is provided", async () => { (store.getSettings as ReturnType).mockResolvedValue({ - ntfyEnabled: true, - ntfyTopic: "test-topic", + ntfyEnabled: false, + ntfyTopic: "saved-topic", + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", }); const res = await REQUEST( buildApp(), "POST", "/api/settings/test-notification", - JSON.stringify({ providerId: "ntfy", messageEventType: "message:agent-to-user" }), + JSON.stringify({ + providerId: "ntfy", + config: { + messageEventType: "message:agent-to-user", + ntfyEnabled: true, + ntfyTopic: "fresh-message-topic", + ntfyBaseUrl: "https://ntfy.message.example//", + ntfyAccessToken: "message-token", + }, + }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(dispatchSpy).toHaveBeenCalledWith( - "message:agent-to-user", - expect.objectContaining({ - event: "message:agent-to-user", - metadata: expect.objectContaining({ - fromId: "system", - toId: "user", - preview: "Fusion test message notification", - }), - }), - ); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockGetActiveNotificationService).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.message.example/fresh-message-topic"); + expect(options.headers).toMatchObject({ + Title: "New message from Fusion", + Priority: "high", + Authorization: "Bearer message-token", + }); + expect(options.body).toBe("Fusion → you: Fusion test message notification"); }); - it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => { - const dispatchSpy = vi.fn().mockResolvedValue(undefined); - mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy }); + it("ntfy provider sends a room message-event test with unsaved config when messageEventType is message:room", async () => { (store.getSettings as ReturnType).mockResolvedValue({ - ntfyEnabled: true, - ntfyTopic: "test-topic", + ntfyEnabled: false, + ntfyTopic: "saved-topic", + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", }); const res = await REQUEST( buildApp(), "POST", "/api/settings/test-notification", - JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }), + JSON.stringify({ + providerId: "ntfy", + config: { + messageEventType: "message:room", + ntfyEnabled: true, + ntfyTopic: "fresh-room-topic", + ntfyBaseUrl: "https://ntfy.room.example//", + ntfyAccessToken: "room-token", + }, + }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(dispatchSpy).toHaveBeenCalledWith( - "message:room", - expect.objectContaining({ - event: "message:room", - metadata: expect.objectContaining({ - roomId: "test-room", - roomName: "Test Room", - senderName: "Fusion", - preview: "Fusion test room notification", - }), - }), - ); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockGetActiveNotificationService).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.room.example/fresh-room-topic"); + expect(options.headers).toMatchObject({ + Title: "#Test Room — Fusion", + Priority: "default", + Authorization: "Bearer room-token", + }); + expect(options.body).toBe("Fusion in #Test Room: Fusion test room notification"); }); it("ntfy provider uses config override for baseUrl", async () => { @@ -2057,6 +2101,69 @@ describe("POST /settings/test-notification", () => { expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic"); }); + it("ntfy provider sends with unsaved config when saved settings are disabled", async () => { + (store.getSettings as ReturnType).mockResolvedValue({ + ntfyEnabled: false, + ntfyTopic: undefined, + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/settings/test-notification", + JSON.stringify({ + providerId: "ntfy", + config: { + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: "https://ntfy.override.example//", + ntfyAccessToken: "override-token", + }, + }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(store.updateSettings).not.toHaveBeenCalled(); + expect(store.updateGlobalSettings).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/fresh-topic"); + expect(options.headers).toHaveProperty("Authorization", "Bearer override-token"); + }); + + it("ntfy provider ignores blank request baseUrl and token overrides", async () => { + (store.getSettings as ReturnType).mockResolvedValue({ + ntfyEnabled: true, + ntfyTopic: "saved-topic", + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/settings/test-notification", + JSON.stringify({ + providerId: "ntfy", + config: { + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: " ", + ntfyAccessToken: " ", + }, + }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.saved.example/fresh-topic"); + expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token"); + }); + it("ntfy provider sends Authorization header from saved or override token", async () => { (store.getSettings as ReturnType).mockResolvedValue({ ntfyEnabled: true, diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 59850ccca4..e8205ab684 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -47,7 +47,6 @@ import { import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, - getActiveNotificationService, probeWorktrunk, resolveWorktrunkBinary, } from "@fusion/engine"; @@ -2044,84 +2043,160 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin * Returns the user's global pi extension settings from ~/.pi/agent/settings.json. * Includes packages, extension paths, skill paths, prompt template paths, and theme paths. */ - router.post("/settings/test-ntfy", async (req, res) => { - const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => { - const trimmed = value.trim(); - if (!trimmed) { - throw badRequest("ntfy server URL cannot be empty"); - } + const normalizeHttpUrl = (value: string, fieldName: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw badRequest(`${fieldName} cannot be empty`); + } - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - throw badRequest(`ntfy server URL from ${source} must be a valid URL`); - } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw badRequest(`${fieldName} must be a valid URL`); + } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw badRequest("ntfy server URL must use http:// or https://"); - } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw badRequest(`${fieldName} must use http:// or https://`); + } - return trimmed.replace(/\/+$/, ""); + return trimmed; + }; + + const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => { + const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`); + return normalized.replace(/\/+$/, ""); + }; + + const getOwnValue = (source: Record, key: string): unknown => ( + Object.prototype.hasOwnProperty.call(source, key) ? source[key] : undefined + ); + + const getRequestNtfyValue = (body: Record, config: Record, key: string): unknown => { + const configValue = getOwnValue(config, key); + return configValue !== undefined ? configValue : getOwnValue(body, key); + }; + + type NtfyTestMessageEventType = "message:agent-to-user" | "message:agent-to-agent" | "message:room"; + + function resolveEffectiveNtfyTestConfig( + settings: Record, + body: Record, + config: Record = {}, + ): { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string } { + /* + FNXC:Notifications 2026-06-23-08:34: + Test sends must honor unsaved Settings form state because users enable ntfy, enter a topic/server/token, and test before saving. Resolve request-scoped values ahead of persisted settings without persisting or logging tokens. + + FNXC:Notifications 2026-06-23-10:21: + Every ntfy test affordance, including message and room tests, must publish with the request-scoped topic/server/token instead of the active notification service's persisted provider state. + */ + const enabledOverride = getRequestNtfyValue(body, config, "ntfyEnabled"); + if (enabledOverride !== undefined && enabledOverride !== null && typeof enabledOverride !== "boolean") { + throw badRequest("ntfy enabled must be a boolean"); + } + const ntfyEnabled = typeof enabledOverride === "boolean" ? enabledOverride : settings.ntfyEnabled === true; + if (!ntfyEnabled) { + throw badRequest("ntfy notifications are not enabled"); + } + + const topicOverride = getRequestNtfyValue(body, config, "ntfyTopic"); + if (topicOverride !== undefined && topicOverride !== null && typeof topicOverride !== "string") { + throw badRequest("ntfy topic must be a string"); + } + const topic = typeof topicOverride === "string" ? topicOverride : settings.ntfyTopic; + if (typeof topic !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) { + throw badRequest("ntfy topic is not configured or invalid"); + } + + const baseUrlOverride = getRequestNtfyValue(body, config, "ntfyBaseUrl"); + if (baseUrlOverride !== undefined && baseUrlOverride !== null && typeof baseUrlOverride !== "string") { + throw badRequest("ntfy server URL must be a string"); + } + const requestBaseUrl = typeof baseUrlOverride === "string" && baseUrlOverride.trim() + ? normalizeNtfyBaseUrl(baseUrlOverride, "request") + : undefined; + const storedBaseUrl = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim() + ? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings") + : undefined; + + const tokenOverride = getRequestNtfyValue(body, config, "ntfyAccessToken"); + if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") { + throw badRequest("ntfy access token must be a string"); + } + const requestToken = typeof tokenOverride === "string" && tokenOverride.trim() + ? tokenOverride.trim() + : undefined; + const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim() + ? settings.ntfyAccessToken.trim() + : undefined; + + return { + topic, + ntfyBaseUrl: requestBaseUrl ?? storedBaseUrl ?? "https://ntfy.sh", + ntfyAccessToken: requestToken ?? storedToken, }; + } + + async function sendNtfyTestNotification( + options: { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string; messageEventType?: NtfyTestMessageEventType }, + ): Promise { + const contentByEvent: Record = { + default: { + title: "Fusion test notification", + message: "Fusion test notification — your notifications are working!", + priority: "default", + }, + "message:agent-to-user": { + title: "New message from Fusion", + message: "Fusion → you: Fusion test message notification", + priority: "high", + }, + "message:agent-to-agent": { + title: "Fusion → recipient", + message: "Fusion messaged recipient: Fusion test message notification", + priority: "default", + }, + "message:room": { + title: "#Test Room — Fusion", + message: "Fusion in #Test Room: Fusion test room notification", + priority: "default", + }, + }; + const content = contentByEvent[options.messageEventType ?? "default"]; + const headers: Record = { + Title: content.title, + Priority: content.priority, + "Content-Type": "text/plain", + }; + if (options.ntfyAccessToken) { + headers.Authorization = `Bearer ${options.ntfyAccessToken}`; + } + + const response = await fetch(`${options.ntfyBaseUrl}/${options.topic}`, { + method: "POST", + headers, + body: content.message, + }); + + if (!response.ok) { + throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`); + } + } + + router.post("/settings/test-ntfy", async (req, res) => { try { + const body = (req.body ?? {}) as Record; + const configValue = body.config; + if (configValue !== undefined && (typeof configValue !== "object" || configValue === null || Array.isArray(configValue))) { + throw badRequest("config must be an object when provided"); + } + const config = (configValue ?? {}) as Record; const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettings(); - - // Validate ntfy is enabled - if (!settings.ntfyEnabled) { - throw badRequest("ntfy notifications are not enabled"); - } - - // Validate topic exists and matches required format - const topic = settings.ntfyTopic; - if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) { - throw badRequest("ntfy topic is not configured or invalid"); - } - - const overrideValue = req.body?.ntfyBaseUrl; - if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") { - throw badRequest("ntfy server URL must be a string"); - } - - const requestOverride = typeof overrideValue === "string" && overrideValue.trim() - ? normalizeNtfyBaseUrl(overrideValue, "request") - : undefined; - const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim() - ? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings") - : undefined; - const tokenOverride = req.body?.ntfyAccessToken; - if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") { - throw badRequest("ntfy access token must be a string"); - } - const requestToken = typeof tokenOverride === "string" && tokenOverride.trim() - ? tokenOverride.trim() - : undefined; - const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim() - ? settings.ntfyAccessToken.trim() - : undefined; - const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh"; - const url = `${ntfyBaseUrl}/${topic}`; - const headers: Record = { - "Title": "Fusion test notification", - "Priority": "default", - "Content-Type": "text/plain", - }; - const ntfyAccessToken = requestToken ?? storedToken; - if (ntfyAccessToken) { - headers.Authorization = `Bearer ${ntfyAccessToken}`; - } - - const response = await fetch(url, { - method: "POST", - headers, - body: "Fusion test notification — your notifications are working!", - }); - - if (!response.ok) { - throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`); - } + const configForTest = resolveEffectiveNtfyTestConfig(settings as Record, body, config); + await sendNtfyTestNotification(configForTest); res.json({ success: true }); } catch (err: unknown) { @@ -2133,31 +2208,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin }); router.post("/settings/test-notification", async (req, res) => { - const normalizeHttpUrl = (value: string, fieldName: string): string => { - const trimmed = value.trim(); - if (!trimmed) { - throw badRequest(`${fieldName} cannot be empty`); - } - - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - throw badRequest(`${fieldName} must be a valid URL`); - } - - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw badRequest(`${fieldName} must use http:// or https://`); - } - - return trimmed; - }; - - const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => { - const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`); - return normalized.replace(/\/+$/, ""); - }; - try { const body = (req.body ?? {}) as Record; const providerId = body.providerId; @@ -2176,113 +2226,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin if (providerId === "ntfy") { const requestedMessageEventType = config.messageEventType ?? body.messageEventType; - if (requestedMessageEventType !== undefined) { - if ( - requestedMessageEventType !== "message:agent-to-user" - && requestedMessageEventType !== "message:agent-to-agent" - && requestedMessageEventType !== "message:room" - ) { - throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room"); - } - - const notificationService = getActiveNotificationService(); - if (!notificationService) { - throw new ApiError(502, "Notification service is not active"); - } - - try { - const messageId = `test-${crypto.randomUUID()}`; - if (requestedMessageEventType === "message:room") { - await notificationService.dispatch(requestedMessageEventType, { - taskId: undefined, - taskTitle: undefined, - event: requestedMessageEventType, - metadata: { - messageId, - roomId: "test-room", - roomName: "Test Room", - senderAgentId: "system", - senderName: "Fusion", - preview: "Fusion test room notification", - type: "room-assistant", - }, - }); - } else { - const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user"; - await notificationService.dispatch(requestedMessageEventType, { - taskId: undefined, - taskTitle: undefined, - event: requestedMessageEventType, - metadata: { - messageId, - fromId: "system", - fromType: "agent", - toId: "user", - toType: "user", - type: messageType, - preview: "Fusion test message notification", - }, - }); - } - res.json({ success: true }); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new ApiError(502, `Failed to dispatch message notification: ${message}`); - } + if ( + requestedMessageEventType !== undefined + && requestedMessageEventType !== "message:agent-to-user" + && requestedMessageEventType !== "message:agent-to-agent" + && requestedMessageEventType !== "message:room" + ) { + throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room"); } - if (!settings.ntfyEnabled) { - throw badRequest("ntfy notifications are not enabled"); - } - - const topic = settings.ntfyTopic; - if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) { - throw badRequest("ntfy topic is not configured or invalid"); - } - - const overrideValue = config.ntfyBaseUrl ?? body.ntfyBaseUrl; - if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") { - throw badRequest("ntfy server URL must be a string"); - } - - const requestOverride = typeof overrideValue === "string" && overrideValue.trim() - ? normalizeNtfyBaseUrl(overrideValue, "request") - : undefined; - const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim() - ? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings") - : undefined; - const tokenOverride = config.ntfyAccessToken ?? body.ntfyAccessToken; - if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") { - throw badRequest("ntfy access token must be a string"); - } - const requestToken = typeof tokenOverride === "string" && tokenOverride.trim() - ? tokenOverride.trim() - : undefined; - const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim() - ? settings.ntfyAccessToken.trim() - : undefined; - const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh"; - const url = `${ntfyBaseUrl}/${topic}`; - const headers: Record = { - "Title": "Fusion test notification", - "Priority": "default", - "Content-Type": "text/plain", - }; - const ntfyAccessToken = requestToken ?? storedToken; - if (ntfyAccessToken) { - headers.Authorization = `Bearer ${ntfyAccessToken}`; - } - - const response = await fetch(url, { - method: "POST", - headers, - body: "Fusion test notification — your notifications are working!", + const configForTest = resolveEffectiveNtfyTestConfig(settings as Record, body, config); + await sendNtfyTestNotification({ + ...configForTest, + messageEventType: requestedMessageEventType as NtfyTestMessageEventType | undefined, }); - if (!response.ok) { - throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`); - } - res.json({ success: true }); return; } From e9a6955b497e868626b6deb9d95a49fd841e33f1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 14:01:19 -0700 Subject: [PATCH 42/44] FN-6939: add narrow dock preview modal Adds an accessible preview modal path for constrained Dev Server right-dock layouts. - Detect narrow direct right-dock hosts while preserving inline previews for full-page, mobile, and expanded modal hosts. - Replace the crowded inline preview with a compact Open preview launcher and accessible modal controls in narrow docks. - Add focused preview/mobile coverage, documentation, styling, and a published package changeset. Files changed: .../fn-6939-dev-server-narrow-preview-modal.md | 5 + docs/dashboard-guide.md | 3 + .../dashboard/app/components/DevServerView.css | 100 ++++- .../dashboard/app/components/DevServerView.tsx | 449 +++++++++++++++------ .../__tests__/DevServerView.mobile.test.tsx | 17 +- .../__tests__/DevServerView.preview.test.tsx | 148 +++++++ 6 files changed, 603 insertions(+), 119 deletions(-) Fusion-Task-Id: FN-6939 Fusion-Task-Lineage: 745ea56d-16bf-4246-bfe7-0461754466d9 --- ...fn-6939-dev-server-narrow-preview-modal.md | 5 + docs/dashboard-guide.md | 3 + .../app/components/DevServerView.css | 100 +++- .../app/components/DevServerView.tsx | 447 +++++++++++++----- .../__tests__/DevServerView.mobile.test.tsx | 17 +- .../__tests__/DevServerView.preview.test.tsx | 148 ++++++ 6 files changed, 602 insertions(+), 118 deletions(-) create mode 100644 .changeset/fn-6939-dev-server-narrow-preview-modal.md diff --git a/.changeset/fn-6939-dev-server-narrow-preview-modal.md b/.changeset/fn-6939-dev-server-narrow-preview-modal.md new file mode 100644 index 0000000000..2133d343fb --- /dev/null +++ b/.changeset/fn-6939-dev-server-narrow-preview-modal.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix narrow right-sidebar Dev Server preview overlap by replacing the inline preview with an accessible modal launcher when the dock is very narrow, while keeping inline preview for full-page, mobile viewport, and expanded pop-out hosts. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index de1e9379d9..0ce4b15586 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -866,6 +866,9 @@ Features: - Start, stop, and restart the current server session - Manage preview URLs with embedded preview and **Open in new tab** fallback - Tail live logs, load older history, and refresh session status +- When Dev Server is hosted in a very narrow right sidebar, open the preview from the compact **Open preview** launcher; the modal keeps preview actions available while configuration and logs stay usable in the sidebar. + + For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md). diff --git a/packages/dashboard/app/components/DevServerView.css b/packages/dashboard/app/components/DevServerView.css index 5bb722fc38..437f83087e 100644 --- a/packages/dashboard/app/components/DevServerView.css +++ b/packages/dashboard/app/components/DevServerView.css @@ -582,6 +582,65 @@ exactly when the surrounding chrome is gone. margin: 0; } +.devserver-preview-modal-launcher { + align-items: stretch; +} + +.devserver-preview-modal-launcher__copy { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; +} + +.devserver-preview-modal-launcher__copy .devserver-preview-url-badge { + max-width: none; +} + +.devserver-preview-modal-launcher__description { + margin: 0; + color: var(--text-muted); + line-height: 1.5; +} + +.devserver-preview-modal-overlay { + align-items: center; + padding: var(--space-xl); +} + +.devserver-preview-modal { + width: min(calc(var(--space-2xl) * 28), calc(100vw - var(--space-xl) * 2)); + max-height: calc(100vh - var(--space-xl) * 2); +} + +.devserver-preview-modal__titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + border-bottom: 1px solid var(--border); +} + +.devserver-preview-modal__titlebar h2 { + margin: 0; + font-size: 1rem; +} + +.devserver-preview-modal__body { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.devserver-preview-modal__body .devserver-preview-container { + flex: 1; + min-height: min(60vh, calc(var(--space-2xl) * 14)); + max-height: none; +} + /* Legacy selector compatibility for static CSS tests */ .dev-server-preview-fallback { border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent); @@ -655,7 +714,8 @@ exactly when the surrounding chrome is gone. max-width: none; } - .devserver-preview-header { + .devserver-preview-header, + .devserver-preview-modal-launcher__copy { flex-wrap: wrap; } @@ -703,6 +763,20 @@ exactly when the surrounding chrome is gone. max-height: calc(var(--space-2xl) * 3); } + .devserver-preview-modal-overlay { + align-items: stretch; + padding: var(--space-md); + } + + .devserver-preview-modal { + width: 100%; + max-height: calc(100vh - var(--space-md) * 2); + } + + .devserver-preview-modal__body .devserver-preview-container { + min-height: calc(var(--space-2xl) * 7); + } + .dev-server-config { max-height: min(48vh, calc(var(--space-2xl) * 13)); } @@ -852,7 +926,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu flex-direction: column; } - .devserver-preview-panel { + .devserver-preview-panel, + .devserver-preview-modal-launcher { grid-column: auto; grid-row: auto; } @@ -862,10 +937,25 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu max-width: none; } - .devserver-preview-header { + .devserver-preview-header, + .devserver-preview-modal-launcher__copy { flex-wrap: wrap; } + .devserver-preview-modal-overlay { + align-items: stretch; + padding: var(--space-md); + } + + .devserver-preview-modal { + width: min(calc(var(--space-2xl) * 20), calc(100vw - var(--space-md) * 2)); + max-height: calc(100vh - var(--space-md) * 2); + } + + .devserver-preview-modal__body .devserver-preview-container { + min-height: calc(var(--space-2xl) * 7); + } + .devserver-preview-url-badge { order: 2; flex: 1 1 100%; @@ -915,8 +1005,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu } .dev-server-logs, - .devserver-preview-container, - .devserver-preview-iframe { + .devserver-preview-panel .devserver-preview-container, + .devserver-preview-panel .devserver-preview-iframe { min-height: calc(var(--space-2xl) * 4 + var(--space-md)); max-height: none; } diff --git a/packages/dashboard/app/components/DevServerView.tsx b/packages/dashboard/app/components/DevServerView.tsx index e88bf63bf8..b3b88febad 100644 --- a/packages/dashboard/app/components/DevServerView.tsx +++ b/packages/dashboard/app/components/DevServerView.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react"; +import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square, X } from "lucide-react"; import type { Task, TaskDetail } from "@fusion/core"; import "./DevServerView.css"; import type { DetectedDevServerCommand } from "../api"; import { useDevServer } from "../hooks/useDevServer"; import { useDevServerLogs } from "../hooks/useDevServerLogs"; import { usePreviewEmbed } from "../hooks/usePreviewEmbed"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; import { DevServerLogViewer } from "./DevServerLogViewer"; import { PreviewIframe } from "./PreviewIframe"; @@ -37,6 +39,85 @@ function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting }; } + +const NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD = 480; + +function isTrueMobileViewport(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + + return window.matchMedia("(max-width: 768px)").matches; +} + +function getDirectRightDockBodyHost(element: HTMLElement): HTMLElement | null { + if (element.closest(".right-dock-expand-modal__body")) { + return null; + } + + const parent = element.parentElement; + if (!parent?.classList.contains("right-dock__body")) { + return null; + } + + return parent; +} + +function readHostInlineSize(host: HTMLElement): number { + if (host.clientWidth > 0) { + return host.clientWidth; + } + + const rect = host.getBoundingClientRect(); + return rect.width; +} + +function shouldUseNarrowRightDockPreviewMode(root: HTMLElement | null): boolean { + if (!root || isTrueMobileViewport()) { + return false; + } + + const host = getDirectRightDockBodyHost(root); + if (!host) { + return false; + } + + return readHostInlineSize(host) <= NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD; +} + +function useNarrowRightDockPreviewMode(rootRef: RefObject): boolean { + const [isNarrowRightDockPreviewMode, setIsNarrowRightDockPreviewMode] = useState(false); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + setIsNarrowRightDockPreviewMode(false); + return; + } + + const host = getDirectRightDockBodyHost(root); + const updateMode = () => setIsNarrowRightDockPreviewMode(shouldUseNarrowRightDockPreviewMode(root)); + + updateMode(); + + if (!host || typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateMode); + return () => window.removeEventListener("resize", updateMode); + } + + const observer = new ResizeObserver(updateMode); + observer.observe(host); + window.addEventListener("resize", updateMode); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateMode); + }; + }, [rootRef]); + + return isNarrowRightDockPreviewMode; +} + let devServerViewWasPreviouslyInactive = false; function normalizeError(error: unknown): string { @@ -142,6 +223,14 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps const effectivePreviewUrl = previewUrl; const selectedSource = session?.config?.cwd ?? null; + const rootRef = useRef(null); + const isNarrowRightDockPreviewMode = useNarrowRightDockPreviewMode(rootRef); + + /* + FNXC:DevServer 2026-06-23-00:00: + The Dev Server preview must escape into a modal when the direct right-dock host is very narrow so preview chrome does not crowd logs and configuration in the same dock column. + The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews. + */ const [showCandidates, setShowCandidates] = useState(true); const [commandInput, setCommandInput] = useState(""); const [previewInput, setPreviewInput] = useState(""); @@ -170,6 +259,9 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps }, [executingTasks, selectedTaskId]); const [previewMode, setPreviewMode] = useState("embedded"); + const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false); + const previewModalLauncherRef = useRef(null); + const previewModalRef = useRef(null); const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null; const { @@ -271,6 +363,60 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps setPreviewInput(effectivePreviewUrl ?? ""); }, [effectivePreviewUrl]); + const closePreviewModal = useCallback(() => { + setIsPreviewModalOpen(false); + window.requestAnimationFrame(() => previewModalLauncherRef.current?.focus()); + }, []); + const previewModalOverlayDismissProps = useOverlayDismiss(closePreviewModal); + + useEffect(() => { + if (!isPreviewModalOpen) { + return; + } + + previewModalRef.current?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePreviewModal(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const focusableElements = Array.from( + previewModalRef.current?.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? [], + ).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"); + + const firstElement = focusableElements[0]; + const lastElement = focusableElements.at(-1); + if (!firstElement || !lastElement) { + return; + } + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [closePreviewModal, isPreviewModalOpen]); + + useEffect(() => { + if (!isNarrowRightDockPreviewMode && isPreviewModalOpen) { + setIsPreviewModalOpen(false); + } + }, [isNarrowRightDockPreviewMode, isPreviewModalOpen]); + const handleOpenInNewTab = useCallback(() => { if (!effectivePreviewUrl) { return; @@ -399,8 +545,136 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps const stopDisabled = status === "stopped" || actionInFlight !== null; const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null; + const renderPreviewContent = () => ( + <> +
+
+ + {t("devserver.preview", "Preview")} +
+ + {isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")} + {effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")} + +
+ + + +
+
+ +
+ {!effectivePreviewUrl && !isRunning && ( +

{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}

+ )} + + {!effectivePreviewUrl && isRunning && ( +

{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}

+ )} + + {effectivePreviewUrl && previewMode === "external" && ( +
+

{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}

+ +
+ )} + + {effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && ( +
+ {embedStatus === "error" + ?
+ )} + + {effectivePreviewUrl && previewMode === "embedded" && !showFallback && ( + + )} +
+ + ); + return ( -
+
{/* FNXC:DevServer 2026-06-22-01:00: Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding. @@ -641,126 +915,75 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
-
-
-
- - {t("devserver.preview", "Preview")} + {isNarrowRightDockPreviewMode ? ( +
+
+
+ + {t("devserver.preview", "Preview")} +
+ + {effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")} +
- + {effectivePreviewUrl + ? t("devserver.previewModalLauncherDescription", "Open the live preview in a modal so logs and configuration stay usable in this narrow dock.") + : t("devserver.previewModalLauncherUnavailable", "Start the dev server or set a preview URL to open the preview modal.")} +

+ - - -
-
+ {t("devserver.openPreview", "Open preview")} + +
+ ) : ( +
+ {renderPreviewContent()} +
+ )} -
- {!effectivePreviewUrl && !isRunning && ( -

{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}

- )} - - {!effectivePreviewUrl && isRunning && ( -

{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}

- )} - - {effectivePreviewUrl && previewMode === "external" && ( -
-

{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}

+ {isNarrowRightDockPreviewMode && isPreviewModalOpen && ( +
+
+
+

{t("devserver.preview", "Preview")}

- )} - - {effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && ( -
- {embedStatus === "error" - ?
- + )}
); } diff --git a/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx index 09d285ec67..cf828f3b7e 100644 --- a/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx @@ -51,12 +51,27 @@ describe("DevServerView mobile CSS/structure", () => { const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? []; const mobileCss = mobileBlockMatch.join("\n"); - const headerRuleCount = (mobileCss.match(/\.devserver-preview-header\s*\{/g) ?? []).length; + const headerRuleCount = (mobileCss.match(/\.devserver-preview-header,\s*\.devserver-preview-modal-launcher__copy\s*\{/g) ?? []).length; expect(headerRuleCount).toBe(1); expect(mobileCss).toMatch(/\.devserver-preview-url-badge\s*\{[\s\S]*max-width:\s*100%/); expect(mobileCss).toMatch(/\.dev-server-header-title\s*\{[\s\S]*flex-wrap:\s*wrap/); }); + it("defines narrow right-dock launcher and modal rules without duplicating mobile media rules", () => { + const css = loadAllAppCss(); + const containerStart = css.indexOf("@container right-dock-body (max-width: 768px)"); + expect(containerStart).toBeGreaterThan(-1); + const containerCss = css.slice(containerStart); + + expect(containerCss).toMatch(/\.devserver-preview-panel,\s*\.devserver-preview-modal-launcher\s*\{[\s\S]*grid-column:\s*auto/); + expect(containerCss).toMatch(/\.devserver-preview-modal\s*\{[\s\S]*width:\s*min\(calc\(var\(--space-2xl\) \* 20\), calc\(100vw - var\(--space-md\) \* 2\)\)/); + expect(containerCss).toMatch(/\.devserver-preview-panel \.devserver-preview-container/); + expect(containerCss).not.toMatch(/\.dev-server-logs,\s*\.devserver-preview-container,\s*\.devserver-preview-iframe/); + + expect(css).toMatch(/@media[^{]*\(max-width: 768px\)/); + expect(css).toMatch(/@container right-dock-body \(max-width: 768px\)/); + }); + it("renders preview header elements and keeps URL badge outside preview actions", () => { mockUseDevServer.mockReturnValue(createDevServerHookState()); mockUseDevServerLogs.mockReturnValue({ diff --git a/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx b/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx index fa9f8df30f..d40f5de30f 100644 --- a/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx +++ b/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx @@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({ Search: () => , ShieldAlert: () => , Square: () => , + X: () => , })); function createState(overrides: Partial = {}): DevServerState { @@ -201,6 +202,153 @@ describe("DevServerView preview panel", () => { afterEach(() => { window.open = originalWindowOpen; + vi.unstubAllGlobals(); + }); + + function renderInRightDock(width: number) { + const host = document.createElement("div"); + host.className = "right-dock__body"; + Object.defineProperty(host, "clientWidth", { configurable: true, value: width }); + document.body.appendChild(host); + + return render(, { container: host }); + } + + it("activates narrow right-dock preview mode only below the dock threshold", async () => { + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + + const narrow = renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true"); + }); + + narrow.unmount(); + document.body.innerHTML = ""; + + renderInRightDock(640); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false"); + }); + expect(screen.queryByTestId("devserver-preview-modal-launcher")).not.toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-panel")).toBeInTheDocument(); + }); + + it("replaces the narrow right-dock inline preview with an accessible modal launcher", async () => { + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState({ + entries: [{ id: "log-1", timestamp: "2026-06-23T00:00:00.000Z", stream: "stdout", text: "ready" }], + total: 1, + })); + previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true }); + + renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true"); + }); + + expect(screen.getByTestId("dev-server-logs-panel")).toBeInTheDocument(); + expect(screen.queryByTestId("devserver-preview-panel")).not.toBeInTheDocument(); + expect(screen.queryByTitle("Dev server preview")).not.toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-modal-launcher")).toHaveTextContent("http://localhost:3000"); + expect(screen.getByTestId("devserver-preview-url-badge")).toHaveTextContent("http://localhost:3000"); + + fireEvent.click(screen.getByTestId("devserver-preview-modal-open")); + + const modal = await screen.findByTestId("devserver-preview-modal"); + expect(modal).toHaveAttribute("role", "dialog"); + expect(modal).toHaveAttribute("aria-modal", "true"); + expect(screen.getByTitle("Dev server preview")).toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-open-tab")).toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-refresh")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByTestId("devserver-preview-modal")).not.toBeInTheDocument(); + }); + }); + + it("keeps preview modes and fallback actions inside the narrow dock modal", async () => { + const retry = vi.fn(); + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true }); + + const { rerender } = renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true"); + }); + + fireEvent.click(screen.getByTestId("devserver-preview-modal-open")); + + previewEmbedState = createPreviewEmbedState({ + embedStatus: "blocked", + isBlocked: true, + embedContext: "The server may block iframe embedding...", + retry, + }); + rerender(); + + await waitFor(() => { + expect(screen.getByTestId("devserver-preview-fallback")).toBeInTheDocument(); + }); + expect(screen.getByText("Preview blocked")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("devserver-preview-fallback-retry")); + expect(retry).toHaveBeenCalledTimes(1); + + previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true }); + rerender(); + fireEvent.click(screen.getByTestId("devserver-preview-mode-toggle")); + + expect(screen.getByTestId("devserver-preview-external-only")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("devserver-preview-external-open-tab")); + expect(window.open).toHaveBeenCalledWith("http://localhost:3000", "_blank", "noopener,noreferrer"); + }); + + it("keeps inline preview mode for true mobile viewport and expanded right-dock hosts", async () => { + vi.stubGlobal("matchMedia", vi.fn().mockImplementation((query: string) => ({ + matches: query === "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }))); + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + + const mobile = renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false"); + }); + + mobile.unmount(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + + const expandedHost = document.createElement("div"); + expandedHost.className = "right-dock-expand-modal__body"; + Object.defineProperty(expandedHost, "clientWidth", { configurable: true, value: 420 }); + document.body.appendChild(expandedHost); + + render(, { container: expandedHost }); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false"); + }); }); it("shows start-empty state when server is not configured", () => { From be0cab1d43b4f68fa647393b31ea1b8e9052cec8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 16:47:08 -0700 Subject: [PATCH 43/44] fix(dashboard): raise model dropdown z-index above floating windows Raise CustomModelDropdown portal z-index from 1200 to 11000 so model selection popups render above the shared floating-window stack (10100+) instead of being obscured by popped-out ChatView and other floating modals. --- packages/dashboard/app/components/CustomModelDropdown.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css index e943c7f93d..933c7d29f6 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.css +++ b/packages/dashboard/app/components/CustomModelDropdown.css @@ -67,8 +67,8 @@ border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); - /* Must sit above floating dashboard panels. */ - z-index: 1200; + /* Must sit above floating dashboard panels and the shared floating-window stack (10100+). */ + z-index: 11000; max-height: 320px; display: flex; flex-direction: column; From e17e9bc867ee66760b737d055e05ad2bcfd3f3f0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 17:24:55 -0700 Subject: [PATCH 44/44] feat(#1675): add X-Session-Id and X-Session-Affinity routing headers to LLM requests Add X-Session-Id and X-Session-Affinity headers to all outbound LLM chat completion requests so LLM gateways can sticky-route consecutive requests from the same conversation and observability tools (Langfuse, Arize) can group stateless API calls into a single multi-turn trace. The headers carry a stable identifier: the task id when available (stable across pause/resume), otherwise the pi session id. The implementation wraps modelRegistry.getApiKeyAndHeaders -- the single chokepoint pi-coding-agent uses for both the main stream and compaction -- merging routing headers into the resolved output. This covers all HTTP-based providers (built-in, custom, and HTTP-streaming extensions) without disturbing auth resolution. Also propagates taskId to four secondary executor sessions (retry, verification-fix, workflow-step, child-agent) that previously fell back to a per-instance pi id, fragmenting per-task observability grouping. Closes #1675 --- .changeset/session-routing-headers.md | 5 ++ .../src/__tests__/pi-create-fn-agent.test.ts | 69 ++++++++++++++- .../pi-session-routing-headers.test.ts | 83 +++++++++++++++++++ packages/engine/src/executor.ts | 17 ++++ packages/engine/src/pi.ts | 72 ++++++++++++++++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 .changeset/session-routing-headers.md create mode 100644 packages/engine/src/__tests__/pi-session-routing-headers.test.ts diff --git a/.changeset/session-routing-headers.md b/.changeset/session-routing-headers.md new file mode 100644 index 0000000000..decbd699ad --- /dev/null +++ b/.changeset/session-routing-headers.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add `X-Session-Id` and `X-Session-Affinity` request headers to all LLM chat completion requests. These let LLM gateways sticky-route consecutive requests from the same conversation to the same backend, and let observability tools (Langfuse, Arize, etc.) group the otherwise-stateless API calls of a session into a single multi-turn trace. Both headers carry the same stable identifier — the task id when available (stable across pause/resume), otherwise the pi session id. (#1675) diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 2d2b169ba6..acb7d55530 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -15,6 +15,11 @@ const findMock = vi.fn(); const getAllMock = vi.fn(() => [] as any[]); const registerProviderMock = vi.fn(); const refreshMock = vi.fn(); +// FNXC:SessionRouting 2026-06-24-11:30: +// #1675: capture model-registry auth resolution + session id so the wiring +// test can assert X-Session-Id/X-Session-Affinity precedence end-to-end. +const getApiKeyAndHeadersMock = vi.fn(async () => ({ ok: true, apiKey: undefined, headers: undefined })); +const sessionManagerGetSessionIdMock = vi.fn(() => undefined); const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create" })); const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" })); const setFallbackResolverMock = vi.fn(); @@ -138,9 +143,12 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ refresh() { return refreshMock(); } + getApiKeyAndHeaders() { + return getApiKeyAndHeadersMock(); + } }, SessionManager: { - inMemory: () => ({ kind: "session-manager" }), + inMemory: () => ({ kind: "session-manager", getSessionId: sessionManagerGetSessionIdMock }), }, SettingsManager: { create: settingsManagerCreateMock, @@ -1024,6 +1032,9 @@ describe("createFnAgent", () => { realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path)); readCustomProvidersMock.mockReturnValue([]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); + // #1675: re-establish default auth + session-id mock returns after clearAllMocks. + getApiKeyAndHeadersMock.mockResolvedValue({ ok: true, apiKey: undefined, headers: undefined }); + sessionManagerGetSessionIdMock.mockReturnValue(undefined); createBashToolMock.mockClear(); createAgentSessionMock.mockResolvedValue({ session: { @@ -1921,6 +1932,62 @@ describe("createFnAgent", () => { warnSpy.mockRestore(); }); + // FNXC:SessionRouting 2026-06-24-11:30: + // #1675: createFnAgent must resolve sessionRoutingId = taskId ?? piSessionId and + // wrap the registry's getApiKeyAndHeaders so outbound requests carry routing + // headers. These assert the wiring precedence end-to-end, not just the helper. + describe("session routing headers wiring (#1675)", () => { + const anyModel = { provider: "anthropic", id: "claude" } as never; + + async function createAndCaptureRegistry(overrides: Record = {}) { + const { createFnAgent } = await import("../pi.js"); + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + ...overrides, + }); + const sessionOptions = createAgentSessionMock.mock.calls.at(-1)?.[0] as { + modelRegistry: { getApiKeyAndHeaders: (model: unknown) => Promise }; + }; + return sessionOptions.modelRegistry; + } + + it("uses taskId as the routing id when provided", async () => { + const registry = await createAndCaptureRegistry({ taskId: "FN-7788" }); + + const result = await registry.getApiKeyAndHeaders(anyModel) as { ok: boolean; headers?: Record }; + + expect(result.ok).toBe(true); + expect(result.headers).toEqual({ + "X-Session-Id": "FN-7788", + "X-Session-Affinity": "FN-7788", + }); + }); + + it("falls back to the pi session id when taskId is absent", async () => { + sessionManagerGetSessionIdMock.mockReturnValue("pi-session-abc"); + const registry = await createAndCaptureRegistry(); + + const result = await registry.getApiKeyAndHeaders(anyModel) as { ok: boolean; headers?: Record }; + + expect(result.headers).toEqual({ + "X-Session-Id": "pi-session-abc", + "X-Session-Affinity": "pi-session-abc", + }); + }); + + it("does not wrap getApiKeyAndHeaders when neither taskId nor a session id is available", async () => { + // getApiKeyAndHeadersMock returns { ok: true, headers: undefined }; if the + // wrapper were applied, headers would be populated with X-Session-*. + const registry = await createAndCaptureRegistry(); + + const result = await registry.getApiKeyAndHeaders(anyModel) as { ok: boolean; headers?: Record }; + + expect(result.headers).toBeUndefined(); + }); + }); + describe("skill selection", () => { beforeEach(() => { // Reset modules to ensure fresh imports for each test diff --git a/packages/engine/src/__tests__/pi-session-routing-headers.test.ts b/packages/engine/src/__tests__/pi-session-routing-headers.test.ts new file mode 100644 index 0000000000..5fd9edf4e5 --- /dev/null +++ b/packages/engine/src/__tests__/pi-session-routing-headers.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { attachSessionRoutingHeaders, buildSessionRoutingHeaders } from "../pi.js"; + +// FNXC:SessionRouting 2026-06-23-16:40: +// Issue #1675: chat completion requests must carry X-Session-Id and +// X-Session-Affinity so LLM gateways can sticky-route and observability tools +// can group the stateless API calls of one conversation into a single trace. + +describe("buildSessionRoutingHeaders", () => { + it("emits X-Session-Id and X-Session-Affinity with the same identifier", () => { + expect(buildSessionRoutingHeaders("sess-123")).toEqual({ + "X-Session-Id": "sess-123", + "X-Session-Affinity": "sess-123", + }); + }); +}); + +describe("attachSessionRoutingHeaders", () => { + // Minimal stand-in for the bits of ModelRegistry the wrapper touches. + function makeRegistry( + resolve: (model: unknown) => Promise<{ ok: boolean; apiKey?: string; headers?: Record; error?: string }>, + ): ModelRegistry { + return { getApiKeyAndHeaders: resolve } as unknown as ModelRegistry; + } + + const anyModel = { provider: "anthropic", id: "claude" } as never; + + it("merges the routing headers into resolved request headers", async () => { + const registry = makeRegistry(async () => ({ ok: true, apiKey: "sk-live", headers: undefined })); + attachSessionRoutingHeaders(registry, "sess-abc"); + + const result = await registry.getApiKeyAndHeaders(anyModel); + + expect(result).toEqual({ + ok: true, + apiKey: "sk-live", + headers: { + "X-Session-Id": "sess-abc", + "X-Session-Affinity": "sess-abc", + }, + }); + }); + + it("preserves the resolved apiKey and any provider-specific headers", async () => { + const registry = makeRegistry(async () => ({ + ok: true, + apiKey: "sk-custom", + headers: { "HTTP-Referer": "https://example.com", "X-Title": "Fusion" }, + })); + attachSessionRoutingHeaders(registry, "sess-xyz"); + + const result = await registry.getApiKeyAndHeaders(anyModel); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok auth result"); + expect(result.apiKey).toBe("sk-custom"); + expect(result.headers).toEqual({ + "HTTP-Referer": "https://example.com", + "X-Title": "Fusion", + "X-Session-Id": "sess-xyz", + "X-Session-Affinity": "sess-xyz", + }); + }); + + it("does not alter failed auth resolutions", async () => { + const registry = makeRegistry(async () => ({ ok: false, error: "No API key found" })); + attachSessionRoutingHeaders(registry, "sess-fail"); + + const result = await registry.getApiKeyAndHeaders(anyModel); + + expect(result).toEqual({ ok: false, error: "No API key found" }); + }); + + it("no-ops without throwing when getApiKeyAndHeaders is absent", () => { + // If a future pi-coding-agent rename removes the method, the wrapper must not + // break session creation. It leaves the registry untouched and warns instead. + const registry = {} as ModelRegistry; + + expect(() => attachSessionRoutingHeaders(registry, "sess-none")).not.toThrow(); + expect((registry as unknown as Record).getApiKeyAndHeaders).toBeUndefined(); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a8b38404e6..d689c54648 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -9067,6 +9067,11 @@ export class TaskExecutor { // mirroring the primary execute-seam session above. actionGateContext: this.buildActionGateContext(task.id, identityAgent, settings.defaultAgentPermissionPolicy), permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, identityAgent, settings.defaultAgentPermissionPolicy), + // FNXC:SessionRouting 2026-06-24-11:20: + // #1675: propagate task id so retry-session requests carry the same + // X-Session-Id/X-Session-Affinity as the primary session, keeping the + // task's LLM requests grouped under one stable routing/observability id. + taskId: task.id, }); retrySession = createdRetrySession.session; if (createdRetrySession.sessionFile) { @@ -11889,6 +11894,10 @@ Do not refactor, rename broadly, or make opportunistic improvements. runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, taskEnv: extraEnv, + // FNXC:SessionRouting 2026-06-24-11:20: + // #1675: propagate task id so verification-fix requests carry the same + // X-Session-Id/X-Session-Affinity as the primary session. + taskId: task.id, ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), }); @@ -13076,6 +13085,10 @@ You have access to the file system to review changes.${verdictBlock}`; runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, taskEnv: stepEnv, + // FNXC:SessionRouting 2026-06-24-11:20: + // #1675: propagate task id so workflow-step requests carry the same + // X-Session-Id/X-Session-Affinity as the primary session. + taskId: task.id, // Skill selection: assigned-agent / role-fallback skills, plus the step's // own named skill (U1) made discoverable via additionalSkillPaths. ...(effectiveSkillSelection ? { skillSelection: effectiveSkillSelection } : {}), @@ -15801,6 +15814,10 @@ Child agent: ${agent.id} (${name})`; runAuditor: createRunAuditor(this.store, this.getRunContextFor(taskId)), settings, taskEnv, + // FNXC:SessionRouting 2026-06-24-11:20: + // #1675: propagate task id so child-agent requests carry the same + // X-Session-Id/X-Session-Affinity as the parent task session. + taskId, // Skill selection: use assigned agent skills if available, otherwise role fallback ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), }); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 84c2bdac7e..cad52e0130 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1895,6 +1895,64 @@ export function wrapToolsWithActionGate( }); } +/** + * FNXC:SessionRouting 2026-06-23-16:40: + * Outbound LLM chat completion requests must carry `X-Session-Id` and + * `X-Session-Affinity` headers (GitHub issue #1675). These are widely + * understood by LLM gateways, proxies, and observability tooling: + * - Gateways/routers use them for sticky routing, keeping consecutive requests + * from one conversation on the same backend or cache instance. + * - Observability tools (e.g. Langfuse, Arize) use them to group individually + * stateless API calls into a single cohesive multi-turn chat trace. + * - Memory/proxy middleware uses them to fetch and append conversation history. + * + * Both headers carry the same stable identifier so sticky-routing affinity and + * trace grouping refer to the same session. Builds the header pair for a given + * session id. + */ +export function buildSessionRoutingHeaders(sessionId: string): Record { + return { + "X-Session-Id": sessionId, + "X-Session-Affinity": sessionId, + }; +} + +/** + * FNXC:SessionRouting 2026-06-23-16:40: + * Merge the session-routing headers into every header set the model registry + * resolves for outbound LLM requests (#1675). `getApiKeyAndHeaders` is the + * single point pi-coding-agent uses to resolve per-request auth and headers + * (for the main stream and compaction alike), so wrapping it applies the + * headers to every HTTP-based provider path (built-in, custom, and + * HTTP-streaming extension providers). Subprocess-based providers that make + * their own outbound HTTP calls inside a child process (e.g. CLI bridges) are + * outside this seam and do not inherit the headers. + * Operating on the resolved output (rather than re-registering providers) + * preserves provider-specific headers and never disturbs API-key resolution. + */ +export function attachSessionRoutingHeaders(modelRegistry: ModelRegistry, sessionId: string): void { + // FNXC:SessionRouting 2026-06-23-16:46: + // Auxiliary feature: never let header injection break session creation. If a + // future pi-coding-agent rename removes getApiKeyAndHeaders, warn (rather than + // silently no-op) so the degraded routing/observability headers are detectable. + if (typeof modelRegistry.getApiKeyAndHeaders !== "function") { + piLog.warn("[pi] session-routing headers not attached: ModelRegistry.getApiKeyAndHeaders is not a function (pi API changed?)"); + return; + } + const routingHeaders = buildSessionRoutingHeaders(sessionId); + const resolveAuth = modelRegistry.getApiKeyAndHeaders.bind(modelRegistry); + modelRegistry.getApiKeyAndHeaders = async (model) => { + const result = await resolveAuth(model); + if (!result.ok) { + return result; + } + return { + ...result, + headers: { ...result.headers, ...routingHeaders }, + }; + }; +} + /** * Create a pi agent session configured for fn. * Reuses the user's existing pi auth and model configuration. @@ -2098,6 +2156,20 @@ export async function createFnAgent(options: AgentOptions): Promise const sessionManager = options.sessionManager ?? SessionManager.inMemory(); normalizeSessionHistoryEntries(sessionManager as unknown as SessionManagerLike); + // FNXC:SessionRouting 2026-06-23-16:40: + // Tag every outbound LLM chat completion request with stable session-routing + // headers (X-Session-Id / X-Session-Affinity) for gateway sticky routing and + // observability trace grouping (#1675). Prefer the task id, which is stable + // across pause/resume (each resume spins up a fresh SessionManager), and fall + // back to the pi session id for non-task sessions (chat, summarizer, reviewer). + const piSessionId = typeof sessionManager.getSessionId === "function" + ? sessionManager.getSessionId() + : undefined; + const sessionRoutingId = options.taskId ?? piSessionId; + if (sessionRoutingId) { + attachSessionRoutingHeaders(modelRegistry, sessionRoutingId); + } + const createSessionWithModel = async (modelOverride?: typeof selectedModel) => { // pi-coding-agent 0.68+: `tools` is a string[] allowlist of tool names, not // Tool instances. We need boundary-wrapped versions of the built-ins, so we