feat(FN-1041): add step session executor and execution settings UI

- Implement StepSessionExecutor class with parallel wave scheduling, conflict detection, and retry logic
- Define interfaces and utility types for step session execution (StepSession, StepSessionConfig, StepResult)
- Add Execution settings section to the dashboard SettingsModal for configuring step session behavior
- Add comprehensive tests for StepSessionExecutor (1225 lines) and SettingsModal Execution section
- Add changeset for @gsxdsm/fusion package
This commit is contained in:
gsxdsm
2026-04-06 21:34:19 -07:00
parent e94c3c8c1c
commit 75afc13d54
3 changed files with 113 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
---
"@gsxdsm/fusion": minor
---
Add Execution section to dashboard settings for step-by-step session control
Users can now configure step execution behavior from the dashboard Settings modal:
- Toggle "Run each step in a new session" for better isolation and error recovery
- Set "Maximum parallel steps" (1-4) when step sessions are enabled

View File

@@ -39,6 +39,7 @@ const SETTINGS_SECTIONS = [
{ id: "appearance", label: "Appearance", scope: "global" as const },
{ id: "scheduling", label: "Scheduling", scope: "project" as const },
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
{ id: "execution", label: "Execution", scope: "project" as const },
{ id: "commands", label: "Commands", scope: "project" as const },
{ id: "merge", label: "Merge", scope: "project" as const },
{ id: "memory", label: "Memory", scope: "project" as const },
@@ -1317,6 +1318,42 @@ export function SettingsModal({
</div>
</>
);
case "execution":
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Execution</h4>
<div className="form-group">
<label htmlFor="runStepsInNewSessions" className="checkbox-label">
<input
id="runStepsInNewSessions"
type="checkbox"
checked={form.runStepsInNewSessions || false}
onChange={(e) =>
setForm((f) => ({ ...f, runStepsInNewSessions: e.target.checked }))
}
/>
Run each step in a new session
</label>
<small>Run each task step in its own fresh agent session for better isolation and error recovery. Failed steps can be retried individually.</small>
</div>
<div className="form-group">
<label htmlFor="maxParallelSteps">Maximum parallel steps</label>
<input
id="maxParallelSteps"
type="number"
min={1}
max={4}
value={form.maxParallelSteps ?? 2}
onChange={(e) =>
setForm((f) => ({ ...f, maxParallelSteps: Number(e.target.value) }))
}
disabled={!form.runStepsInNewSessions}
/>
<small>Maximum number of steps to run in parallel when file scopes don&apos;t overlap (1-4)</small>
</div>
</>
);
case "commands":
return (
<>

View File

@@ -25,6 +25,8 @@ const defaultSettings: Settings = {
ntfyEvents: ["in-review", "merged", "failed"],
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
runStepsInNewSessions: false,
maxParallelSteps: 2,
};
vi.mock("../../api", () => ({
@@ -1274,7 +1276,7 @@ describe("SettingsModal", () => {
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(11);
expect(navItems.length).toBe(12);
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
const labels = Array.from(navItems).map((el) => el.textContent);
@@ -1284,6 +1286,7 @@ describe("SettingsModal", () => {
"🌐Appearance",
"📁Scheduling",
"📁Worktrees",
"📁Execution",
"📁Commands",
"📁Merge",
"📁Memory",
@@ -2072,4 +2075,67 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Internal server error", "error"));
});
// --- Execution section tests ---
it("shows Execution in sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Execution").length).toBeGreaterThanOrEqual(1);
});
it("shows runStepsInNewSessions checkbox and maxParallelSteps input in Execution section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Execution"));
const checkbox = screen.getByLabelText("Run each step in a new session");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
const input = screen.getByLabelText("Maximum parallel steps");
expect(input).toBeTruthy();
expect(input.getAttribute("type")).toBe("number");
});
it("maxParallelSteps input is disabled when runStepsInNewSessions is false", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Execution"));
const input = screen.getByLabelText("Maximum parallel steps") as HTMLInputElement;
expect(input.disabled).toBe(true);
});
it("toggling runStepsInNewSessions to true enables the maxParallelSteps input", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Execution"));
const checkbox = screen.getByLabelText("Run each step in a new session");
fireEvent.click(checkbox);
const input = screen.getByLabelText("Maximum parallel steps") as HTMLInputElement;
expect(input.disabled).toBe(false);
});
it("saving with runStepsInNewSessions true includes both fields in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Execution"));
const checkbox = screen.getByLabelText("Run each step in a new session");
fireEvent.click(checkbox);
const input = screen.getByLabelText("Maximum parallel steps") as HTMLInputElement;
fireEvent.change(input, { target: { value: "3" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.runStepsInNewSessions).toBe(true);
expect(payload.maxParallelSteps).toBe(3);
});
});