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

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -216,6 +216,135 @@ describe("TaskStore", () => {
});
});
describe("selectNextTaskForAgent", () => {
it("returns null when no tasks exist", async () => {
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("returns in-progress task assigned to the agent", async () => {
const inProgress = await store.createTask({
description: "In-progress task",
column: "in-progress",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(inProgress.id);
expect(selected?.priority).toBe("in_progress");
});
it("prefers in-progress over todo when both exist for the agent", async () => {
await store.createTask({
description: "Ready todo task",
column: "todo",
assignedAgentId: "agent-1",
});
const inProgress = await store.createTask({
description: "In-progress task",
column: "in-progress",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(inProgress.id);
expect(selected?.priority).toBe("in_progress");
});
it("returns todo task with all dependencies done", async () => {
const dep = await store.createTask({ description: "Done dep", column: "done" });
const readyTodo = await store.createTask({
description: "Ready todo",
column: "todo",
assignedAgentId: "agent-1",
dependencies: [dep.id],
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(readyTodo.id);
expect(selected?.priority).toBe("todo");
});
it("skips todo task with unresolved dependencies that are not actionable", async () => {
const dep = await store.createTask({ description: "Unresolved dep", column: "todo" });
await store.createTask({
description: "Blocked todo",
column: "todo",
assignedAgentId: "agent-1",
dependencies: [dep.id],
});
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("returns blocked task with partially done dependencies when no higher-priority tasks exist", async () => {
const doneDep = await store.createTask({ description: "Done dep", column: "done" });
const blockedDep = await store.createTask({ description: "Blocked dep", column: "todo" });
const partiallyActionable = await store.createTask({
description: "Partially actionable todo",
column: "todo",
assignedAgentId: "agent-1",
dependencies: [doneDep.id, blockedDep.id],
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(partiallyActionable.id);
expect(selected?.priority).toBe("blocked");
});
it("skips paused tasks", async () => {
const pausedTodo = await store.createTask({
description: "Paused todo",
column: "todo",
assignedAgentId: "agent-1",
});
await store.updateTask(pausedTodo.id, { paused: true });
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("skips tasks assigned to a different agent", async () => {
await store.createTask({
description: "Other agent task",
column: "todo",
assignedAgentId: "agent-2",
});
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
});
it("resolves FIFO ordering within the same priority tier", async () => {
const older = await store.createTask({
description: "Older ready todo",
column: "todo",
assignedAgentId: "agent-1",
});
await new Promise((resolve) => setTimeout(resolve, 5));
await store.createTask({
description: "Newer ready todo",
column: "todo",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1");
expect(selected?.task.id).toBe(older.id);
expect(selected?.priority).toBe("todo");
});
it("returns null when no tasks are assigned to the queried agent", async () => {
await store.createTask({
description: "Unassigned todo",
column: "todo",
});
await expect(store.selectNextTaskForAgent("agent-without-tasks")).resolves.toBeNull();
});
});
// ── Lock serialization test ──────────────────────────────────────
describe("write lock serialization", () => {

View File

@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, InboxTask } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
@@ -1175,6 +1175,83 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return sorted.slice(offset, offset + Math.max(0, limit));
}
async selectNextTaskForAgent(agentId: string): Promise<InboxTask | null> {
const tasks = await this.listTasks();
if (tasks.length === 0) {
return null;
}
const tasksById = new Map(tasks.map((task) => [task.id, task]));
const isCheckoutAware = "checkoutTask" in this && typeof (this as any).checkoutTask === "function";
const isDoneLike = (task: Task | undefined) => task?.column === "done" || task?.column === "archived";
const sortByOldestColumnMove = (a: Task, b: Task) => {
const aSortAt = a.columnMovedAt ?? a.createdAt;
const bSortAt = b.columnMovedAt ?? b.createdAt;
return aSortAt.localeCompare(bSortAt);
};
const assignedTasks = tasks.filter((task) => task.assignedAgentId === agentId);
const inProgress = assignedTasks.filter((task) => task.column === "in-progress").sort(sortByOldestColumnMove);
if (inProgress.length > 0) {
return {
task: inProgress[0],
priority: "in_progress",
reason: "Resuming in-progress task assigned to this agent",
};
}
const todoCandidates = assignedTasks.filter((task) => task.column === "todo" && task.paused !== true);
const readyTodo = todoCandidates
.filter((task) => {
if (isCheckoutAware && task.checkedOutBy && task.checkedOutBy !== agentId) {
return false;
}
return this.areAllDependenciesDone(task.dependencies, tasksById);
})
.sort(sortByOldestColumnMove);
if (readyTodo.length > 0) {
return {
task: readyTodo[0],
priority: "todo",
reason: "Selecting oldest ready todo task assigned to this agent",
};
}
const actionableBlocked = todoCandidates
.filter((task) => {
if (isCheckoutAware && task.checkedOutBy && task.checkedOutBy !== agentId) {
return false;
}
if (this.areAllDependenciesDone(task.dependencies, tasksById)) {
return false;
}
return task.dependencies.some((dependencyId) => isDoneLike(tasksById.get(dependencyId)));
})
.sort(sortByOldestColumnMove);
if (actionableBlocked.length > 0) {
return {
task: actionableBlocked[0],
priority: "blocked",
reason: "Selecting partially actionable blocked task assigned to this agent",
};
}
return null;
}
private areAllDependenciesDone(dependencies: string[], tasksById: Map<string, Task>): boolean {
return dependencies.every((dependencyId) => {
const dependency = tasksById.get(dependencyId);
return dependency?.column === "done" || dependency?.column === "archived";
});
}
async moveTask(id: string, toColumn: Column): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);

View File

@@ -682,6 +682,13 @@ export interface TaskDetail extends Task {
prompt: string;
}
/** A task candidate from the inbox-lite work selection, with metadata about why it was selected. */
export interface InboxTask {
task: Task;
priority: "in_progress" | "todo" | "blocked";
reason: string;
}
export interface TaskCreateInput {
title?: string;
description: string;

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.

View File

@@ -960,8 +960,12 @@ describe("HeartbeatMonitor", () => {
};
}
type MockTaskStoreOverrides = Partial<TaskStore> & {
checkoutTask?: (taskId: string, agentId: string) => Promise<unknown>;
};
// Helper: create a basic mock task store
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
function createMockTaskStore(overrides: MockTaskStoreOverrides = {}): TaskStore {
return {
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
@@ -976,6 +980,7 @@ describe("HeartbeatMonitor", () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "Created task",
@@ -1010,6 +1015,10 @@ describe("HeartbeatMonitor", () => {
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn().mockResolvedValue(mockAgent),
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
mockAgent.taskId = taskId;
return mockAgent;
}),
startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001",
agentId: "agent-001",
@@ -1116,6 +1125,187 @@ describe("HeartbeatMonitor", () => {
});
});
describe("executeHeartbeat - inbox selection", () => {
const makeInboxSelection = (taskId: string, priority: "in_progress" | "todo" | "blocked" = "todo") => {
const now = new Date().toISOString();
return {
task: {
id: taskId,
description: `Inbox task ${taskId}`,
column: priority === "in_progress" ? "in-progress" : "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: now,
updatedAt: now,
},
priority,
reason: `selected:${priority}`,
} as any;
};
it("when agent has no taskId, inbox selects a todo task and assigns it", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent,
getTask: vi.fn().mockResolvedValue({
id: "FN-INBOX",
title: "Inbox Task",
description: "Inbox-selected task",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
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" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001");
expect(store.assignTask).toHaveBeenCalledWith("agent-001", "FN-INBOX");
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-INBOX");
});
it("explicit taskId override takes precedence over inbox selection", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
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",
taskId: "FN-EXPLICIT",
});
expect(selectNextTaskForAgent).not.toHaveBeenCalled();
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-EXPLICIT");
});
it("agent's existing taskId takes precedence over inbox selection", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-EXISTING" });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
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" });
expect(selectNextTaskForAgent).not.toHaveBeenCalled();
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-EXISTING");
});
it("when inbox returns null, heartbeat completes with no_assignment", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(null);
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001");
expect(result.resultJson).toEqual({ reason: "no_assignment" });
});
it("records inbox selection metadata in resultJson", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent: vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo")),
getTask: vi.fn().mockResolvedValue({
id: "FN-INBOX",
title: "Inbox Task",
description: "Inbox-selected task",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
const mockSession = createMockAgentSession();
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result.resultJson).toEqual(expect.objectContaining({
reason: "inbox_selected",
priority: "todo",
taskId: "FN-INBOX",
}));
});
it("supports in-progress inbox selections before todo", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent: vi.fn().mockResolvedValue(makeInboxSelection("FN-RESUME", "in_progress")),
getTask: vi.fn().mockResolvedValue({
id: "FN-RESUME",
title: "Resume task",
description: "Resume in-progress work",
prompt: "",
steps: [],
column: "in-progress",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
const mockSession = createMockAgentSession();
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-RESUME");
expect(result.resultJson).toEqual(expect.objectContaining({
reason: "inbox_selected",
priority: "in_progress",
taskId: "FN-RESUME",
}));
});
it("gracefully skips inbox selection when checkoutTask throws", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-CHECKOUT", "todo"));
const checkoutTask = vi.fn().mockRejectedValue(new Error("Task is already checked out"));
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent,
checkoutTask: checkoutTask as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001");
expect(checkoutTask).toHaveBeenCalledWith("FN-CHECKOUT", "agent-001");
expect(result.resultJson).toEqual({ reason: "no_assignment" });
expect(mockedCreateKbAgent).not.toHaveBeenCalled();
});
});
describe("execution", () => {
it("creates session with correct system prompt and tools", async () => {
const store = createStoreWithAgentForExec();

View File

@@ -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 } from "@fusion/core";
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask } 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";
@@ -656,8 +656,38 @@ export class HeartbeatMonitor {
return (await this.store.getRunDetail(agentId, run.id))!;
}
// Resolve task assignment
const taskId = explicitTaskId ?? agent.taskId;
// Resolve task assignment (explicit override → existing assignment → inbox-lite selection)
let taskId = explicitTaskId ?? agent.taskId;
let inboxSelection: InboxTask | null = null;
if (!taskId) {
inboxSelection = await taskStore.selectNextTaskForAgent(agentId);
if (inboxSelection) {
taskId = inboxSelection.task.id;
heartbeatLog.log(`Inbox selected task ${taskId} (priority: ${inboxSelection.priority}) for agent ${agentId}`);
// Persist assignment to AgentStore so subsequent runs retain linkage.
if (agent.taskId !== taskId) {
await this.store.assignTask(agentId, taskId);
}
// FN-1253 compatibility: if checkout API is available on TaskStore,
// try to claim the lease. On conflict, skip this task gracefully.
const checkoutTask = (taskStore as TaskStore & {
checkoutTask?: (taskId: string, agentId: string) => Promise<unknown>;
}).checkoutTask;
if (typeof checkoutTask === "function") {
try {
await checkoutTask.call(taskStore, taskId, agentId);
} catch {
heartbeatLog.log(`Task ${taskId} already checked out — skipping`);
taskId = undefined;
inboxSelection = null;
}
}
}
}
if (taskId && run.contextSnapshot?.taskId !== taskId) {
const updatedRun: AgentHeartbeatRun = {
...run,
@@ -822,10 +852,20 @@ export class HeartbeatMonitor {
await flushAgentLogger();
// Complete run successfully
const completionResultJson: Record<string, unknown> = {
summary: heartbeatSummary,
toolCallCount,
};
if (inboxSelection) {
completionResultJson.reason = "inbox_selected";
completionResultJson.priority = inboxSelection.priority;
completionResultJson.taskId = taskId;
}
await this.completeRun(agentId, run.id, {
status: "completed",
usageJson: { inputTokens: 0, outputTokens: estimatedOutputTokens, cachedTokens: 0 },
resultJson: { summary: heartbeatSummary, toolCallCount },
resultJson: completionResultJson,
stdoutExcerpt: stdoutExcerpt || undefined,
});