feat(FN-4068): add branch conflict detection and recovery for stale worktre

Implements branch conflict detection and recovery across the Fusion engine, CLI, and dashboard — surfacing git worktree conflicts when tasks conflict with unrelated branch state, and providing a recovery workflow to resolve them. The executor and worktree pool now integrate typed branch conflict che

Fusion-Task-Id: FN-4068
This commit is contained in:
Fusion
2026-05-12 17:10:26 -07:00
committed by gsxdsm
parent fdd0af3b49
commit 75fe39d051
28 changed files with 1341 additions and 209 deletions

View File

@@ -4,6 +4,7 @@
gap: var(--space-md);
height: 100%;
min-height: 0;
overflow: hidden;
padding: var(--space-lg);
padding-bottom: var(--space-lg);
}
@@ -30,6 +31,7 @@
gap: var(--space-md);
min-height: 0;
flex: 1;
overflow: hidden;
}
.research-view__sidebar,
@@ -39,6 +41,26 @@
flex-direction: column;
gap: var(--space-md);
min-height: 0;
overflow: hidden;
}
.research-view__reader {
overflow: auto;
}
.research-view__reader-content {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-md);
min-height: 0;
}
.research-view__run-detail {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-height: 0;
}
.research-view__form {
@@ -127,6 +149,7 @@
.research-view__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
@@ -171,11 +194,13 @@
padding-left: var(--space-lg);
}
.research-view__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-sm);
margin-top: auto;
padding-top: var(--space-sm);
border-top: var(--btn-border-width) solid var(--border);
}
.research-view__stat-card {
@@ -201,6 +226,7 @@
@media (max-width: 768px) {
.research-view {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
@@ -210,6 +236,21 @@
display: flex;
flex-direction: column;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-md);
flex: initial;
overflow: visible;
}
.research-view__sidebar,
.research-view__reader {
overflow: visible;
}
.research-view__reader {
overflow: visible;
}
.research-view__reader-content {
flex: initial;
}
@@ -223,6 +264,10 @@
flex-direction: column;
}
.research-view__header .btn {
align-self: flex-start;
}
.research-view__stats {
grid-template-columns: minmax(0, 1fr);
}

View File

