feat(FN-3382): add agent self-improvement service with heartbeat evaluation

This merge introduces autosave for agent and task detail settings in the dashboard (`TaskDetailModal.tsx`, `AgentDetailView.tsx`), adds an agent self-improvement service with heartbeat evaluation logic (`agent-self-improve.ts`, `agent-heartbeat.ts`), and expands agent tools for direct-report managem

Fusion-Task-Id: FN-3382
This commit is contained in:
Fusion
2026-05-05 04:09:55 -07:00
committed by gsxdsm
parent 499417bc8e
commit 9defb7616c
3 changed files with 211 additions and 162 deletions

View File

@@ -3563,6 +3563,19 @@ function ConfigTab({
await persistSettings(true, "manual");
};
const scheduleAutoSave = useCallback(() => {
if (!hasChanges || isSaving) {
return;
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
void persistSettings(false, "auto");
}, [hasChanges, isSaving, persistSettings, validationErrors]);
useEffect(() => {
if (!hasChanges || isSaving) {
return;
@@ -3605,6 +3618,7 @@ function ConfigTab({
className="input"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={() => { void scheduleAutoSave(); }}
/>
</div>
@@ -3614,7 +3628,10 @@ function ConfigTab({
id="agent-role"
className="select"
value={roleValue}
onChange={(e) => setRoleValue(e.target.value as AgentCapability)}
onChange={(e) => {
setRoleValue(e.target.value as AgentCapability);
void scheduleAutoSave();
}}
>
<option value="triage">Triage</option>
<option value="executor">Executor</option>
@@ -3634,6 +3651,7 @@ function ConfigTab({
placeholder="e.g. Senior Code Reviewer"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={() => { void scheduleAutoSave(); }}
/>
</div>
@@ -3646,6 +3664,7 @@ function ConfigTab({
placeholder="e.g. 🤖"
value={iconValue}
onChange={(e) => setIconValue(e.target.value)}
onBlur={() => { void scheduleAutoSave(); }}
/>
</div>
@@ -3655,7 +3674,10 @@ function ConfigTab({
id="agent-reports-to"
className="select"
value={reportsToValue}
onChange={(e) => setReportsToValue(e.target.value)}
onChange={(e) => {
setReportsToValue(e.target.value);
void scheduleAutoSave();
}}
disabled={isLoadingManagers}
>
<option value="">No manager</option>
@@ -3684,7 +3706,10 @@ function ConfigTab({
id="agent-skills"
label="Skills"
value={selectedSkills}
onChange={setSelectedSkills}
onChange={(nextSkills) => {
setSelectedSkills(nextSkills);
void scheduleAutoSave();
}}
projectId={projectId}
/>
</div>
@@ -3710,6 +3735,7 @@ function ConfigTab({
onClick={() => {
setRuntimeMode("model");
setSelectedRuntimeId("");
void scheduleAutoSave();
}}
>
Built-in Model
@@ -3720,7 +3746,10 @@ function ConfigTab({
role="tab"
aria-selected={runtimeMode === "runtime"}
tabIndex={runtimeMode === "runtime" ? 0 : -1}
onClick={() => setRuntimeMode("runtime")}
onClick={() => {
setRuntimeMode("runtime");
void scheduleAutoSave();
}}
>
Plugin Runtime
</button>
@@ -3732,7 +3761,10 @@ function ConfigTab({
<CustomModelDropdown
models={availableModels}
value={modelValue}
onChange={setModelValue}
onChange={(value) => {
setModelValue(value);
void scheduleAutoSave();
}}
placeholder="Use global default"
label="Agent Model"
disabled={modelsLoading}
@@ -3752,7 +3784,10 @@ function ConfigTab({
id="agent-runtime-hint"
className="select"
value={selectedRuntimeId}
onChange={(e) => setSelectedRuntimeId(e.target.value)}
onChange={(e) => {
setSelectedRuntimeId(e.target.value);
void scheduleAutoSave();
}}
>
<option value="">
{availableRuntimes.length > 0 ? "Select a plugin runtime…" : "No plugin runtimes available"}
@@ -3782,7 +3817,10 @@ function ConfigTab({
id="hb-enabled"
type="checkbox"
checked={heartbeatEnabled}
onChange={(e) => handleHeartbeatEnabledChange(e.target.checked)}
onChange={(e) => {
handleHeartbeatEnabledChange(e.target.checked);
void scheduleAutoSave();
}}
/>
Heartbeat Enabled
</label>
@@ -3795,7 +3833,10 @@ function ConfigTab({
id="hb-autoClaimRelevantTasks"
type="checkbox"
checked={autoClaimRelevantTasksEnabled}
onChange={(e) => setAutoClaimRelevantTasksEnabled(e.target.checked)}
onChange={(e) => {
setAutoClaimRelevantTasksEnabled(e.target.checked);
void scheduleAutoSave();
}}
/>
Auto-Claim Relevant Tasks
</label>

View File

@@ -732,166 +732,168 @@ export function TaskDetailContent({
setEditPendingImages([]);
}, [task.title, task.description, task.dependencies, task.nodeId, task.priority, task.executionMode, editPendingImages]);
const handleSave = useCallback(async () => {
const [editAutoSaveStatus, setEditAutoSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const editAutoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editAutoSaveRevisionRef = useRef(0);
const buildEditUpdates = useCallback((includeDescription: boolean) => {
const updates: Record<string, unknown> = {};
const trimmedTitle = editTitle.trim();
const trimmedDescription = editDescription.trim();
if (trimmedTitle && trimmedTitle !== (task.title ?? "")) updates.title = trimmedTitle;
if (includeDescription && trimmedDescription && trimmedDescription !== (task.description ?? "")) updates.description = trimmedDescription;
if (!sameStringArray(editDependencies, task.dependencies ?? [])) updates.dependencies = editDependencies;
if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) updates.enabledWorkflowSteps = editSelectedWorkflowSteps;
const executorSelection = splitModelSelection(editExecutorModel);
const currentExecutorModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
if (editExecutorModel !== currentExecutorModel) {
updates.modelProvider = executorSelection?.provider ?? null;
updates.modelId = executorSelection?.modelId ?? null;
}
const validatorSelection = splitModelSelection(editValidatorModel);
const currentValidatorModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
if (editValidatorModel !== currentValidatorModel) {
updates.validatorModelProvider = validatorSelection?.provider ?? null;
updates.validatorModelId = validatorSelection?.modelId ?? null;
}
const planningSelection = splitModelSelection(editPlanningModel);
const currentPlanningModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
if (editPlanningModel !== currentPlanningModel) {
updates.planningModelProvider = planningSelection?.provider ?? null;
updates.planningModelId = planningSelection?.modelId ?? null;
}
const currentThinkingLevel = task.thinkingLevel ?? "";
if (editThinkingLevel !== currentThinkingLevel) updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
if ((task.nodeId ?? undefined) !== editNodeId) updates.nodeId = editNodeId ?? null;
if (editReviewLevel !== task.reviewLevel) updates.reviewLevel = editReviewLevel;
if (editPriority !== normalizeTaskPriorityValue(task.priority)) updates.priority = editPriority;
if (editExecutionMode !== normalizeExecutionModeValue(task.executionMode)) updates.executionMode = editExecutionMode === "fast" ? "fast" : null;
const normalizedProvider = normalizeSourceIssueText(editSourceIssueProvider);
const normalizedRepository = normalizeSourceIssueText(editSourceIssueRepository);
const normalizedExternalId = normalizeSourceIssueText(editSourceIssueExternalId);
const normalizedUrl = normalizeSourceIssueUrl(editSourceIssueUrl);
const allSourceFieldsEmpty = normalizedProvider.length === 0 && normalizedRepository.length === 0 && normalizedExternalId.length === 0 && !normalizedUrl;
if (allSourceFieldsEmpty) {
if (task.sourceIssue) updates.sourceIssue = null;
} else {
if (!normalizedProvider || !normalizedRepository || !normalizedExternalId) {
return { updates: null, error: "Source issue provider, repository, and issue identifier are required" };
}
const fallbackIssueNumber = Number.parseInt(normalizedExternalId, 10);
const issueNumber = task.sourceIssue?.issueNumber ?? fallbackIssueNumber;
if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
return { updates: null, error: "Source issue identifier must be numeric for new metadata" };
}
const nextSourceIssue: TaskSourceIssue = {
provider: normalizedProvider,
repository: normalizedRepository,
externalIssueId: normalizedExternalId,
issueNumber,
...(normalizedUrl ? { url: normalizedUrl } : {}),
};
const previousSourceIssue = task.sourceIssue;
const sourceIssueChanged = !previousSourceIssue
|| previousSourceIssue.provider !== nextSourceIssue.provider
|| previousSourceIssue.repository !== nextSourceIssue.repository
|| previousSourceIssue.externalIssueId !== nextSourceIssue.externalIssueId
|| previousSourceIssue.issueNumber !== nextSourceIssue.issueNumber
|| (previousSourceIssue.url ?? undefined) !== nextSourceIssue.url;
if (sourceIssueChanged) updates.sourceIssue = nextSourceIssue;
}
return { updates, error: null as string | null };
}, [editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editTitle, editValidatorModel, task]);
const persistEditChanges = useCallback(async (includeDescription: boolean) => {
const { updates, error } = buildEditUpdates(includeDescription);
if (!updates) {
setEditAutoSaveStatus("error");
if (error) {
addToast(`Failed to update ${task.id}: ${error}`, "error");
}
return false;
}
if (Object.keys(updates).length === 0) {
return true;
}
const revision = ++editAutoSaveRevisionRef.current;
setIsSaving(true);
setEditAutoSaveStatus("saving");
try {
const updates: Record<string, unknown> = {};
const trimmedTitle = editTitle.trim();
const trimmedDescription = editDescription.trim();
if (trimmedTitle && trimmedTitle !== (task.title ?? "")) {
updates.title = trimmedTitle;
}
if (trimmedDescription && trimmedDescription !== (task.description ?? "")) {
updates.description = trimmedDescription;
}
if (!sameStringArray(editDependencies, task.dependencies ?? [])) {
updates.dependencies = editDependencies;
}
if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) {
updates.enabledWorkflowSteps = editSelectedWorkflowSteps;
}
const executorSelection = splitModelSelection(editExecutorModel);
const currentExecutorModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
if (editExecutorModel !== currentExecutorModel) {
updates.modelProvider = executorSelection?.provider ?? null;
updates.modelId = executorSelection?.modelId ?? null;
}
const validatorSelection = splitModelSelection(editValidatorModel);
const currentValidatorModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
if (editValidatorModel !== currentValidatorModel) {
updates.validatorModelProvider = validatorSelection?.provider ?? null;
updates.validatorModelId = validatorSelection?.modelId ?? null;
}
const planningSelection = splitModelSelection(editPlanningModel);
const currentPlanningModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
if (editPlanningModel !== currentPlanningModel) {
updates.planningModelProvider = planningSelection?.provider ?? null;
updates.planningModelId = planningSelection?.modelId ?? null;
}
const currentThinkingLevel = task.thinkingLevel ?? "";
if (editThinkingLevel !== currentThinkingLevel) {
updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
}
if ((task.nodeId ?? undefined) !== editNodeId) {
updates.nodeId = editNodeId ?? null;
}
const currentReviewLevel = task.reviewLevel;
if (editReviewLevel !== currentReviewLevel) {
updates.reviewLevel = editReviewLevel;
}
const currentPriority = normalizeTaskPriorityValue(task.priority);
if (editPriority !== currentPriority) {
updates.priority = editPriority;
}
const currentExecutionMode = normalizeExecutionModeValue(task.executionMode);
if (editExecutionMode !== currentExecutionMode) {
updates.executionMode = editExecutionMode === "fast" ? "fast" : null;
}
const normalizedProvider = normalizeSourceIssueText(editSourceIssueProvider);
const normalizedRepository = normalizeSourceIssueText(editSourceIssueRepository);
const normalizedExternalId = normalizeSourceIssueText(editSourceIssueExternalId);
const normalizedUrl = normalizeSourceIssueUrl(editSourceIssueUrl);
const allSourceFieldsEmpty =
normalizedProvider.length === 0
&& normalizedRepository.length === 0
&& normalizedExternalId.length === 0
&& !normalizedUrl;
if (allSourceFieldsEmpty) {
if (task.sourceIssue) {
updates.sourceIssue = null;
}
} else {
if (!normalizedProvider || !normalizedRepository || !normalizedExternalId) {
addToast("Source issue provider, repository, and issue identifier are required", "error");
setIsSaving(false);
return;
}
const fallbackIssueNumber = Number.parseInt(normalizedExternalId, 10);
const issueNumber = task.sourceIssue?.issueNumber ?? fallbackIssueNumber;
if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
addToast("Source issue identifier must be numeric for new metadata", "error");
setIsSaving(false);
return;
}
const nextSourceIssue: TaskSourceIssue = {
provider: normalizedProvider,
repository: normalizedRepository,
externalIssueId: normalizedExternalId,
issueNumber,
...(normalizedUrl ? { url: normalizedUrl } : {}),
};
const previousSourceIssue = task.sourceIssue;
const sourceIssueChanged =
!previousSourceIssue
|| previousSourceIssue.provider !== nextSourceIssue.provider
|| previousSourceIssue.repository !== nextSourceIssue.repository
|| previousSourceIssue.externalIssueId !== nextSourceIssue.externalIssueId
|| previousSourceIssue.issueNumber !== nextSourceIssue.issueNumber
|| (previousSourceIssue.url ?? undefined) !== nextSourceIssue.url;
if (sourceIssueChanged) {
updates.sourceIssue = nextSourceIssue;
}
}
const hasTaskUpdates = Object.keys(updates).length > 0;
if (hasTaskUpdates) {
const updatedTask = await updateTask(task.id, updates as never, projectId);
onTaskUpdated?.(updatedTask);
}
// Upload pending images as attachments
if (editPendingImages.length > 0) {
const failures: string[] = [];
for (const img of editPendingImages) {
try {
const attachment = await uploadAttachment(task.id, img.file, projectId);
setAttachments((prev) => [...prev, attachment]);
} catch {
failures.push(img.file.name);
}
}
if (failures.length > 0) {
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
}
}
// Clean up
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setEditPendingImages([]);
addToast(`Updated ${task.id}`, "success");
setIsEditing(false);
const updatedTask = await updateTask(task.id, updates as never, projectId);
if (revision !== editAutoSaveRevisionRef.current) return;
onTaskUpdated?.(updatedTask);
setEditAutoSaveStatus("saved");
return true;
} catch (err) {
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
if (revision === editAutoSaveRevisionRef.current) {
setEditAutoSaveStatus("error");
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
}
return false;
} finally {
if (mountedRef.current) {
if (mountedRef.current && revision === editAutoSaveRevisionRef.current) {
setIsSaving(false);
}
}
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editNodeId, editReviewLevel, editPriority, editExecutionMode, editSelectedWorkflowSteps, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueExternalId, editSourceIssueUrl, editPendingImages, addToast, projectId, onTaskUpdated]);
}, [addToast, buildEditUpdates, onTaskUpdated, projectId, task.id]);
const handleAutoSaveDescription = useCallback(async (description: string) => {
try {
const updatedTask = await updateTask(task.id, { description }, projectId);
onTaskUpdated?.(updatedTask);
addToast("Description saved", "success");
} catch (err) {
addToast(`Failed to save: ${getErrorMessage(err)}`, "error");
const handleAutoSaveDescription = useCallback(async (_description: string) => {
await persistEditChanges(true);
}, [persistEditChanges]);
const handleSave = useCallback(async () => {
const didSave = await persistEditChanges(true);
if (!didSave) {
return;
}
}, [task.id, addToast, projectId, onTaskUpdated]);
addToast(`Updated ${task.id}`, "success");
if (mountedRef.current) {
setIsEditing(false);
}
}, [addToast, persistEditChanges, task.id]);
useEffect(() => {
if (!isEditing) return;
if (editAutoSaveTimeoutRef.current) {
clearTimeout(editAutoSaveTimeoutRef.current);
}
editAutoSaveTimeoutRef.current = setTimeout(() => {
void persistEditChanges(false);
}, 700);
return () => {
if (editAutoSaveTimeoutRef.current) {
clearTimeout(editAutoSaveTimeoutRef.current);
editAutoSaveTimeoutRef.current = null;
}
};
}, [
isEditing,
editTitle,
editDependencies,
editExecutorModel,
editValidatorModel,
editPlanningModel,
editThinkingLevel,
editNodeId,
editReviewLevel,
editPriority,
editExecutionMode,
editSelectedWorkflowSteps,
editSourceIssueProvider,
editSourceIssueRepository,
editSourceIssueExternalId,
editSourceIssueUrl,
persistEditChanges,
]);
const handleInlinePriorityChange = useCallback(async (nextValue: string) => {
const normalizedNextPriority = normalizeTaskPriorityValue(nextValue as Task["priority"]);
@@ -929,7 +931,7 @@ export function TaskDetailContent({
exitEditMode();
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSave();
void handleSave();
}
}, [isEditing, exitEditMode, handleSave]);
@@ -2410,7 +2412,7 @@ export function TaskDetailContent({
{isEditing ? (
<>
<span className="modal-edit-hint">
<kbd>Ctrl+Enter</kbd> to save · <kbd>Escape</kbd> to cancel
{editAutoSaveStatus === "saving" ? "Autosaving…" : editAutoSaveStatus === "saved" ? "Saved" : editAutoSaveStatus === "error" ? "Save failed" : "Changes autosave as you edit"}
</span>
<div className="modal-actions-spacer" />
<button
@@ -2422,7 +2424,7 @@ export function TaskDetailContent({
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleSave}
onClick={() => void handleSave()}
disabled={isSaving}
>
{isSaving ? "Saving…" : "Save"}