Merge pull request #1571 from Runfusion/feature/workflow-owned-merge-retry-scheduling-plan

refactor(workflow): add workflow-owned merge migration slice
This commit is contained in:
gsxdsm
2026-06-11 07:50:32 -07:00
committed by GitHub
21 changed files with 1746 additions and 83 deletions

View File

@@ -715,8 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -749,8 +748,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -800,8 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -830,8 +827,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -872,8 +868,7 @@ describe("schema migration", () => {
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(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -907,8 +902,7 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -945,8 +939,7 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -1007,7 +1000,7 @@ describe("schema migration", () => {
expect(customFieldsColumn).toBeDefined();
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -1045,7 +1038,7 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -1127,7 +1120,7 @@ describe("schema migration", () => {
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
expect(indexNames).toContain("idx_cli_sessions_project_state");
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -1159,7 +1152,7 @@ describe("schema migration", () => {
.all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -1169,7 +1162,7 @@ describe("schema migration", () => {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("cli_sessions");
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -1226,23 +1219,20 @@ describe("schema migration", () => {
.get() as { migrated_fragment_id: string | null };
expect(stepRow.migrated_fragment_id).toBeNull();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
it("migration 109 is idempotent on re-init", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
const reopened = new Database(fusionDir);
reopened.init();
expect(reopened.getSchemaVersion()).toBe(114);
expect(reopened.getSchemaVersion()).toBe(114);
expect(reopened.getSchemaVersion()).toBe(115);
const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;

View File

@@ -334,8 +334,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -394,8 +393,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1465,8 +1463,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1491,16 +1488,15 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
db.close();
});
@@ -1535,8 +1531,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1577,8 +1572,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1650,8 +1644,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1891,8 +1884,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1966,8 +1958,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1991,8 +1982,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2096,8 +2086,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2316,8 +2305,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(114);
expect(localDb.getSchemaVersion()).toBe(114);
expect(localDb.getSchemaVersion()).toBe(115);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2628,8 +2616,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2783,8 +2770,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(115);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2815,8 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(114);
expect(fresh.getSchemaVersion()).toBe(114);
expect(fresh.getSchemaVersion()).toBe(115);
const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2844,8 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(115);
const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2871,8 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(114);
expect(fresh.getSchemaVersion()).toBe(114);
expect(fresh.getSchemaVersion()).toBe(115);
const table = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2906,8 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(115);
const table = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2948,8 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(114);
expect(migrated.getSchemaVersion()).toBe(115);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2976,8 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(114);
expect(fresh.getSchemaVersion()).toBe(114);
expect(fresh.getSchemaVersion()).toBe(115);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

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

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(114);
expect(db1.getSchemaVersion()).toBe(115);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(114);
expect(db3.getSchemaVersion()).toBe(115);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(114);
expect(db1.getSchemaVersion()).toBe(115);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(114);
expect(db2.getSchemaVersion()).toBe(115);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(114);
expect(db1.getSchemaVersion()).toBe(115);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => {
.all() as Array<{ name: string }>;
expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
});
it("upserts merge request records", async () => {

View File

@@ -3746,7 +3746,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 101 after migration", () => {
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
});
it("mission_features table has loop state columns", () => {

View File

@@ -583,8 +583,8 @@ describe("Run Audit", () => {
expect(indexNames).toContain("idxRunAuditEventsTimestamp");
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(114);
it("schema version is bumped to 115", () => {
expect(db.getSchemaVersion()).toBe(115);
});
});
});

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(114);
expect(store.getDatabase().getSchemaVersion()).toBe(115);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SCHEMA_VERSION } from "../db.js";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-workflow-runtime-test-"));
}
describe("TaskStore workflow work items", () => {
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 });
});
async function createTaskId(): Promise<string> {
const task = await store.createTask({ description: "workflow work item test" });
return task.id;
}
it("creates workflow work-item tables on fresh schema", () => {
const db = store.getDatabase();
const table = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workflow_work_items'")
.get() as { name: string } | undefined;
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'workflow_work_items' ORDER BY name")
.all() as Array<{ name: string }>;
expect(table).toEqual({ name: "workflow_work_items" });
expect(indexes.map((row) => row.name)).toEqual(
expect.arrayContaining([
"idx_workflow_work_items_due",
"idx_workflow_work_items_leaseExpiresAt",
"idx_workflow_work_items_task_run",
]),
);
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
});
it("upserts by run, task, node, and kind without duplicating work", async () => {
const taskId = await createTaskId();
const created = store.upsertWorkflowWorkItem({
runId: "run-1",
taskId,
nodeId: "merge.node",
kind: "merge",
now: "2026-06-09T00:00:00.000Z",
});
const updated = store.upsertWorkflowWorkItem({
runId: "run-1",
taskId,
nodeId: "merge.node",
kind: "merge",
state: "held",
blockedReason: "shared branch is assembling",
now: "2026-06-09T00:00:01.000Z",
});
expect(updated).toMatchObject({
id: created.id,
runId: "run-1",
taskId,
nodeId: "merge.node",
kind: "merge",
state: "held",
attempt: 0,
blockedReason: "shared branch is assembling",
});
const rows = store
.getDatabase()
.prepare("SELECT COUNT(*) AS count FROM workflow_work_items WHERE runId = ? AND taskId = ?")
.get("run-1", taskId) as { count: number };
expect(rows.count).toBe(1);
});
it("lists due runnable and retrying work independently of task column", async () => {
const taskId = await createTaskId();
await store.moveTask(taskId, "todo");
await store.moveTask(taskId, "in-progress");
await store.moveTask(taskId, "in-review");
const now = "2026-06-09T00:00:00.000Z";
const runnable = store.upsertWorkflowWorkItem({
runId: "run-1",
taskId,
nodeId: "plan.node",
kind: "task",
state: "runnable",
now,
});
const futureRetry = store.upsertWorkflowWorkItem({
runId: "run-1",
taskId,
nodeId: "retry.node",
kind: "retry",
state: "retrying",
retryAfter: "2026-06-09T00:05:00.000Z",
now,
});
store.upsertWorkflowWorkItem({
runId: "run-1",
taskId,
nodeId: "hold.node",
kind: "manual-hold",
state: "held",
now,
});
expect(store.listDueWorkflowWorkItems({ now }).map((item) => item.id)).toEqual([runnable.id]);
expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:05:00.000Z" }).map((item) => item.id)).toEqual([
runnable.id,
futureRetry.id,
]);
});
it("acquires due leases and exposes expired running leases for reclaim", async () => {
const taskId = await createTaskId();
const item = store.upsertWorkflowWorkItem({
runId: "run-lease",
taskId,
nodeId: "merge.node",
kind: "merge",
state: "runnable",
now: "2026-06-09T00:00:00.000Z",
});
const leased = store.acquireWorkflowWorkItemLease(item.id, "worker-a", {
now: "2026-06-09T00:00:00.000Z",
leaseDurationMs: 60_000,
});
expect(leased).toMatchObject({
id: item.id,
state: "running",
leaseOwner: "worker-a",
leaseExpiresAt: "2026-06-09T00:01:00.000Z",
});
expect(
store.acquireWorkflowWorkItemLease(item.id, "worker-b", {
now: "2026-06-09T00:00:30.000Z",
leaseDurationMs: 60_000,
}),
).toBeNull();
expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:00:30.000Z" })).toEqual([]);
expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:01:00.000Z" }).map((due) => due.id)).toEqual([item.id]);
const reclaimed = store.acquireWorkflowWorkItemLease(item.id, "worker-b", {
now: "2026-06-09T00:01:00.000Z",
leaseDurationMs: 60_000,
});
expect(reclaimed).toMatchObject({
id: item.id,
state: "running",
leaseOwner: "worker-b",
leaseExpiresAt: "2026-06-09T00:02:00.000Z",
});
});
it("honors due-list state filters and validates lease duration", async () => {
const taskId = await createTaskId();
const item = store.upsertWorkflowWorkItem({
runId: "run-filter",
taskId,
nodeId: "merge.node",
kind: "merge",
state: "runnable",
now: "2026-06-09T00:00:00.000Z",
});
store.acquireWorkflowWorkItemLease(item.id, "worker-a", {
now: "2026-06-09T00:00:00.000Z",
leaseDurationMs: 60_000,
});
expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:01:00.000Z", states: ["runnable"] })).toEqual([]);
expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:01:00.000Z", states: ["running"] }).map((due) => due.id)).toEqual([
item.id,
]);
expect(() =>
store.acquireWorkflowWorkItemLease(item.id, "worker-b", {
now: "2026-06-09T00:01:00.000Z",
leaseDurationMs: 0,
}),
).toThrow("workflow work item leaseDurationMs must be > 0 (received 0)");
});
it("preserves lease and retry metadata on idempotent duplicate upserts", async () => {
const taskId = await createTaskId();
const item = store.upsertWorkflowWorkItem({
runId: "run-idempotent",
taskId,
nodeId: "retry.node",
kind: "retry",
state: "retrying",
retryAfter: "2026-06-09T00:05:00.000Z",
leaseOwner: "worker-a",
leaseExpiresAt: "2026-06-09T00:06:00.000Z",
lastError: "temporary failure",
now: "2026-06-09T00:00:00.000Z",
});
const duplicate = store.upsertWorkflowWorkItem({
runId: "run-idempotent",
taskId,
nodeId: "retry.node",
kind: "retry",
now: "2026-06-09T00:01:00.000Z",
});
expect(duplicate).toMatchObject({
id: item.id,
state: "retrying",
retryAfter: "2026-06-09T00:05:00.000Z",
leaseOwner: "worker-a",
leaseExpiresAt: "2026-06-09T00:06:00.000Z",
lastError: "temporary failure",
updatedAt: "2026-06-09T00:01:00.000Z",
});
});
it("does not requeue terminal work", async () => {
const taskId = await createTaskId();
const item = store.upsertWorkflowWorkItem({
runId: "run-terminal",
taskId,
nodeId: "merge.node",
kind: "merge",
state: "runnable",
});
store.transitionWorkflowWorkItem(item.id, "succeeded", { now: "2026-06-09T00:00:01.000Z" });
expect(() =>
store.upsertWorkflowWorkItem({
runId: "run-terminal",
taskId,
nodeId: "merge.node",
kind: "merge",
state: "runnable",
}),
).toThrow(/terminal \(succeeded\) and cannot be requeued as runnable/);
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(114);
expect(db.getSchemaVersion()).toBe(115);
const index = db
.prepare(

View File

@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 114;
const SCHEMA_VERSION = 115;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -602,6 +602,27 @@ CREATE TABLE IF NOT EXISTS completion_handoff_markers (
);
CREATE INDEX IF NOT EXISTS idx_completion_handoff_markers_acceptedAt ON completion_handoff_markers(acceptedAt);
CREATE TABLE IF NOT EXISTS workflow_work_items (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL,
taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
nodeId TEXT NOT NULL,
kind TEXT NOT NULL,
state TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
retryAfter TEXT,
leaseOwner TEXT,
leaseExpiresAt TEXT,
lastError TEXT,
blockedReason TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
UNIQUE(runId, taskId, nodeId, kind)
);
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_due ON workflow_work_items(state, retryAfter, createdAt);
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_leaseExpiresAt ON workflow_work_items(leaseExpiresAt);
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_task_run ON workflow_work_items(taskId, runId);
-- Per-branch run state for concurrent workflow fan-out/join (U13, KTD-11/R21).
-- Reconstructible per ADR-0001: a crashed parallel run resumes each branch from
-- its persisted node; completed branches are not re-run. Additive-only.
@@ -4634,6 +4655,40 @@ export class Database {
});
}
// Migration 115: Workflow-owned merge/retry/scheduling S1.
// Adds durable workflow work items so runnable, held, retrying, merge,
// manual-hold, and recovery work can be claimed generically before legacy
// merge queue and retry policy are deleted.
if (version < 115) {
this.applyMigration(115, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS workflow_work_items (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL,
taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
nodeId TEXT NOT NULL,
kind TEXT NOT NULL,
state TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
retryAfter TEXT,
leaseOwner TEXT,
leaseExpiresAt TEXT,
lastError TEXT,
blockedReason TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
UNIQUE(runId, taskId, nodeId, kind)
);
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_due
ON workflow_work_items(state, retryAfter, createdAt);
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_leaseExpiresAt
ON workflow_work_items(leaseExpiresAt);
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_task_run
ON workflow_work_items(taskId, runId);
`);
});
}
}
/**

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, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, 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, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, 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, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, 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, TaskBranchContext, CliAgentSettings } from "./types.js";
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, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, 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, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, 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, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, 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, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, 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, TaskBranchContext, CliAgentSettings } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,

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, ColumnId, 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, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, 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, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
@@ -625,6 +625,23 @@ interface CompletionHandoffMarkerRow {
source: string;
}
interface WorkflowWorkItemRow {
id: string;
runId: string;
taskId: string;
nodeId: string;
kind: string;
state: string;
attempt: number;
retryAfter: string | null;
leaseOwner: string | null;
leaseExpiresAt: string | null;
lastError: string | null;
blockedReason: string | null;
createdAt: string;
updatedAt: string;
}
/** Database row shape for the config table. */
interface ConfigRow {
nextId: number;
@@ -8803,6 +8820,59 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
};
}
private normalizeWorkflowWorkItemKind(value: string): WorkflowWorkItemKind {
switch (value) {
case "task":
case "merge":
case "retry":
case "manual-hold":
case "recovery":
return value;
default:
return "task";
}
}
private normalizeWorkflowWorkItemState(value: string): WorkflowWorkItemState {
switch (value) {
case "runnable":
case "running":
case "held":
case "retrying":
case "manual-required":
case "succeeded":
case "failed":
case "cancelled":
case "exhausted":
return value;
default:
return "runnable";
}
}
private isTerminalWorkflowWorkItemState(state: WorkflowWorkItemState): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "exhausted";
}
private rowToWorkflowWorkItem(row: WorkflowWorkItemRow): WorkflowWorkItem {
return {
id: row.id,
runId: row.runId,
taskId: row.taskId,
nodeId: row.nodeId,
kind: this.normalizeWorkflowWorkItemKind(row.kind),
state: this.normalizeWorkflowWorkItemState(row.state),
attempt: row.attempt,
retryAfter: row.retryAfter,
leaseOwner: row.leaseOwner,
leaseExpiresAt: row.leaseExpiresAt,
lastError: row.lastError,
blockedReason: row.blockedReason,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private isValidMergeRequestTransition(from: MergeRequestState, to: MergeRequestState): boolean {
if (from === to) return true;
const allowed: Record<MergeRequestState, ReadonlySet<MergeRequestState>> = {
@@ -8892,6 +8962,201 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return row ? this.rowToMergeRequestRecord(row) : null;
}
upsertWorkflowWorkItem(input: WorkflowWorkItemUpsertInput): WorkflowWorkItem {
return this.db.transactionImmediate(() => {
const existing = this.db
.prepare("SELECT * FROM workflow_work_items WHERE runId = ? AND taskId = ? AND nodeId = ? AND kind = ?")
.get(input.runId, input.taskId, input.nodeId, input.kind) as WorkflowWorkItemRow | undefined;
const now = input.now ?? new Date().toISOString();
const existingState = existing ? this.normalizeWorkflowWorkItemState(existing.state) : null;
const state = input.state ?? existingState ?? "runnable";
if (existingState && this.isTerminalWorkflowWorkItemState(existingState) && existingState !== state) {
throw new Error(
`Workflow work item ${existing?.id ?? input.id ?? input.nodeId} is terminal (${existingState}) and cannot be requeued as ${state}`,
);
}
const id = existing?.id ?? input.id ?? randomUUID();
this.db
.prepare(
`INSERT INTO workflow_work_items (
id, runId, taskId, nodeId, kind, state, attempt, retryAfter,
leaseOwner, leaseExpiresAt, lastError, blockedReason, createdAt, updatedAt
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(runId, taskId, nodeId, kind) DO UPDATE SET
state = excluded.state,
attempt = excluded.attempt,
retryAfter = excluded.retryAfter,
leaseOwner = excluded.leaseOwner,
leaseExpiresAt = excluded.leaseExpiresAt,
lastError = excluded.lastError,
blockedReason = excluded.blockedReason,
updatedAt = excluded.updatedAt`,
)
.run(
id,
input.runId,
input.taskId,
input.nodeId,
input.kind,
state,
input.attempt ?? existing?.attempt ?? 0,
input.retryAfter === undefined ? existing?.retryAfter ?? null : input.retryAfter,
input.leaseOwner === undefined ? existing?.leaseOwner ?? null : input.leaseOwner,
input.leaseExpiresAt === undefined ? existing?.leaseExpiresAt ?? null : input.leaseExpiresAt,
input.lastError === undefined ? existing?.lastError ?? null : input.lastError,
input.blockedReason === undefined ? existing?.blockedReason ?? null : input.blockedReason,
existing?.createdAt ?? now,
now,
);
const row = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined;
if (!row) throw new Error(`Failed to upsert workflow work item ${id}`);
this.insertRunAuditEventRow({
taskId: row.taskId,
runId: row.runId,
domain: "database",
mutationType: "workflowWorkItem:upsert",
target: row.id,
metadata: { id: row.id, nodeId: row.nodeId, kind: row.kind, state: row.state, attempt: row.attempt },
});
return this.rowToWorkflowWorkItem(row);
});
}
transitionWorkflowWorkItem(
id: string,
state: WorkflowWorkItemState,
patch: WorkflowWorkItemTransitionPatch = {},
): WorkflowWorkItem {
return this.db.transactionImmediate(() => {
const now = patch.now ?? new Date().toISOString();
const existing = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined;
if (!existing) throw new Error(`Workflow work item ${id} not found`);
const fromState = this.normalizeWorkflowWorkItemState(existing.state);
if (this.isTerminalWorkflowWorkItemState(fromState) && fromState !== state) {
throw new Error(`Workflow work item ${id} is terminal (${fromState}) and cannot transition to ${state}`);
}
this.db
.prepare(
`UPDATE workflow_work_items
SET state = ?,
attempt = ?,
retryAfter = ?,
leaseOwner = ?,
leaseExpiresAt = ?,
lastError = ?,
blockedReason = ?,
updatedAt = ?
WHERE id = ?`,
)
.run(
state,
patch.attempt ?? existing.attempt,
patch.retryAfter === undefined ? existing.retryAfter : patch.retryAfter,
patch.leaseOwner === undefined ? existing.leaseOwner : patch.leaseOwner,
patch.leaseExpiresAt === undefined ? existing.leaseExpiresAt : patch.leaseExpiresAt,
patch.lastError === undefined ? existing.lastError : patch.lastError,
patch.blockedReason === undefined ? existing.blockedReason : patch.blockedReason,
now,
id,
);
const updated = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined;
if (!updated) throw new Error(`Workflow work item ${id} disappeared`);
this.insertRunAuditEventRow({
taskId: updated.taskId,
runId: updated.runId,
domain: "database",
mutationType: "workflowWorkItem:transition",
target: updated.id,
metadata: { id: updated.id, fromState, toState: state, attempt: updated.attempt },
});
return this.rowToWorkflowWorkItem(updated);
});
}
getWorkflowWorkItem(id: string): WorkflowWorkItem | null {
const row = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined;
return row ? this.rowToWorkflowWorkItem(row) : null;
}
listDueWorkflowWorkItems(filter: WorkflowWorkItemDueFilter = {}): WorkflowWorkItem[] {
const now = filter.now ?? new Date().toISOString();
const includeExpiredRunning = !filter.states || filter.states.includes("running");
const states = filter.states?.length ? filter.states : ["runnable", "retrying"];
const stateConditions = [`(state IN (${states.map(() => "?").join(", ")}) AND (leaseExpiresAt IS NULL OR leaseExpiresAt <= ?))`];
const params: unknown[] = [...states, now];
if (includeExpiredRunning) {
stateConditions.push("(state = 'running' AND leaseExpiresAt IS NOT NULL AND leaseExpiresAt <= ?)");
params.push(now);
}
const conditions = [
`(${stateConditions.join(" OR ")})`,
"(retryAfter IS NULL OR retryAfter <= ?)",
];
params.push(now);
if (filter.kinds?.length) {
conditions.push(`kind IN (${filter.kinds.map(() => "?").join(", ")})`);
params.push(...filter.kinds);
}
params.push(filter.limit ?? 100);
const rows = this.db
.prepare(
`SELECT *
FROM workflow_work_items
WHERE ${conditions.join(" AND ")}
ORDER BY retryAfter IS NOT NULL, retryAfter ASC, createdAt ASC
LIMIT ?`,
)
.all(...params) as WorkflowWorkItemRow[];
return rows.map((row) => this.rowToWorkflowWorkItem(row));
}
acquireWorkflowWorkItemLease(
id: string,
leaseOwner: string,
opts: { leaseDurationMs: number; now?: string },
): WorkflowWorkItem | null {
if (opts.leaseDurationMs <= 0) {
throw new Error(`workflow work item leaseDurationMs must be > 0 (received ${opts.leaseDurationMs})`);
}
return this.db.transactionImmediate(() => {
const now = opts.now ?? new Date().toISOString();
const leaseExpiresAt = new Date(new Date(now).getTime() + opts.leaseDurationMs).toISOString();
const result = this.db
.prepare(
`UPDATE workflow_work_items
SET state = 'running',
leaseOwner = ?,
leaseExpiresAt = ?,
updatedAt = ?
WHERE id = ?
AND state IN ('runnable', 'retrying', 'running')
AND (retryAfter IS NULL OR retryAfter <= ?)
AND (leaseExpiresAt IS NULL OR leaseExpiresAt <= ?)`,
)
.run(leaseOwner, leaseExpiresAt, now, id, now, now);
if (result.changes === 0) return null;
const row = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined;
if (!row) throw new Error(`Workflow work item ${id} disappeared`);
this.insertRunAuditEventRow({
taskId: row.taskId,
runId: row.runId,
domain: "database",
mutationType: "workflowWorkItem:lease-acquired",
target: row.id,
metadata: { id: row.id, leaseOwner: row.leaseOwner, leaseExpiresAt: row.leaseExpiresAt },
});
return this.rowToWorkflowWorkItem(row);
});
}
setCompletionHandoffAcceptedMarker(
taskId: string,
opts: { source: string; acceptedAt?: string },

View File

@@ -86,6 +86,80 @@ export const MERGE_REQUEST_STATES = [
export type MergeRequestState = (typeof MERGE_REQUEST_STATES)[number];
export const WORKFLOW_WORK_ITEM_KINDS = [
"task",
"merge",
"retry",
"manual-hold",
"recovery",
] as const;
export type WorkflowWorkItemKind = (typeof WORKFLOW_WORK_ITEM_KINDS)[number];
export const WORKFLOW_WORK_ITEM_STATES = [
"runnable",
"running",
"held",
"retrying",
"manual-required",
"succeeded",
"failed",
"cancelled",
"exhausted",
] as const;
export type WorkflowWorkItemState = (typeof WORKFLOW_WORK_ITEM_STATES)[number];
export interface WorkflowWorkItem {
id: string;
runId: string;
taskId: string;
nodeId: string;
kind: WorkflowWorkItemKind;
state: WorkflowWorkItemState;
attempt: number;
retryAfter: string | null;
leaseOwner: string | null;
leaseExpiresAt: string | null;
lastError: string | null;
blockedReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface WorkflowWorkItemUpsertInput {
id?: string;
runId: string;
taskId: string;
nodeId: string;
kind: WorkflowWorkItemKind;
state?: WorkflowWorkItemState;
attempt?: number;
retryAfter?: string | null;
leaseOwner?: string | null;
leaseExpiresAt?: string | null;
lastError?: string | null;
blockedReason?: string | null;
now?: string;
}
export interface WorkflowWorkItemTransitionPatch {
attempt?: number;
retryAfter?: string | null;
leaseOwner?: string | null;
leaseExpiresAt?: string | null;
lastError?: string | null;
blockedReason?: string | null;
now?: string;
}
export interface WorkflowWorkItemDueFilter {
now?: string;
limit?: number;
kinds?: WorkflowWorkItemKind[];
states?: WorkflowWorkItemState[];
}
export interface MergeQueueEntry {
taskId: string;
enqueuedAt: string;

View File

@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const DOC_PATH = resolve(__dirname, "../../../../docs/workflow-policy-ownership-map.md");
const REQUIRED_SOURCE_FILES = [
"packages/engine/src/project-engine.ts",
"packages/engine/src/scheduler.ts",
"packages/engine/src/self-healing.ts",
"packages/engine/src/merger.ts",
"packages/engine/src/merger-ai.ts",
"packages/engine/src/merger-integration-worktree.ts",
"packages/engine/src/group-merge-coordinator.ts",
"packages/engine/src/transient-merge-error-classifier.ts",
"packages/engine/src/retry-with-backoff.ts",
"packages/engine/src/rate-limit-retry.ts",
"packages/core/src/store.ts",
"packages/core/src/task-merge.ts",
"packages/core/src/retry-summary.ts",
"packages/core/src/manual-retry-reset.ts",
"packages/core/src/builtin-coding-workflow-ir.ts",
"packages/core/src/builtin-stepwise-coding-workflow-ir.ts",
"packages/core/src/builtin-pr-workflow-ir.ts",
"packages/dashboard/app/components/TaskCard.tsx",
] as const;
const REQUIRED_POLICY_SURFACES = [
"Auto-merge queue enqueue and dequeue",
"Merge checkout, integration, conflict resolution, squash, finalize",
"Branch-group member integration and group promotion",
"Dependency satisfaction treats `in-review` as satisfied",
"Active scope leases include unmerged `in-review` worktrees",
"Manual retry reset",
"Recover mergeable in-review tasks",
"Completion handoff limbo recovery",
"Transient merge failure recovery",
"Already-landed and no-op finalization",
"Built-in default workflow definitions",
"Dashboard task-card merge/retry/stall badges",
] as const;
describe("workflow policy ownership map", () => {
const doc = readFileSync(DOC_PATH, "utf-8");
it("classifies every required policy surface from the workflow-owned merge plan", () => {
for (const surface of REQUIRED_POLICY_SURFACES) {
expect(doc, `missing ownership surface: ${surface}`).toContain(surface);
}
});
it("anchors the map to the production source files that own merge, retry, scheduling, and projection today", () => {
for (const file of REQUIRED_SOURCE_FILES) {
expect(doc, `missing source file: ${file}`).toContain(file);
}
});
it("records the migration dispositions needed for later deletion gates", () => {
for (const disposition of [
"`substrate`",
"`workflow-policy`",
"`capability`",
"`compat-projection`",
"`delete-after-cutover`",
]) {
expect(doc).toContain(disposition);
}
expect(doc).toContain("## Deletion Gates");
expect(doc).toContain("No production caller may start checkout, branch integration, squash, or finalize");
expect(doc).toContain("Task-level retry and merge fields are compatibility summaries");
});
});

View File

@@ -84,6 +84,7 @@ export default defineConfig({
"src/__tests__/self-healing.test.ts",
"src/__tests__/heartbeat-monitor.test.ts",
"src/__tests__/workflow-node-handlers.test.ts",
"src/__tests__/workflow-policy-ownership-map.test.ts",
],
exclude: ["node_modules/**", "dist/**"],
},