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