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:
Fusion
2026-05-11 02:50:38 -07:00
committed by gsxdsm
parent 4b5e289817
commit 3f85d5a5f4
16 changed files with 417 additions and 69 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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");
});
});

View File

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

View File

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

View File

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

View 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),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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