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:
gsxdsm
2026-04-06 22:55:25 -07:00
parent 6d98946aeb
commit ab064ebc26
12 changed files with 473 additions and 66 deletions

View File

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

View File

@@ -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");
});
}
}
/**

View File

@@ -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

View File

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