feat(FN-1255): add comment-aware heartbeat wakes and blocked-state dedup
- Add BlockedStateSnapshot typing/export and AgentStore persistence APIs for last blocked heartbeat state - Deduplicate blocked-task heartbeat comments using blockedBy + context hash, and clear blocked snapshots when tasks are no longer blocked - Thread triggeringCommentIds/triggeringCommentType through heartbeat execution, wake context, scheduler assignment triggers, and runtime wiring - Trigger immediate heartbeat runs from task/steering comment routes for assigned immediate-response agents, with validation for comment wake fields on /api/agents/:id/runs - Expand core, engine, and dashboard tests to cover blocked dedup logic, comment-triggered wakes, validation, and skip scenarios
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { HeartbeatMonitor, HeartbeatTriggerScheduler, type AgentSession, type HeartbeatExecutionOptions, HEARTBEAT_SYSTEM_PROMPT } from "./agent-heartbeat.js";
|
||||
import { HeartbeatMonitor, HeartbeatTriggerScheduler, isBlockedStateDuplicate, type AgentSession, type HeartbeatExecutionOptions, HEARTBEAT_SYSTEM_PROMPT } from "./agent-heartbeat.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
|
||||
|
||||
@@ -130,6 +130,32 @@ describe("HeartbeatMonitor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBlockedStateDuplicate", () => {
|
||||
it("returns true when blockedBy and contextHash match", () => {
|
||||
expect(
|
||||
isBlockedStateDuplicate(
|
||||
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-01T00:00:00.000Z", contextHash: "abc" },
|
||||
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-02T00:00:00.000Z", contextHash: "abc" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when blockedBy differs or contextHash differs", () => {
|
||||
expect(
|
||||
isBlockedStateDuplicate(
|
||||
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-01T00:00:00.000Z", contextHash: "abc" },
|
||||
{ taskId: "FN-1", blockedBy: "FN-2", recordedAt: "2026-01-02T00:00:00.000Z", contextHash: "abc" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isBlockedStateDuplicate(
|
||||
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-01T00:00:00.000Z", contextHash: "abc" },
|
||||
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-02T00:00:00.000Z", contextHash: "xyz" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
it("initiates polling interval", () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
@@ -988,6 +1014,7 @@ describe("HeartbeatMonitor", () => {
|
||||
column: "triage",
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue({}),
|
||||
addComment: vi.fn().mockResolvedValue({}),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
@@ -1041,6 +1068,9 @@ describe("HeartbeatMonitor", () => {
|
||||
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
|
||||
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
|
||||
getCachedAgent: vi.fn().mockReturnValue(null),
|
||||
getLastBlockedState: vi.fn().mockResolvedValue(null),
|
||||
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
@@ -1125,6 +1155,171 @@ describe("HeartbeatMonitor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocked-task dedup", () => {
|
||||
const buildContextHash = (blockedBy: string, taskDetail: Partial<TaskDetail>): string => {
|
||||
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
|
||||
const lastCommentId = taskDetail.comments?.at(-1)?.id;
|
||||
const lastSteeringCommentId = taskDetail.steeringComments?.at(-1)?.id;
|
||||
|
||||
return Buffer.from(
|
||||
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
|
||||
)
|
||||
.toString("base64")
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
it("skips duplicate blocked comments when blocked snapshot is unchanged", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
|
||||
const taskDetail = {
|
||||
id: "FN-BLOCKED",
|
||||
title: "Blocked Task",
|
||||
description: "Blocked task description",
|
||||
prompt: "",
|
||||
status: "queued",
|
||||
blockedBy: "FN-DEP-1",
|
||||
comments: [{ id: "comment-1", text: "Still blocked", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
steeringComments: [],
|
||||
steps: [],
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail;
|
||||
|
||||
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
taskId: "FN-BLOCKED",
|
||||
blockedBy: "FN-DEP-1",
|
||||
recordedAt: "2026-01-01T00:00:00.000Z",
|
||||
contextHash: buildContextHash("FN-DEP-1", taskDetail),
|
||||
});
|
||||
|
||||
mockTaskStore = createMockTaskStore({
|
||||
getTask: vi.fn().mockResolvedValue(taskDetail),
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.resultJson).toEqual({ reason: "blocked_duplicate", taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" });
|
||||
expect(mockTaskStore.addComment).not.toHaveBeenCalled();
|
||||
expect(store.setLastBlockedState).not.toHaveBeenCalled();
|
||||
expect(mockedCreateKbAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-logs blocked state when new comments change context hash", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
|
||||
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
taskId: "FN-BLOCKED",
|
||||
blockedBy: "FN-DEP-1",
|
||||
recordedAt: "2026-01-01T00:00:00.000Z",
|
||||
contextHash: "stale-context-hash",
|
||||
});
|
||||
|
||||
const taskDetail = {
|
||||
id: "FN-BLOCKED",
|
||||
title: "Blocked Task",
|
||||
description: "Blocked task description",
|
||||
prompt: "",
|
||||
status: "queued",
|
||||
blockedBy: "FN-DEP-1",
|
||||
comments: [{ id: "comment-2", text: "New context", author: "user", createdAt: "2026-01-02T00:00:00.000Z" }],
|
||||
steeringComments: [],
|
||||
steps: [],
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail;
|
||||
|
||||
mockTaskStore = createMockTaskStore({ getTask: vi.fn().mockResolvedValue(taskDetail) });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.resultJson).toEqual({ reason: "blocked", taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" });
|
||||
expect(mockTaskStore.addComment).toHaveBeenCalledOnce();
|
||||
expect(store.setLastBlockedState).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({ taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" }),
|
||||
);
|
||||
expect(mockedCreateKbAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats changed blockedBy as a new blocked state", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
|
||||
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
taskId: "FN-BLOCKED",
|
||||
blockedBy: "FN-DEP-OLD",
|
||||
recordedAt: "2026-01-01T00:00:00.000Z",
|
||||
contextHash: "samehash",
|
||||
});
|
||||
|
||||
const taskDetail = {
|
||||
id: "FN-BLOCKED",
|
||||
title: "Blocked Task",
|
||||
description: "Blocked task description",
|
||||
prompt: "",
|
||||
status: "queued",
|
||||
blockedBy: "FN-DEP-NEW",
|
||||
comments: [],
|
||||
steeringComments: [],
|
||||
steps: [],
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail;
|
||||
|
||||
mockTaskStore = createMockTaskStore({ getTask: vi.fn().mockResolvedValue(taskDetail) });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(mockTaskStore.addComment).toHaveBeenCalledOnce();
|
||||
expect(store.setLastBlockedState).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({ blockedBy: "FN-DEP-NEW" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears blocked state when task is no longer blocked", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-READY" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
mockTaskStore = createMockTaskStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-READY",
|
||||
title: "Ready Task",
|
||||
description: "Ready to run",
|
||||
prompt: "",
|
||||
status: undefined,
|
||||
blockedBy: undefined,
|
||||
comments: [],
|
||||
steeringComments: [],
|
||||
steps: [],
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail),
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
expect(store.clearLastBlockedState).toHaveBeenCalledWith("agent-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeHeartbeat - inbox selection", () => {
|
||||
const makeInboxSelection = (taskId: string, priority: "in_progress" | "todo" | "blocked" = "todo") => {
|
||||
const now = new Date().toISOString();
|
||||
@@ -1349,6 +1544,59 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(promptArg).toContain("PROMPT.md");
|
||||
});
|
||||
|
||||
it("includes triggering comment context in execution prompt when comment IDs are provided", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({
|
||||
session: mockSession as any,
|
||||
});
|
||||
|
||||
mockTaskStore.getTask = vi.fn().mockResolvedValue({
|
||||
id: "FN-001",
|
||||
title: "Test Task",
|
||||
description: "Test task description",
|
||||
prompt: "# Prompt",
|
||||
comments: [{ id: "c-1", author: "user", text: "Please cover edge cases", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
steeringComments: [{ id: "s-1", author: "agent", text: "Investigating blocker", createdAt: "2026-01-01T00:01:00.000Z" }],
|
||||
steps: [],
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail);
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
await monitor.executeHeartbeat({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggeringCommentIds: ["c-1", "s-1"],
|
||||
triggeringCommentType: "steering",
|
||||
});
|
||||
|
||||
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
|
||||
expect(promptArg).toContain("You were woken because of new comments on this task");
|
||||
expect(promptArg).toContain("Please cover edge cases");
|
||||
expect(promptArg).toContain("Investigating blocker");
|
||||
});
|
||||
|
||||
it("keeps standard prompt when no triggering comments are provided", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({
|
||||
session: mockSession as any,
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
|
||||
expect(promptArg).not.toContain("You were woken because of new comments on this task");
|
||||
expect(promptArg).not.toContain("New comments since last run:");
|
||||
});
|
||||
|
||||
it("completes run with status completed on successful execution", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
const mockSession = createMockAgentSession();
|
||||
@@ -1454,6 +1702,8 @@ describe("HeartbeatMonitor", () => {
|
||||
agentId: "agent-001",
|
||||
source: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
triggeringCommentIds: ["comment-1"],
|
||||
triggeringCommentType: "task",
|
||||
contextSnapshot: {
|
||||
wakeReason: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
@@ -1465,6 +1715,8 @@ describe("HeartbeatMonitor", () => {
|
||||
wakeReason: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
taskId: "FN-001",
|
||||
triggeringCommentIds: ["comment-1"],
|
||||
triggeringCommentType: "task",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2734,6 +2986,40 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes new steering comment IDs for assignment wakes when taskStore is available", async () => {
|
||||
scheduler.stop();
|
||||
|
||||
(eventStore as any).getRecentRuns = vi.fn().mockResolvedValue([
|
||||
{ startedAt: "2026-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const assignmentTaskStore = {
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-006",
|
||||
steeringComments: [
|
||||
{ id: "steer-old", text: "older", author: "user", createdAt: "2025-12-31T23:00:00.000Z" },
|
||||
{ id: "steer-new", text: "new guidance", author: "user", createdAt: "2026-01-01T01:00:00.000Z" },
|
||||
],
|
||||
}),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
scheduler = new HeartbeatTriggerScheduler(eventStore, callback, assignmentTaskStore);
|
||||
scheduler.start();
|
||||
|
||||
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
|
||||
eventStore.emit("agent:assigned", agent, "FN-006");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
}, { timeout: 1000 });
|
||||
|
||||
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", expect.objectContaining({
|
||||
taskId: "FN-006",
|
||||
triggeringCommentIds: ["steer-new"],
|
||||
triggeringCommentType: "steering",
|
||||
}));
|
||||
});
|
||||
|
||||
it("cleans up listener on unwatch", async () => {
|
||||
scheduler.unwatchAssignments();
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* - onTerminated: Called when an unresponsive agent is terminated
|
||||
*/
|
||||
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask } from "@fusion/core";
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot } from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createTaskCreateTool, createTaskLogTool, taskCreateParams } from "./agent-tools.js";
|
||||
@@ -89,6 +89,10 @@ export interface HeartbeatExecutionOptions {
|
||||
triggerDetail?: string;
|
||||
/** Optional task ID override (uses agent.taskId if not set) */
|
||||
taskId?: string;
|
||||
/** IDs of comments that triggered this wake (if any) */
|
||||
triggeringCommentIds?: string[];
|
||||
/** Type of comment that triggered this wake */
|
||||
triggeringCommentType?: "steering" | "task" | "pr";
|
||||
/** Optional structured context persisted on the run record */
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
}
|
||||
@@ -110,6 +114,11 @@ interface TrackedAgent {
|
||||
sessionIdBefore?: string;
|
||||
}
|
||||
|
||||
/** Compare blocked-state snapshots to decide whether blocked messaging is duplicate noise. */
|
||||
export function isBlockedStateDuplicate(current: BlockedStateSnapshot, previous: BlockedStateSnapshot): boolean {
|
||||
return current.blockedBy === previous.blockedBy && current.contextHash === previous.contextHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt for heartbeat agent sessions.
|
||||
* Instructs the agent to perform a single-pass check on its assigned task
|
||||
@@ -573,7 +582,15 @@ export class HeartbeatMonitor {
|
||||
* @throws Error if taskStore or rootDir are not configured
|
||||
*/
|
||||
async executeHeartbeat(options: HeartbeatExecutionOptions): Promise<AgentHeartbeatRun> {
|
||||
const { agentId, source, triggerDetail, taskId: explicitTaskId, contextSnapshot } = options;
|
||||
const {
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail,
|
||||
taskId: explicitTaskId,
|
||||
contextSnapshot,
|
||||
triggeringCommentIds,
|
||||
triggeringCommentType,
|
||||
} = options;
|
||||
|
||||
// Validate execution dependencies
|
||||
if (!this.taskStore || !this.rootDir) {
|
||||
@@ -594,9 +611,25 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
const resolvedTaskId = explicitTaskId ?? preloadedAgent?.taskId;
|
||||
const contextTriggeringCommentIds = Array.isArray(contextSnapshot?.triggeringCommentIds)
|
||||
? contextSnapshot.triggeringCommentIds.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: undefined;
|
||||
const contextTriggeringCommentType =
|
||||
contextSnapshot?.triggeringCommentType === "steering"
|
||||
|| contextSnapshot?.triggeringCommentType === "task"
|
||||
|| contextSnapshot?.triggeringCommentType === "pr"
|
||||
? contextSnapshot.triggeringCommentType
|
||||
: undefined;
|
||||
const effectiveTriggeringCommentIds = triggeringCommentIds ?? contextTriggeringCommentIds;
|
||||
const effectiveTriggeringCommentType = triggeringCommentType ?? contextTriggeringCommentType;
|
||||
|
||||
const runContextSnapshot = {
|
||||
...(contextSnapshot ?? {}),
|
||||
...(resolvedTaskId ? { taskId: resolvedTaskId } : {}),
|
||||
...(effectiveTriggeringCommentIds?.length
|
||||
? { triggeringCommentIds: effectiveTriggeringCommentIds }
|
||||
: {}),
|
||||
...(effectiveTriggeringCommentType ? { triggeringCommentType: effectiveTriggeringCommentType } : {}),
|
||||
};
|
||||
|
||||
// Start run
|
||||
@@ -752,6 +785,50 @@ export class HeartbeatMonitor {
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
const blockedBy = typeof taskDetail.blockedBy === "string" ? taskDetail.blockedBy.trim() : "";
|
||||
const isBlockedTask = taskDetail.status === "queued" && blockedBy.length > 0;
|
||||
|
||||
if (isBlockedTask) {
|
||||
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
|
||||
const lastCommentId = taskDetail.comments?.at(-1)?.id;
|
||||
const lastSteeringCommentId = taskDetail.steeringComments?.at(-1)?.id;
|
||||
const contextHash = Buffer.from(
|
||||
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
|
||||
)
|
||||
.toString("base64")
|
||||
.slice(0, 16);
|
||||
|
||||
const currentBlockedState: BlockedStateSnapshot = {
|
||||
taskId,
|
||||
blockedBy,
|
||||
recordedAt: new Date().toISOString(),
|
||||
contextHash,
|
||||
};
|
||||
|
||||
const previousBlockedState = await this.store.getLastBlockedState(agentId);
|
||||
if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
|
||||
heartbeatLog.log(`Task ${taskId} is still blocked by ${blockedBy} (duplicate state) — skipping comment`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked_duplicate", taskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
const blockedMessage = `Task is blocked by ${blockedBy}; waiting for dependency/context changes before retrying.`;
|
||||
await taskStore.addComment(taskId, blockedMessage, "agent");
|
||||
await this.store.setLastBlockedState(agentId, currentBlockedState);
|
||||
|
||||
heartbeatLog.log(`Task ${taskId} is blocked by ${blockedBy} — recorded blocked state`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked", taskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
await this.store.clearLastBlockedState(agentId);
|
||||
|
||||
// Track usage via callbacks
|
||||
const STDOUT_EXCERPT_LIMIT = 4000;
|
||||
let outputLength = 0;
|
||||
@@ -831,6 +908,36 @@ export class HeartbeatMonitor {
|
||||
try {
|
||||
// Build execution prompt
|
||||
const taskTitle = taskDetail.title ?? taskDetail.description.slice(0, 100);
|
||||
|
||||
const triggeringCommentLines: string[] = [];
|
||||
if (effectiveTriggeringCommentIds && effectiveTriggeringCommentIds.length > 0) {
|
||||
const commentLookup = new Map<string, { author: string; text: string }>();
|
||||
for (const comment of taskDetail.comments ?? []) {
|
||||
commentLookup.set(comment.id, { author: comment.author, text: comment.text });
|
||||
}
|
||||
for (const steeringComment of taskDetail.steeringComments ?? []) {
|
||||
commentLookup.set(steeringComment.id, { author: steeringComment.author, text: steeringComment.text });
|
||||
}
|
||||
|
||||
const formatCommentText = (text: string): string => text.replace(/\s+/g, " ").trim();
|
||||
|
||||
for (const commentId of effectiveTriggeringCommentIds) {
|
||||
const comment = commentLookup.get(commentId);
|
||||
if (comment) {
|
||||
triggeringCommentLines.push(`- [${comment.author}]: "${formatCommentText(comment.text)}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (triggeringCommentLines.length > 0) {
|
||||
triggeringCommentLines.unshift(
|
||||
"",
|
||||
"You were woken because of new comments on this task. Review them and take appropriate action.",
|
||||
`Triggering comment type: ${effectiveTriggeringCommentType ?? "task"}`,
|
||||
"New comments since last run:",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const executionPrompt = [
|
||||
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
@@ -840,6 +947,7 @@ export class HeartbeatMonitor {
|
||||
taskDetail.description,
|
||||
"",
|
||||
taskDetail.prompt ? `PROMPT.md:\n${taskDetail.prompt}` : "No PROMPT.md available.",
|
||||
...triggeringCommentLines,
|
||||
"",
|
||||
"Review the task status and take appropriate action. Call heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
@@ -1087,6 +1195,10 @@ export interface WakeContext {
|
||||
wakeReason: string;
|
||||
/** Detail about the specific trigger */
|
||||
triggerDetail: string;
|
||||
/** IDs of comments that triggered this wake (if any) */
|
||||
triggeringCommentIds?: string[];
|
||||
/** Type of comment that triggered this wake */
|
||||
triggeringCommentType?: "steering" | "task" | "pr";
|
||||
/** Budget governance status for the agent at trigger time */
|
||||
budgetStatus?: AgentBudgetStatus;
|
||||
/** Additional context (intervalMs, etc.) */
|
||||
@@ -1130,13 +1242,15 @@ interface AgentTimer {
|
||||
export class HeartbeatTriggerScheduler {
|
||||
private store: AgentStore;
|
||||
private callback: TriggerCallback;
|
||||
private taskStore?: TaskStore;
|
||||
private timers: Map<string, AgentTimer> = new Map();
|
||||
private running = false;
|
||||
private assignedListener: ((agent: import("@fusion/core").Agent, taskId: string) => void) | null = null;
|
||||
|
||||
constructor(store: AgentStore, callback: TriggerCallback) {
|
||||
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore) {
|
||||
this.store = store;
|
||||
this.callback = callback;
|
||||
this.taskStore = taskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1261,11 +1375,39 @@ export class HeartbeatTriggerScheduler {
|
||||
// If getBudgetStatus fails, proceed without budget check
|
||||
}
|
||||
|
||||
let triggeringCommentIds: string[] | undefined;
|
||||
if (this.taskStore && typeof this.taskStore.getTask === "function") {
|
||||
try {
|
||||
const [task, recentRuns] = await Promise.all([
|
||||
this.taskStore.getTask(taskId),
|
||||
this.store.getRecentRuns(agent.id, 1),
|
||||
]);
|
||||
|
||||
const lastRunAt = recentRuns[0]?.startedAt;
|
||||
const newSteeringComments = (task.steeringComments ?? []).filter((comment) =>
|
||||
!lastRunAt || comment.createdAt > lastRunAt,
|
||||
);
|
||||
if (newSteeringComments.length > 0) {
|
||||
triggeringCommentIds = newSteeringComments.map((comment) => comment.id);
|
||||
}
|
||||
} catch (error) {
|
||||
heartbeatLog.warn(
|
||||
`Failed to resolve triggering steering comments for assignment wake (${agent.id}/${taskId}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
heartbeatLog.log(`Assignment trigger for ${agent.id} (task: ${taskId})`);
|
||||
await this.callback(agent.id, "assignment", {
|
||||
taskId,
|
||||
wakeReason: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
...(triggeringCommentIds?.length
|
||||
? {
|
||||
triggeringCommentIds,
|
||||
triggeringCommentType: "steering" as const,
|
||||
}
|
||||
: {}),
|
||||
...(budgetStatus && { budgetStatus }),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -266,9 +266,19 @@ export class InProcessRuntime
|
||||
source,
|
||||
triggerDetail: context.triggerDetail,
|
||||
taskId: typeof context.taskId === "string" ? context.taskId : undefined,
|
||||
triggeringCommentIds: Array.isArray(context.triggeringCommentIds)
|
||||
? context.triggeringCommentIds.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: undefined,
|
||||
triggeringCommentType:
|
||||
context.triggeringCommentType === "steering"
|
||||
|| context.triggeringCommentType === "task"
|
||||
|| context.triggeringCommentType === "pr"
|
||||
? context.triggeringCommentType
|
||||
: undefined,
|
||||
contextSnapshot: { ...context },
|
||||
});
|
||||
},
|
||||
this.taskStore,
|
||||
);
|
||||
this.triggerScheduler.start();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user