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 edf91528b0
commit cb4df9f476
8 changed files with 532 additions and 7 deletions

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);