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:
9
.changeset/add-step-session-execution.md
Normal file
9
.changeset/add-step-session-execution.md
Normal 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
|
||||
@@ -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't overlap (1-4)</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "commands":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user