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:
@@ -3149,6 +3149,158 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/comments — triggers immediate heartbeat wake for assigned agent", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-comment-heartbeat-"));
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({ name: "Wake Agent", role: "executor" });
|
||||
await agentStore.updateAgent(agent.id, {
|
||||
runtimeConfig: { messageResponseMode: "immediate" },
|
||||
});
|
||||
|
||||
const heartbeatMonitor = {
|
||||
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
|
||||
};
|
||||
|
||||
const updatedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-001",
|
||||
assignedAgentId: agent.id,
|
||||
comments: [{ id: "comment-1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
addTaskComment: vi.fn().mockResolvedValue(updatedTask),
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { heartbeatMonitor } as any));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/tasks/KB-001/comments", JSON.stringify({ text: "Hello" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agentId: agent.id,
|
||||
source: "on_demand",
|
||||
taskId: "KB-001",
|
||||
triggeringCommentIds: ["comment-1"],
|
||||
triggeringCommentType: "task",
|
||||
}));
|
||||
}, { timeout: 1000 });
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/comments — skips heartbeat wake when task has no assigned agent", async () => {
|
||||
const heartbeatMonitor = {
|
||||
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
|
||||
};
|
||||
const updatedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-001",
|
||||
assignedAgentId: undefined,
|
||||
comments: [{ id: "comment-1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
addTaskComment: vi.fn().mockResolvedValue(updatedTask),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { heartbeatMonitor } as any));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/tasks/KB-001/comments", JSON.stringify({ text: "Hello" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/comments — succeeds without heartbeat monitor when task is assigned", async () => {
|
||||
const updatedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-001",
|
||||
assignedAgentId: "agent-123",
|
||||
comments: [{ id: "comment-1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
addTaskComment: vi.fn().mockResolvedValue(updatedTask),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/tasks/KB-001/comments", JSON.stringify({ text: "Hello" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/comments — skips heartbeat wake when an active run already exists", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-comment-active-run-"));
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({ name: "Active Run Agent", role: "executor" });
|
||||
await agentStore.updateAgent(agent.id, {
|
||||
runtimeConfig: { messageResponseMode: "immediate" },
|
||||
});
|
||||
await agentStore.startHeartbeatRun(agent.id);
|
||||
|
||||
const heartbeatMonitor = {
|
||||
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
|
||||
};
|
||||
|
||||
const updatedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-001",
|
||||
assignedAgentId: agent.id,
|
||||
comments: [{ id: "comment-1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
addTaskComment: vi.fn().mockResolvedValue(updatedTask),
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { heartbeatMonitor } as any));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/tasks/KB-001/comments", JSON.stringify({ text: "Hello" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
|
||||
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [{ id: "c1", text: "Updated", author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:01:00.000Z" }] };
|
||||
const store = createMockStore({ updateTaskComment: vi.fn().mockResolvedValue(updatedTask) });
|
||||
@@ -3208,6 +3360,107 @@ describe("Pause/Unpause endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers immediate heartbeat wake for assigned agent", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-steer-heartbeat-"));
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({ name: "Steer Wake Agent", role: "executor" });
|
||||
await agentStore.updateAgent(agent.id, {
|
||||
runtimeConfig: { messageResponseMode: "immediate" },
|
||||
});
|
||||
|
||||
const heartbeatMonitor = {
|
||||
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
|
||||
};
|
||||
|
||||
const steeredTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-001",
|
||||
assignedAgentId: agent.id,
|
||||
steeringComments: [{ id: "steer-1", text: "Please handle edge case", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
};
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(steeredTask);
|
||||
(store.getFusionDir as any) = vi.fn().mockReturnValue(fusionDir);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { heartbeatMonitor } as any));
|
||||
|
||||
const res = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/KB-001/steer",
|
||||
JSON.stringify({ text: "Please handle edge case" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agentId: agent.id,
|
||||
source: "on_demand",
|
||||
taskId: "KB-001",
|
||||
triggeringCommentIds: ["steer-1"],
|
||||
triggeringCommentType: "steering",
|
||||
}));
|
||||
}, { timeout: 1000 });
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips heartbeat wake when assigned agent is not in immediate response mode", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-steer-non-immediate-"));
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({ name: "Non-immediate Agent", role: "executor" });
|
||||
await agentStore.updateAgent(agent.id, {
|
||||
runtimeConfig: { messageResponseMode: "on-heartbeat" },
|
||||
});
|
||||
|
||||
const heartbeatMonitor = {
|
||||
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
|
||||
};
|
||||
|
||||
const steeredTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-001",
|
||||
assignedAgentId: agent.id,
|
||||
steeringComments: [{ id: "steer-1", text: "Please handle edge case", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
};
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(steeredTask);
|
||||
(store.getFusionDir as any) = vi.fn().mockReturnValue(fusionDir);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { heartbeatMonitor } as any));
|
||||
|
||||
const res = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/KB-001/steer",
|
||||
JSON.stringify({ text: "Please handle edge case" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 400 when text is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/steer", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
@@ -9324,6 +9577,70 @@ describe("POST /api/agents/:id/runs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts triggering comment wake fields and persists them in contextSnapshot", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/runs`,
|
||||
JSON.stringify({
|
||||
source: "on_demand",
|
||||
triggerDetail: "task-comment",
|
||||
taskId: "FN-001",
|
||||
triggeringCommentIds: ["c1", "c2"],
|
||||
triggeringCommentType: "task",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contextSnapshot).toMatchObject({
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "task-comment",
|
||||
taskId: "FN-001",
|
||||
triggeringCommentIds: ["c1", "c2"],
|
||||
triggeringCommentType: "task",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when triggeringCommentIds is not an array", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/runs`,
|
||||
JSON.stringify({ triggeringCommentIds: "not-an-array" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("triggeringCommentIds must be an array of strings");
|
||||
});
|
||||
|
||||
it("returns 400 when triggeringCommentIds contains non-string values", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/runs`,
|
||||
JSON.stringify({ triggeringCommentIds: ["c1", 42] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("triggeringCommentIds must be an array of strings");
|
||||
});
|
||||
|
||||
it("returns 400 when triggeringCommentType is invalid", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/runs`,
|
||||
JSON.stringify({ triggeringCommentType: "invalid" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("triggeringCommentType must be one of: steering, task, pr");
|
||||
});
|
||||
|
||||
it("includes wake context without taskId when not provided", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
|
||||
@@ -1443,6 +1443,58 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
|
||||
const aiSessionStore = options?.aiSessionStore;
|
||||
|
||||
const triggerCommentWakeForAssignedAgent = async (
|
||||
scopedStore: TaskStore,
|
||||
task: Task,
|
||||
wake: {
|
||||
triggeringCommentType: "steering" | "task" | "pr";
|
||||
triggeringCommentIds?: string[];
|
||||
triggerDetail: string;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (!hasHeartbeatExecutor || !heartbeatMonitor || !task.assignedAgentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const assignedAgent = await agentStore.getAgent(task.assignedAgentId);
|
||||
if (!assignedAgent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseMode = (assignedAgent.runtimeConfig as { messageResponseMode?: string } | undefined)?.messageResponseMode;
|
||||
if (responseMode !== "immediate") {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRun = await agentStore.getActiveHeartbeatRun(assignedAgent.id);
|
||||
if (activeRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggeringCommentIds = wake.triggeringCommentIds?.filter((id) => typeof id === "string" && id.length > 0);
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: wake.triggerDetail,
|
||||
taskId: task.id,
|
||||
...(triggeringCommentIds?.length ? { triggeringCommentIds } : {}),
|
||||
triggeringCommentType: wake.triggeringCommentType,
|
||||
};
|
||||
|
||||
await heartbeatMonitor.executeHeartbeat({
|
||||
agentId: assignedAgent.id,
|
||||
source: "on_demand",
|
||||
triggerDetail: wake.triggerDetail,
|
||||
taskId: task.id,
|
||||
triggeringCommentIds,
|
||||
triggeringCommentType: wake.triggeringCommentType,
|
||||
contextSnapshot,
|
||||
});
|
||||
};
|
||||
|
||||
// Scheduler config (includes persisted settings)
|
||||
router.get("/config", async (req, res) => {
|
||||
try {
|
||||
@@ -2644,6 +2696,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("author must be a string");
|
||||
}
|
||||
const task = await scopedStore.addTaskComment(req.params.id, text, author?.trim() || "user");
|
||||
|
||||
const newCommentId = task.comments?.at(-1)?.id;
|
||||
void triggerCommentWakeForAssignedAgent(scopedStore, task, {
|
||||
triggeringCommentType: "task",
|
||||
triggeringCommentIds: newCommentId ? [newCommentId] : undefined,
|
||||
triggerDetail: "task-comment",
|
||||
}).catch((error) => {
|
||||
console.warn(
|
||||
`[routes] failed to trigger task-comment heartbeat for ${task.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -2705,6 +2769,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("text must be between 1 and 2000 characters");
|
||||
}
|
||||
const task = await scopedStore.addSteeringComment(req.params.id, text, "user");
|
||||
|
||||
const newSteeringCommentId = task.steeringComments?.at(-1)?.id;
|
||||
void triggerCommentWakeForAssignedAgent(scopedStore, task, {
|
||||
triggeringCommentType: "steering",
|
||||
triggeringCommentIds: newSteeringCommentId ? [newSteeringCommentId] : undefined,
|
||||
triggerDetail: "steering-comment",
|
||||
}).catch((error) => {
|
||||
console.warn(
|
||||
`[routes] failed to trigger steering-comment heartbeat for ${task.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -8828,7 +8904,13 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
/**
|
||||
* POST /api/agents/:id/runs
|
||||
* Manually start a heartbeat run for an agent.
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string, taskId?: string }
|
||||
* Body: {
|
||||
* source?: HeartbeatInvocationSource,
|
||||
* triggerDetail?: string,
|
||||
* taskId?: string,
|
||||
* triggeringCommentIds?: string[],
|
||||
* triggeringCommentType?: "steering" | "task" | "pr",
|
||||
* }
|
||||
*
|
||||
* When HeartbeatMonitor is available, delegates to executeHeartbeat() with
|
||||
* a structured wake context snapshot. This ensures a single authoritative run
|
||||
@@ -8838,10 +8920,32 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
router.post("/agents/:id/runs", async (req, res) => {
|
||||
try {
|
||||
const { source, triggerDetail, taskId } = req.body || {};
|
||||
const { source, triggerDetail, taskId, triggeringCommentIds, triggeringCommentType } = req.body || {};
|
||||
const invocationSource = source ?? "on_demand";
|
||||
const trigger = triggerDetail ?? "Triggered from dashboard";
|
||||
|
||||
if (triggeringCommentIds !== undefined) {
|
||||
if (!Array.isArray(triggeringCommentIds) || triggeringCommentIds.some((id) => typeof id !== "string")) {
|
||||
throw badRequest("triggeringCommentIds must be an array of strings");
|
||||
}
|
||||
}
|
||||
if (
|
||||
triggeringCommentType !== undefined
|
||||
&& triggeringCommentType !== "steering"
|
||||
&& triggeringCommentType !== "task"
|
||||
&& triggeringCommentType !== "pr"
|
||||
) {
|
||||
throw badRequest("triggeringCommentType must be one of: steering, task, pr");
|
||||
}
|
||||
|
||||
const normalizedTriggeringCommentIds = Array.isArray(triggeringCommentIds)
|
||||
? triggeringCommentIds.map((id) => id.trim()).filter((id) => id.length > 0)
|
||||
: undefined;
|
||||
const normalizedTriggeringCommentType =
|
||||
triggeringCommentType === "steering" || triggeringCommentType === "task" || triggeringCommentType === "pr"
|
||||
? triggeringCommentType
|
||||
: undefined;
|
||||
|
||||
// Build structured wake context
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
wakeReason: invocationSource,
|
||||
@@ -8850,6 +8954,12 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
if (taskId) {
|
||||
contextSnapshot.taskId = taskId;
|
||||
}
|
||||
if (normalizedTriggeringCommentIds?.length) {
|
||||
contextSnapshot.triggeringCommentIds = normalizedTriggeringCommentIds;
|
||||
}
|
||||
if (normalizedTriggeringCommentType) {
|
||||
contextSnapshot.triggeringCommentType = normalizedTriggeringCommentType;
|
||||
}
|
||||
|
||||
if (hasHeartbeatExecutor && heartbeatMonitor) {
|
||||
// Check for existing active run
|
||||
@@ -8869,6 +8979,8 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
taskId,
|
||||
triggeringCommentIds: normalizedTriggeringCommentIds,
|
||||
triggeringCommentType: normalizedTriggeringCommentType,
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
|
||||
@@ -92,7 +92,15 @@ export interface ServerOptions {
|
||||
/** Optional HeartbeatMonitor for triggering agent execution runs */
|
||||
heartbeatMonitor?: {
|
||||
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: {
|
||||
agentId: string;
|
||||
source: import("@fusion/core").HeartbeatInvocationSource;
|
||||
triggerDetail?: string;
|
||||
taskId?: string;
|
||||
triggeringCommentIds?: string[];
|
||||
triggeringCommentType?: "steering" | "task" | "pr";
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
}): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
stopRun(agentId: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user