FN-5678: add branch-group data model foundation

Establish per-mission/per-planning branch-group persistence and task metadata plumbing for shared auto-merge behavior.

- add `branch_groups` schema, indexes, exports, and store types for mission/planning branch assignment
- wire task source metadata branch-context helpers and branch-group row handling into core store flows
- add branch-group and migration coverage plus roadmap schema-version assertion wording fix
- add published changeset and storage doc note for the new branch-group persistence layer
- preserve forward-only migration safety by advancing schema to 96 and gating branch-group migration at `< 96`

Files changed:
 .changeset/per-mission-automerge-foundation.md     |  11 ++
 docs/storage.md                                    |   1 +
 packages/core/src/__tests__/backup.test.ts         |  32 ++++
 .../core/src/__tests__/branch-group-store.test.ts  | 148 ++++++++++++++++++
 packages/core/src/__tests__/db-migrate.test.ts     |  40 +++++
 packages/core/src/__tests__/db.test.ts             |  16 ++
 packages/core/src/__tests__/mission-store.test.ts  |  16 ++
 packages/core/src/db.ts                            |  52 ++++++-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/mission-store.ts                 |  19 ++-
 packages/core/src/mission-types.ts                 |   4 +
 packages/core/src/store.ts                         | 171 ++++++++++++++++++++-
 packages/core/src/types.ts                         |  59 +++++++
 13 files changed, 556 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-5678
Fusion-Task-Lineage: cd6e5312-a9c5-475f-a0de-1d3090395bdd
This commit is contained in:
gsxdsm
2026-05-29 11:15:47 -07:00
parent 91137ecb51
commit 72214132d2
13 changed files with 556 additions and 15 deletions

View File

@@ -0,0 +1,11 @@
---
"@runfusion/fusion": minor
---
Add per-mission/planning branch-group data-model foundations in `@fusion/core`.
- Introduce durable `branch_groups` storage with source linkage (`mission`/`planning`), branch metadata, PR state, status, and auto-merge override.
- Add `TaskStore` branch-group APIs: create/get/getBySource/list/update/setTaskBranchGroup.
- Persist `Task.autoMerge` and `Mission.autoMerge` as optional overrides.
- Reuse `Task.branchContext.groupId` for task↔group linkage (no separate `branchGroupId` column).
- Bump project schema version to `94` with migration coverage and schema assertions.

View File

@@ -322,6 +322,7 @@ Backups in `.fusion/backups/` now capture the project DB and (when present) the
| Table | Purpose |
|---|---|
| `tasks` | Core task metadata and JSON-backed nested fields (priority, dependencies, steps, log, attachments, comments, model overrides, workflow results, merge details, assignment, mission linkage). |
| `branch_groups` | Durable shared-branch group records keyed by `BG-*` id with source linkage (`mission`/`planning`), branch/worktree metadata, optional PR tracking fields, lifecycle status, and per-group `autoMerge` override. |
| `mergeQueue` | Durable merge handoff queue keyed by `taskId`. Stores enqueue ordering (`enqueuedAt`, mirrored `priority`), single-owner lease state (`leasedBy`, `leasedAt`, `leaseExpiresAt`), and retry diagnostics (`attemptCount`, `lastError`). Leasing is priority-first + FIFO within priority, and expired leases are recoverable without incrementing attempts. FN-5242 adds the persistence/lease primitive; FN-5241 and FN-5243 wire executor enqueue + merger consumption. |
FN-5240/FN-5241/FN-5242 establish the handoff invariant: the only legal executor/self-healing path into `in-review` after execution finishes is `TaskStore.handoffToReview(...)`. That helper runs the column move, `mergeQueue` insert, and handoff audit fan-out inside one `BEGIN IMMEDIATE` transaction so observers never see `column = "in-review"` without the matching queue row. Direct `moveTask(taskId, "in-review")` writes remain allowed for explicit non-handoff/test paths but emit `task:handoff-invariant-violation` run-audit events unless the caller opts into the narrow allowlist flag.

View File

