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:
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user