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:
22
.changeset/parent-task-dedup-intake.md
Normal file
22
.changeset/parent-task-dedup-intake.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
79
packages/core/src/__tests__/store-parent-task-dedup.test.ts
Normal file
79
packages/core/src/__tests__/store-parent-task-dedup.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(", ")})` : "";
|
||||
|
||||
Reference in New Issue
Block a user