feat(FN-1254): add inbox-lite task selection for heartbeat agents
- Add InboxTask typing and TaskStore.selectNextTaskForAgent() with priority ordering, dependency checks, paused filtering, and FIFO selection - Wire heartbeat execution to auto-select and assign inbox work when no task is set, with optional checkout attempts and graceful conflict fallback - Add POST /api/agents/:id/inbox to expose next-task selection details (task, priority, reason) and return task:null when no work is available - Expand core, engine, and dashboard tests to cover selection priorities, heartbeat precedence/metadata, checkout-conflict handling, and route behavior with type-safe mocks
This commit is contained in:
@@ -2117,6 +2117,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
updateTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
|
||||
} as any);
|
||||
}, 30_000);
|
||||
|
||||
@@ -2201,6 +2202,48 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
|
||||
expect(res.body.error).toBe("Agent not found");
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
}, 30_000);
|
||||
|
||||
it("POST /api/agents/:id/inbox returns next selection when work exists", async () => {
|
||||
const inboxTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-500",
|
||||
assignedAgentId: agentId,
|
||||
};
|
||||
|
||||
(store.selectNextTaskForAgent as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
task: inboxTask,
|
||||
priority: "todo",
|
||||
reason: "Selecting oldest ready todo task assigned to this agent",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/inbox`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.selectNextTaskForAgent).toHaveBeenCalledWith(agentId);
|
||||
expect(res.body).toEqual({
|
||||
task: expect.objectContaining({ id: "FN-500" }),
|
||||
priority: "todo",
|
||||
reason: "Selecting oldest ready todo task assigned to this agent",
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("POST /api/agents/:id/inbox returns task:null when no work exists", async () => {
|
||||
(store.selectNextTaskForAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/inbox`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.selectNextTaskForAgent).toHaveBeenCalledWith(agentId);
|
||||
expect(res.body).toEqual({ task: null });
|
||||
}, 30_000);
|
||||
|
||||
it("POST /api/agents/:id/inbox returns 404 for missing agent", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/agents/agent-missing/inbox");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Agent not found");
|
||||
expect(store.selectNextTaskForAgent).not.toHaveBeenCalled();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("Task checkout routes", () => {
|
||||
|
||||
@@ -8689,6 +8689,45 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/inbox
|
||||
* Select the next inbox-lite task candidate for an agent.
|
||||
*
|
||||
* Returns `{ task, priority, reason }` when work is available,
|
||||
* or `{ task: null }` when no matching work is found.
|
||||
*/
|
||||
router.post("/agents/:id/inbox", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agentId = req.params.id;
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
const selection = await scopedStore.selectNextTaskForAgent(agentId);
|
||||
if (!selection) {
|
||||
res.json({ task: null });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
task: selection.task,
|
||||
priority: selection.priority,
|
||||
reason: selection.reason,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/heartbeat
|
||||
* Record a heartbeat for an agent.
|
||||
|
||||
Reference in New Issue
Block a user