@@ -15,6 +15,7 @@ import {
} from "../backup.js";
import { Database } from "../db.js";
import { RoutineStore } from "../routine-store.js";
import { TaskStore } from "../store.js";
import type { ProjectSettings } from "../types.js";
describe("BackupManager", () => {
@@ -392,6 +393,37 @@ describe("BackupManager", () => {
const backups = await readdir(join(tempDir, ".fusion/backups"));
expect(backups.some((name) => name.startsWith("fusion-central-pre-restore-"))).toBe(true);
});
it("preserves branch groups + mission/task autoMerge across backup restore", async () => {
const rootDir = tempDir;
const globalDir = join(tempDir, ".fusion-global");
await rm(join(fusionDir, "fusion.db"), { force: true });
const store = new TaskStore(rootDir, globalDir);
await store.init();
const mission = store.getMissionStore().createMission({ title: "Backup Mission", autoMerge: true });
const task = await store.createTask({ description: "Backup task", autoMerge: true });
const group = store.createBranchGroup({ sourceType: "mission", sourceId: mission.id, branchName: "fn/backup-shared" });
await store.setTaskBranchGroup(task.id, group.id);
store.close();
const backup = await backupManager.createBackup();
await writeFile(join(fusionDir, "fusion.db"), "corrupted");
await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: false });
const restoredStore = new TaskStore(rootDir, globalDir);
await restoredStore.init();
const restoredMission = restoredStore.getMissionStore().getMission(mission.id);
const restoredTask = await restoredStore.getTask(task.id);
const restoredGroup = restoredStore.getBranchGroup(group.id);
expect(restoredMission?.autoMerge).toBe(true);
expect(restoredTask.autoMerge).toBe(true);
expect(restoredTask.branchContext?.groupId).toBe(group.id);
expect(restoredGroup?.sourceId).toBe(mission.id);
restoredStore.close();
});
});
});

View File

