feat(FN-3990): add task lineage identity and association storage
Adds task lineage storage infrastructure (FN-3990 Step 2), introducing a dedicated `task-lineage.ts` module and related types to track task identity and association relationships, with corresponding database schema and store support plus tests and documentation for the lineage reconciliation process Fusion-Task-Id: FN-3990
This commit is contained in:
11
docs/task-lineage-reconciliation.md
Normal file
11
docs/task-lineage-reconciliation.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Task Lineage Reconciliation Notes
|
||||||
|
|
||||||
|
## FN-3953 historical mismatch (GitHub-tracking vs current task)
|
||||||
|
|
||||||
|
- Historical commit subject evidence:
|
||||||
|
- `6871c510a feat(FN-3953): enable tracking issue creation on task edit and document the...`
|
||||||
|
- Current unrelated FN-3953 evidence:
|
||||||
|
- `f6a1862f9 feat(FN-3953): wire agent provisioning approval policy into engine tools`
|
||||||
|
- Reconciled GitHub-tracking task lineage: `FN-3874`, `FN-3940`, `FN-3943`
|
||||||
|
- Summary: raw task-ID references in historical commits can map to different board meanings over time; therefore historical attribution must use immutable lineage IDs plus persisted association records rather than display task ID alone.
|
||||||
|
- Confidence: high
|
||||||
@@ -200,7 +200,7 @@ describe("Database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
});
|
});
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
const ts = db.getLastModified();
|
const ts = db.getLastModified();
|
||||||
@@ -222,7 +222,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
});
|
});
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
// Update the config
|
// Update the config
|
||||||
@@ -1021,7 +1021,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1046,11 +1046,11 @@ describe("schema migrations", () => {
|
|||||||
const db = new Database(fusionDir);
|
const db = new Database(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -1085,7 +1085,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("priority");
|
expect(cols.map((col) => col.name)).toContain("priority");
|
||||||
@@ -1126,7 +1126,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -1195,7 +1195,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -1435,7 +1435,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||||
@@ -1509,7 +1509,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -1533,7 +1533,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||||
@@ -1637,7 +1637,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29
|
// Verify version bumped to 29
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -2106,7 +2106,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(fusionDir);
|
const db = createDatabase(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
@@ -2239,7 +2239,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
|||||||
|
|
||||||
const migrated = new Database(fusion);
|
const migrated = new Database(fusion);
|
||||||
migrated.init();
|
migrated.init();
|
||||||
expect(migrated.getSchemaVersion()).toBe(71);
|
expect(migrated.getSchemaVersion()).toBe(72);
|
||||||
const tables = migrated
|
const tables = migrated
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||||
.all() as Array<{ name: string }>;
|
.all() as Array<{ name: string }>;
|
||||||
@@ -2253,7 +2253,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
|||||||
const fusion = join(temp, ".fusion");
|
const fusion = join(temp, ".fusion");
|
||||||
const fresh = new Database(fusion);
|
const fresh = new Database(fusion);
|
||||||
fresh.init();
|
fresh.init();
|
||||||
expect(fresh.getSchemaVersion()).toBe(71);
|
expect(fresh.getSchemaVersion()).toBe(72);
|
||||||
const tables = fresh
|
const tables = fresh
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||||
.all() as Array<{ name: string }>;
|
.all() as Array<{ name: string }>;
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||||
const db1 = createDatabase(legacyDir);
|
const db1 = createDatabase(legacyDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(71);
|
expect(db1.getSchemaVersion()).toBe(72);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||||
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||||
// Now run init — this triggers the v32→v33 migration
|
// Now run init — this triggers the v32→v33 migration
|
||||||
db3.init();
|
db3.init();
|
||||||
expect(db3.getSchemaVersion()).toBe(71);
|
expect(db3.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
// Step 4: Verify insight tables exist after migration
|
// Step 4: Verify insight tables exist after migration
|
||||||
const tablesAfter = db3.prepare(
|
const tablesAfter = db3.prepare(
|
||||||
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
try {
|
try {
|
||||||
const db1 = createDatabase(testDir);
|
const db1 = createDatabase(testDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(71);
|
expect(db1.getSchemaVersion()).toBe(72);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
const db2 = createDatabase(testDir);
|
const db2 = createDatabase(testDir);
|
||||||
expect(() => db2.init()).not.toThrow();
|
expect(() => db2.init()).not.toThrow();
|
||||||
expect(db2.getSchemaVersion()).toBe(71);
|
expect(db2.getSchemaVersion()).toBe(72);
|
||||||
db2.close();
|
db2.close();
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
// Step 1: Create a fresh DB and run migrations
|
// Step 1: Create a fresh DB and run migrations
|
||||||
const db1 = createDatabase(compatDir);
|
const db1 = createDatabase(compatDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(71);
|
expect(db1.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||||
// table without them. This simulates a DB that was created before the
|
// table without them. This simulates a DB that was created before the
|
||||||
|
|||||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
|||||||
|
|
||||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||||
it("schema version is 40 after migration", () => {
|
it("schema version is 40 after migration", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mission_features table has loop state columns", () => {
|
it("mission_features table has loop state columns", () => {
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("schema version is bumped to 40", () => {
|
it("schema version is bumped to 40", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
|||||||
|
|
||||||
expect(tableNames.has("task_documents")).toBe(true);
|
expect(tableNames.has("task_documents")).toBe(true);
|
||||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
|
|
||||||
const index = db
|
const index = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
28
packages/core/src/__tests__/task-lineage.test.ts
Normal file
28
packages/core/src/__tests__/task-lineage.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
FUSION_TASK_LINEAGE_TRAILER_KEY,
|
||||||
|
buildTaskLineageTrailer,
|
||||||
|
classifyTaskCommitAssociationConfidence,
|
||||||
|
generateTaskLineageId,
|
||||||
|
parseTaskLineageTrailer,
|
||||||
|
} from "../task-lineage.js";
|
||||||
|
|
||||||
|
describe("task-lineage", () => {
|
||||||
|
it("generates UUID lineage ids", () => {
|
||||||
|
const id = generateTaskLineageId();
|
||||||
|
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips canonical trailer", () => {
|
||||||
|
const lineageId = generateTaskLineageId();
|
||||||
|
const trailer = buildTaskLineageTrailer(lineageId);
|
||||||
|
expect(trailer).toBe(`${FUSION_TASK_LINEAGE_TRAILER_KEY}: ${lineageId}`);
|
||||||
|
expect(parseTaskLineageTrailer(`subject\n\n${trailer}\n`)).toBe(lineageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies match confidence", () => {
|
||||||
|
expect(classifyTaskCommitAssociationConfidence("canonical-lineage-trailer")).toBe("canonical");
|
||||||
|
expect(classifyTaskCommitAssociationConfidence("legacy-task-id-trailer")).toBe("legacy");
|
||||||
|
expect(classifyTaskCommitAssociationConfidence("manual-reconciliation")).toBe("ambiguous");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { DatabaseSync } from "./sqlite-adapter.js";
|
|||||||
import { isAbsolute, join } from "node:path";
|
import { isAbsolute, join } from "node:path";
|
||||||
import { mkdirSync, existsSync } from "node:fs";
|
import { mkdirSync, existsSync } from "node:fs";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
|
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
|
||||||
import type { PluginOnSchemaInit } from "./plugin-types.js";
|
import type { PluginOnSchemaInit } from "./plugin-types.js";
|
||||||
import type { SteeringComment, TaskComment } from "./types.js";
|
import type { SteeringComment, TaskComment } from "./types.js";
|
||||||
@@ -88,7 +89,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 71;
|
const SCHEMA_VERSION = 72;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
steeringComments: SteeringComment[] | undefined,
|
||||||
@@ -152,6 +153,7 @@ const SCHEMA_SQL = `
|
|||||||
-- Tasks table with JSON columns for nested data
|
-- Tasks table with JSON columns for nested data
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
lineageId TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
priority TEXT DEFAULT 'normal',
|
priority TEXT DEFAULT 'normal',
|
||||||
@@ -317,6 +319,23 @@ CREATE TABLE IF NOT EXISTS archivedTasks (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idxArchivedTasksId ON archivedTasks(id);
|
CREATE INDEX IF NOT EXISTS idxArchivedTasksId ON archivedTasks(id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS task_commit_associations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
taskLineageId TEXT NOT NULL,
|
||||||
|
taskIdSnapshot TEXT NOT NULL,
|
||||||
|
commitSha TEXT NOT NULL,
|
||||||
|
commitSubject TEXT NOT NULL,
|
||||||
|
authoredAt TEXT NOT NULL,
|
||||||
|
matchedBy TEXT NOT NULL CHECK (matchedBy IN ('canonical-lineage-trailer', 'legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')),
|
||||||
|
confidence TEXT NOT NULL CHECK (confidence IN ('canonical', 'legacy', 'ambiguous')),
|
||||||
|
note TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
UNIQUE(taskLineageId, commitSha, matchedBy)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idxTaskCommitAssociationsLineage ON task_commit_associations(taskLineageId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idxTaskCommitAssociationsCommitSha ON task_commit_associations(commitSha);
|
||||||
|
|
||||||
-- Automations table
|
-- Automations table
|
||||||
CREATE TABLE IF NOT EXISTS automations (
|
CREATE TABLE IF NOT EXISTS automations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -2969,6 +2988,37 @@ export class Database {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 72) {
|
||||||
|
this.applyMigration(72, () => {
|
||||||
|
this.addColumnIfMissing("tasks", "lineageId", "TEXT");
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksLineageId ON tasks(lineageId)`);
|
||||||
|
const missing = this.db.prepare("SELECT id FROM tasks WHERE lineageId IS NULL OR trim(lineageId) = ''").all() as Array<{ id: string }>;
|
||||||
|
const updateLineage = this.db.prepare("UPDATE tasks SET lineageId = ? WHERE id = ?");
|
||||||
|
for (const row of missing) {
|
||||||
|
updateLineage.run(randomUUID(), row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS task_commit_associations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
taskLineageId TEXT NOT NULL,
|
||||||
|
taskIdSnapshot TEXT NOT NULL,
|
||||||
|
commitSha TEXT NOT NULL,
|
||||||
|
commitSubject TEXT NOT NULL,
|
||||||
|
authoredAt TEXT NOT NULL,
|
||||||
|
matchedBy TEXT NOT NULL CHECK (matchedBy IN ('canonical-lineage-trailer', 'legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')),
|
||||||
|
confidence TEXT NOT NULL CHECK (confidence IN ('canonical', 'legacy', 'ambiguous')),
|
||||||
|
note TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
UNIQUE(taskLineageId, commitSha, matchedBy)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.db.exec("CREATE INDEX IF NOT EXISTS idxTaskCommitAssociationsLineage ON task_commit_associations(taskLineageId)");
|
||||||
|
this.db.exec("CREATE INDEX IF NOT EXISTS idxTaskCommitAssociationsCommitSha ON task_commit_associations(commitSha)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLU
|
|||||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, 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, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, 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, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
||||||
|
export type {
|
||||||
|
TaskCommitAssociation,
|
||||||
|
TaskCommitAssociationConfidence,
|
||||||
|
TaskCommitAssociationMatchSource,
|
||||||
|
} from "./types.js";
|
||||||
export * from "./mesh-replication-protocol.js";
|
export * from "./mesh-replication-protocol.js";
|
||||||
export * from "./mesh-task-replication.js";
|
export * from "./mesh-task-replication.js";
|
||||||
export * from "./shared-mesh-state.js";
|
export * from "./shared-mesh-state.js";
|
||||||
@@ -80,6 +85,14 @@ export type {
|
|||||||
AgentProvisioningPolicyDecision,
|
AgentProvisioningPolicyDecision,
|
||||||
} from "./agent-provisioning-policy.js";
|
} from "./agent-provisioning-policy.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
|
export {
|
||||||
|
FUSION_TASK_LINEAGE_TRAILER_KEY,
|
||||||
|
buildTaskLineageTrailer,
|
||||||
|
classifyTaskCommitAssociationConfidence,
|
||||||
|
generateTaskLineageId,
|
||||||
|
normalizeTaskCommitAssociation,
|
||||||
|
parseTaskLineageTrailer,
|
||||||
|
} from "./task-lineage.js";
|
||||||
export {
|
export {
|
||||||
createDistributedTaskIdAllocator,
|
createDistributedTaskIdAllocator,
|
||||||
formatDistributedTaskId,
|
formatDistributedTaskId,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord } from "./types.js";
|
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, 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 } from "./types.js";
|
||||||
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
|
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
|
||||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
|
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
|
||||||
import { normalizeTaskPriority } from "./task-priority.js";
|
import { normalizeTaskPriority } from "./task-priority.js";
|
||||||
@@ -27,6 +27,7 @@ import { createLogger } from "./logger.js";
|
|||||||
import { validateNodeOverrideChange } from "./node-override-guard.js";
|
import { validateNodeOverrideChange } from "./node-override-guard.js";
|
||||||
import { sanitizeTitle } from "./ai-summarize.js";
|
import { sanitizeTitle } from "./ai-summarize.js";
|
||||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||||
|
import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js";
|
||||||
import { createDistributedTaskIdAllocator, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
import { createDistributedTaskIdAllocator, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
||||||
import {
|
import {
|
||||||
buildBootstrapPrompt,
|
buildBootstrapPrompt,
|
||||||
@@ -38,6 +39,7 @@ import type { MeshReplicatedTaskApplyResult, MeshReplicatedTaskCreatePayload } f
|
|||||||
/** Database row shape for the tasks table (all columns). */
|
/** Database row shape for the tasks table (all columns). */
|
||||||
interface TaskRow {
|
interface TaskRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
lineageId: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
@@ -165,6 +167,20 @@ function withTaskBranchContextInSourceMetadata(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskCommitAssociationRow {
|
||||||
|
id: string;
|
||||||
|
taskLineageId: string;
|
||||||
|
taskIdSnapshot: string;
|
||||||
|
commitSha: string;
|
||||||
|
commitSubject: string;
|
||||||
|
authoredAt: string;
|
||||||
|
matchedBy: TaskCommitAssociationMatchSource;
|
||||||
|
confidence: TaskCommitAssociationConfidence;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskDocumentRow {
|
interface TaskDocumentRow {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -750,6 +766,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private rowToTask(row: TaskRow): Task {
|
private rowToTask(row: TaskRow): Task {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
lineageId: row.lineageId || generateTaskLineageId(),
|
||||||
title: row.title || undefined,
|
title: row.title || undefined,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
priority: normalizeTaskPriority(row.priority),
|
priority: normalizeTaskPriority(row.priority),
|
||||||
@@ -894,6 +911,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private archiveEntryToTask(entry: ArchivedTaskEntry, slim = false): Task {
|
private archiveEntryToTask(entry: ArchivedTaskEntry, slim = false): Task {
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
lineageId: entry.lineageId || generateTaskLineageId(),
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
description: entry.description,
|
description: entry.description,
|
||||||
priority: normalizeTaskPriority(entry.priority),
|
priority: normalizeTaskPriority(entry.priority),
|
||||||
@@ -1019,6 +1037,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
lineageId: task.lineageId || generateTaskLineageId(),
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
priority: normalizeTaskPriority(task.priority),
|
priority: normalizeTaskPriority(task.priority),
|
||||||
@@ -1105,7 +1124,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
const prefix = tableAlias ? `${tableAlias}.` : "";
|
const prefix = tableAlias ? `${tableAlias}.` : "";
|
||||||
return [
|
return [
|
||||||
"id", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
|
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
|
||||||
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
|
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
|
||||||
"modelPresetId", "modelProvider", "modelId",
|
"modelPresetId", "modelProvider", "modelId",
|
||||||
"validatorModelProvider", "validatorModelId",
|
"validatorModelProvider", "validatorModelId",
|
||||||
@@ -1154,7 +1173,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
|
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
|
||||||
const columns = [
|
const columns = [
|
||||||
"id", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
|
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
|
||||||
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
|
"worktree", "blockedBy", "paused", "baseBranch", "branch", "executionStartBranch", "baseCommitSha",
|
||||||
"modelPresetId", "modelProvider", "modelId",
|
"modelPresetId", "modelProvider", "modelId",
|
||||||
"validatorModelProvider", "validatorModelId",
|
"validatorModelProvider", "validatorModelId",
|
||||||
@@ -1199,7 +1218,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private upsertTask(task: Task): void {
|
private upsertTask(task: Task): void {
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
INSERT INTO tasks (
|
INSERT INTO tasks (
|
||||||
id, title, description, priority, "column", status, size, reviewLevel, currentStep,
|
id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep,
|
||||||
worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
|
worktree, blockedBy, paused, baseBranch, branch, executionStartBranch, baseCommitSha, modelPresetId, modelProvider,
|
||||||
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
||||||
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, nextRecoveryAt, error,
|
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, nextRecoveryAt, error,
|
||||||
@@ -1211,9 +1230,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||||
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
lineageId = excluded.lineageId,
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
description = excluded.description,
|
description = excluded.description,
|
||||||
priority = excluded.priority,
|
priority = excluded.priority,
|
||||||
@@ -1304,6 +1324,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch
|
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch
|
||||||
`).run(
|
`).run(
|
||||||
task.id,
|
task.id,
|
||||||
|
task.lineageId ?? generateTaskLineageId(),
|
||||||
task.title ?? null,
|
task.title ?? null,
|
||||||
task.description,
|
task.description,
|
||||||
normalizeTaskPriority(task.priority),
|
normalizeTaskPriority(task.priority),
|
||||||
@@ -2509,6 +2530,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const now = options?.createdAt ?? new Date().toISOString();
|
const now = options?.createdAt ?? new Date().toISOString();
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id,
|
id,
|
||||||
|
lineageId: input.lineageId ?? generateTaskLineageId(),
|
||||||
title,
|
title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
priority: normalizeTaskPriority(input.priority),
|
priority: normalizeTaskPriority(input.priority),
|
||||||
@@ -2586,6 +2608,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
// Create new task with copied title/description, but fresh state
|
// Create new task with copied title/description, but fresh state
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
id: newId,
|
id: newId,
|
||||||
|
lineageId: generateTaskLineageId(),
|
||||||
title: sourceTask.title,
|
title: sourceTask.title,
|
||||||
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
|
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
|
||||||
priority: normalizeTaskPriority(sourceTask.priority),
|
priority: normalizeTaskPriority(sourceTask.priority),
|
||||||
@@ -2666,6 +2689,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
// Create new refinement task
|
// Create new refinement task
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
id: newId,
|
id: newId,
|
||||||
|
lineageId: generateTaskLineageId(),
|
||||||
title: `Refinement: ${sourceLabel}`,
|
title: `Refinement: ${sourceLabel}`,
|
||||||
description: `${feedback.trim()}\n\nRefines: ${id}`,
|
description: `${feedback.trim()}\n\nRefines: ${id}`,
|
||||||
priority: normalizeTaskPriority(sourceTask.priority),
|
priority: normalizeTaskPriority(sourceTask.priority),
|
||||||
@@ -6418,6 +6442,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
// Build restored task (clear transient fields)
|
// Build restored task (clear transient fields)
|
||||||
const restoredTask: Task = {
|
const restoredTask: Task = {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
lineageId: entry.lineageId || generateTaskLineageId(),
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
description: entry.description,
|
description: entry.description,
|
||||||
priority: normalizeTaskPriority(entry.priority),
|
priority: normalizeTaskPriority(entry.priority),
|
||||||
@@ -7328,6 +7353,63 @@ ${notificationsSection}`;
|
|||||||
return { applied, skipped };
|
return { applied, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertTaskCommitAssociation(
|
||||||
|
input: Omit<TaskCommitAssociation, "id" | "createdAt" | "updatedAt"> & { id?: string },
|
||||||
|
): Promise<TaskCommitAssociation> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const association: TaskCommitAssociation = normalizeTaskCommitAssociation({
|
||||||
|
id: input.id ?? randomUUID(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
this.db.prepare(
|
||||||
|
`INSERT INTO task_commit_associations
|
||||||
|
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET
|
||||||
|
taskIdSnapshot = excluded.taskIdSnapshot,
|
||||||
|
commitSubject = excluded.commitSubject,
|
||||||
|
authoredAt = excluded.authoredAt,
|
||||||
|
confidence = excluded.confidence,
|
||||||
|
note = excluded.note,
|
||||||
|
updatedAt = excluded.updatedAt`,
|
||||||
|
).run(
|
||||||
|
association.id,
|
||||||
|
association.taskLineageId,
|
||||||
|
association.taskIdSnapshot,
|
||||||
|
association.commitSha,
|
||||||
|
association.commitSubject,
|
||||||
|
association.authoredAt,
|
||||||
|
association.matchedBy,
|
||||||
|
association.confidence,
|
||||||
|
association.note ?? null,
|
||||||
|
association.createdAt,
|
||||||
|
association.updatedAt,
|
||||||
|
);
|
||||||
|
return association;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTaskCommitAssociationsByLineageId(lineageId: string): Promise<TaskCommitAssociation[]> {
|
||||||
|
const rows = this.db.prepare(
|
||||||
|
`SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`,
|
||||||
|
).all(lineageId) as TaskCommitAssociationRow[];
|
||||||
|
return rows.map((row) => normalizeTaskCommitAssociation({ ...row, note: row.note ?? undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceLegacyTaskCommitAssociations(
|
||||||
|
lineageId: string,
|
||||||
|
associations: Array<Omit<TaskCommitAssociation, "id" | "createdAt" | "updatedAt" | "taskLineageId">>,
|
||||||
|
): Promise<void> {
|
||||||
|
const deleteStmt = this.db.prepare(
|
||||||
|
`DELETE FROM task_commit_associations WHERE taskLineageId = ? AND matchedBy IN ('legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')`,
|
||||||
|
);
|
||||||
|
deleteStmt.run(lineageId);
|
||||||
|
for (const association of associations) {
|
||||||
|
await this.upsertTaskCommitAssociation({ ...association, taskLineageId: lineageId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
|
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
47
packages/core/src/task-lineage.ts
Normal file
47
packages/core/src/task-lineage.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type {
|
||||||
|
TaskCommitAssociation,
|
||||||
|
TaskCommitAssociationConfidence,
|
||||||
|
TaskCommitAssociationMatchSource,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export const FUSION_TASK_LINEAGE_TRAILER_KEY = "Fusion-Task-Lineage";
|
||||||
|
|
||||||
|
export function generateTaskLineageId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTaskLineageTrailer(lineageId: string): string {
|
||||||
|
return `${FUSION_TASK_LINEAGE_TRAILER_KEY}: ${lineageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTaskLineageTrailer(message: string): string | undefined {
|
||||||
|
const lines = message.split(/\r?\n/);
|
||||||
|
const prefix = `${FUSION_TASK_LINEAGE_TRAILER_KEY}:`;
|
||||||
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||||
|
const line = lines[i]?.trim();
|
||||||
|
if (line?.startsWith(prefix)) {
|
||||||
|
const value = line.slice(prefix.length).trim();
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyTaskCommitAssociationConfidence(
|
||||||
|
matchedBy: TaskCommitAssociationMatchSource,
|
||||||
|
): TaskCommitAssociationConfidence {
|
||||||
|
if (matchedBy === "canonical-lineage-trailer") return "canonical";
|
||||||
|
if (matchedBy === "manual-reconciliation") return "ambiguous";
|
||||||
|
return "legacy";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTaskCommitAssociation(
|
||||||
|
row: TaskCommitAssociation,
|
||||||
|
): TaskCommitAssociation {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
note: row.note?.trim() || undefined,
|
||||||
|
confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1095,6 +1095,8 @@ export interface TaskBranchContext {
|
|||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Immutable lineage identity used for durable commit/task attribution. */
|
||||||
|
lineageId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
/**
|
||||||
@@ -1325,6 +1327,8 @@ export interface InboxTask {
|
|||||||
|
|
||||||
export interface TaskCreateInput {
|
export interface TaskCreateInput {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Optional lineage override for trusted replication/import paths only. */
|
||||||
|
lineageId?: string;
|
||||||
description: string;
|
description: string;
|
||||||
/** Configured merge target/base branch for this task (task intent).
|
/** Configured merge target/base branch for this task (task intent).
|
||||||
* Defaults to the project default branch when omitted. */
|
* Defaults to the project default branch when omitted. */
|
||||||
@@ -2587,6 +2591,28 @@ export interface MergeResult extends MergeDetails {
|
|||||||
_buildRetried?: boolean;
|
_buildRetried?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaskCommitAssociationMatchSource =
|
||||||
|
| "canonical-lineage-trailer"
|
||||||
|
| "legacy-task-id-trailer"
|
||||||
|
| "legacy-subject"
|
||||||
|
| "manual-reconciliation";
|
||||||
|
|
||||||
|
export type TaskCommitAssociationConfidence = "canonical" | "legacy" | "ambiguous";
|
||||||
|
|
||||||
|
export interface TaskCommitAssociation {
|
||||||
|
id: string;
|
||||||
|
taskLineageId: string;
|
||||||
|
taskIdSnapshot: string;
|
||||||
|
commitSha: string;
|
||||||
|
commitSubject: string;
|
||||||
|
authoredAt: string;
|
||||||
|
matchedBy: TaskCommitAssociationMatchSource;
|
||||||
|
confidence: TaskCommitAssociationConfidence;
|
||||||
|
note?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const COLUMN_LABELS: Record<Column, string> = {
|
export const COLUMN_LABELS: Record<Column, string> = {
|
||||||
triage: "Planning",
|
triage: "Planning",
|
||||||
todo: "Todo",
|
todo: "Todo",
|
||||||
@@ -2623,6 +2649,8 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
|
|||||||
*/
|
*/
|
||||||
export interface ArchivedTaskEntry {
|
export interface ArchivedTaskEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Immutable lineage identity preserved across archive/restore. */
|
||||||
|
lineageId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { hostname } from "node:os";
|
import { hostname } from "node:os";
|
||||||
import {
|
import {
|
||||||
|
buildTaskLineageTrailer,
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
normalizeMergeConflictStrategy,
|
normalizeMergeConflictStrategy,
|
||||||
resolveTaskMergeTarget,
|
resolveTaskMergeTarget,
|
||||||
@@ -3241,7 +3242,12 @@ export async function commitOrAmendMergeWithFixes(
|
|||||||
aiSummary,
|
aiSummary,
|
||||||
aiSubject,
|
aiSubject,
|
||||||
});
|
});
|
||||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
let lineageId: string | undefined;
|
||||||
|
if (store) {
|
||||||
|
const existingTask = await store.getTask(taskId);
|
||||||
|
lineageId = existingTask?.lineageId;
|
||||||
|
}
|
||||||
|
const trailerArg = buildTaskTrailerArgs(taskId, lineageId);
|
||||||
|
|
||||||
if (!headMoved) {
|
if (!headMoved) {
|
||||||
// No merge commit yet — create one fresh on top of preAttemptHeadSha.
|
// No merge commit yet — create one fresh on top of preAttemptHeadSha.
|
||||||
@@ -3252,6 +3258,20 @@ export async function commitOrAmendMergeWithFixes(
|
|||||||
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||||
{ cwd: rootDir },
|
{ cwd: rootDir },
|
||||||
);
|
);
|
||||||
|
if (store && lineageId) {
|
||||||
|
const sha = (await execAsync("git rev-parse HEAD", { cwd: rootDir })).stdout.trim();
|
||||||
|
const subject = (await execAsync("git log -1 --format=%s HEAD", { cwd: rootDir })).stdout.trim();
|
||||||
|
const authoredAt = (await execAsync("git log -1 --format=%aI HEAD", { cwd: rootDir })).stdout.trim();
|
||||||
|
await store.upsertTaskCommitAssociation({
|
||||||
|
taskLineageId: lineageId,
|
||||||
|
taskIdSnapshot: taskId,
|
||||||
|
commitSha: sha,
|
||||||
|
commitSubject: subject,
|
||||||
|
authoredAt,
|
||||||
|
matchedBy: "canonical-lineage-trailer",
|
||||||
|
confidence: "canonical",
|
||||||
|
});
|
||||||
|
}
|
||||||
mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`);
|
mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`);
|
||||||
return { ok: true, reason: "completed" };
|
return { ok: true, reason: "completed" };
|
||||||
}
|
}
|
||||||
@@ -3263,6 +3283,20 @@ export async function commitOrAmendMergeWithFixes(
|
|||||||
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||||
{ cwd: rootDir },
|
{ cwd: rootDir },
|
||||||
);
|
);
|
||||||
|
if (store && lineageId) {
|
||||||
|
const sha = (await execAsync("git rev-parse HEAD", { cwd: rootDir })).stdout.trim();
|
||||||
|
const subject = (await execAsync("git log -1 --format=%s HEAD", { cwd: rootDir })).stdout.trim();
|
||||||
|
const authoredAt = (await execAsync("git log -1 --format=%aI HEAD", { cwd: rootDir })).stdout.trim();
|
||||||
|
await store.upsertTaskCommitAssociation({
|
||||||
|
taskLineageId: lineageId,
|
||||||
|
taskIdSnapshot: taskId,
|
||||||
|
commitSha: sha,
|
||||||
|
commitSubject: subject,
|
||||||
|
authoredAt,
|
||||||
|
matchedBy: "canonical-lineage-trailer",
|
||||||
|
confidence: "canonical",
|
||||||
|
});
|
||||||
|
}
|
||||||
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
|
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
|
||||||
return { ok: true, reason: "completed" };
|
return { ok: true, reason: "completed" };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -3775,9 +3809,10 @@ export const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
|
|||||||
|
|
||||||
/** Build the `-m "Fusion-Task-Id: <id>"` arg fragment used in fallback commit
|
/** Build the `-m "Fusion-Task-Id: <id>"` arg fragment used in fallback commit
|
||||||
* invocations. Returns a leading space + quoted -m arg. */
|
* invocations. Returns a leading space + quoted -m arg. */
|
||||||
function buildTaskIdTrailerArg(taskId: string): string {
|
function buildTaskTrailerArgs(taskId: string, lineageId?: string): string {
|
||||||
// Task IDs are constrained ([A-Z]+-[0-9]+) so embedding directly is safe.
|
const taskIdTrailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`;
|
||||||
return ` -m "${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}"`;
|
const lineageArg = lineageId ? ` -m "${buildTaskLineageTrailer(lineageId)}"` : "";
|
||||||
|
return ` -m "${taskIdTrailer}"${lineageArg}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True iff HEAD's commit message contains the `Fusion-Task-Id: <taskId>`
|
/** True iff HEAD's commit message contains the `Fusion-Task-Id: <taskId>`
|
||||||
@@ -3807,27 +3842,28 @@ async function headCarriesTaskIdTrailer(rootDir: string, taskId: string): Promis
|
|||||||
* agent didn't include it (especially under includeTaskIdInCommit=false,
|
* agent didn't include it (especially under includeTaskIdInCommit=false,
|
||||||
* where the subject also lacks the task ID and recovery has nothing to
|
* where the subject also lacks the task ID and recovery has nothing to
|
||||||
* grep against). No-op if the trailer is already on HEAD. */
|
* grep against). No-op if the trailer is already on HEAD. */
|
||||||
async function ensureTaskIdTrailerOnHead(rootDir: string, taskId: string): Promise<void> {
|
async function ensureTaskTrailersOnHead(rootDir: string, task: Pick<Task, "id"> & { lineageId?: string }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { stdout: existingMessage } = await execAsync("git log -1 --pretty=%B", {
|
const { stdout: existingMessage } = await execAsync("git log -1 --pretty=%B", {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
const trailerLine = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`;
|
const taskIdTrailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${task.id}`;
|
||||||
if (existingMessage.includes(trailerLine)) return;
|
const trailersToAdd: string[] = [];
|
||||||
// git interpret-trailers is the canonical way to add trailers without
|
if (!existingMessage.includes(taskIdTrailer)) trailersToAdd.push(taskIdTrailer);
|
||||||
// disturbing the rest of the body. --if-exists addIfDifferentNeighbor
|
if (task.lineageId) {
|
||||||
// ensures we don't double-up if a slightly different trailer is present.
|
const lineageTrailer = buildTaskLineageTrailer(task.lineageId);
|
||||||
await execAsync(
|
if (!existingMessage.includes(lineageTrailer)) trailersToAdd.push(lineageTrailer);
|
||||||
`git -c trailer.ifExists=addIfDifferent commit --amend --no-edit --trailer "${trailerLine}"`,
|
}
|
||||||
{ cwd: rootDir },
|
if (trailersToAdd.length === 0) return;
|
||||||
);
|
let amendCommand = "git -c trailer.ifExists=addIfDifferent commit --amend --no-edit";
|
||||||
|
for (const trailer of trailersToAdd) {
|
||||||
|
amendCommand += ` --trailer "${trailer}"`;
|
||||||
|
}
|
||||||
|
await execAsync(amendCommand, { cwd: rootDir });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort: if amending fails (detached HEAD, sign-off conflict, etc.)
|
|
||||||
// we still recorded mergeDetails further on. Recovery will fall back to
|
|
||||||
// subject grep. Don't surface this as a merge failure.
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
mergerLog.warn(`${taskId}: failed to add ${FUSION_TASK_ID_TRAILER_KEY} trailer to HEAD (${msg}) — relying on subject grep for recovery`);
|
mergerLog.warn(`${task.id}: failed to add merge trailers to HEAD (${msg}) — relying on fallback ownership signals`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4741,6 +4777,7 @@ export async function aiMergeTask(
|
|||||||
runId: mergeRunId,
|
runId: mergeRunId,
|
||||||
agentId: "merger",
|
agentId: "merger",
|
||||||
taskId,
|
taskId,
|
||||||
|
taskLineageId: task.lineageId,
|
||||||
phase: "merge",
|
phase: "merge",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5916,6 +5953,20 @@ export async function aiMergeTask(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await store.updateTask(taskId, { mergeDetails });
|
await store.updateTask(taskId, { mergeDetails });
|
||||||
|
if (recordedSha) {
|
||||||
|
const currentTask = await store.getTask(taskId);
|
||||||
|
if (currentTask?.lineageId) {
|
||||||
|
await store.upsertTaskCommitAssociation({
|
||||||
|
taskLineageId: currentTask.lineageId,
|
||||||
|
taskIdSnapshot: currentTask.id,
|
||||||
|
commitSha: recordedSha,
|
||||||
|
commitSubject: aiMergeSummary || commitLog,
|
||||||
|
authoredAt: mergeDetails.mergedAt ?? new Date().toISOString(),
|
||||||
|
matchedBy: "canonical-lineage-trailer",
|
||||||
|
confidence: "canonical",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
mergerLog.log(`${taskId}: merge details stored (commitSha: ${recordedSha?.slice(0, 8) ?? "<deferred>"})`);
|
mergerLog.log(`${taskId}: merge details stored (commitSha: ${recordedSha?.slice(0, 8) ?? "<deferred>"})`);
|
||||||
|
|
||||||
// Surface the high-level outcome on the agent-log timeline so users can
|
// Surface the high-level outcome on the agent-log timeline so users can
|
||||||
@@ -6456,7 +6507,7 @@ async function executeMergeAttempt(
|
|||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
const authorArg = getCommitAuthorArg(settings);
|
const authorArg = getCommitAuthorArg(settings);
|
||||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
const trailerArg = buildTaskTrailerArgs(taskId);
|
||||||
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
||||||
taskId,
|
taskId,
|
||||||
branch,
|
branch,
|
||||||
@@ -6674,7 +6725,7 @@ async function executeMergeAttempt(
|
|||||||
aiSummary,
|
aiSummary,
|
||||||
aiSubject,
|
aiSubject,
|
||||||
});
|
});
|
||||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
const trailerArg = buildTaskTrailerArgs(taskId);
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
|
||||||
{ cwd: rootDir },
|
{ cwd: rootDir },
|
||||||
@@ -6802,7 +6853,7 @@ async function attemptWithSideStrategy(
|
|||||||
signal: params.options.signal,
|
signal: params.options.signal,
|
||||||
});
|
});
|
||||||
const authorArg = getCommitAuthorArg(settings);
|
const authorArg = getCommitAuthorArg(settings);
|
||||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
const trailerArg = buildTaskTrailerArgs(taskId);
|
||||||
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
|
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
|
||||||
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -7150,7 +7201,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
|||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
const authorArg = getCommitAuthorArg(settings);
|
const authorArg = getCommitAuthorArg(settings);
|
||||||
const trailerArg = buildTaskIdTrailerArg(taskId);
|
const trailerArg = buildTaskTrailerArgs(taskId);
|
||||||
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
|
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
|
||||||
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -7171,11 +7222,9 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
|||||||
throw new Error(`Agent did not commit and did not report build failure for ${taskId}`);
|
throw new Error(`Agent did not commit and did not report build failure for ${taskId}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// The agent committed. Idempotently ensure the Fusion-Task-Id trailer
|
// The agent committed. Idempotently ensure canonical task trailers are
|
||||||
// is present on HEAD — recovery (findLandedTaskCommit) relies on it
|
// present on HEAD for durable lineage attribution and fallback recovery.
|
||||||
// when includeTaskIdInCommit=false, since the subject won't carry the
|
await ensureTaskTrailersOnHead(rootDir, task);
|
||||||
// task ID and subject grep would miss the commit.
|
|
||||||
await ensureTaskIdTrailerOnHead(rootDir, taskId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface EngineRunContext {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
/** Task ID being operated on (if applicable). */
|
/** Task ID being operated on (if applicable). */
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
|
/** Immutable task lineage ID for durable cross-history correlation. */
|
||||||
|
taskLineageId?: string;
|
||||||
/** Execution phase for disambiguating sub-operations (e.g., "heartbeat", "execute", "merge-attempt-1"). */
|
/** Execution phase for disambiguating sub-operations (e.g., "heartbeat", "execute", "merge-attempt-1"). */
|
||||||
phase?: string;
|
phase?: string;
|
||||||
/** Invocation source for heartbeat runs (e.g., "timer", "on_demand", "assignment"). */
|
/** Invocation source for heartbeat runs (e.g., "timer", "on_demand", "assignment"). */
|
||||||
@@ -193,6 +195,7 @@ export function createRunAuditor(store: TaskStore, context: EngineRunContext | n
|
|||||||
metadata: {
|
metadata: {
|
||||||
phase: context.phase,
|
phase: context.phase,
|
||||||
...(context.source ? { source: context.source } : {}),
|
...(context.source ? { source: context.source } : {}),
|
||||||
|
...(context.taskLineageId ? { taskLineageId: context.taskLineageId } : {}),
|
||||||
...input.metadata,
|
...input.metadata,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -217,6 +220,7 @@ export function createRunAuditor(store: TaskStore, context: EngineRunContext | n
|
|||||||
metadata: {
|
metadata: {
|
||||||
phase: context.phase,
|
phase: context.phase,
|
||||||
...(context.source ? { source: context.source } : {}),
|
...(context.source ? { source: context.source } : {}),
|
||||||
|
...(context.taskLineageId ? { taskLineageId: context.taskLineageId } : {}),
|
||||||
...input.metadata,
|
...input.metadata,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -234,6 +238,7 @@ export function createRunAuditor(store: TaskStore, context: EngineRunContext | n
|
|||||||
metadata: {
|
metadata: {
|
||||||
phase: context.phase,
|
phase: context.phase,
|
||||||
...(context.source ? { source: context.source } : {}),
|
...(context.source ? { source: context.source } : {}),
|
||||||
|
...(context.taskLineageId ? { taskLineageId: context.taskLineageId } : {}),
|
||||||
...input.metadata,
|
...input.metadata,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id";
|
|||||||
|
|
||||||
interface AlreadyMergedLookupInput {
|
interface AlreadyMergedLookupInput {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
lineageId?: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
taskBranch?: string;
|
taskBranch?: string;
|
||||||
@@ -148,7 +149,10 @@ interface AlreadyMergedLookupResult {
|
|||||||
strategy: AlreadyMergedDetectionStrategy;
|
strategy: AlreadyMergedDetectionStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
|
function commitOwnedByTask(taskId: string, lineageId: string | undefined, subject: string, body: string): boolean {
|
||||||
|
if (lineageId && body.includes(`Fusion-Task-Lineage: ${lineageId}`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return body.includes(`Fusion-Task-Id: ${taskId}`) || subject.includes(taskId);
|
return body.includes(`Fusion-Task-Id: ${taskId}`) || subject.includes(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,9 +510,9 @@ export class SelfHealingManager {
|
|||||||
// Search strategies, tried in order of reliability:
|
// Search strategies, tried in order of reliability:
|
||||||
// 1. mergeDetails.commitSha — already stored by the merger; verify it's
|
// 1. mergeDetails.commitSha — already stored by the merger; verify it's
|
||||||
// reachable from HEAD before trusting it.
|
// reachable from HEAD before trusting it.
|
||||||
// 2. Fusion-Task-Id trailer — emitted into every Fusion-managed merge
|
// 2. Fusion-Task-Lineage trailer — canonical immutable lineage marker.
|
||||||
// commit body; survives `includeTaskIdInCommit: false`.
|
// 3. Fusion-Task-Id trailer — legacy human task-id marker.
|
||||||
// 3. Subject grep — legacy/AI commits where the task ID lives in the
|
// 4. Subject grep — legacy/AI commits where the task ID lives in the
|
||||||
// subject line (e.g. `feat(FN-123): …`).
|
// subject line (e.g. `feat(FN-123): …`).
|
||||||
//
|
//
|
||||||
// (1) gives us the right sha even if the commit subject is exotic; (2)
|
// (1) gives us the right sha even if the commit subject is exotic; (2)
|
||||||
@@ -528,7 +532,7 @@ export class SelfHealingManager {
|
|||||||
{ cwd: this.options.rootDir, maxBuffer: 1024 * 1024 },
|
{ cwd: this.options.rootDir, maxBuffer: 1024 * 1024 },
|
||||||
);
|
);
|
||||||
const [sha, subject = "", body = ""] = stdout.trim().split("\x1f");
|
const [sha, subject = "", body = ""] = stdout.trim().split("\x1f");
|
||||||
if (sha && commitOwnedByTask(task.id, subject, body)) {
|
if (sha && commitOwnedByTask(task.id, task.lineageId, subject, body)) {
|
||||||
const commit: LandedTaskCommit = { sha, subject };
|
const commit: LandedTaskCommit = { sha, subject };
|
||||||
try {
|
try {
|
||||||
const stats = await execAsync(`git show --shortstat --format= ${shellQuote(sha)}`, {
|
const stats = await execAsync(`git show --shortstat --format= ${shellQuote(sha)}`, {
|
||||||
@@ -561,8 +565,8 @@ export class SelfHealingManager {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Search (2) trailer first, then (3) subject as fallback. Both share the
|
// Search canonical lineage trailer, then legacy task-id trailer, then
|
||||||
// same range-resolution logic (bounded, then full HEAD if empty).
|
// legacy subject fallback. All share bounded/full HEAD range resolution.
|
||||||
const search = async (grepArg: string, fixedStrings: boolean): Promise<string> => {
|
const search = async (grepArg: string, fixedStrings: boolean): Promise<string> => {
|
||||||
let out: string;
|
let out: string;
|
||||||
try {
|
try {
|
||||||
@@ -590,11 +594,20 @@ export class SelfHealingManager {
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
// (2) Trailer — anchored regex so we don't false-match ID substrings.
|
// (2) Canonical lineage trailer.
|
||||||
const trailerPattern = `^Fusion-Task-Id: ${task.id}$`;
|
let stdout = "";
|
||||||
let stdout = await search(shellQuote(trailerPattern), false);
|
if (task.lineageId) {
|
||||||
|
const lineagePattern = `^Fusion-Task-Lineage: ${task.lineageId}$`;
|
||||||
|
stdout = await search(shellQuote(lineagePattern), false);
|
||||||
|
}
|
||||||
|
|
||||||
// (3) Subject grep fallback (legacy commits).
|
// (3) Legacy task-id trailer.
|
||||||
|
if (!stdout.trim()) {
|
||||||
|
const trailerPattern = `^Fusion-Task-Id: ${task.id}$`;
|
||||||
|
stdout = await search(shellQuote(trailerPattern), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (4) Subject grep fallback (legacy commits).
|
||||||
if (!stdout.trim()) {
|
if (!stdout.trim()) {
|
||||||
stdout = await search(shellQuote(task.id), true);
|
stdout = await search(shellQuote(task.id), true);
|
||||||
}
|
}
|
||||||
@@ -626,9 +639,30 @@ export class SelfHealingManager {
|
|||||||
private async findAlreadyMergedTaskCommit(
|
private async findAlreadyMergedTaskCommit(
|
||||||
input: AlreadyMergedLookupInput,
|
input: AlreadyMergedLookupInput,
|
||||||
): Promise<AlreadyMergedLookupResult | null> {
|
): Promise<AlreadyMergedLookupResult | null> {
|
||||||
const { taskId, repoDir, baseBranch, taskBranch, baseCommitSha } = input;
|
const { taskId, lineageId, repoDir, baseBranch, taskBranch, baseCommitSha } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (lineageId) {
|
||||||
|
const lineagePattern = `^Fusion-Task-Lineage: ${lineageId}$`;
|
||||||
|
const lineageCommand = [
|
||||||
|
"git log",
|
||||||
|
`--grep=${shellQuote(lineagePattern)}`,
|
||||||
|
"-E",
|
||||||
|
"--max-count=1",
|
||||||
|
"--format=%H",
|
||||||
|
shellQuote(baseBranch),
|
||||||
|
].join(" ");
|
||||||
|
const lineage = await execAsync(lineageCommand, {
|
||||||
|
cwd: repoDir,
|
||||||
|
timeout: 30_000,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
const lineageSha = lineage.stdout.trim();
|
||||||
|
if (lineageSha) {
|
||||||
|
return { sha: lineageSha, strategy: "trailer" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const trailerPattern = `^Fusion-Task-Id: ${taskId}$`;
|
const trailerPattern = `^Fusion-Task-Id: ${taskId}$`;
|
||||||
const trailerCommand = [
|
const trailerCommand = [
|
||||||
"git log",
|
"git log",
|
||||||
@@ -1854,6 +1888,7 @@ export class SelfHealingManager {
|
|||||||
|
|
||||||
const landed = await this.findAlreadyMergedTaskCommit({
|
const landed = await this.findAlreadyMergedTaskCommit({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
|
lineageId: task.lineageId,
|
||||||
repoDir: this.options.rootDir,
|
repoDir: this.options.rootDir,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
taskBranch: task.branch,
|
taskBranch: task.branch,
|
||||||
|
|||||||
@@ -744,7 +744,7 @@ describe("RoadmapStore", () => {
|
|||||||
|
|
||||||
describe("schema version", () => {
|
describe("schema version", () => {
|
||||||
it("schema version is 40 after init", () => {
|
it("schema version is 40 after init", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(71);
|
expect(db.getSchemaVersion()).toBe(72);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user