FN-5674: add per-task auto-merge persistence and resolver
Add per-task auto-merge overrides to the core task model and persist/snapshot them through review transitions. - add Task.autoMerge typing, storage row mapping, updateTask support, and DB schema migration to persist per-task auto-merge state - snapshot settings.autoMerge onto tasks when moving into in-review when no explicit task override is set - export resolveEffectiveAutoMerge to consistently resolve task-level override vs global settings precedence - expand core tests for migration, persistence, movement, and merge behavior; update roadmap schema-version expectation to match the new schema version Files changed: packages/core/src/__tests__/db-migrate.test.ts | 10 +++---- packages/core/src/__tests__/db.test.ts | 34 +++++++++++----------- packages/core/src/__tests__/goals-schema.test.ts | 2 +- packages/core/src/__tests__/insight-store.test.ts | 10 +++---- packages/core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- packages/core/src/__tests__/secrets-schema.test.ts | 6 ++-- .../core/src/__tests__/store-merge-queue.test.ts | 2 +- packages/core/src/__tests__/store-movement.test.ts | 33 +++++++++++++++++++++ .../core/src/__tests__/store-persistence.test.ts | 18 ++++++++++++ packages/core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/__tests__/task-merge.test.ts | 19 ++++++++++++ packages/core/src/db.ts | 9 +++++- packages/core/src/index.ts | 1 + packages/core/src/store.ts | 23 +++++++++++++-- packages/core/src/task-merge.ts | 15 +++++++++- packages/core/src/types.ts | 7 +++++ .../src/store/__tests__/roadmap-store.test.ts | 4 +-- 18 files changed, 157 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-5674 Fusion-Task-Lineage: 8e1e3988-2e41-4d7a-9653-e88c36ec272c
This commit is contained in:
@@ -715,7 +715,7 @@ describe("schema migration", () => {
|
||||
|
||||
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
|
||||
expect(row.deletedAt).toBeNull();
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -748,7 +748,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -798,7 +798,7 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -827,7 +827,7 @@ describe("schema migration", () => {
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -862,7 +862,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -330,7 +330,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
});
|
||||
|
||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||
@@ -373,7 +373,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -1443,7 +1443,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1468,11 +1468,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1507,7 +1507,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1548,7 +1548,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1620,7 +1620,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1860,7 +1860,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1934,7 +1934,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
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" }]);
|
||||
@@ -1958,7 +1958,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
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" }]);
|
||||
@@ -2062,7 +2062,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2281,7 +2281,7 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(93);
|
||||
expect(localDb.getSchemaVersion()).toBe(94);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2592,7 +2592,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2746,7 +2746,7 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(93);
|
||||
expect(migrated.getSchemaVersion()).toBe(94);
|
||||
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||
@@ -2792,7 +2792,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(93);
|
||||
expect(migrated.getSchemaVersion()).toBe(94);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2819,7 +2819,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(93);
|
||||
expect(fresh.getSchemaVersion()).toBe(94);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 92", () => {
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1000,7 +1000,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(93);
|
||||
expect(db1.getSchemaVersion()).toBe(94);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -1035,7 +1035,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(93);
|
||||
expect(db3.getSchemaVersion()).toBe(94);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(93);
|
||||
expect(db1.getSchemaVersion()).toBe(94);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(93);
|
||||
expect(db2.getSchemaVersion()).toBe(94);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh DB and run migrations
|
||||
const db1 = createDatabase(compatDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(93);
|
||||
expect(db1.getSchemaVersion()).toBe(94);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -2886,7 +2886,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("93");
|
||||
expect(version.value).toBe("94");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("93");
|
||||
expect(version.value).toBe("94");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
|
||||
expect(projectVersion.value).toBe("93");
|
||||
expect(projectVersion.value).toBe("94");
|
||||
expect(centralVersion.value).toBe("13");
|
||||
} finally {
|
||||
projectDb.close();
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
|
||||
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
|
||||
);
|
||||
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(93);
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(94);
|
||||
});
|
||||
|
||||
it("migrates a legacy v88 database and preserves task rows", async () => {
|
||||
|
||||
@@ -45,6 +45,39 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("moveTask — autoMerge snapshot on in-review", () => {
|
||||
it("snapshots global autoMerge=true when task override is undefined", async () => {
|
||||
await store.updateSettings({ autoMerge: true });
|
||||
const task = await store.createTask({ description: "snapshot true" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
|
||||
const moved = await store.moveTask(task.id, "in-review");
|
||||
expect(moved.autoMerge).toBe(true);
|
||||
});
|
||||
|
||||
it("snapshots global autoMerge=false when task override is undefined", async () => {
|
||||
await store.updateSettings({ autoMerge: false });
|
||||
const task = await store.createTask({ description: "snapshot false" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
|
||||
const moved = await store.moveTask(task.id, "in-review");
|
||||
expect(moved.autoMerge).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves explicit task autoMerge override when entering in-review", async () => {
|
||||
await store.updateSettings({ autoMerge: false });
|
||||
const task = await store.createTask({ description: "explicit override" });
|
||||
await store.updateTask(task.id, { autoMerge: true });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
|
||||
const moved = await store.moveTask(task.id, "in-review");
|
||||
expect(moved.autoMerge).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTask — resets steps when moving back to todo/triage", () => {
|
||||
async function setMixedStepStatuses(taskId: string): Promise<void> {
|
||||
await store.updateStep(taskId, 0, "done");
|
||||
|
||||
@@ -338,6 +338,24 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoMerge field persistence", () => {
|
||||
it("persists true/false and clears with null via updateTask", async () => {
|
||||
const task = await harness.store().createTask({ description: "autoMerge persistence" });
|
||||
|
||||
const enabled = await harness.store().updateTask(task.id, { autoMerge: true });
|
||||
expect(enabled.autoMerge).toBe(true);
|
||||
|
||||
const disabled = await harness.store().updateTask(task.id, { autoMerge: false });
|
||||
expect(disabled.autoMerge).toBe(false);
|
||||
|
||||
const cleared = await harness.store().updateTask(task.id, { autoMerge: null });
|
||||
expect(cleared.autoMerge).toBeUndefined();
|
||||
|
||||
const detail = await harness.store().getTask(task.id);
|
||||
expect(detail.autoMerge).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeId persistence", () => {
|
||||
it("creates a task with nodeId when provided", async () => {
|
||||
const task = await harness.store().createTask({
|
||||
|
||||
@@ -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(93);
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getTaskHardMergeBlocker,
|
||||
getTaskMergeBlocker,
|
||||
isTaskReadyForMerge,
|
||||
resolveEffectiveAutoMerge,
|
||||
resolveTaskMergeTarget,
|
||||
} from "../task-merge.js";
|
||||
|
||||
@@ -25,6 +26,24 @@ const baseCompletionTask = {
|
||||
blockedBy: undefined as string | undefined,
|
||||
};
|
||||
|
||||
describe("resolveEffectiveAutoMerge", () => {
|
||||
it("prefers explicit true over global false", () => {
|
||||
expect(resolveEffectiveAutoMerge({ autoMerge: true }, { autoMerge: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers explicit false over global true", () => {
|
||||
expect(resolveEffectiveAutoMerge({ autoMerge: false }, { autoMerge: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to global true when task value is undefined", () => {
|
||||
expect(resolveEffectiveAutoMerge({ autoMerge: undefined }, { autoMerge: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to global false when task value is undefined", () => {
|
||||
expect(resolveEffectiveAutoMerge({ autoMerge: undefined }, { autoMerge: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaskMergeTarget", () => {
|
||||
it("prefers task baseBranch when present", () => {
|
||||
expect(resolveTaskMergeTarget({ baseBranch: "release/1.2", branchContext: undefined })).toEqual({
|
||||
|
||||
@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 93;
|
||||
const SCHEMA_VERSION = 94;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -291,6 +291,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
mergeDetails TEXT,
|
||||
breakIntoSubtasks INTEGER DEFAULT 0,
|
||||
noCommitsExpected INTEGER DEFAULT 0,
|
||||
autoMerge INTEGER,
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]',
|
||||
modifiedFiles TEXT DEFAULT '[]',
|
||||
missionId TEXT,
|
||||
@@ -3651,6 +3652,12 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 94) {
|
||||
this.applyMigration(94, () => {
|
||||
this.addColumnIfMissing("tasks", "autoMerge", "INTEGER");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -294,6 +294,7 @@ export {
|
||||
getTaskHardMergeBlocker,
|
||||
getTaskCompletionBlocker,
|
||||
isTaskReadyForMerge,
|
||||
resolveEffectiveAutoMerge,
|
||||
resolveTaskMergeTarget,
|
||||
type MergeTargetResolution,
|
||||
type MergeTargetResolverOptions,
|
||||
|
||||
@@ -148,6 +148,7 @@ interface TaskRow {
|
||||
mergeDetails: string | null;
|
||||
breakIntoSubtasks: number | null;
|
||||
noCommitsExpected: number | null;
|
||||
autoMerge: number | null;
|
||||
enabledWorkflowSteps: string | null;
|
||||
modifiedFiles: string | null;
|
||||
missionId: string | null;
|
||||
@@ -1498,6 +1499,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
|
||||
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
|
||||
noCommitsExpected: row.noCommitsExpected ? true : undefined,
|
||||
autoMerge: row.autoMerge === null ? undefined : row.autoMerge === 1,
|
||||
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
|
||||
modifiedFiles: (() => { const m = fromJson<string[]>(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(),
|
||||
missionId: row.missionId || undefined,
|
||||
@@ -2044,6 +2046,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
toJsonNullable(task.mergeDetails),
|
||||
task.breakIntoSubtasks ? 1 : 0,
|
||||
task.noCommitsExpected ? 1 : 0,
|
||||
task.autoMerge === undefined ? null : task.autoMerge ? 1 : 0,
|
||||
toJson(task.enabledWorkflowSteps || []),
|
||||
toJson(task.modifiedFiles || []),
|
||||
task.missionId ?? null,
|
||||
@@ -2094,7 +2097,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
||||
) VALUES (${placeholders})
|
||||
`).run(...values);
|
||||
this.db.bumpLastModified();
|
||||
@@ -2121,7 +2124,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
||||
mergeDetails, breakIntoSubtasks, noCommitsExpected, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
|
||||
) VALUES (${placeholders})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
lineageId = excluded.lineageId,
|
||||
@@ -2206,6 +2209,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
mergeDetails = excluded.mergeDetails,
|
||||
breakIntoSubtasks = excluded.breakIntoSubtasks,
|
||||
noCommitsExpected = excluded.noCommitsExpected,
|
||||
autoMerge = excluded.autoMerge,
|
||||
enabledWorkflowSteps = excluded.enabledWorkflowSteps,
|
||||
modifiedFiles = excluded.modifiedFiles,
|
||||
missionId = excluded.missionId,
|
||||
@@ -5140,6 +5144,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
const settingsForInReview =
|
||||
toColumn === "in-review" && task.autoMerge === undefined
|
||||
? await this.getSettingsFast()
|
||||
: undefined;
|
||||
|
||||
const movedAt = internal.now ?? new Date().toISOString();
|
||||
task.column = toColumn;
|
||||
task.columnMovedAt = movedAt;
|
||||
@@ -5215,6 +5224,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
if (toColumn === "in-review") {
|
||||
if (task.autoMerge === undefined && settingsForInReview) {
|
||||
task.autoMerge = settingsForInReview.autoMerge;
|
||||
}
|
||||
task.recoveryRetryCount = undefined;
|
||||
task.nextRecoveryAt = undefined;
|
||||
// Clear scheduler-side dispatch state: `queued`, `blockedBy`, and
|
||||
@@ -5378,7 +5390,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
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; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: 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; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; 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; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | 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; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: 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; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; 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; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
runContext?: RunMutationContext,
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
@@ -5653,6 +5665,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.baseBranch !== undefined) {
|
||||
task.baseBranch = updates.baseBranch;
|
||||
}
|
||||
if (updates.autoMerge === null) {
|
||||
task.autoMerge = undefined;
|
||||
} else if (updates.autoMerge !== undefined) {
|
||||
task.autoMerge = updates.autoMerge;
|
||||
}
|
||||
if (updates.branch === null) {
|
||||
task.branch = undefined;
|
||||
} else if (updates.branch !== undefined) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task, WorkflowStepResult } from "./types.js";
|
||||
import type { Settings, Task, WorkflowStepResult } from "./types.js";
|
||||
|
||||
export interface MergeTargetResolution {
|
||||
branch: string;
|
||||
@@ -29,6 +29,19 @@ function isFusionSiblingBranch(branch: string): boolean {
|
||||
return FUSION_SIBLING_BRANCH_RE.test(branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a task's effective auto-merge behavior.
|
||||
* Explicit per-task values (`true`/`false`) take precedence over the global
|
||||
* setting; when `task.autoMerge` is `undefined`, falls back to
|
||||
* `settings.autoMerge`.
|
||||
*/
|
||||
export function resolveEffectiveAutoMerge(
|
||||
task: Pick<Task, "autoMerge">,
|
||||
settings: Pick<Settings, "autoMerge">,
|
||||
): boolean {
|
||||
return task.autoMerge ?? settings.autoMerge;
|
||||
}
|
||||
|
||||
export function resolveTaskMergeTarget(
|
||||
task: Pick<Task, "baseBranch" | "branchContext">,
|
||||
options: MergeTargetResolverOptions = {},
|
||||
|
||||
@@ -1710,6 +1710,13 @@ export interface Task {
|
||||
/** Configured merge target/base branch for this task (task intent).
|
||||
* Defaults to the project default branch when omitted. */
|
||||
baseBranch?: string;
|
||||
/** Per-task auto-merge override.
|
||||
* `undefined` means no explicit per-task value: follow `settings.autoMerge`
|
||||
* and snapshot that global setting when the task enters `in-review`.
|
||||
* `true`/`false` are explicit user overrides and take precedence.
|
||||
* Distinct from GitHub PR metadata (`PrInfo.autoMergeOnGreen` /
|
||||
* `PrInfo.autoMergeStrategy`), which must not be conflated with this field. */
|
||||
autoMerge?: boolean;
|
||||
/** Actual git working branch name used for this task's worktree. May differ from
|
||||
* the conventional `fn/{task-id}` when conflict recovery generated a
|
||||
* unique suffixed name (e.g., `fn/fn-042-2`). */
|
||||
|
||||
@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
|
||||
});
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 93 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(93);
|
||||
it("schema version is 94 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user