feat(FN-1647): merge fusion/fn-1647
This commit is contained in:
13
.changeset/spec-staleness-settings-ui.md
Normal file
13
.changeset/spec-staleness-settings-ui.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add spec staleness controls to dashboard settings UI
|
||||||
|
|
||||||
|
- New "Enable specification staleness enforcement" checkbox in Settings → Scheduling section
|
||||||
|
- Numeric input for "Stale Spec Threshold (hours)" with automatic hours ↔ milliseconds conversion
|
||||||
|
- Threshold input disabled when enforcement toggle is off
|
||||||
|
- Previously configured threshold preserved when toggling off/on
|
||||||
|
- Helper text explains the conversion formula and default (6 hours = 21,600,000 ms)
|
||||||
|
- Regression tests cover defaults, display conversion, boundary cases, and payload semantics
|
||||||
|
- Updated README and settings-reference.md documentation
|
||||||
14
README.md
14
README.md
@@ -603,6 +603,7 @@ The engine automatically recovers from transient failures using bounded exponent
|
|||||||
- **Recoverable failures** — Transient errors trigger retry with increasing delay (60s → 120s → 240s, capped at 5 minutes). Up to 3 retries before permanent failure.
|
- **Recoverable failures** — Transient errors trigger retry with increasing delay (60s → 120s → 240s, capped at 5 minutes). Up to 3 retries before permanent failure.
|
||||||
- **Stale worktree/branch references** — Automatic pruning, branch deletion, and force-ref cleanup.
|
- **Stale worktree/branch references** — Automatic pruning, branch deletion, and force-ref cleanup.
|
||||||
- **Stuck task detection** — When `taskStuckTimeoutMs` is set, tasks with no agent activity are terminated and re-queued. Detects both dead sessions (no heartbeats) and loops (active but no step progress). Loop recovery attempts compact-and-resume before kill/requeue.
|
- **Stuck task detection** — When `taskStuckTimeoutMs` is set, tasks with no agent activity are terminated and re-queued. Detects both dead sessions (no heartbeats) and loops (active but no step progress). Loop recovery attempts compact-and-resume before kill/requeue.
|
||||||
|
- **Specification staleness** — When `specStalenessEnabled` is `true`, tasks with specifications older than `specStalenessMaxAgeMs` are automatically sent back to triage for re-specification. Default: 6 hours (21600000 ms).
|
||||||
- **Context-limit recovery** — When an LLM returns context-window overflow, the executor compacts the session and resumes with a fresh prompt.
|
- **Context-limit recovery** — When an LLM returns context-window overflow, the executor compacts the session and resumes with a fresh prompt.
|
||||||
|
|
||||||
### Project Memory
|
### Project Memory
|
||||||
@@ -716,6 +717,8 @@ Project settings override global settings. Configure in the dashboard under **Se
|
|||||||
| `smartConflictResolution` | Project | true | Auto-resolve lock/generated files |
|
| `smartConflictResolution` | Project | true | Auto-resolve lock/generated files |
|
||||||
| `requirePlanApproval` | Project | false | Manual approval for AI specs |
|
| `requirePlanApproval` | Project | false | Manual approval for AI specs |
|
||||||
| `taskStuckTimeoutMs` | Project | - | Stuck task detection timeout (ms) |
|
| `taskStuckTimeoutMs` | Project | - | Stuck task detection timeout (ms) |
|
||||||
|
| `specStalenessEnabled` | Project | false | Enable specification staleness enforcement |
|
||||||
|
| `specStalenessMaxAgeMs` | Project | 21600000 | Max spec age before re-triaging (ms, default 6h) |
|
||||||
| `runStepsInNewSessions` | Project | false | Run each task step in its own agent session |
|
| `runStepsInNewSessions` | Project | false | Run each task step in its own agent session |
|
||||||
| `maxParallelSteps` | Project | 2 | Max concurrent steps when per-step sessions are enabled |
|
| `maxParallelSteps` | Project | 2 | Max concurrent steps when per-step sessions are enabled |
|
||||||
| `worktreeNaming` | Project | random | Worktree naming: random/task-id/task-title |
|
| `worktreeNaming` | Project | random | Worktree naming: random/task-id/task-title |
|
||||||
@@ -753,6 +756,17 @@ Automatically resolves lock files ("ours"), generated files ("theirs"), and triv
|
|||||||
```
|
```
|
||||||
Terminates and retries tasks with no agent activity for 10 minutes. Detects dead sessions and loops separately, with compact-and-resume recovery for loops.
|
Terminates and retries tasks with no agent activity for 10 minutes. Detects dead sessions and loops separately, with compact-and-resume recovery for loops.
|
||||||
|
|
||||||
|
**Specification Staleness:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"specStalenessEnabled": true,
|
||||||
|
"specStalenessMaxAgeMs": 21600000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
When enabled, tasks with specifications (PROMPT.md) older than the configured threshold are automatically sent back to triage for re-specification. Default: 6 hours (21600000 ms). Stored in milliseconds; dashboard UI accepts hours and converts automatically.
|
||||||
|
|
||||||
**Push Notifications:**
|
**Push Notifications:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `reviewHandoffPolicy` | `"disabled" \| "comment-triggered" \| "always"` | `"disabled"` | Policy for agent-to-user review handoff. |
|
| `reviewHandoffPolicy` | `"disabled" \| "comment-triggered" \| "always"` | `"disabled"` | Policy for agent-to-user review handoff. |
|
||||||
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button. Chat accessible from More menu when hidden. |
|
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button. Chat accessible from More menu when hidden. |
|
||||||
| `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. |
|
| `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. |
|
||||||
|
| `specStalenessEnabled` | `boolean` | `false` | Enable automatic re-triaging of tasks with stale specifications. |
|
||||||
|
| `specStalenessMaxAgeMs` | `number` | `21600000` | Maximum age in ms before a specification (PROMPT.md) is considered stale and requires re-specification. Default: 6 hours. |
|
||||||
| `autoUnpauseEnabled` | `boolean` | `true` | Auto-unpause after rate-limit-triggered pauses. |
|
| `autoUnpauseEnabled` | `boolean` | `true` | Auto-unpause after rate-limit-triggered pauses. |
|
||||||
| `autoUnpauseBaseDelayMs` | `number` | `300000` | Base unpause retry delay in ms (5 min). |
|
| `autoUnpauseBaseDelayMs` | `number` | `300000` | Base unpause retry delay in ms (5 min). |
|
||||||
| `autoUnpauseMaxDelayMs` | `number` | `3600000` | Max unpause delay cap in ms (1 hour). |
|
| `autoUnpauseMaxDelayMs` | `number` | `3600000` | Max unpause delay cap in ms (1 hour). |
|
||||||
|
|||||||
@@ -1317,6 +1317,37 @@ export function SettingsModal({
|
|||||||
/>
|
/>
|
||||||
<small>Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
|
<small>Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="specStalenessEnabled" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="specStalenessEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.specStalenessEnabled || false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Enable specification staleness enforcement
|
||||||
|
</label>
|
||||||
|
<small>When enabled, tasks with stale specifications (PROMPT.md older than the threshold) are automatically sent back to triage for re-specification</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="specStalenessMaxAgeMs">Stale Spec Threshold (hours)</label>
|
||||||
|
<input
|
||||||
|
id="specStalenessMaxAgeMs"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={form.specStalenessMaxAgeMs !== undefined ? Math.round(form.specStalenessMaxAgeMs / 3600000) : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
const num = Number(val);
|
||||||
|
setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined }));
|
||||||
|
}}
|
||||||
|
disabled={!form.specStalenessEnabled}
|
||||||
|
/>
|
||||||
|
<small>Maximum age in hours before a specification is considered stale. Default: 6 (21600000 ms). Stored as milliseconds internally.</small>
|
||||||
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="maxStuckKills">Max Stuck Retries</label>
|
<label htmlFor="maxStuckKills">Max Stuck Retries</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const defaultSettings: Settings = {
|
|||||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||||||
taskStuckTimeoutMs: undefined,
|
taskStuckTimeoutMs: undefined,
|
||||||
maxStuckKills: 6,
|
maxStuckKills: 6,
|
||||||
|
specStalenessEnabled: false,
|
||||||
|
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
||||||
runStepsInNewSessions: false,
|
runStepsInNewSessions: false,
|
||||||
maxParallelSteps: 2,
|
maxParallelSteps: 2,
|
||||||
showQuickChatFAB: false,
|
showQuickChatFAB: false,
|
||||||
@@ -70,7 +72,7 @@ vi.mock("../PluginManager", () => ({
|
|||||||
)),
|
)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchPlugins } from "../../api";
|
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification } from "../../api";
|
||||||
|
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
@@ -2316,6 +2318,162 @@ describe("SettingsModal", () => {
|
|||||||
expect(payload.maxStuckKills).toBeUndefined();
|
expect(payload.maxStuckKills).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Specification Staleness field tests ---
|
||||||
|
|
||||||
|
it("shows Specification Staleness fields in Scheduling section", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable specification staleness enforcement");
|
||||||
|
expect(checkbox).toBeTruthy();
|
||||||
|
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||||
|
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)");
|
||||||
|
expect(thresholdInput).toBeTruthy();
|
||||||
|
expect(thresholdInput.getAttribute("type")).toBe("number");
|
||||||
|
expect(thresholdInput.getAttribute("min")).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Specification Staleness threshold input is disabled when toggle is off", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||||
|
expect(thresholdInput).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Specification Staleness threshold input is enabled when toggle is on", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||||
|
expect(thresholdInput).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Specification Staleness threshold displays rounded hours from milliseconds", async () => {
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
specStalenessEnabled: true,
|
||||||
|
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||||
|
expect(thresholdInput.value).toBe("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Specification Staleness threshold converts hours to milliseconds on save", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||||
|
fireEvent.change(thresholdInput, { target: { value: "12" } });
|
||||||
|
expect(thresholdInput.value).toBe("12");
|
||||||
|
|
||||||
|
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.specStalenessEnabled).toBe(true);
|
||||||
|
expect(payload.specStalenessMaxAgeMs).toBe(12 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Specification Staleness threshold of 0 hours persists 0 milliseconds", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||||
|
fireEvent.change(thresholdInput, { target: { value: "0" } });
|
||||||
|
expect(thresholdInput.value).toBe("0");
|
||||||
|
|
||||||
|
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.specStalenessEnabled).toBe(true);
|
||||||
|
expect(payload.specStalenessMaxAgeMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Specification Staleness enabled with empty threshold submits without invalid numeric payload", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||||
|
fireEvent.change(thresholdInput, { target: { value: "" } });
|
||||||
|
|
||||||
|
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.specStalenessEnabled).toBe(true);
|
||||||
|
expect(payload.specStalenessMaxAgeMs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Disabling Specification Staleness retains configured max age", async () => {
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
specStalenessEnabled: true,
|
||||||
|
specStalenessMaxAgeMs: 8 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||||
|
expect(thresholdInput.value).toBe("8");
|
||||||
|
expect(thresholdInput).not.toBeDisabled();
|
||||||
|
|
||||||
|
// Disable the toggle
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
expect(thresholdInput).toBeDisabled();
|
||||||
|
|
||||||
|
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.specStalenessEnabled).toBe(false);
|
||||||
|
expect(payload.specStalenessMaxAgeMs).toBe(8 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Specification Staleness helper text is visible", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Scheduling"));
|
||||||
|
expect(screen.getByText(/Maximum age in hours before a specification is considered stale/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/When enabled, tasks with stale specifications/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("scope banners render for global and project sections with theme-aware icons", async () => {
|
it("scope banners render for global and project sections with theme-aware icons", async () => {
|
||||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|||||||
Reference in New Issue
Block a user