feat(FN-965): add per-task planning model override
- Add planningModelProvider and planningModelId fields to core Task type - Update backend API validation to accept planning model fields on create/update - Update frontend API client and TaskForm to handle planning model - Add planning model selector row to ModelSelectorTab component - Wire up planning model in TaskDetailModal edit mode with save support - Update AGENTS.md with planning model override documentation - Add comprehensive tests for ModelSelectorTab and API routes - Bump schema version from 10 to 11
This commit is contained in:
@@ -89,7 +89,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(10);
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -112,7 +112,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(10);
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -719,7 +719,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(10);
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -744,11 +744,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(10);
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(10);
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -843,7 +843,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(10);
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1053,7 +1053,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(10);
|
||||
expect(db.getSchemaVersion()).toBe(11);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 10;
|
||||
const SCHEMA_VERSION = 11;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -141,6 +141,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
modelId TEXT,
|
||||
validatorModelProvider TEXT,
|
||||
validatorModelId TEXT,
|
||||
planningModelProvider TEXT,
|
||||
planningModelId TEXT,
|
||||
mergeRetries INTEGER,
|
||||
recoveryRetryCount INTEGER,
|
||||
nextRecoveryAt TEXT,
|
||||
@@ -449,7 +451,7 @@ export class Database {
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if (version < 11) { this.applyMigration(11, () => { ... }); }
|
||||
// if (version < 12) { this.applyMigration(12, () => { ... }); }
|
||||
|
||||
if (version < 10) {
|
||||
this.applyMigration(10, () => {
|
||||
@@ -458,6 +460,13 @@ export class Database {
|
||||
this.addColumnIfMissing("missions", "lastAutopilotActivityAt", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 11) {
|
||||
this.applyMigration(11, () => {
|
||||
this.addColumnIfMissing("tasks", "planningModelProvider", "TEXT");
|
||||
this.addColumnIfMissing("tasks", "planningModelId", "TEXT");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -174,6 +174,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
modelId: row.modelId || undefined,
|
||||
validatorModelProvider: row.validatorModelProvider || undefined,
|
||||
validatorModelId: row.validatorModelId || undefined,
|
||||
planningModelProvider: row.planningModelProvider || undefined,
|
||||
planningModelId: row.planningModelId || undefined,
|
||||
mergeRetries: row.mergeRetries ?? undefined,
|
||||
stuckKillCount: row.stuckKillCount ?? undefined,
|
||||
recoveryRetryCount: row.recoveryRetryCount ?? undefined,
|
||||
@@ -225,15 +227,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
INSERT OR REPLACE INTO tasks (
|
||||
id, title, description, "column", status, size, reviewLevel, currentStep,
|
||||
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha, modelPresetId, modelProvider,
|
||||
modelId, validatorModelProvider, validatorModelId, mergeRetries,
|
||||
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
||||
stuckKillCount, recoveryRetryCount, nextRecoveryAt, error,
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`).run(
|
||||
task.id,
|
||||
@@ -255,6 +257,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.modelId ?? null,
|
||||
task.validatorModelProvider ?? null,
|
||||
task.validatorModelId ?? null,
|
||||
task.planningModelProvider ?? null,
|
||||
task.planningModelId ?? null,
|
||||
task.mergeRetries ?? null,
|
||||
task.stuckKillCount ?? 0,
|
||||
task.recoveryRetryCount ?? null,
|
||||
@@ -808,6 +812,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
modelId: input.modelId,
|
||||
validatorModelProvider: input.validatorModelProvider,
|
||||
validatorModelId: input.validatorModelId,
|
||||
planningModelProvider: input.planningModelProvider,
|
||||
planningModelId: input.planningModelId,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [{ timestamp: now, action: "Task created" }],
|
||||
@@ -1095,7 +1101,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[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: 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; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: 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; 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 },
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
// Validate that task doesn't depend on itself
|
||||
@@ -1203,6 +1209,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.validatorModelId !== undefined) {
|
||||
task.validatorModelId = updates.validatorModelId;
|
||||
}
|
||||
if (updates.planningModelProvider === null) {
|
||||
task.planningModelProvider = undefined;
|
||||
} else if (updates.planningModelProvider !== undefined) {
|
||||
task.planningModelProvider = updates.planningModelProvider;
|
||||
}
|
||||
if (updates.planningModelId === null) {
|
||||
task.planningModelId = undefined;
|
||||
} else if (updates.planningModelId !== undefined) {
|
||||
task.planningModelId = updates.planningModelId;
|
||||
}
|
||||
if (updates.error === null) {
|
||||
task.error = undefined;
|
||||
} else if (updates.error !== undefined) {
|
||||
@@ -1821,6 +1837,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
modelId: task.modelId,
|
||||
validatorModelProvider: task.validatorModelProvider,
|
||||
validatorModelId: task.validatorModelId,
|
||||
planningModelProvider: task.planningModelProvider,
|
||||
planningModelId: task.planningModelId,
|
||||
breakIntoSubtasks: task.breakIntoSubtasks,
|
||||
paused: task.paused,
|
||||
baseBranch: task.baseBranch,
|
||||
@@ -2709,6 +2727,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
modelId: entry.modelId,
|
||||
validatorModelProvider: entry.validatorModelProvider,
|
||||
validatorModelId: entry.validatorModelId,
|
||||
planningModelProvider: entry.planningModelProvider,
|
||||
planningModelId: entry.planningModelId,
|
||||
breakIntoSubtasks: entry.breakIntoSubtasks,
|
||||
modifiedFiles: entry.modifiedFiles,
|
||||
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, baseCommitSha, error, comments
|
||||
|
||||
@@ -542,6 +542,14 @@ export interface Task {
|
||||
* Must be set together with `validatorModelProvider`. When both validator model
|
||||
* fields are undefined, the reviewer uses global settings defaults. */
|
||||
validatorModelId?: string;
|
||||
/** AI model provider override for the planning/triage agent.
|
||||
* Must be set together with `planningModelId`. When both planning model fields
|
||||
* are undefined, the triage agent uses global settings defaults. */
|
||||
planningModelProvider?: string;
|
||||
/** AI model ID override for the planning/triage agent.
|
||||
* Must be set together with `planningModelProvider`. When both planning model
|
||||
* fields are undefined, the triage agent uses global settings defaults. */
|
||||
planningModelId?: string;
|
||||
/** IDs of workflow steps enabled for this task, run after implementation completes */
|
||||
enabledWorkflowSteps?: string[];
|
||||
/** Results from workflow step executions (populated after task implementation) */
|
||||
@@ -608,6 +616,14 @@ export interface TaskCreateInput {
|
||||
* Must be set together with `validatorModelProvider`. When both validator model
|
||||
* fields are undefined, the reviewer uses global settings defaults. */
|
||||
validatorModelId?: string;
|
||||
/** AI model provider override for the planning/triage agent.
|
||||
* Must be set together with `planningModelId`. When both planning model fields
|
||||
* are undefined, the triage agent uses global settings defaults. */
|
||||
planningModelProvider?: string;
|
||||
/** AI model ID override for the planning/triage agent.
|
||||
* Must be set together with `planningModelProvider`. When both planning model
|
||||
* fields are undefined, the triage agent uses global settings defaults. */
|
||||
planningModelId?: string;
|
||||
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** When true, trigger AI title summarization if description is long and no title provided */
|
||||
@@ -1193,6 +1209,9 @@ export interface ArchivedTaskEntry {
|
||||
modelId?: string;
|
||||
validatorModelProvider?: string;
|
||||
validatorModelId?: string;
|
||||
/** Optional: planning model override for triage agent */
|
||||
planningModelProvider?: string;
|
||||
planningModelId?: string;
|
||||
/** Optional: other metadata to preserve */
|
||||
breakIntoSubtasks?: boolean;
|
||||
paused?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user