@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fusion-branch-group-test-"));
}
describe("TaskStore branch groups", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = join(rootDir, ".fusion-global");
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("creates, reads, lists, and updates branch groups", () => {
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
expect(group.id.startsWith("BG-")).toBe(true);
expect(group.autoMerge).toBe(false);
expect(group.prState).toBe("none");
expect(group.status).toBe("open");
expect(store.getBranchGroup(group.id)?.branchName).toBe("fn/shared");
expect(store.getBranchGroupBySource("mission", "M-1")?.id).toBe(group.id);
expect(store.listBranchGroups().map((entry) => entry.id)).toContain(group.id);
const updated = store.updateBranchGroup(group.id, { status: "finalized", autoMerge: true, prState: "open", prNumber: 12 });
expect(updated.autoMerge).toBe(true);
expect(updated.prState).toBe("open");
expect(updated.prNumber).toBe(12);
expect(updated.closedAt).toBeTypeOf("number");
expect(store.listBranchGroups({ status: "finalized" }).map((entry) => entry.id)).toContain(group.id);
const abandoned = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-2", branchName: "fn/abandoned" });
const abandonedUpdated = store.updateBranchGroup(abandoned.id, { status: "abandoned" });
expect(abandonedUpdated.closedAt).toBeTypeOf("number");
});
it("enforces unique branchName", () => {
store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
expect(() =>
store.createBranchGroup({ sourceType: "planning", sourceId: "PS-1", branchName: "fn/shared" })
).toThrow();
});
it("rejects duplicate branch group primary key id", () => {
const now = Date.now();
(store as any).db
.prepare(
"INSERT INTO branch_groups (id, sourceType, sourceId, branchName, worktreePath, autoMerge, prState, prUrl, prNumber, status, createdAt, updatedAt, closedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run("BG-fixed", "mission", "M-1", "fn/fixed-1", null, 0, "none", null, null, "open", now, now, null);
expect(() =>
(store as any).db
.prepare(
"INSERT INTO branch_groups (id, sourceType, sourceId, branchName, worktreePath, autoMerge, prState, prUrl, prNumber, status, createdAt, updatedAt, closedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run("BG-fixed", "mission", "M-2", "fn/fixed-2", null, 0, "none", null, null, "open", now, now, null)
).toThrow();
});
it("sets and clears task branchContext via setTaskBranchGroup", async () => {
const task = await store.createTask({ description: "branch link test" });
const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-1", branchName: "fn/planning" });
const onUpdated = vi.fn();
store.on("task:updated", onUpdated);
await store.setTaskBranchGroup(task.id, group.id);
const linked = await store.getTask(task.id);
expect(linked.branchContext).toEqual({ groupId: group.id, source: "planning", assignmentMode: "shared" });
await store.setTaskBranchGroup(task.id, null);
const cleared = await store.getTask(task.id);
expect(cleared.branchContext).toBeUndefined();
expect(onUpdated).toHaveBeenCalled();
await expect(store.setTaskBranchGroup(task.id, "BG-missing")).rejects.toThrow("not found");
});
it("keeps task autoMerge/branchContext undefined when unset", async () => {
const task = await store.createTask({ description: "defaults" });
const reloaded = await store.getTask(task.id);
expect(reloaded.autoMerge).toBeUndefined();
expect(reloaded.branchContext).toBeUndefined();
const slim = await store.listTasks({ slim: true, includeArchived: false });
const slimTask = slim.find((entry) => entry.id === task.id)!;
expect(slimTask.autoMerge).toBeUndefined();
expect(slimTask.branchContext).toBeUndefined();
});
it("hides linked tasks from slim output after soft delete", async () => {
const task = await store.createTask({ description: "soft delete" });
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-3", branchName: "fn/deleted" });
await store.setTaskBranchGroup(task.id, group.id);
await store.deleteTask(task.id);
const slim = await store.listTasks({ slim: true, includeArchived: false });
expect(slim.find((entry) => entry.id === task.id)).toBeUndefined();
});
it("preserves autoMerge + branchContext in slim list/search/modifiedSince and archived slim", async () => {
const task = await store.createTask({ description: "slim check" });
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-2", branchName: "fn/mission" });
await store.setTaskBranchGroup(task.id, group.id);
await store.updateTask(task.id, { autoMerge: true });
const slim = await store.listTasks({ slim: true, includeArchived: false });
const slimTask = slim.find((entry) => entry.id === task.id)!;
expect(slimTask.autoMerge).toBe(true);
expect(slimTask.branchContext?.groupId).toBe(group.id);
const search = await store.searchTasks(task.id, { slim: true, includeArchived: false });
expect(search[0].autoMerge).toBe(true);
expect(search[0].branchContext?.groupId).toBe(group.id);
const since = new Date(Date.now() - 60_000).toISOString();
const modified = await store.listTasksModifiedSince(since, 50, { includeArchived: false });
const modifiedTask = modified.tasks.find((entry) => entry.id === task.id)!;
expect(modifiedTask.autoMerge).toBe(true);
expect(modifiedTask.branchContext?.groupId).toBe(group.id);
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const archivedSlim = await store.listTasks({ column: "archived", slim: true, includeArchived: true });
const archivedTask = archivedSlim.find((entry) => entry.id === task.id)!;
expect(archivedTask.autoMerge).toBe(true);
expect(archivedTask.branchContext?.groupId).toBe(group.id);
});
});

View File

@@ -832,6 +832,46 @@ describe("schema migration", () => {
db.close();
});
it("adds branch_groups table and autoMerge columns when migrating from schema version 93", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '93')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS missions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
interviewState TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.init();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("branch_groups");
const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(taskColumns.map((column) => column.name)).toContain("autoMerge");
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
expect(db.getSchemaVersion()).toBe(94);
db.close();
});
it("v76 backfill preserves explicit gateMode and defaults the rest to advisory (FN-4497)", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");

View File

@@ -338,6 +338,22 @@ describe("Database", () => {
const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("tokenUsageCacheWriteTokens");
});
it("creates branch_groups table, indexes, and autoMerge columns", () => {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("branch_groups");
const branchIndexes = db.prepare("PRAGMA index_list('branch_groups')").all() as Array<{ name: string }>;
const indexNames = branchIndexes.map((row) => row.name);
expect(indexNames).toContain("idxBranchGroupsSource");
expect(indexNames).toContain("idxBranchGroupsBranchName");
const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(taskColumns.map((column) => column.name)).toContain("autoMerge");
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
});
it("seeds lastModified", () => {
const ts = db.getLastModified();
expect(ts).toBeGreaterThan(0);

View File

@@ -3293,6 +3293,22 @@ describe("MissionStore", () => {
});
});
it("persists mission autoMerge true/false/undefined", () => {
const enabled = store.createMission({ title: "Enabled", autoMerge: true });
const disabled = store.createMission({ title: "Disabled", autoMerge: false });
const unset = store.createMission({ title: "Unset" });
expect(store.getMission(enabled.id)?.autoMerge).toBe(true);
expect(store.getMission(disabled.id)?.autoMerge).toBe(false);
expect(store.getMission(unset.id)?.autoMerge).toBeUndefined();
store.updateMission(enabled.id, { autoMerge: false });
store.updateMission(disabled.id, { autoMerge: true });
expect(store.getMission(enabled.id)?.autoMerge).toBe(false);
expect(store.getMission(disabled.id)?.autoMerge).toBe(true);
});
it("exports and applies mission hierarchy snapshots", () => {
const mission = store.createMission({ title: "Snapshot Mission" });
const milestone = store.addMilestone(mission.id, { title: "MS" });

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 95;
const SCHEMA_VERSION = 96;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -230,6 +230,7 @@ CREATE TABLE IF NOT EXISTS tasks (
pausedReason TEXT,
baseBranch TEXT,
branch TEXT,
autoMerge INTEGER,
executionStartBranch TEXT,
baseCommitSha TEXT,
modelPresetId TEXT,
@@ -759,10 +760,29 @@ CREATE TABLE IF NOT EXISTS missions (
interviewState TEXT NOT NULL,
baseBranch TEXT,
autoAdvance INTEGER DEFAULT 0,
autoMerge INTEGER,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS branch_groups (
id TEXT PRIMARY KEY,
sourceType TEXT NOT NULL CHECK (sourceType IN ('mission','planning')),
sourceId TEXT NOT NULL,
branchName TEXT NOT NULL UNIQUE,
worktreePath TEXT,
autoMerge INTEGER NOT NULL DEFAULT 0,
prState TEXT NOT NULL DEFAULT 'none' CHECK (prState IN ('none','open','merged','closed')),
prUrl TEXT,
prNumber INTEGER,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','finalized','abandoned')),
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
closedAt INTEGER
);
CREATE INDEX IF NOT EXISTS idxBranchGroupsSource ON branch_groups(sourceType, sourceId);
CREATE INDEX IF NOT EXISTS idxBranchGroupsBranchName ON branch_groups(branchName);
-- Goals table (strategic intent across mission timelines)
CREATE TABLE IF NOT EXISTS goals (
id TEXT PRIMARY KEY,
@@ -3659,9 +3679,35 @@ export class Database {
});
}
if (version < 95) {
this.applyMigration(95, () => {
if (version < 96) {
this.applyMigration(96, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS branch_groups (
id TEXT PRIMARY KEY,
sourceType TEXT NOT NULL CHECK (sourceType IN ('mission','planning')),
sourceId TEXT NOT NULL,
branchName TEXT NOT NULL UNIQUE,
worktreePath TEXT,
autoMerge INTEGER NOT NULL DEFAULT 0,
prState TEXT NOT NULL DEFAULT 'none' CHECK (prState IN ('none','open','merged','closed')),
prUrl TEXT,
prNumber INTEGER,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','finalized','abandoned')),
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
closedAt INTEGER
)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxBranchGroupsSource
ON branch_groups(sourceType, sourceId)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxBranchGroupsBranchName
ON branch_groups(branchName)
`);
this.addColumnIfMissing("tasks", "autoMerge", "INTEGER");
this.addColumnIfMissing("missions", "autoMerge", "INTEGER");
});
}

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";

View File

@@ -168,6 +168,7 @@ interface MissionRow {
status: string;
interviewState: string;
baseBranch: string | null;
autoMerge: number | null;
autoAdvance: number;
autopilotEnabled: number;
autopilotState: string;
@@ -340,6 +341,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: row.status as MissionStatus,
interviewState: row.interviewState as InterviewState,
baseBranch: row.baseBranch || undefined,
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
autoAdvance: Boolean(row.autoAdvance),
autopilotEnabled: Boolean(row.autopilotEnabled),
autopilotState: (row.autopilotState as AutopilotState) || "inactive",
@@ -530,6 +532,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: "planning",
interviewState: "not_started",
baseBranch: input.baseBranch,
autoMerge: input.autoMerge,
autoAdvance: false,
autopilotEnabled: input.autopilotEnabled ?? false,
autopilotState: "inactive",
@@ -538,8 +541,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};
this.db.prepare(`
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
mission.id,
mission.title,
@@ -547,6 +550,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
mission.status,
mission.interviewState,
mission.baseBranch ?? null,
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0),
mission.autoAdvance ? 1 : 0,
mission.autopilotEnabled ? 1 : 0,
mission.autopilotState ?? "inactive",
@@ -1099,6 +1103,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status = ?,
interviewState = ?,
baseBranch = ?,
autoMerge = ?,
autoAdvance = ?,
autopilotEnabled = ?,
autopilotState = ?,
@@ -1111,6 +1116,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
updated.status,
updated.interviewState,
updated.baseBranch ?? null,
updated.autoMerge === undefined ? null : (updated.autoMerge ? 1 : 0),
updated.autoAdvance ? 1 : 0,
updated.autopilotEnabled ? 1 : 0,
updated.autopilotState ?? "inactive",
@@ -3418,13 +3424,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
let applied = 0;
for (const mission of snapshot.payload.missions) {
this.db.prepare(`INSERT INTO missions (id, title, description, status, interviewState, autoAdvance, autopilotEnabled, autopilotState, lastAutopilotActivityAt, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
this.db.prepare(`INSERT INTO missions (id, title, description, status, interviewState, autoMerge, autoAdvance, autopilotEnabled, autopilotState, lastAutopilotActivityAt, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title=excluded.title, description=excluded.description, status=excluded.status, interviewState=excluded.interviewState,
autoAdvance=excluded.autoAdvance, autopilotEnabled=excluded.autopilotEnabled, autopilotState=excluded.autopilotState,
autoMerge=excluded.autoMerge, autoAdvance=excluded.autoAdvance, autopilotEnabled=excluded.autopilotEnabled, autopilotState=excluded.autopilotState,
lastAutopilotActivityAt=excluded.lastAutopilotActivityAt, updatedAt=excluded.updatedAt`).run(
mission.id, mission.title, mission.description ?? null, mission.status, mission.interviewState, mission.autoAdvance ? 1 : 0,
mission.id, mission.title, mission.description ?? null, mission.status, mission.interviewState,
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0), mission.autoAdvance ? 1 : 0,
mission.autopilotEnabled ? 1 : 0, mission.autopilotState, mission.lastAutopilotActivityAt ?? null, mission.createdAt, mission.updatedAt,
);
applied++;

View File

@@ -132,6 +132,8 @@ export interface Mission {
* enabled and watching.
*/
autoAdvance?: boolean;
/** Optional mission-level auto-merge override for linked task branches. */
autoMerge?: boolean;
/** When true, enable autopilot monitoring system for this mission */
autopilotEnabled?: boolean;
/** Current autopilot runtime state */
@@ -373,6 +375,8 @@ export interface MissionCreateInput {
description?: string;
/** Optional integration base branch for tasks created from this mission */
baseBranch?: string;
/** Optional mission-level auto-merge override for linked task branches. */
autoMerge?: boolean;
}
/** Input for creating a new Milestone */

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
@@ -83,6 +83,7 @@ interface TaskRow {
baseBranch: string | null;
executionStartBranch: string | null;
branch: string | null;
autoMerge: number | null;
baseCommitSha: string | null;
modelPresetId: string | null;
modelProvider: string | null;
@@ -217,6 +218,22 @@ function withTaskBranchContextInSourceMetadata(
};
}
interface BranchGroupRow {
id: string;
sourceType: "mission" | "planning";
sourceId: string;
branchName: string;
worktreePath: string | null;
autoMerge: number;
prState: "none" | "open" | "merged" | "closed";
prUrl: string | null;
prNumber: number | null;
status: "open" | "finalized" | "abandoned";
createdAt: number;
updatedAt: number;
closedAt: number | null;
}
interface TaskCommitAssociationRow {
id: string;
taskLineageId: string;
@@ -1384,6 +1401,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
baseBranch: row.baseBranch || undefined,
executionStartBranch: row.executionStartBranch || undefined,
branch: row.branch || undefined,
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
baseCommitSha: row.baseCommitSha || undefined,
scopeOverride: row.scopeOverride ? true : undefined,
scopeOverrideReason: row.scopeOverrideReason || undefined,
@@ -1537,6 +1555,30 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
};
}
private rowToBranchGroup(row: BranchGroupRow): BranchGroup {
return {
id: row.id,
sourceType: row.sourceType,
sourceId: row.sourceId,
branchName: row.branchName,
worktreePath: row.worktreePath ?? undefined,
autoMerge: Boolean(row.autoMerge),
prState: row.prState,
prUrl: row.prUrl ?? undefined,
prNumber: row.prNumber ?? undefined,
status: row.status,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
closedAt: row.closedAt ?? undefined,
};
}
private generateBranchGroupId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `BG-${timestamp}-${random}`;
}
private archiveEntryToTask(entry: ArchivedTaskEntry, slim = false): Task {
return {
id: entry.id,
@@ -1576,6 +1618,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
planningModelId: entry.planningModelId,
breakIntoSubtasks: entry.breakIntoSubtasks,
noCommitsExpected: entry.noCommitsExpected,
branchContext: entry.branchContext,
autoMerge: entry.autoMerge,
modifiedFiles: slim ? undefined : entry.modifiedFiles,
missionId: entry.missionId,
sliceId: entry.sliceId,
@@ -1711,6 +1755,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
noCommitsExpected: task.noCommitsExpected,
baseBranch: task.baseBranch,
branch: task.branch,
branchContext: task.branchContext,
autoMerge: task.autoMerge,
baseCommitSha: task.baseCommitSha,
mergeRetries: task.mergeRetries,
error: task.error,
@@ -1876,7 +1922,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const prefix = tableAlias ? `${tableAlias}.` : "";
return [
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
@@ -1925,7 +1971,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
const columns = [
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
"worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
@@ -1982,6 +2028,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.userPaused ? 1 : 0,
task.baseBranch ?? null,
task.branch ?? null,
task.autoMerge === undefined ? null : (task.autoMerge ? 1 : 0),
task.executionStartBranch ?? null,
task.baseCommitSha ?? null,
task.modelPresetId ?? null,
@@ -2091,7 +2138,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT INTO tasks (
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, mergeTransientRetryCount, branchConflictRecoveryCount, reviewerContextRetryCount, reviewerFallbackRetryCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
@@ -2118,7 +2165,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT INTO tasks (
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, worktreeSessionRetryCount, completionHandoffLimboRecoveryCount, verificationFailureCount, mergeConflictBounceCount, mergeAuditBounceCount, mergeTransientRetryCount, branchConflictRecoveryCount, reviewerContextRetryCount, reviewerFallbackRetryCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
@@ -2146,6 +2193,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
userPaused = excluded.userPaused,
baseBranch = excluded.baseBranch,
branch = excluded.branch,
autoMerge = excluded.autoMerge,
executionStartBranch = excluded.executionStartBranch,
baseCommitSha = excluded.baseCommitSha,
modelPresetId = excluded.modelPresetId,
@@ -3825,6 +3873,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext),
branchContext: input.branchContext,
autoMerge: input.autoMerge,
column: input.column || "triage",
dependencies: input.dependencies || [],
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
@@ -4217,6 +4266,113 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
createBranchGroup(input: BranchGroupCreateInput): BranchGroup {
const now = Date.now();
const id = this.generateBranchGroupId();
this.db.prepare(`
INSERT INTO branch_groups (id, sourceType, sourceId, branchName, worktreePath, autoMerge, prState, prUrl, prNumber, status, createdAt, updatedAt, closedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.sourceType,
input.sourceId,
input.branchName,
input.worktreePath ?? null,
input.autoMerge ? 1 : 0,
input.prState ?? "none",
input.prUrl ?? null,
input.prNumber ?? null,
input.status ?? "open",
now,
now,
input.closedAt ?? null,
);
this.db.bumpLastModified();
return this.getBranchGroup(id)!;
}
getBranchGroup(id: string): BranchGroup | null {
const row = this.db.prepare(`SELECT * FROM branch_groups WHERE id = ?`).get(id) as BranchGroupRow | undefined;
return row ? this.rowToBranchGroup(row) : null;
}
getBranchGroupBySource(sourceType: BranchGroup["sourceType"], sourceId: string): BranchGroup | null {
const row = this.db.prepare(`SELECT * FROM branch_groups WHERE sourceType = ? AND sourceId = ?`).get(sourceType, sourceId) as BranchGroupRow | undefined;
return row ? this.rowToBranchGroup(row) : null;
}
listBranchGroups(options?: { status?: BranchGroup["status"] }): BranchGroup[] {
const rows = options?.status
? this.db.prepare(`SELECT * FROM branch_groups WHERE status = ? ORDER BY createdAt ASC`).all(options.status)
: this.db.prepare(`SELECT * FROM branch_groups ORDER BY createdAt ASC`).all();
return (rows as BranchGroupRow[]).map((row) => this.rowToBranchGroup(row));
}
updateBranchGroup(id: string, patch: BranchGroupUpdate): BranchGroup {
const current = this.getBranchGroup(id);
if (!current) {
throw new Error(`Branch group ${id} not found`);
}
const nextStatus = patch.status ?? current.status;
const now = Date.now();
const nextClosedAt = patch.closedAt === null
? null
: patch.closedAt ?? (nextStatus !== "open" && current.status === "open" ? now : current.closedAt ?? null);
this.db.prepare(`
UPDATE branch_groups
SET sourceId = ?, branchName = ?, worktreePath = ?, autoMerge = ?, prState = ?, prUrl = ?, prNumber = ?, status = ?, updatedAt = ?, closedAt = ?
WHERE id = ?
`).run(
patch.sourceId ?? current.sourceId,
patch.branchName ?? current.branchName,
patch.worktreePath === null ? null : (patch.worktreePath ?? current.worktreePath ?? null),
patch.autoMerge === undefined ? (current.autoMerge ? 1 : 0) : (patch.autoMerge ? 1 : 0),
patch.prState ?? current.prState,
patch.prUrl === null ? null : (patch.prUrl ?? current.prUrl ?? null),
patch.prNumber === null ? null : (patch.prNumber ?? current.prNumber ?? null),
nextStatus,
now,
nextClosedAt,
id,
);
this.db.bumpLastModified();
return this.getBranchGroup(id)!;
}
async setTaskBranchGroup(taskId: string, branchGroupId: string | null): Promise<void> {
await this.withTaskLock(taskId, async () => {
const dir = this.taskDir(taskId);
const task = await this.readTaskJson(dir);
let branchContext: Task["branchContext"];
if (branchGroupId) {
const group = this.getBranchGroup(branchGroupId);
if (!group) {
throw new Error(`Branch group ${branchGroupId} not found`);
}
branchContext = {
groupId: group.id,
source: group.sourceType,
assignmentMode: "shared",
};
}
task.branchContext = branchContext;
task.sourceMetadata = withTaskBranchContextInSourceMetadata(task.sourceMetadata, branchContext);
if (!branchContext && task.sourceMetadata) {
const nextSourceMetadata = { ...task.sourceMetadata };
delete nextSourceMetadata[TASK_BRANCH_CONTEXT_METADATA_KEY];
task.sourceMetadata = Object.keys(nextSourceMetadata).length > 0 ? nextSourceMetadata : undefined;
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(taskId, { ...task });
this.emit("task:updated", task);
});
}
async getTaskColumns(ids: string[]): Promise<Map<string, Column>> {
if (ids.length === 0) {
return new Map();
@@ -5679,6 +5835,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.branch !== undefined) {
task.branch = updates.branch;
}
if (updates.autoMerge === null) {
task.autoMerge = undefined;
} else if (updates.autoMerge !== undefined) {
task.autoMerge = updates.autoMerge;
}
if (updates.executionStartBranch === null) {
task.executionStartBranch = undefined;
} else if (updates.executionStartBranch !== undefined) {

View File

@@ -1648,6 +1648,51 @@ export interface TaskBranchContext {
inheritedBaseBranch?: string;
}
export type BranchGroupPrState = "none" | "open" | "merged" | "closed";
export type BranchGroupStatus = "open" | "finalized" | "abandoned";
export interface BranchGroup {
id: string;
sourceType: TaskBranchGroupSource;
sourceId: string;
branchName: string;
worktreePath?: string;
autoMerge: boolean;
prState: BranchGroupPrState;
prUrl?: string;
prNumber?: number;
status: BranchGroupStatus;
createdAt: number;
updatedAt: number;
closedAt?: number;
}
export interface BranchGroupCreateInput {
sourceType: TaskBranchGroupSource;
sourceId: string;
branchName: string;
worktreePath?: string;
autoMerge?: boolean;
prState?: BranchGroupPrState;
prUrl?: string;
prNumber?: number;
status?: BranchGroupStatus;
closedAt?: number;
}
export interface BranchGroupUpdate {
sourceId?: string;
branchName?: string;
worktreePath?: string | null;
autoMerge?: boolean;
prState?: BranchGroupPrState;
prUrl?: string | null;
prNumber?: number | null;
status?: BranchGroupStatus;
closedAt?: number | null;
}
export interface Task {
id: string;
/** Immutable lineage identity used for durable commit/task attribution. */
@@ -1723,6 +1768,11 @@ export interface Task {
branch?: string;
/** Optional planning/mission branch-group metadata carried across related tasks. */
branchContext?: TaskBranchContext;
/**
* Optional per-task auto-merge override.
* Undefined means no task-level override is set.
*/
autoMerge?: boolean;
/** Internal execution-only provenance for dependency-start handoff.
* When set, the scheduler asked executor to start from an upstream dependency
* branch. This is transient execution state and should be cleared after use. */
@@ -2033,6 +2083,8 @@ export interface TaskCreateInput {
branch?: string;
/** Optional planning/mission branch-group metadata carried across related tasks. */
branchContext?: TaskBranchContext;
/** Optional per-task auto-merge override. Undefined means no task-level override. */
autoMerge?: boolean;
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
/** Optional persisted aggregate token usage snapshot for task creation/import paths. */
@@ -3885,6 +3937,8 @@ export interface ArchivedTaskEntry {
branch?: string;
/** Optional planning/mission branch-group metadata carried across related tasks. */
branchContext?: TaskBranchContext;
/** Optional per-task auto-merge override. Undefined means no task-level override. */
autoMerge?: boolean;
/** Base commit SHA for the task's worktree */
baseCommitSha?: string;
/** List of files modified by this task */
@@ -5009,6 +5063,11 @@ export interface PlanningSession {
history: Array<{ question: PlanningQuestion; response: unknown }>;
currentQuestion?: PlanningQuestion;
summary?: PlanningSummary;
/**
* Optional per-session auto-merge override for tasks planned in this session.
* Not separately persisted; durable form is a branch_groups row keyed by session id.
*/
autoMerge?: boolean;
createdAt: Date;
updatedAt: Date;
}