@@ -322,113 +322,115 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
{loading && <p data-testid="research-state-loading">Loading research runs</p>}
{!loading && error && <p data-testid="research-state-error">{error}</p>}
{!loading && !error && runs.length === 0 && <p data-testid="research-state-empty">No research runs yet</p>}
{selectedRun && (
<div>
<div className="research-view__status-row">
<span className={statusDotClass} />
<strong>{statusLabel}</strong>
</div>
<h3 className="research-view__run-title">{selectedRun.title}</h3>
<p className="research-view__run-query">{selectedRun.query}</p>
<p className="research-view__run-summary" data-testid="research-state-results">{selectedRun.results?.summary ?? "No summary yet."}</p>
<div className="research-view__actions">
<button
className="btn"
type="button"
title={!runActionState.cancelable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.cancelable}
onClick={() => void runAction("cancel", () => cancelRun(selectedRun.id), "Run cancelled")}
>
Cancel
</button>
<button
className="btn"
type="button"
title={!runActionState.retryable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.retryable}
onClick={() => void runAction("retry", () => retryRun(selectedRun.id), "Run retried")}
>
Retry
</button>
{supportedExportFormats.includes("markdown") && <button className="btn" type="button" disabled={actionLoading === "export-markdown"} onClick={() => void handleExport("markdown")}>Export MD</button>}
{supportedExportFormats.includes("json") && <button className="btn" type="button" disabled={actionLoading === "export-json"} onClick={() => void handleExport("json")}>Export JSON</button>}
{supportedExportFormats.includes("html") && <button className="btn" type="button" disabled={actionLoading === "export-html"} onClick={() => void handleExport("html")}>Export HTML</button>}
</div>
{selectedRun.error && <p className="research-view__error">{selectedRun.error}</p>}
{uiError && (
<div className="form-error" role="alert">
<p>{uiError.message}</p>
{uiError.setupHint && <p>{uiError.setupHint}</p>}
{uiError.code === "MISSING_CREDENTIALS" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("authentication")}>
Open Authentication Settings
</button>
)}
{uiError.code === "FEATURE_DISABLED" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("research-project")}>
Open Research Settings
</button>
)}
<div className="research-view__reader-content">
{selectedRun && (
<div className="research-view__run-detail">
<div className="research-view__status-row">
<span className={statusDotClass} />
<strong>{statusLabel}</strong>
</div>
)}
{runActionState.blockingReason && (
<p className="research-view__run-query">{runActionState.blockingReason}</p>
)}
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
<div className="research-view__findings">
{selectedRun.results.findings.map((finding, index) => {
const findingRecord = finding as { id?: string };
const findingId = findingRecord.id?.trim() || `finding-${index + 1}`;
return (
<article key={findingId} className="research-view__finding card">
<h4>{finding.heading}</h4>
<p>{finding.content}</p>
<div className="research-view__actions research-view__finding-actions">
<button
className="btn btn-primary btn-sm"
type="button"
onClick={() => setModalState({ mode: "create", findingId })}
>
Create Task
</button>
<button
className="btn btn-sm"
type="button"
onClick={() => setModalState({ mode: "enrich", findingId })}
>
Enrich Task
</button>
</div>
</article>
);
})}
<h3 className="research-view__run-title">{selectedRun.title}</h3>
<p className="research-view__run-query">{selectedRun.query}</p>
<p className="research-view__run-summary" data-testid="research-state-results">{selectedRun.results?.summary ?? "No summary yet."}</p>
<div className="research-view__actions">
<button
className="btn"
type="button"
title={!runActionState.cancelable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.cancelable}
onClick={() => void runAction("cancel", () => cancelRun(selectedRun.id), "Run cancelled")}
>
Cancel
</button>
<button
className="btn"
type="button"
title={!runActionState.retryable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.retryable}
onClick={() => void runAction("retry", () => retryRun(selectedRun.id), "Run retried")}
>
Retry
</button>
{supportedExportFormats.includes("markdown") && <button className="btn" type="button" disabled={actionLoading === "export-markdown"} onClick={() => void handleExport("markdown")}>Export MD</button>}
{supportedExportFormats.includes("json") && <button className="btn" type="button" disabled={actionLoading === "export-json"} onClick={() => void handleExport("json")}>Export JSON</button>}
{supportedExportFormats.includes("html") && <button className="btn" type="button" disabled={actionLoading === "export-html"} onClick={() => void handleExport("html")}>Export HTML</button>}
</div>
)}
{Array.isArray(selectedRun.results?.citations) && selectedRun.results!.citations!.length > 0 && (
<ul className="research-view__citations">
{selectedRun.results!.citations!.map((citation) => (
<li key={citation}><a href={citation} target="_blank" rel="noreferrer">{citation}</a></li>
))}
</ul>
)}
{selectedRun.events.length > 0 && (
<details>
<summary>Run history</summary>
<ul className="research-view__events">
{selectedRun.events.map((event) => (
<li key={event.id}>{event.message}</li>
{selectedRun.error && <p className="research-view__error">{selectedRun.error}</p>}
{uiError && (
<div className="form-error" role="alert">
<p>{uiError.message}</p>
{uiError.setupHint && <p>{uiError.setupHint}</p>}
{uiError.code === "MISSING_CREDENTIALS" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("authentication")}>
Open Authentication Settings
</button>
)}
{uiError.code === "FEATURE_DISABLED" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("research-project")}>
Open Research Settings
</button>
)}
</div>
)}
{runActionState.blockingReason && (
<p className="research-view__run-query">{runActionState.blockingReason}</p>
)}
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
<div className="research-view__findings">
{selectedRun.results.findings.map((finding, index) => {
const findingRecord = finding as { id?: string };
const findingId = findingRecord.id?.trim() || `finding-${index + 1}`;
return (
<article key={findingId} className="research-view__finding card">
<h4>{finding.heading}</h4>
<p>{finding.content}</p>
<div className="research-view__actions research-view__finding-actions">
<button
className="btn btn-primary btn-sm"
type="button"
onClick={() => setModalState({ mode: "create", findingId })}
>
Create Task
</button>
<button
className="btn btn-sm"
type="button"
onClick={() => setModalState({ mode: "enrich", findingId })}
>
Enrich Task
</button>
</div>
</article>
);
})}
</div>
)}
{Array.isArray(selectedRun.results?.citations) && selectedRun.results!.citations!.length > 0 && (
<ul className="research-view__citations">
{selectedRun.results!.citations!.map((citation) => (
<li key={citation}><a href={citation} target="_blank" rel="noreferrer">{citation}</a></li>
))}
</ul>
</details>
)}
</div>
)}
{!selectedRun && runs.length > 0 && <p>Select a run to view details.</p>}
)}
{selectedRun.events.length > 0 && (
<details>
<summary>Run history</summary>
<ul className="research-view__events">
{selectedRun.events.map((event) => (
<li key={event.id}>{event.message}</li>
))}
</ul>
</details>
)}
</div>
)}
{!selectedRun && runs.length > 0 && <p>Select a run to view details.</p>}
<div className="research-view__stats">
<div className="research-view__stat-card"><div className="research-view__stat-label">Running</div><div className="research-view__stat-value">{statusCounts.running}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Completed</div><div className="research-view__stat-value">{statusCounts.completed}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Failed</div><div className="research-view__stat-value">{statusCounts.failed}</div></div>
<div className="research-view__stats">
<div className="research-view__stat-card"><div className="research-view__stat-label">Running</div><div className="research-view__stat-value">{statusCounts.running}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Completed</div><div className="research-view__stat-value">{statusCounts.completed}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Failed</div><div className="research-view__stat-value">{statusCounts.failed}</div></div>
</div>
</div>
</div>
</div>

