feat(FN-4056): restore bound reports lookup in heartbeat executor

Restores the bound reports lookup in the heartbeat executor (FN-4056), updating the relevant logic in `agent-heartbeat.ts` with a corresponding test and documentation clarification in `agents.md`.

Fusion-Task-Id: FN-4056
This commit is contained in:
Fusion
2026-05-12 00:59:55 -07:00
committed by gsxdsm
parent 43c8aa58f9
commit 079772a106
3 changed files with 24 additions and 3 deletions

View File

@@ -761,6 +761,8 @@ The shipped default `HEARTBEAT_PROCEDURE` (in `packages/engine/src/agent-heartbe
When the bound task is `executor-class` or `blocked`, the default procedure directs the run to pivot toward coordination levers (in-progress risk scan, stale in-review queue, idle direct reports, strategic memory themes) rather than trying to advance implementation from heartbeat. When the task is `coordination-class`, the heartbeat can engage directly with the bound task.
The manager-facing reports health block in that prompt is populated from `AgentStore.getAgentsByReportsTo(agent.id)`. Engine code must call that store method with its `AgentStore` instance binding intact because some implementations resolve direct reports through `this.listAgents()`. If the section disappears unexpectedly, look for logs like `Failed to load reports ... Cannot read properties of undefined (reading 'listAgents')`, which indicate an unbound method call regressed.
This behavior is inherited by new non-ephemeral agents because agent creation seeds a per-agent `HEARTBEAT.md` file from the built-in default. If an agent sets `heartbeatProcedurePath`, that markdown file fully replaces the built-in default at runtime.
For pre-existing agents, use `POST /api/agents/:id/upgrade-heartbeat-procedure` (also exposed as **Upgrade to Default Heartbeat Procedure** in the agent detail Config tab) to re-seed from the current built-in constant. When the built-in default changes, running this upgrade propagates the new default to existing agents; direct operator edits to an agents existing procedure file are preserved unless this upgrade is run (the upgrade overwrites the per-agent file).

View File

@@ -221,6 +221,25 @@ describe("executeHeartbeat", () => {
expect(section).toContain("**stale**");
});
it("buildReportsHealthSection preserves AgentStore method binding for direct-report lookups", async () => {
const now = new Date().toISOString();
const report = { id: "agent-004", name: "bound-report", state: "active", taskId: "FN-102", reportsTo: "agent-001", lastHeartbeatAt: now, updatedAt: now } as Agent;
const store = createStoreWithAgentForExec() as AgentStore & {
listAgents: ReturnType<typeof vi.fn>;
getAgentsByReportsTo: (agentId: string) => Promise<Agent[]>;
};
store.listAgents = vi.fn().mockResolvedValue([mockAgent, report]);
store.getAgentsByReportsTo = async function (agentId: string) {
const agents = await this.listAgents();
return agents.filter((candidate: Agent) => candidate.reportsTo === agentId);
};
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const section = await (monitor as any).buildReportsHealthSection("agent-001", store);
expect(section).toContain("bound-report");
expect(store.listAgents).toHaveBeenCalledTimes(1);
});
it("executeHeartbeat includes reports health section when agent has reports", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const now = new Date().toISOString();

View File

@@ -2430,14 +2430,14 @@ export class HeartbeatMonitor {
}
private async buildReportsHealthSection(agentId: string, agentStore: AgentStore): Promise<string | null> {
const getReports = (agentStore as AgentStore & { getAgentsByReportsTo?: (id: string) => Promise<Agent[]> }).getAgentsByReportsTo;
if (typeof getReports !== "function") {
const storeWithReports = agentStore as AgentStore & { getAgentsByReportsTo?: (id: string) => Promise<Agent[]> };
if (typeof storeWithReports.getAgentsByReportsTo !== "function") {
return null;
}
let reports: Agent[];
try {
reports = await getReports(agentId);
reports = await storeWithReports.getAgentsByReportsTo(agentId);
} catch (err) {
heartbeatLog.warn(`Failed to load reports for ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
return null;