fix(engine,core): dedup heartbeat-spawned follow-ups by parent task

Same-agent intake guard now also matches siblings sharing a
sourceParentTaskId, so repeated heartbeats from one parent task
can't bypass dedup just because triage rewrites the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 03:05:46 -07:00
parent 02971efcfe
commit 51fc826199
8 changed files with 250 additions and 20 deletions

View File

@@ -0,0 +1,22 @@
---
"@fusion/core": minor
"@fusion/engine": minor
---
fix(engine,core): dedup heartbeat-spawned follow-ups by parent task
Heartbeat agents create follow-up tasks via `fn_task_create`. Until
now, the intake similarity guard scoped candidates by `sourceAgentId`
only, so the same parent task could spawn many sibling tasks across
heartbeats whenever triage rewrote their titles enough to dodge the
title-fingerprint guard.
The task-scoped heartbeat now stamps `sourceParentTaskId` (and
`sourceRunId`) on every `fn_task_create`, and the intake duplicate
matcher treats a candidate as a sibling when it shares either the
caller's agent ID or the caller's parent task ID. Same-parent
siblings with similar descriptions are auto-archived as before.
Tool description and heartbeat prompts also now instruct agents to
scan existing open tasks before creating, as a belt-and-suspenders
layer above the deterministic dedup.

View File

@@ -16,7 +16,7 @@ describe("findSameAgentDuplicates", () => {
createdAt: nowMs - 60 * 60 * 1000,
sourceAgentId: "agent-x",
}],
{ nowMs },
{ nowMs, sourceAgentId: "agent-x" },
);
expect(matches[0]?.id).toBe("FN-1");
});
@@ -25,16 +25,16 @@ describe("findSameAgentDuplicates", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck", description: "typecheck error" },
[{ id: "FN-1", title: "Fix typecheck", description: "typecheck error", column: "todo", createdAt: nowMs - 25 * 60 * 60 * 1000, sourceAgentId: "agent-x" }],
{ nowMs },
{ nowMs, sourceAgentId: "agent-x" },
);
expect(matches).toEqual([]);
});
it("filters out candidates without source agent", () => {
it("filters out candidates with no shared caller identity", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck", description: "typecheck error" },
[{ id: "FN-1", title: "Fix typecheck", description: "typecheck error", column: "todo", createdAt: nowMs - 60 * 1000, sourceAgentId: null }],
{ nowMs },
{ nowMs, sourceAgentId: "agent-x" },
);
expect(matches).toEqual([]);
});
@@ -43,7 +43,7 @@ describe("findSameAgentDuplicates", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck", description: "typecheck error" },
[{ id: "FN-1", title: "Fix typecheck", description: "typecheck error", column: "archived", createdAt: nowMs - 60 * 1000, sourceAgentId: "agent-x" }],
{ nowMs },
{ nowMs, sourceAgentId: "agent-x" },
);
expect(matches).toEqual([]);
});
@@ -52,8 +52,67 @@ describe("findSameAgentDuplicates", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix parser", description: "parse errors on sync job" },
[{ id: "FN-1", title: "Refactor dashboard layout", description: "button spacing and css", column: "todo", createdAt: nowMs - 60 * 1000, sourceAgentId: "agent-x" }],
{ nowMs },
{ nowMs, sourceAgentId: "agent-x" },
);
expect(matches).toEqual([]);
});
it("matches siblings sharing the same parent task even when sourceAgentId differs", () => {
const matches = findSameAgentDuplicates(
{
title: "Add structured run-audit event for lane selection",
description: "Emit a run-audit event for per-lane provider/runtime selection",
sourceParentTaskId: "FN-5206",
},
[{
id: "FN-5544",
title: "Add structured run-audit event for per-lane provider/runtime selection",
description: "Emit run-audit event recording per-lane provider/runtime selection",
column: "triage",
createdAt: nowMs - 5 * 60 * 1000,
sourceAgentId: "different-agent",
sourceParentTaskId: "FN-5206",
}],
{ nowMs, sourceAgentId: "calling-agent" },
);
expect(matches[0]?.id).toBe("FN-5544");
});
it("does not match sibling with different parent task", () => {
const matches = findSameAgentDuplicates(
{
title: "Add structured run-audit event",
description: "Emit a run-audit event for per-lane provider/runtime selection",
sourceParentTaskId: "FN-5206",
},
[{
id: "FN-5544",
title: "Add structured run-audit event",
description: "Emit a run-audit event for per-lane provider/runtime selection",
column: "triage",
createdAt: nowMs - 5 * 60 * 1000,
sourceAgentId: "agent-x",
sourceParentTaskId: "FN-OTHER",
}],
{ nowMs, sourceAgentId: "agent-y" },
);
expect(matches).toEqual([]);
});
it("falls back to sourceAgentId match when parent is unset", () => {
const matches = findSameAgentDuplicates(
{ title: "Fix typecheck", description: "promisify scrypt causes typecheck error" },
[{
id: "FN-1",
title: "Fix typecheck",
description: "promisify scrypt causes typecheck error",
column: "todo",
createdAt: nowMs - 60 * 60 * 1000,
sourceAgentId: "agent-x",
sourceParentTaskId: null,
}],
{ nowMs, sourceAgentId: "agent-x" },
);
expect(matches[0]?.id).toBe("FN-1");
});
});

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore parent-task duplicate intake", () => {
const harness = createTaskStoreTestHarness();
let store = harness.store();
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("auto-archives a sibling created by the same parent task with similar description", async () => {
const parentId = "FN-PARENT";
const first = await store.createTask({
title: "Add structured run-audit event for per-lane provider/runtime selection",
description:
"Add structured run-audit event recording per-lane provider/runtime selection (FN-5206 deferral)",
column: "triage",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-alpha",
sourceParentTaskId: parentId,
},
});
const second = await store.createTask({
title: "Emit run-audit event capturing lane provider/runtime selection",
description:
"Emit a structured run-audit event capturing per-lane provider/runtime selection for FN-5206 deferral",
column: "triage",
source: {
// Different agent — but same parent. Should still dedup.
sourceType: "agent_heartbeat",
sourceAgentId: "agent-beta",
sourceParentTaskId: parentId,
},
});
const refreshed = await store.getTask(second.id);
expect(refreshed.column).toBe("archived");
const firstRefreshed = await store.getTask(first.id);
expect(firstRefreshed.column).toBe("triage");
});
it("does not archive siblings with different parent tasks", async () => {
await store.createTask({
title: "Add structured run-audit event",
description: "Add structured run-audit event recording per-lane provider/runtime selection",
column: "triage",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-alpha",
sourceParentTaskId: "FN-PARENT-A",
},
});
const second = await store.createTask({
title: "Add structured run-audit event",
description: "Add structured run-audit event recording per-lane provider/runtime selection",
column: "triage",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-beta",
sourceParentTaskId: "FN-PARENT-B",
},
});
const refreshed = await store.getTask(second.id);
expect(refreshed.column).toBe("triage");
});
});

