diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 5d979bfa3..5af5116a8 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -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(); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index da8f7499a..385a20e98 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -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 }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 5f4f04c7e..f3a2d1cc6 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 92", () => { - expect(db.getSchemaVersion()).toBe(93); + expect(db.getSchemaVersion()).toBe(94); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 7e25c2ad8..e6ad1873d 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -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 diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index cca70d023..de5846450 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -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", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 47cd3103a..33069655e 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(93); + expect(db.getSchemaVersion()).toBe(94); }); }); }); diff --git a/packages/core/src/__tests__/secrets-schema.test.ts b/packages/core/src/__tests__/secrets-schema.test.ts index eb478e488..fdb7cf6f8 100644 --- a/packages/core/src/__tests__/secrets-schema.test.ts +++ b/packages/core/src/__tests__/secrets-schema.test.ts @@ -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(); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index da6675b7e..445fb336c 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -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 () => { diff --git a/packages/core/src/__tests__/store-movement.test.ts b/packages/core/src/__tests__/store-movement.test.ts index 5930d6c46..3062bd313 100644 --- a/packages/core/src/__tests__/store-movement.test.ts +++ b/packages/core/src/__tests__/store-movement.test.ts @@ -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 { await store.updateStep(taskId, 0, "done"); diff --git a/packages/core/src/__tests__/store-persistence.test.ts b/packages/core/src/__tests__/store-persistence.test.ts index 842b8d984..690b4412c 100644 --- a/packages/core/src/__tests__/store-persistence.test.ts +++ b/packages/core/src/__tests__/store-persistence.test.ts @@ -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({ diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 3f8c0b33a..9a20c574a 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -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( diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index 6ad9c256c..2ed8e7063 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -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({ diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 5ab7e4513..1f4c3b727 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -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"); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55e90d1e1..0c28d48bd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -294,6 +294,7 @@ export { getTaskHardMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge, + resolveEffectiveAutoMerge, resolveTaskMergeTarget, type MergeTargetResolution, type MergeTargetResolverOptions, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index f2aa5fa89..5291147cb 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { mergeDetails: fromJson(row.mergeDetails), breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined, noCommitsExpected: row.noCommitsExpected ? true : undefined, + autoMerge: row.autoMerge === null ? undefined : row.autoMerge === 1, enabledWorkflowSteps: (() => { const e = fromJson(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(), modifiedFiles: (() => { const m = fromJson(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(), missionId: row.missionId || undefined, @@ -2044,6 +2046,7 @@ export class TaskStore extends EventEmitter { 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 { 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 { 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 { 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 { } } + 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 { } 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 { 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 | 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 | 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 { return this.withTaskLock(id, async () => { @@ -5653,6 +5665,11 @@ export class TaskStore extends EventEmitter { } 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) { diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index dc08203f2..67dcfb43a 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -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, + settings: Pick, +): boolean { + return task.autoMerge ?? settings.autoMerge; +} + export function resolveTaskMergeTarget( task: Pick, options: MergeTargetResolverOptions = {}, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 13e3bdbf9..0d6261996 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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`). */ diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index e9e080f3e..99e1e9b39 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -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); }); });