feat(FN-2383): persist task priority across core storage

- Add task-priority contract, normalization helpers, and exports in @fusion/core types/index
- Store task priority in SQLite and migrate existing databases with default values
- Update task store behavior and sorting tests to preserve and order by persisted priority
- Add migration/regression coverage for archived tasks and refresh storage/task-management docs
This commit is contained in:
Fusion
2026-04-24 03:48:37 -07:00
committed by gsxdsm
parent a07cb53e1d
commit 52168d7f2c
18 changed files with 387 additions and 30 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { TaskStore } from "../store.js";
import { sortTasksByPriorityThenAgeAndId } from "../task-priority.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
@@ -52,4 +53,16 @@ describe("TaskStore.listTasks() sort order", () => {
expect(nums[i]).toBeGreaterThan(nums[i - 1]);
}
});
it("provides deterministic helper ordering by priority then age then id", () => {
const sorted = sortTasksByPriorityThenAgeAndId([
{ id: "FN-010", createdAt: "2026-01-02T00:00:00Z", priority: "normal" },
{ id: "FN-001", createdAt: "2026-01-01T00:00:00Z", priority: "high" },
{ id: "FN-002", createdAt: "2026-01-01T00:00:00Z", priority: "high" },
{ id: "FN-003", createdAt: "2026-01-01T00:00:00Z" },
{ id: "FN-004", createdAt: "2026-01-01T00:00:00Z", priority: "urgent" },
]);
expect(sorted.map((task) => task.id)).toEqual(["FN-004", "FN-001", "FN-002", "FN-003", "FN-010"]);
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
const index = db
.prepare(

View File

@@ -184,6 +184,7 @@ describe("migrateFromLegacy", () => {
id: "FN-001",
title: "Test task",
description: "A test task",
priority: "urgent",
column: "todo",
dependencies: ["FN-000"],
steps: [{ name: "Step 1", status: "done" }],
@@ -205,6 +206,7 @@ describe("migrateFromLegacy", () => {
expect(row).toBeDefined();
expect(row.title).toBe("Test task");
expect(row.column).toBe("todo");
expect(row.priority).toBe("urgent");
expect(row.size).toBe("M");
expect(row.reviewLevel).toBe(2);
expect(JSON.parse(row.dependencies)).toEqual(["FN-000"]);
@@ -212,6 +214,32 @@ describe("migrateFromLegacy", () => {
expect(JSON.parse(row.prInfo).number).toBe(1);
});
it("defaults migrated tasks to normal priority when legacy task.json omits priority", async () => {
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-001",
description: "Legacy priorityless task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
}),
);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT priority FROM tasks WHERE id = 'FN-001'").get() as { priority: string };
expect(row.priority).toBe("normal");
});
it("skips invalid task.json files", async () => {
const tasksDir = join(fusionDir, "tasks");
const validDir = join(tasksDir, "FN-001");

View File

@@ -13,6 +13,7 @@ import { readFile, readdir, rename } from "node:fs/promises";
import { join } from "node:path";
import type { Database } from "./db.js";
import { toJson, toJsonNullable, normalizeTaskComments } from "./db.js";
import { normalizeTaskPriority } from "./task-priority.js";
import type { Task, BoardConfig, ActivityLogEntry, ArchivedTaskEntry, WorkflowStep } from "./types.js";
import type { ScheduledTask } from "./automation.js";
@@ -215,7 +216,7 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
const insertStmt = db.prepare(`
INSERT OR REPLACE INTO tasks (
id, title, description, "column", status, size, reviewLevel, currentStep,
id, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, baseCommitSha, modelPresetId,
modelProvider, modelId, validatorModelProvider, validatorModelId,
mergeRetries, recoveryRetryCount, nextRecoveryAt,
@@ -224,7 +225,7 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, sliceId
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
@@ -248,6 +249,7 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
task.id,
task.title ?? null,
task.description,
normalizeTaskPriority(task.priority),
task.column,
task.status ?? null,
task.size ?? null,

View File

@@ -131,7 +131,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
});
it("seeds lastModified", () => {
@@ -154,7 +154,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
});
it("does not overwrite existing config on re-init", () => {
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -786,11 +786,52 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
db.close();
});
it("migrates v42 databases by adding task priority with normal default", () => {
tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(fusionDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
executionMode TEXT DEFAULT 'standard'
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '42')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-1', 'legacy', 'triage', '2026-01-01', '2026-01-01')`);
db.init();
expect(db.getSchemaVersion()).toBe(43);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
const task = db.prepare("SELECT priority FROM tasks WHERE id = 'FN-1'").get() as { priority: string };
expect(task.priority).toBe("normal");
db.close();
});
@@ -806,7 +847,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -830,7 +871,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -934,7 +975,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1303,7 +1344,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 42;
const SCHEMA_VERSION = 43;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -152,6 +152,7 @@ CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT,
description TEXT NOT NULL,
priority TEXT DEFAULT 'normal',
"column" TEXT NOT NULL,
status TEXT,
size TEXT,
@@ -1700,6 +1701,19 @@ export class Database {
});
}
// Task priority contract (FN-2383)
// Adds priority column and normalizes legacy/missing values to 'normal'.
if (version < 43) {
this.applyMigration(43, () => {
this.addColumnIfMissing("tasks", "priority", "TEXT DEFAULT 'normal'");
this.db.exec(`
UPDATE tasks
SET priority = 'normal'
WHERE priority IS NULL OR priority = '' OR priority NOT IN ('low', 'normal', 'high', 'urgent')
`);
});
}
}
/**

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, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, 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, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, 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, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,
@@ -223,6 +223,15 @@ export {
applyRoadmapFeatureReorder,
moveRoadmapFeature,
} from "./roadmap-ordering.js";
export {
isTaskPriority,
normalizeTaskPriority,
getTaskPriorityRank,
compareTaskPriority,
compareTasksByPriorityThenAgeAndId,
sortTasksByPriorityThenAgeAndId,
} from "./task-priority.js";
export type { TaskPrioritySortable } from "./task-priority.js";
export {
mapFeatureToTaskHandoff,
mapRoadmapToMissionHandoff,

View File

@@ -776,7 +776,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(42);
expect(db1.getSchemaVersion()).toBe(43);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -811,7 +811,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(42);
expect(db3.getSchemaVersion()).toBe(43);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -842,12 +842,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(42);
expect(db1.getSchemaVersion()).toBe(43);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(42);
expect(db2.getSchemaVersion()).toBe(43);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -2626,7 +2626,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
});
it("mission_features table has loop state columns", () => {

View File

@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(42);
expect(db.getSchemaVersion()).toBe(43);
});
});
});

View File

@@ -155,6 +155,74 @@ describe("TaskStore", () => {
});
describe("task priority", () => {
it("defaults to normal priority when omitted", async () => {
const task = await store.createTask({
description: "Priority default task",
});
expect(task.priority).toBe("normal");
const detail = await store.getTask(task.id);
expect(detail.priority).toBe("normal");
});
it("persists explicit priority on create and update, and normalizes null update to default", async () => {
const task = await store.createTask({
description: "Priority explicit task",
priority: "urgent",
});
expect(task.priority).toBe("urgent");
const lowered = await store.updateTask(task.id, { priority: "low" });
expect(lowered.priority).toBe("low");
const reset = await store.updateTask(task.id, { priority: null });
expect(reset.priority).toBe("normal");
const detail = await store.getTask(task.id);
expect(detail.priority).toBe("normal");
});
it("preserves explicit priority through archive and unarchive", async () => {
const task = await store.createTask({
description: "Archive priority task",
column: "done",
priority: "high",
});
await store.archiveTask(task.id, false);
const archived = await store.getTask(task.id);
expect(archived.priority).toBe("high");
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.priority).toBe("high");
});
it("restores legacy archive entries missing priority as normal", async () => {
const now = new Date().toISOString();
const legacyEntry = {
id: "FN-999",
title: "Legacy archive task",
description: "Legacy task without explicit priority",
column: "archived" as const,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: now,
updatedAt: now,
archivedAt: now,
};
const restored = await (store as any).restoreFromArchive(legacyEntry);
expect(restored.priority).toBe("normal");
const unarchived = await store.unarchiveTask(legacyEntry.id);
expect(unarchived.priority).toBe("normal");
});
});
describe("breakIntoSubtasks task creation flag", () => {
it("persists breakIntoSubtasks=true when explicitly requested", async () => {
const task = await store.createTask({

View File

@@ -3,8 +3,9 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, 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, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { normalizeTaskPriority } from "./task-priority.js";
import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
import { ArchiveDatabase } from "./archive-db.js";
@@ -25,6 +26,7 @@ interface TaskRow {
id: string;
title: string | null;
description: string;
priority: string | null;
column: string;
status: string | null;
size: string | null;
@@ -445,6 +447,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
id: row.id,
title: row.title || undefined,
description: row.description,
priority: normalizeTaskPriority(row.priority),
column: row.column as Column,
status: row.status || undefined,
size: (row.size || undefined) as Task["size"],
@@ -519,6 +522,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
id: entry.id,
title: entry.title,
description: entry.description,
priority: normalizeTaskPriority(entry.priority),
column: "archived",
dependencies: entry.dependencies ?? [],
steps: entry.steps ?? [],
@@ -637,6 +641,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
id: task.id,
title: task.title,
description: task.description,
priority: normalizeTaskPriority(task.priority),
column: "archived",
dependencies: task.dependencies,
steps: task.steps,
@@ -714,7 +719,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const prefix = tableAlias ? `${tableAlias}.` : "";
return [
"id", "title", "description", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"id", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
@@ -732,7 +737,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
const columns = [
"id", "title", "description", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"id", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "paused", "baseBranch", "branch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
@@ -775,7 +780,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private upsertTask(task: Task): void {
this.db.prepare(`
INSERT INTO tasks (
id, title, description, "column", status, size, reviewLevel, currentStep,
id, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, nextRecoveryAt, error,
@@ -784,12 +789,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
priority = excluded.priority,
"column" = excluded."column",
status = excluded.status,
size = excluded.size,
@@ -845,6 +851,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.id,
task.title ?? null,
task.description,
normalizeTaskPriority(task.priority),
task.column,
task.status ?? null,
task.size ?? null,
@@ -1121,6 +1128,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (!Array.isArray(fileTask.log)) fileTask.log = [];
if (!Array.isArray(fileTask.dependencies)) fileTask.dependencies = [];
if (!Array.isArray(fileTask.steps)) fileTask.steps = [];
fileTask.priority = normalizeTaskPriority(fileTask.priority);
return fileTask;
} catch (err) {
throw new Error(
@@ -1832,6 +1840,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
id,
title,
description: input.description,
priority: normalizeTaskPriority(input.priority),
column: input.column || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
@@ -1894,6 +1903,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
id: newId,
title: sourceTask.title,
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
priority: normalizeTaskPriority(sourceTask.priority),
column: "triage",
modelPresetId: sourceTask.modelPresetId,
dependencies: [], // Fresh task should have no dependencies
@@ -1970,6 +1980,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
id: newId,
title: `Refinement: ${sourceLabel}`,
description: `${feedback.trim()}\n\nRefines: ${id}`,
priority: normalizeTaskPriority(sourceTask.priority),
column: "triage",
dependencies: [id], // Refinement depends on the original being complete
steps: [], // Reset execution state
@@ -2398,7 +2409,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -2417,6 +2428,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (updates.title !== undefined) task.title = updates.title;
if (updates.description !== undefined) task.description = updates.description;
if (updates.priority === null) {
task.priority = normalizeTaskPriority(undefined);
} else if (updates.priority !== undefined) {
task.priority = normalizeTaskPriority(updates.priority);
}
if (updates.worktree === null) {
task.worktree = undefined;
} else if (updates.worktree !== undefined) {
@@ -4812,6 +4828,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
id: entry.id,
title: entry.title,
description: entry.description,
priority: normalizeTaskPriority(entry.priority),
column: "archived", // Will be changed to "done" by unarchiveTask
dependencies: entry.dependencies,
steps: entry.steps,

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
compareTaskPriority,
compareTasksByPriorityThenAgeAndId,
getTaskPriorityRank,
isTaskPriority,
normalizeTaskPriority,
sortTasksByPriorityThenAgeAndId,
} from "./task-priority.js";
import {
DEFAULT_TASK_PRIORITY,
TASK_PRIORITIES,
type TaskPriority,
} from "./types.js";
import * as core from "./index.js";
describe("task-priority", () => {
it("defines the bounded priority contract in order", () => {
expect(TASK_PRIORITIES).toEqual(["low", "normal", "high", "urgent"]);
expect(DEFAULT_TASK_PRIORITY).toBe("normal");
});
it("normalizes missing or invalid values to default", () => {
expect(normalizeTaskPriority(undefined)).toBe("normal");
expect(normalizeTaskPriority(null)).toBe("normal");
expect(normalizeTaskPriority("")).toBe("normal");
});
it("identifies valid task priorities", () => {
for (const value of TASK_PRIORITIES) {
expect(isTaskPriority(value)).toBe(true);
}
expect(isTaskPriority("in_progress")).toBe(false);
});
it("provides deterministic ranks and priority comparator", () => {
const orderedByRank: TaskPriority[] = ["low", "normal", "high", "urgent"];
expect(orderedByRank.map((priority) => getTaskPriorityRank(priority))).toEqual([0, 1, 2, 3]);
expect(compareTaskPriority("urgent", "low")).toBeLessThan(0);
expect(compareTaskPriority(undefined, "normal")).toBe(0);
});
it("sorts tasks by priority desc then createdAt asc then id asc", () => {
const tasks = [
{ id: "FN-002", createdAt: "2026-01-01T00:00:00.000Z", priority: "high" as TaskPriority },
{ id: "FN-001", createdAt: "2026-01-01T00:00:00.000Z", priority: "high" as TaskPriority },
{ id: "FN-009", createdAt: "2026-01-02T00:00:00.000Z", priority: "urgent" as TaskPriority },
{ id: "FN-003", createdAt: "2026-01-01T00:00:00.000Z", priority: undefined },
];
const sorted = sortTasksByPriorityThenAgeAndId(tasks);
expect(sorted.map((task) => task.id)).toEqual(["FN-009", "FN-001", "FN-002", "FN-003"]);
// comparator function should match sorted behavior
expect(compareTasksByPriorityThenAgeAndId(tasks[0], tasks[1])).toBeGreaterThan(0);
});
it("re-exports priority helpers from the core index", () => {
expect(core.TASK_PRIORITIES).toEqual(TASK_PRIORITIES);
expect(core.DEFAULT_TASK_PRIORITY).toBe("normal");
expect(core.normalizeTaskPriority("bogus")).toBe(DEFAULT_TASK_PRIORITY);
});
});

View File

@@ -0,0 +1,78 @@
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES } from "./types.js";
import type { TaskPriority } from "./types.js";
export interface TaskPrioritySortable {
id: string;
createdAt: string;
priority?: TaskPriority | null;
}
const PRIORITY_RANK: Record<TaskPriority, number> = {
low: 0,
normal: 1,
high: 2,
urgent: 3,
};
export function isTaskPriority(value: unknown): value is TaskPriority {
return typeof value === "string" && (TASK_PRIORITIES as readonly string[]).includes(value);
}
/**
* Normalize an optional/legacy task priority value to the bounded core contract.
* Missing or invalid values map to DEFAULT_TASK_PRIORITY (`normal`).
*/
export function normalizeTaskPriority(priority: unknown): TaskPriority {
return isTaskPriority(priority) ? priority : DEFAULT_TASK_PRIORITY;
}
/**
* Return a numeric rank where higher values indicate higher priority.
*/
export function getTaskPriorityRank(priority: unknown): number {
return PRIORITY_RANK[normalizeTaskPriority(priority)];
}
/**
* Compare priorities so higher-priority tasks sort first.
*/
export function compareTaskPriority(a: unknown, b: unknown): number {
return getTaskPriorityRank(b) - getTaskPriorityRank(a);
}
function compareTaskId(a: string, b: string): number {
const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10);
const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10);
if (Number.isFinite(aNum) && Number.isFinite(bNum) && aNum !== bNum) {
return aNum - bNum;
}
return a.localeCompare(b);
}
/**
* Deterministic comparator for priority-aware task ordering:
* 1) priority (urgent → low), 2) createdAt ASC, 3) id ASC.
*/
export function compareTasksByPriorityThenAgeAndId<T extends TaskPrioritySortable>(a: T, b: T): number {
const priorityCmp = compareTaskPriority(a.priority, b.priority);
if (priorityCmp !== 0) {
return priorityCmp;
}
if (a.createdAt !== b.createdAt) {
return a.createdAt.localeCompare(b.createdAt);
}
return compareTaskId(a.id, b.id);
}
/**
* Return a sorted copy (input remains unchanged).
*/
export function sortTasksByPriorityThenAgeAndId<T extends TaskPrioritySortable>(
tasks: readonly T[],
): T[] {
return [...tasks].sort(compareTasksByPriorityThenAgeAndId);
}

