feat(FN-4371): complete remaining decision-only task handling

Fusion-Task-Id: FN-4371
Fusion-Task-Lineage: 4175ba4c-b7cb-4f3a-87c5-75e51f06c0ff
This commit is contained in:
Fusion
2026-05-14 09:52:19 -07:00
committed by gsxdsm
parent c8a2caf948
commit 77308601e5
9 changed files with 188 additions and 1 deletions

View File

@@ -132,7 +132,8 @@
.card-status-badge,
.card-priority-badge,
.card-size-badge {
.card-size-badge,
.card-no-commits-expected-badge {
display: inline-flex;
align-items: center;
font-size: 0.625rem;
@@ -148,6 +149,13 @@
letter-spacing: 0.5px;
}
.card-no-commits-expected-badge {
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 35%, transparent);
color: var(--text-muted);
text-transform: lowercase;
}
.card-status-badge.stalled-review {
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--color-warning);

View File

@@ -1519,6 +1519,9 @@ function TaskCardComponent({
<span className="visually-hidden">Fast mode</span>
</span>
)}
{task.noCommitsExpected === true && (
<span className="card-no-commits-expected-badge" title="Decision-only task">decision-only</span>
)}
{task.missionId && (
<span
className="card-mission-badge"

View File

@@ -612,6 +612,8 @@ export function TaskDetailContent({
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false);
const [inlineNoCommitsExpected, setInlineNoCommitsExpected] = useState<boolean>(task.noCommitsExpected === true);
const [isSavingInlineNoCommitsExpected, setIsSavingInlineNoCommitsExpected] = useState(false);
const mountedRef = useRef(false);
// Split-menu dropdown state for footer actions
@@ -689,6 +691,10 @@ export function TaskDetailContent({
setInlineExecutionMode(normalizeExecutionModeValue(task.executionMode));
}, [task.id, task.executionMode]);
useEffect(() => {
setInlineNoCommitsExpected(task.noCommitsExpected === true);
}, [task.id, task.noCommitsExpected]);
useEffect(() => {
if (githubTrackingEnabledDraft === null) return;
if ((workingTask.githubTracking?.enabled === true) === githubTrackingEnabledDraft) {
@@ -1231,6 +1237,29 @@ export function TaskDetailContent({
}
}, [task.id, task.executionMode, projectId, inlineExecutionMode, onTaskUpdated, addToast]);
const handleInlineNoCommitsExpectedToggle = useCallback(async () => {
const nextValue = !inlineNoCommitsExpected;
const previousValue = inlineNoCommitsExpected;
setInlineNoCommitsExpected(nextValue);
setIsSavingInlineNoCommitsExpected(true);
try {
const updatedTask = await updateTask(task.id, { noCommitsExpected: nextValue }, projectId);
const normalizedUpdatedValue = updatedTask.noCommitsExpected === true;
setInlineNoCommitsExpected(normalizedUpdatedValue);
onTaskUpdated?.(updatedTask);
addToast(`No-commits expectation ${normalizedUpdatedValue ? "enabled" : "disabled"}`, "success");
} catch (err) {
setInlineNoCommitsExpected(previousValue);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) {
setIsSavingInlineNoCommitsExpected(false);
}
}
}, [task.id, projectId, inlineNoCommitsExpected, onTaskUpdated, addToast]);
// Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
if (!isEditing) return;
@@ -2147,6 +2176,21 @@ export function TaskDetailContent({
<span>{inlineExecutionMode === "fast" ? "Fast" : "Standard"}</span>
</button>
</div>
<div className="form-group">
<label className="checkbox-label" htmlFor="detail-no-commits-expected-toggle">
<input
id="detail-no-commits-expected-toggle"
type="checkbox"
checked={inlineNoCommitsExpected}
disabled={isSavingInlineNoCommitsExpected}
onChange={() => {
void handleInlineNoCommitsExpectedToggle();
}}
/>
No commits expected (decision-only task)
</label>
<small>Allows the task to complete without producing git commits. Use for evaluation, verification, or audit tasks where the deliverable is the recorded decision.</small>
</div>
{provenanceDisplay && (
<div className="detail-provenance">
<GitBranch aria-hidden="true" />

View File

@@ -578,6 +578,16 @@ describe("TaskCard", () => {
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("renders decision-only badge when noCommitsExpected is true", () => {
render(<TaskCard task={makeTask({ noCommitsExpected: true })} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByText("decision-only")).toBeTruthy();
});
it("hides decision-only badge when noCommitsExpected is false", () => {
render(<TaskCard task={makeTask({ noCommitsExpected: false })} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByText("decision-only")).toBeNull();
});
it("does not render fan-out badge when fanout is missing or zero", () => {
const { container, rerender } = render(
<TaskCard task={makeTask({ column: "todo" })} onOpenDetail={noop} addToast={noop} />,

View File

@@ -1159,6 +1159,30 @@ describe("TaskDetailModal", () => {
});
});
it("toggles no-commits-expected checkbox and patches task", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", noCommitsExpected: true }) as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", noCommitsExpected: false })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByLabelText("No commits expected (decision-only task)"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { noCommitsExpected: true }, undefined);
});
});
it("pre-populates form with existing task values", () => {
const { container } = render(
<TaskDetailModal

View File

@@ -5984,3 +5984,45 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-502", "in-review");
});
});
describe("SelfHealingManager no-commits-expected audit", () => {
it("logs candidate task IDs without mutating tasks", async () => {
const store = createMockStore();
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const now = new Date().toISOString();
const candidate = {
id: "FN-900",
column: "in-review",
status: "failed",
error: "fn_task_done refused: no_commits",
noCommitsExpected: undefined,
branch: "fusion/fn-900",
baseBranch: "main",
paused: false,
steps: [{ id: "s1", name: "done", status: "done" }],
log: [],
createdAt: now,
updatedAt: now,
description: "audit",
dependencies: [],
currentStep: 1,
} as unknown as Task;
vi.mocked(store.listTasks)
.mockResolvedValueOnce([candidate])
.mockResolvedValueOnce([candidate]);
mockedExecSync.mockImplementation((command: string) => {
if (command.includes("git rev-list --count")) {
return Buffer.from("0\n");
}
return Buffer.from("ok\n");
});
const count = await manager.auditNoCommitsExpectedCandidates();
expect(count).toBe(1);
expect(getSelfHealingLogger().warn).toHaveBeenCalledWith(expect.stringContaining("FN-900"));
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
});
});

View File

@@ -412,6 +412,7 @@ export class SelfHealingManager {
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates().then(() => undefined) },
];
for (const step of steps) {
@@ -1114,6 +1115,7 @@ export class SelfHealingManager {
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates() },
];
for (const fn of batch2Fns) {
try {
@@ -2825,6 +2827,44 @@ export class SelfHealingManager {
}
}
async auditNoCommitsExpectedCandidates(): Promise<number> {
try {
const inReviewTasks = await this.store.listTasks({ column: "in-review", slim: true });
const allTasks = await this.store.listTasks({ slim: true });
const failedTasks = allTasks.filter((task) => task.status === "failed");
const candidateMap = new Map<string, Task>();
for (const task of [...inReviewTasks, ...failedTasks]) {
candidateMap.set(task.id, task);
}
const candidates = [...candidateMap.values()].filter((task) => {
if (task.noCommitsExpected === true) return false;
if (task.steps.length === 0 || !task.steps.every((step) => step.status === "done" || step.status === "skipped")) return false;
const noCommitsError = typeof task.error === "string" && /no_commits/i.test(task.error);
return task.column === "in-review" || noCommitsError;
});
if (candidates.length === 0) return 0;
const taskIds: string[] = [];
for (const task of candidates) {
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch || "main");
if (ahead && ahead.aheadCount === 0) {
taskIds.push(task.id);
}
}
if (taskIds.length > 0) {
log.warn(`no-commits-expected audit candidates: ${JSON.stringify({ taskIds })}`);
}
return taskIds.length;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`No-commits-expected audit failed: ${errorMessage}`);
return 0;
}
}
/**
* Recover executor tasks stranded in `in-progress` before a real session was
* established, typically when the scheduler reserved a worktree path but the