View File

@@ -394,6 +394,7 @@ export function SettingsModal({
autoMerge: true,
mergeStrategy: "direct",
recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
includeTaskIdInCommit: true,
worktreeInitCommand: "",
@@ -3485,6 +3486,22 @@ export function SettingsModal({
</label>
<small>When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup</small>
</div>
<div className="form-group">
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
<input
id="executorAllowSiblingBranchRename"
type="checkbox"
checked={form.executorAllowSiblingBranchRename === true}
onChange={(e) =>
setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))
}
/>
Allow silent sibling branch rename during executor conflicts
</label>
<small>
Discouraged. This restores the legacy behavior where a live <code>fusion/&lt;task-id&gt;</code> branch collision silently forks work onto sibling branches like <code>-2</code> and can hide prior commits from the default recovery flow.
</small>
</div>
<div className="form-group">
<label htmlFor="worktreeNaming">Worktree Naming Style</label>
<select

View File

@@ -525,12 +525,27 @@ describe("ResearchView", () => {
await waitFor(() => expect(enrichButton).not.toBeDisabled());
});
it("FN-3912: research view content is scrollable on mobile", () => {
const css = loadAllAppCss();
it("FN-3912: keeps the desktop research split view as a bounded grid", () => {
const baseCss = loadAllAppCssBaseOnly();
expect(baseCss).toMatch(/\.research-view__layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*minmax\(0,\s*2fr\);[^}]*\}/);
expect(baseCss).toMatch(/\.research-view__layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*minmax\(0,\s*2fr\);[^}]*overflow:\s*hidden;[^}]*\}/);
expect(baseCss).toMatch(/\.research-view__stats\s*\{[^}]*margin-top:\s*auto;[^}]*border-top:\s*var\(--btn-border-width\)\s+solid\s+var\(--border\);[^}]*\}/);
expect(baseCss).not.toMatch(/\.research-view__stats\s*\{[^}]*position:\s*absolute;[^}]*\}/);
});
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[^}]*\.research-view\s*\{[^}]*overflow-y:\s*auto;[^}]*-webkit-overflow-scrolling:\s*touch;[^}]*padding-bottom:\s*calc\(var\(--space-md\)\s*\+\s*var\(--mobile-nav-height\)\s*\+\s*env\(safe-area-inset-bottom,\s*0px\)\s*\+\s*var\(--standalone-bottom-gap\)\);[^}]*\}/);
it("FN-3912: keeps the desktop reader pane scroll-contained", () => {
const baseCss = loadAllAppCssBaseOnly();
expect(baseCss).toMatch(/\.research-view__sidebar,\s*\.research-view__reader\s*\{[^}]*min-height:\s*0;[^}]*\}/);
expect(baseCss).toMatch(/\.research-view__reader\s*\{[^}]*overflow:\s*auto;[^}]*\}/);
expect(baseCss).not.toMatch(/\.research-view__reader\s*\{[^}]*position:\s*(absolute|fixed);[^}]*\}/);
expect(baseCss).toMatch(/\.research-view__reader-content\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1(?:\s+1\s+0%)?;[^}]*min-height:\s*0;[^}]*\}/);
});
it("FN-3912: research view content is scrollable on mobile", () => {
const css = loadAllAppCss();
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.research-view\s*\{[^}]*overflow-y:\s*auto;[^}]*padding-bottom:\s*calc\(var\(--space-md\)\s*\+\s*var\(--mobile-nav-height\)\s*\+\s*env\(safe-area-inset-bottom,\s*0px\)\s*\+\s*var\(--standalone-bottom-gap\)\);[^}]*\}/);
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.research-view__layout\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*var\(--space-md\);[^}]*\}/);
});
});

View File

@@ -183,6 +183,7 @@ const defaultSettings = {
verificationFixRetries: 2,
workflowRevisionForkOnScopeMismatch: true,
recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
includeTaskIdInCommit: true,
worktreeInitCommand: "",
@@ -260,6 +261,27 @@ describe("SettingsModal", () => {
expect(screen.queryByLabelText("Direct merge commit routing")).not.toBeInTheDocument();
});
it("persists the legacy sibling branch rename escape hatch in worktree settings", async () => {
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /^Worktrees$/ }));
const checkbox = screen.getByRole("checkbox", { name: "Allow silent sibling branch rename during executor conflicts" });
expect(checkbox).not.toBeChecked();
await userEvent.click(checkbox);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({ executorAllowSiblingBranchRename: true }),
undefined,
);
});
expect(screen.getByText(/restores the legacy behavior/i)).toBeInTheDocument();
});
beforeEach(() => {
vi.clearAllMocks();
clearPluginUiSlotsCache();