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:
gsxdsm
2026-04-08 17:57:16 -07:00
parent 86e3869f75
commit 61117c8a2b
8 changed files with 532 additions and 7 deletions

View File

@@ -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", () => {

View File

@@ -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.