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:
11
.changeset/per-mission-automerge-foundation.md
Normal file
11
.changeset/per-mission-automerge-foundation.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
148
packages/core/src/__tests__/branch-group-store.test.ts
Normal file
148
packages/core/src/__tests__/branch-group-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user