View File

@@ -5,6 +5,16 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
export type Column = (typeof COLUMNS)[number];
/** Ordered task-priority levels for the core task domain contract. */
export const TASK_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export type TaskPriority = (typeof TASK_PRIORITIES)[number];
/**
* Default task priority used for legacy rows/entries and create flows when
* callers omit the priority field.
*/
export const DEFAULT_TASK_PRIORITY: TaskPriority = "normal";
/**
* Execution mode for task implementation.
* Controls how the executor agent approaches the task:
@@ -651,6 +661,11 @@ export interface Task {
id: string;
title?: string;
description: string;
/**
* Task importance level. Missing legacy values normalize to `normal` when
* tasks are hydrated from persistence.
*/
priority?: TaskPriority;
column: Column;
dependencies: string[];
/** User-requested hint for triage: prefer splitting into child tasks when appropriate. */
@@ -804,6 +819,10 @@ export interface InboxTask {
export interface TaskCreateInput {
title?: string;
description: string;
/**
* Optional task importance level. Omitted values default to `normal`.
*/
priority?: TaskPriority;
column?: Column;
dependencies?: string[];
breakIntoSubtasks?: boolean;
@@ -1554,6 +1573,11 @@ export interface ArchivedTaskEntry {
id: string;
title?: string;
description: string;
/**
* Task importance level at archive time. Missing legacy values should be
* interpreted as `normal` during restore/read flows.
*/
priority?: TaskPriority;
column: "archived"; // Always archived when in the log
dependencies: string[];
steps: TaskStep[];