feat(FN-3479): add agent heartbeat monitoring, auto-claim setting, and work

The merge introduces the auto-claim setting feature, enabling agents to automatically claim tasks on assignment, with corresponding UI in AgentDetailView and documentation. It also adds the agent heartbeat execution system to the engine, refinements to workflow results styling and design tokens, aut

Fusion-Task-Id: FN-3479
This commit is contained in:
Fusion
2026-05-05 01:34:57 -07:00
committed by gsxdsm
parent 2326a04c4d
commit 8a30b6f7a5
4 changed files with 334 additions and 40 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Deduplicate auto-merge recovery follow-up task creation so repeated verification-cap and conflict-bounce-cap failures reuse an existing active recovery task instead of spawning duplicates.

View File

@@ -114,6 +114,8 @@ Use supported TaskStore/API paths to reconcile safely:
Completion gating treats dependencies as resolved only when the dependency task is in `done`, `in-review`, or `archived`.
Auto-merge recovery follow-up creation is deduplicated: Fusion creates at most one active (`not done/archived`) recovery task per unresolved parent failure, and merge-conflict recovery also deduplicates by active branch ownership to prevent parallel duplicate follow-ups on the same conflict branch.
## Task Execution Modes
Each task has an execution mode that controls how the executor agent approaches the task:

View File

