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

@@ -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(", ")})` : "";