feat(FN-2719): merge fusion/fn-2719

This commit is contained in:
gsxdsm
2026-04-27 22:53:51 -07:00
parent 35db935feb
commit 3c18f481f8
12 changed files with 190 additions and 27 deletions

View File

@@ -131,7 +131,7 @@ describe("Database", () => {
}); });
it("seeds schema version", () => { it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
}); });
it("seeds lastModified", () => { it("seeds lastModified", () => {
@@ -154,7 +154,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => { it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow(); expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
}); });
it("does not overwrite existing config on re-init", () => { it("does not overwrite existing config on re-init", () => {
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29) // Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir); const db = new Database(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
// Re-init should not fail // Re-init should not fail
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
db.close(); db.close();
}); });
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority"); expect(cols.map((col) => col.name)).toContain("priority");
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -976,7 +976,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; 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" }]); expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1000,7 +1000,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; 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" }]); expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1104,7 +1104,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 // Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1473,7 +1473,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir); const db = createDatabase(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(48); expect(db.getSchemaVersion()).toBe(49);
expect(db.getLastModified()).toBeGreaterThan(0); expect(db.getLastModified()).toBeGreaterThan(0);
db.close(); db.close();

View File

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

View File

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

View File

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

View File

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

View File

@@ -474,6 +474,43 @@ describe("TaskStore", () => {
}); });
}); });
describe("nodeId persistence", () => {
it("creates a task with nodeId when provided", async () => {
const task = await store.createTask({
description: "Node-targeted task",
nodeId: "node-123",
});
expect(task.nodeId).toBe("node-123");
const detail = await store.getTask(task.id);
expect(detail.nodeId).toBe("node-123");
});
it("updates and clears nodeId via updateTask", async () => {
const task = await store.createTask({ description: "Task to mutate nodeId" });
const updated = await store.updateTask(task.id, { nodeId: "node-456" });
expect(updated.nodeId).toBe("node-456");
const cleared = await store.updateTask(task.id, { nodeId: null });
expect(cleared.nodeId).toBeUndefined();
});
it("returns nodeId values from listTasks", async () => {
const assignedNode = await store.createTask({
description: "Task with node in list",
nodeId: "node-list",
});
await store.createTask({ description: "Task without node in list" });
const tasks = await store.listTasks();
const listed = tasks.find((t) => t.id === assignedNode.id);
expect(listed?.nodeId).toBe("node-list");
});
});
describe("selectNextTaskForAgent", () => { describe("selectNextTaskForAgent", () => {
it("returns null when no tasks exist", async () => { it("returns null when no tasks exist", async () => {
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull(); await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();

View File

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

View File

@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-task-node-override-"));
}
describe("task node override persistence", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.stopWatching();
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("creates a task with nodeId when provided", async () => {
const created = await store.createTask({ description: "Task with node", nodeId: "node-abc" });
const fetched = await store.getTask(created.id);
expect(fetched.nodeId).toBe("node-abc");
});
it("leaves nodeId undefined when not provided", async () => {
const created = await store.createTask({ description: "Task without node" });
const fetched = await store.getTask(created.id);
expect(fetched.nodeId).toBeUndefined();
});
it("updates nodeId on an existing task", async () => {
const created = await store.createTask({ description: "Task to update node" });
await store.updateTask(created.id, { nodeId: "node-xyz" });
const fetched = await store.getTask(created.id);
expect(fetched.nodeId).toBe("node-xyz");
});
it("clears nodeId when updateTask sets null", async () => {
const created = await store.createTask({ description: "Task to clear node", nodeId: "node-abc" });
await store.updateTask(created.id, { nodeId: null });
const fetched = await store.getTask(created.id);
expect(fetched.nodeId).toBeUndefined();
});
it("persists nodeId across store reload", async () => {
const diskRoot = makeTmpDir();
const diskGlobal = makeTmpDir();
const firstStore = new TaskStore(diskRoot, diskGlobal);
await firstStore.init();
const created = await firstStore.createTask({ description: "Disk-backed node task", nodeId: "node-persist" });
firstStore.close();
const reloadedStore = new TaskStore(diskRoot, diskGlobal);
await reloadedStore.init();
const fetched = await reloadedStore.getTask(created.id);
expect(fetched.nodeId).toBe("node-persist");
reloadedStore.close();
await rm(diskRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(diskGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("updates nodeId without mutating other task fields", async () => {
const created = await store.createTask({
description: "Task with multiple fields",
nodeId: "node-a",
priority: "high",
modelProvider: "anthropic",
});
await store.updateTask(created.id, { nodeId: "node-b" });
const fetched = await store.getTask(created.id);
expect(fetched.nodeId).toBe("node-b");
expect(fetched.priority).toBe("high");
expect(fetched.modelProvider).toBe("anthropic");
});
it("returns nodeId values via listTasks", async () => {
const first = await store.createTask({ description: "Node one", nodeId: "node-one" });
const second = await store.createTask({ description: "Node two", nodeId: "node-two" });
const third = await store.createTask({ description: "No node" });
const tasks = await store.listTasks();
expect(tasks.find((task) => task.id === first.id)?.nodeId).toBe("node-one");
expect(tasks.find((task) => task.id === second.id)?.nodeId).toBe("node-two");
expect(tasks.find((task) => task.id === third.id)?.nodeId).toBeUndefined();
});
});

View File

@@ -1845,6 +1845,13 @@ export class Database {
}); });
} }
// Per-task node override for remote/local execution routing selection.
if (version < 49) {
this.applyMigration(49, () => {
this.addColumnIfMissing("tasks", "nodeId", "TEXT");
});
}
} }
/** /**

View File

@@ -89,6 +89,7 @@ interface TaskRow {
sliceId: string | null; sliceId: string | null;
assignedAgentId: string | null; assignedAgentId: string | null;
assigneeUserId: string | null; assigneeUserId: string | null;
nodeId: string | null;
checkedOutBy: string | null; checkedOutBy: string | null;
checkedOutAt: string | null; checkedOutAt: string | null;
} }
@@ -612,6 +613,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sliceId: row.sliceId || undefined, sliceId: row.sliceId || undefined,
assignedAgentId: row.assignedAgentId || undefined, assignedAgentId: row.assignedAgentId || undefined,
assigneeUserId: row.assigneeUserId || undefined, assigneeUserId: row.assigneeUserId || undefined,
nodeId: row.nodeId || undefined,
checkedOutBy: row.checkedOutBy || undefined, checkedOutBy: row.checkedOutBy || undefined,
checkedOutAt: row.checkedOutAt || undefined, checkedOutAt: row.checkedOutAt || undefined,
}; };
@@ -834,7 +836,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments", "dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles", "breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId",
"checkedOutBy", "checkedOutAt", "checkedOutBy", "checkedOutAt",
// `log` is fetched in slim mode so the server can aggregate // `log` is fetched in slim mode so the server can aggregate
// `timedExecutionMs` from `[timing] … in <N>ms` entries before // `timedExecutionMs` from `[timing] … in <N>ms` entries before
@@ -882,7 +884,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "attachments", "steeringComments", "dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles", "breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId",
"checkedOutBy", "checkedOutAt", "checkedOutBy", "checkedOutAt",
]; ];
@@ -923,9 +925,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments, dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, checkedOutBy, checkedOutAt mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, checkedOutBy, checkedOutAt
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
title = excluded.title, title = excluded.title,
@@ -992,6 +994,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sliceId = excluded.sliceId, sliceId = excluded.sliceId,
assignedAgentId = excluded.assignedAgentId, assignedAgentId = excluded.assignedAgentId,
assigneeUserId = excluded.assigneeUserId, assigneeUserId = excluded.assigneeUserId,
nodeId = excluded.nodeId,
checkedOutBy = excluded.checkedOutBy, checkedOutBy = excluded.checkedOutBy,
checkedOutAt = excluded.checkedOutAt checkedOutAt = excluded.checkedOutAt
`).run( `).run(
@@ -1060,6 +1063,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.sliceId ?? null, task.sliceId ?? null,
task.assignedAgentId ?? null, task.assignedAgentId ?? null,
task.assigneeUserId ?? null, task.assigneeUserId ?? null,
task.nodeId ?? null,
task.checkedOutBy ?? null, task.checkedOutBy ?? null,
task.checkedOutAt ?? null, task.checkedOutAt ?? null,
); );
@@ -2034,6 +2038,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
modelPresetId: input.modelPresetId, modelPresetId: input.modelPresetId,
assignedAgentId: input.assignedAgentId, assignedAgentId: input.assignedAgentId,
assigneeUserId: input.assigneeUserId, assigneeUserId: input.assigneeUserId,
nodeId: input.nodeId,
modelProvider: input.modelProvider, modelProvider: input.modelProvider,
modelId: input.modelId, modelId: input.modelId,
validatorModelProvider: input.validatorModelProvider, validatorModelProvider: input.validatorModelProvider,
@@ -2613,7 +2618,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask( async updateTask(
id: string, id: string,
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; verificationFailureCount?: 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; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | 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; nodeId?: 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; verificationFailureCount?: 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; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext, runContext?: RunMutationContext,
): Promise<Task> { ): Promise<Task> {
return this.withTaskLock(id, async () => { return this.withTaskLock(id, async () => {
@@ -2686,6 +2691,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.assigneeUserId !== undefined) { } else if (updates.assigneeUserId !== undefined) {
task.assigneeUserId = updates.assigneeUserId; task.assigneeUserId = updates.assigneeUserId;
} }
if (updates.nodeId === null) {
task.nodeId = undefined;
} else if (updates.nodeId !== undefined) {
task.nodeId = updates.nodeId;
}
if (updates.checkedOutBy === null) { if (updates.checkedOutBy === null) {
task.checkedOutBy = undefined; task.checkedOutBy = undefined;
task.checkedOutAt = undefined; task.checkedOutAt = undefined;

View File

@@ -843,6 +843,8 @@ export interface Task {
executionMode?: ExecutionMode; executionMode?: ExecutionMode;
/** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */ /** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */
assignedAgentId?: string; assignedAgentId?: string;
/** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */
nodeId?: string;
/** Explicitly assigned user ID for task-user linking. Used during review handoff to indicate /** Explicitly assigned user ID for task-user linking. Used during review handoff to indicate
* which user should review the task. The sentinel value "requesting-user" indicates the * which user should review the task. The sentinel value "requesting-user" indicates the
* user who created or steered the task. */ * user who created or steered the task. */
@@ -929,6 +931,8 @@ export interface TaskCreateInput {
sliceId?: string; sliceId?: string;
/** Optional explicit agent assignment for this task */ /** Optional explicit agent assignment for this task */
assignedAgentId?: string; assignedAgentId?: string;
/** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */
nodeId?: string;
/** Optional explicit user assignment for this task (used during review handoff) */ /** Optional explicit user assignment for this task (used during review handoff) */
assigneeUserId?: string; assigneeUserId?: string;
/** Review level for task execution — controls review rigor: 0=None, 1=Plan Only, 2=Plan and Code, 3=Full */ /** Review level for task execution — controls review rigor: 0=None, 1=Plan Only, 2=Plan and Code, 3=Full */

View File

@@ -1679,8 +1679,8 @@ describe("useRoadmaps", () => {
rerender({ projectId: "proj-2" }); rerender({ projectId: "proj-2" });
// Resolve the promise // Resolve the promise
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(resolveHandoff).not.toBeNull();
resolveHandoff!(mockHandoffPayload); resolveHandoff?.(mockHandoffPayload);
await fetchPromise; await fetchPromise;
// Handoff should NOT be set because we're in a different project now // Handoff should NOT be set because we're in a different project now