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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/<task-id></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
|
||||
|
||||
@@ -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\);[^}]*\}/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user