@@ -31,7 +31,7 @@ import { aiMergeTask } from "../merger.js";
type MockTask = {
id: string;
title?: string;
column: "in-review";
column: "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
mergeRetries: number;
status: string | null;
error: string | null;
@@ -39,6 +39,8 @@ type MockTask = {
mergeConflictBounceCount?: number;
branch?: string;
worktree?: string;
sourceType?: string;
sourceParentTaskId?: string;
updatedAt: string;
log: Array<{ action?: string }>;
};
@@ -72,10 +74,12 @@ function makeTask(overrides: Partial<MockTask> = {}): MockTask {
function makeStore({
tasks,
listedTasks,
settings,
updateTask,
}: {
tasks?: Array<MockTask | null>;
listedTasks?: MockTask[];
settings?: Partial<Settings>;
updateTask?: ReturnType<typeof vi.fn>;
} = {}): MockTaskStore {
@@ -91,7 +95,7 @@ function makeStore({
pollIntervalMs: 15_000,
...settings,
})),
listTasks: vi.fn(async () => taskSequence.filter((task): task is MockTask => Boolean(task))),
listTasks: vi.fn(async () => listedTasks ?? taskSequence.filter((task): task is MockTask => Boolean(task))),
getTask: vi.fn(async () => {
const value = taskSequence[Math.min(taskIdx, taskSequence.length - 1)] ?? null;
taskIdx += 1;
@@ -327,6 +331,188 @@ describe("ProjectEngine merge error recovery", () => {
);
});
it("skips duplicate conflict follow-up creation when active recovery task exists for same parent", async () => {
const store = makeStore({
tasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
],
listedTasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
{
...makeTask({ id: "FN-7778", column: "triage", branch: "fusion/fn-2084" }),
sourceType: "recovery",
sourceParentTaskId: TASK_ID,
},
],
});
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.createTask).not.toHaveBeenCalled();
expect(store.addTaskComment).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("follow-up already exists (FN-7778"),
"agent",
);
expect(store.logEntry).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("skipped duplicate follow-up (existing FN-7778"),
"MergeConflictGiveUp",
);
});
it("skips conflict follow-up creation when another active recovery owns the same branch", async () => {
const store = makeStore({
tasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
],
listedTasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
{
...makeTask({ id: "FN-8888", column: "todo", branch: "fusion/fn-2084" }),
sourceType: "recovery",
sourceParentTaskId: "FN-1111",
},
],
});
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.createTask).not.toHaveBeenCalled();
expect(store.addTaskComment).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("already owns branch `fusion/fn-2084`"),
"agent",
);
});
it("creates a new conflict follow-up when previous recovery tasks are done or archived", async () => {
const store = makeStore({
tasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
],
listedTasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
{
...makeTask({ id: "FN-9001", column: "done", branch: "fusion/fn-2084" }),
sourceType: "recovery",
sourceParentTaskId: TASK_ID,
},
{
...makeTask({ id: "FN-9002", column: "archived", branch: "fusion/fn-2084" }),
sourceType: "recovery",
sourceParentTaskId: "FN-1111",
},
],
});
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
source: {
sourceType: "recovery",
sourceParentTaskId: TASK_ID,
},
}),
);
});
it("skips duplicate conflict follow-up creation when active recovery exists for parent", async () => {
const store = makeStore({
tasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
],
listedTasks: [
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
{
...makeTask({ id: "FN-7777", column: "triage", branch: "fusion/fn-2084" }),
sourceType: "recovery",
sourceParentTaskId: TASK_ID,
},
],
});
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.createTask).not.toHaveBeenCalled();
expect(store.addTaskComment).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("Skipping duplicate follow-up creation"),
"agent",
);
expect(store.logEntry).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("skipped duplicate follow-up (existing FN-7777"),
"MergeConflictGiveUp",
);
});
it("skips conflict follow-up creation when active recovery already owns same branch", async () => {
const store = makeStore({
tasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
],
listedTasks: [
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
{
...makeTask({ id: "FN-8888", column: "todo", branch: "fusion/fn-2084" }),
sourceType: "recovery",
sourceParentTaskId: "FN-OTHER",
},
],
});
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.createTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("active recovery already owns branch"),
"MergeConflictGiveUp",
);
});
it("creates new conflict follow-up when prior recovery is archived", async () => {
const store = makeStore({
tasks: [
makeTask({ mergeRetries: 2, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
],
listedTasks: [
makeTask({ mergeRetries: 3, mergeConflictBounceCount: 2, branch: "fusion/fn-2084" }),
{
...makeTask({ id: "FN-6666", column: "archived", branch: "fusion/fn-2084" }),
sourceType: "recovery",
sourceParentTaskId: TASK_ID,
},
],
});
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("merge conflict detected"));
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ column: "triage", priority: "high" }),
);
});
it("stores terminal merge metadata for non-conflict direct merge errors", async () => {
const store = makeStore();
vi.mocked(aiMergeTask).mockRejectedValueOnce(new Error("remote branch missing"));
@@ -477,6 +663,39 @@ describe("ProjectEngine merge error recovery", () => {
);
});
it("skips duplicate verification follow-up creation when active recovery task exists", async () => {
const verificationError = new Error("Deterministic test verification failed");
verificationError.name = "VerificationError";
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
const store = makeStore({
tasks: [makeTask({ verificationFailureCount: 2, title: "do the thing" })],
listedTasks: [
makeTask({ verificationFailureCount: 2, title: "do the thing" }),
{
...makeTask({ id: "FN-7777", column: "triage" }),
sourceType: "recovery",
sourceParentTaskId: TASK_ID,
},
],
});
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.createTask).not.toHaveBeenCalled();
expect(store.addTaskComment).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("Reusing existing follow-up FN-7777"),
"agent",
);
expect(store.logEntry).toHaveBeenCalledWith(
TASK_ID,
expect.stringContaining("skipped creating duplicate follow-up (existing FN-7777)"),
"VerificationError",
);
});
it("logs when verification-error recovery fails", async () => {
const verificationError = new Error("Deterministic test verification failed");
verificationError.name = "VerificationError";

View File

@@ -1107,6 +1107,32 @@ export class ProjectEngine {
return eligible.length;
}
private async findActiveRecoveryFollowUp(
store: TaskStore,
parentTaskId: string,
branch?: string,
): Promise<{ task: Task; reason: "parent" | "branch" } | null> {
const tasks = await store.listTasks({ slim: true }).catch(() => [] as Task[]);
const activeRecoveryTasks = tasks.filter(
(task) =>
task.column !== "done" &&
task.column !== "archived" &&
task.sourceType === "recovery",
);
const sameParent = activeRecoveryTasks.find(
(task) => task.sourceParentTaskId === parentTaskId,
);
if (sameParent) return { task: sameParent, reason: "parent" };
if (branch) {
const sameBranch = activeRecoveryTasks.find((task) => task.branch === branch);
if (sameBranch) return { task: sameBranch, reason: "branch" };
}
return null;
}
private async drainMergeQueue(): Promise<void> {
if (this.mergeRunning) return;
this.mergeRunning = true;
@@ -1368,28 +1394,45 @@ export class ProjectEngine {
`Investigate repeated ${failedKind} verification failure on ${taskId} (${taskOnErr.title || "untitled"}). ` +
`Auto-merge attempted to fix and re-verify ${nextBounces} times without success — likely a flaky test or unrelated regression rather than a fix this task can produce on its own. ` +
`Look at the most recent [verification] log entries on ${taskId} for the failing command and output, then either fix the underlying issue or quarantine the flake.`;
const followUp = await store.createTask({
description: followUpDescription,
column: "triage",
priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
});
await store.addTaskComment(
taskId,
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Created follow-up ${followUp.id} to investigate.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge gave up after ${nextBounces} verification-failure bounces — created follow-up ${followUp.id}`,
"VerificationError",
);
runtimeLog.warn(
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and created follow-up ${followUp.id}`,
);
const existingFollowUp = await this.findActiveRecoveryFollowUp(store, taskId);
if (existingFollowUp) {
await store.addTaskComment(
taskId,
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Reusing existing follow-up ${existingFollowUp.task.id}.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge gave up after ${nextBounces} verification-failure bounces — skipped creating duplicate follow-up (existing ${existingFollowUp.task.id})`,
"VerificationError",
);
runtimeLog.warn(
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — skipped duplicate follow-up (existing ${existingFollowUp.task.id})`,
);
} else {
const followUp = await store.createTask({
description: followUpDescription,
column: "triage",
priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
});
await store.addTaskComment(
taskId,
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Created follow-up ${followUp.id} to investigate.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge gave up after ${nextBounces} verification-failure bounces — created follow-up ${followUp.id}`,
"VerificationError",
);
runtimeLog.warn(
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and created follow-up ${followUp.id}`,
);
}
} catch (followUpErr) {
runtimeLog.error(
`Auto-merge: failed to fail-and-followup ${taskId} after verification cap: ${followUpErr instanceof Error ? followUpErr.message : String(followUpErr)}`,
@@ -1503,24 +1546,49 @@ export class ProjectEngine {
// auto-resolve is just disabled, the user is presumed to
// be handling merges manually and a follow-up is noise.
try {
const followUp = await store.createTask({
description:
`Resolve auto-merge conflict on ${taskId} (${taskOnErr.title || "untitled"}). ` +
`Auto-merge attempted to rebase + resolve ${nextBounces - 1} times against main and exhausted retries each pass. ` +
`Branch: \`${taskOnErr.branch ?? "?"}\`. Worktree: \`${taskOnErr.worktree ?? "?"}\`. ` +
`Last merge error: ${errorMsg}`,
column: "triage",
priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
});
await store.addTaskComment(
const existingFollowUp = await this.findActiveRecoveryFollowUp(
store,
taskId,
`Created follow-up ${followUp.id} to track manual conflict resolution.`,
"agent",
taskOnErr.branch,
);
if (existingFollowUp) {
const dedupReason =
existingFollowUp.reason === "branch"
? `active recovery already owns branch \`${taskOnErr.branch ?? "?"}\``
: "active recovery already exists for this parent task";
await store.addTaskComment(
taskId,
`Auto-merge recovery follow-up already exists (${existingFollowUp.task.id}; ${dedupReason}). Skipping duplicate follow-up creation.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge conflict recovery skipped duplicate follow-up (existing ${existingFollowUp.task.id}; ${dedupReason})`,
"MergeConflictGiveUp",
);
runtimeLog.warn(
`Auto-merge: ${taskId} conflict give-up skipped duplicate follow-up (existing ${existingFollowUp.task.id}; reason=${existingFollowUp.reason})`,
);
} else {
const followUp = await store.createTask({
description:
`Resolve auto-merge conflict on ${taskId} (${taskOnErr.title || "untitled"}). ` +
`Auto-merge attempted to rebase + resolve ${nextBounces - 1} times against main and exhausted retries each pass. ` +
`Branch: \`${taskOnErr.branch ?? "?"}\`. Worktree: \`${taskOnErr.worktree ?? "?"}\`. ` +
`Last merge error: ${errorMsg}`,
column: "triage",
priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
});
await store.addTaskComment(
taskId,
`Created follow-up ${followUp.id} to track manual conflict resolution.`,
"agent",
);
}
} catch (followUpErr) {
runtimeLog.warn(
`Auto-merge: failed to create follow-up for ${taskId}: ${followUpErr instanceof Error ? followUpErr.message : String(followUpErr)}`,