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:
gsxdsm
2026-05-29 09:56:28 -07:00
parent 4fee2c1324
commit a312da63e4
18 changed files with 157 additions and 42 deletions

View File

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

View File

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

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 92", () => {
expect(db.getSchemaVersion()).toBe(93);
expect(db.getSchemaVersion()).toBe(94);
});
});

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -294,6 +294,7 @@ export {
getTaskHardMergeBlocker,
getTaskCompletionBlocker,
isTaskReadyForMerge,
resolveEffectiveAutoMerge,
resolveTaskMergeTarget,
type MergeTargetResolution,
type MergeTargetResolverOptions,

View File

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

View File

@@ -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 = {},

View File

@@ -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`). */

View File

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