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:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user