View File

@@ -5,6 +5,12 @@ import type { TaskStore } from "./store.js";
export interface SameAgentDuplicateInput {
title?: string | null;
description: string;
/**
* Parent task that spawned this task (e.g., the executing task whose heartbeat
* agent called fn_task_create). When set, candidates sharing the same parent
* are considered siblings even if they have different sourceAgentId values.
*/
sourceParentTaskId?: string | null;
}
export interface SameAgentDuplicateCandidate {
@@ -14,6 +20,7 @@ export interface SameAgentDuplicateCandidate {
column: Column;
createdAt: number;
sourceAgentId: string | null;
sourceParentTaskId?: string | null;
}
export interface SameAgentDuplicateMatch {
@@ -21,17 +28,34 @@ export interface SameAgentDuplicateMatch {
score: number;
}
/**
* Find candidate tasks that look like duplicates spawned by the same caller.
*
* "Same caller" means the candidate shares the input's `sourceAgentId` (legacy
* FN-5233 behavior) OR shares the input's `sourceParentTaskId` when set
* (provenance dedup — same parent task spawned similar siblings).
*
* Filters out candidates older than `windowMs` (default 24h) and candidates
* with neither a matching sourceAgentId nor a matching sourceParentTaskId.
*/
export function findSameAgentDuplicates(
input: SameAgentDuplicateInput,
candidates: SameAgentDuplicateCandidate[],
opts?: { threshold?: number; nowMs?: number; windowMs?: number },
opts?: { threshold?: number; nowMs?: number; windowMs?: number; sourceAgentId?: string | null },
): SameAgentDuplicateMatch[] {
const threshold = opts?.threshold ?? 0.75;
const nowMs = opts?.nowMs ?? Date.now();
const windowMs = opts?.windowMs ?? 24 * 60 * 60 * 1000;
const cutoff = nowMs - windowMs;
const inputAgentId = opts?.sourceAgentId ?? null;
const inputParentId = input.sourceParentTaskId ?? null;
const recent = candidates.filter((candidate) => candidate.createdAt >= cutoff && candidate.sourceAgentId != null);
const recent = candidates.filter((candidate) => {
if (candidate.createdAt < cutoff) return false;
const agentMatch = inputAgentId != null && candidate.sourceAgentId === inputAgentId;
const parentMatch = inputParentId != null && candidate.sourceParentTaskId === inputParentId;
return agentMatch || parentMatch;
});
const matches = findDuplicateMatches(
{ title: input.title ?? undefined, description: input.description },

View File

@@ -3714,20 +3714,28 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private async _maybeAutoArchiveSameAgentDuplicate(task: Task, input: TaskCreateInput): Promise<void> {
const sourceAgentId = task.sourceAgentId ?? null;
if (!sourceAgentId) return;
const sourceParentTaskId = task.sourceParentTaskId ?? null;
// Need at least one provenance handle to scope the dedup check.
if (!sourceAgentId && !sourceParentTaskId) return;
try {
const nowMs = Date.now();
const recent = (await this.listTasks({ slim: true, includeArchived: false })).filter((candidate) => {
if (candidate.id === task.id) return false;
if (candidate.sourceAgentId !== sourceAgentId) return false;
const createdMs = Date.parse(candidate.createdAt);
if (Number.isNaN(createdMs)) return false;
return createdMs >= nowMs - 24 * 60 * 60 * 1000;
if (createdMs < nowMs - 24 * 60 * 60 * 1000) return false;
const agentMatch = sourceAgentId != null && candidate.sourceAgentId === sourceAgentId;
const parentMatch = sourceParentTaskId != null && candidate.sourceParentTaskId === sourceParentTaskId;
return agentMatch || parentMatch;
});
const matches = findSameAgentDuplicates(
{ title: input.title ?? task.title, description: input.description },
{
title: input.title ?? task.title,
description: input.description,
sourceParentTaskId,
},
recent.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
@@ -3735,8 +3743,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
column: candidate.column,
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
})),
{ nowMs },
{ nowMs, sourceAgentId },
);
if (matches.length === 0) return;

View File

@@ -170,10 +170,40 @@ describe("createTaskCreateTool", () => {
await tool.execute("call-1", { description: "Test" } as any, undefined, undefined, {} as any);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
source: { sourceType: "agent_heartbeat", sourceAgentId: "agent-123", sourceRunId: undefined },
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-123",
sourceRunId: undefined,
sourceParentTaskId: undefined,
},
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
});
it("threads sourceParentTaskId from provenance into store.createTask", async () => {
const store = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
createTask: vi.fn().mockResolvedValue({ id: "PROJ-101", description: "Test", dependencies: [], column: "triage" }),
};
const tool = createTaskCreateTool(store as any, {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-123",
sourceRunId: "run-abc",
sourceParentTaskId: "FN-5206",
});
await tool.execute("call-1", { description: "spawn follow-up" } as any, undefined, undefined, {} as any);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-123",
sourceRunId: "run-abc",
sourceParentTaskId: "FN-5206",
},
}), expect.anything());
});
it("surfaces linked-existing text when deterministic duplicate is found", async () => {
const existing = { id: "PROJ-500", description: "duplicate", dependencies: [], column: "triage" };
vi.spyOn(core, "runDeterministicDuplicateGuard").mockResolvedValueOnce({

View File

@@ -327,7 +327,7 @@ delegate, and route work to the right place. Think in single-pass interventions,
Your job:
1. Check your assigned task context — review its state, blockedBy field, and any new comments.
2. Do ONE useful coordination action.
3. Use fn_task_create to spawn follow-up work, fn_task_log to record observations, and fn_task_document_write for durable artifacts.
3. Use fn_task_create to spawn follow-up work, fn_task_log to record observations, and fn_task_document_write for durable artifacts. Before calling fn_task_create, scan existing open tasks (the board context provided to you, or fn_task_list when in doubt) — if an open task already covers this work, log against it or update it instead of creating a duplicate.
4. Use fn_list_agents + fn_delegate_task when work should be assigned to a specific capable agent now.
5. Use fn_get_agent_config and fn_update_agent_config to tune direct reports before delegating recurring work.
6. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
@@ -396,7 +396,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
- If the message requires a response, use fn_send_message to reply.
- When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output.
- If the message is informational, acknowledge it by logging with fn_task_log.
- If the message requests net-new work, create a follow-up task with fn_task_create.
- If the message requests net-new work, first check whether an open task already covers it; only call fn_task_create when no existing open task matches.
- If ownership is clear and an agent is available, delegate using fn_delegate_task.
4. If a Pending Room Messages section is present, review it too:
- Use fn_post_room_message only when the room content is relevant to your role, soul, or identity.
@@ -428,7 +428,7 @@ You are not expected to implement large code changes in no-task mode.
Your job:
1. Review your context — check messages, memory, and project state.
2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory.
3. Use fn_task_create to spawn follow-up work.
3. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate.
4. Use fn_list_agents and fn_delegate_task to coordinate with other agents.
5. Use fn_get_agent_config and fn_update_agent_config to read/tune direct-report agents for better routing outcomes.
6. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
@@ -486,7 +486,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
- If the message requires a response and fn_send_message is available, use fn_send_message to reply.
- When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output.
- If the message is informational, acknowledge it and respond via fn_send_message when appropriate.
- If the message requests work, create a follow-up task with fn_task_create.
- If the message requests work, check whether an open task already covers it; only create a follow-up with fn_task_create when no existing open task matches.
- If the request has a clear owner and fn_delegate_task is available, delegate it directly.
3. If a Pending Room Messages section is present, review it too and use fn_post_room_message only when the room content is relevant to your role or identity; if Room Ambiguity Notices are present, follow their resolve/clarify branch instructions exactly. If a Room Coordination Notices section is present, follow its claim/defer branch exactly: under "claim" post a one-line claim before calling fn_task_create; under "defer-suggested" do NOT call fn_task_create and instead acknowledge the prior claim via fn_post_room_message.
4. After processing messages, continue with your ambient work.
@@ -3223,10 +3223,14 @@ export class HeartbeatMonitor {
): ToolDefinition[] {
const tools: ToolDefinition[] = [];
// Wrap createTaskCreateTool with tracking and agent-link logging
// Wrap createTaskCreateTool with tracking and agent-link logging.
// Stamp the parent task ID so sibling tasks spawned from the same parent
// can be deduped even if the AI rewrites their titles during triage.
const baseCreateTool = createTaskCreateTool(taskStore, {
sourceType: "agent_heartbeat",
sourceAgentId: agentId,
sourceRunId: runContext?.runId,
sourceParentTaskId: taskId,
}, { rootDir: this.rootDir });
const trackedCreateTool: ToolDefinition = {
...baseCreateTool,

View File

@@ -688,7 +688,7 @@ export async function createAgentTask(
*/
export function createTaskCreateTool(
store: TaskStore,
provenance?: { sourceType: SourceType; sourceAgentId?: string; sourceRunId?: string },
provenance?: { sourceType: SourceType; sourceAgentId?: string; sourceRunId?: string; sourceParentTaskId?: string },
options?: AgentTaskCreationOptions,
): ToolDefinition {
return {
@@ -697,6 +697,8 @@ export function createTaskCreateTool(
description:
"Create a new task for out-of-scope work discovered during execution. " +
"The task goes into triage where it will be specified by the AI. " +
"Before creating, scan existing open tasks for similar work — if an open task " +
"already covers this, do not create a duplicate. " +
"Optionally set dependencies (e.g., the new task depends on the current one, " +
"or the current task should wait for the new one).",
parameters: taskCreateParams,
@@ -711,6 +713,7 @@ export function createTaskCreateTool(
sourceType: provenance.sourceType,
sourceAgentId: provenance.sourceAgentId,
sourceRunId: provenance.sourceRunId,
sourceParentTaskId: provenance.sourceParentTaskId,
} : undefined,
}, options);
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";