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", () => {
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
});
it("seeds lastModified", () => {
const ts = db.getLastModified();
@@ -222,7 +222,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1021,7 +1021,7 @@ describe("schema migrations", () => {
db.init();
// 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
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);
db.init();
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
db.close();
});
@@ -1085,7 +1085,7 @@ describe("schema migrations", () => {
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 }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1126,7 +1126,7 @@ describe("schema migrations", () => {
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 colNames = cols.map((col) => col.name);
@@ -1195,7 +1195,7 @@ describe("schema migrations", () => {
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 colNames = cols.map((col) => col.name);
@@ -1435,7 +1435,7 @@ describe("schema migrations", () => {
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 }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1509,7 +1509,7 @@ describe("schema migrations", () => {
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 }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1533,7 +1533,7 @@ describe("schema migrations", () => {
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 }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1637,7 +1637,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2106,7 +2106,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2239,7 +2239,7 @@ describe("migration v67 drops orphan project auth tables", () => {
const migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(71);
expect(migrated.getSchemaVersion()).toBe(72);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2253,7 +2253,7 @@ describe("migration v67 drops orphan project auth tables", () => {
const fusion = join(temp, ".fusion");
const fresh = new Database(fusion);
fresh.init();
expect(fresh.getSchemaVersion()).toBe(71);
expect(fresh.getSchemaVersion()).toBe(72);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.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)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(71);
expect(db1.getSchemaVersion()).toBe(72);
db1.close();
// 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");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(71);
expect(db3.getSchemaVersion()).toBe(72);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(71);
expect(db1.getSchemaVersion()).toBe(72);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(71);
expect(db2.getSchemaVersion()).toBe(72);
db2.close();
} finally {
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
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(71);
expect(db1.getSchemaVersion()).toBe(72);
// Step 2: Strip lifecycle and cancelledAt columns by recreating 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)", () => {
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", () => {

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
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_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
const index = db
.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 { mkdirSync, existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import type { PluginOnSchemaInit } from "./plugin-types.js";
import type { SteeringComment, TaskComment } from "./types.js";
@@ -88,7 +89,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 71;
const SCHEMA_VERSION = 72;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -152,6 +153,7 @@ const SCHEMA_SQL = `
-- Tasks table with JSON columns for nested data
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
lineageId TEXT,
title TEXT,
description TEXT NOT NULL,
priority TEXT DEFAULT 'normal',
@@ -317,6 +319,23 @@ CREATE TABLE IF NOT EXISTS archivedTasks (
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
CREATE TABLE IF NOT EXISTS automations (
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 { AGENT_VALID_TRANSITIONS } 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-task-replication.js";
export * from "./shared-mesh-state.js";
@@ -80,6 +85,14 @@ export type {
AgentProvisioningPolicyDecision,
} from "./agent-provisioning-policy.js";
export { TaskStore } from "./store.js";
export {
FUSION_TASK_LINEAGE_TRAILER_KEY,
buildTaskLineageTrailer,
classifyTaskCommitAssociationConfidence,
generateTaskLineageId,
normalizeTaskCommitAssociation,
parseTaskLineageTrailer,
} from "./task-lineage.js";
export {
createDistributedTaskIdAllocator,
formatDistributedTaskId,

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, 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 { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { normalizeTaskPriority } from "./task-priority.js";
@@ -27,6 +27,7 @@ import { createLogger } from "./logger.js";
import { validateNodeOverrideChange } from "./node-override-guard.js";
import { sanitizeTitle } from "./ai-summarize.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 {
buildBootstrapPrompt,
@@ -38,6 +39,7 @@ import type { MeshReplicatedTaskApplyResult, MeshReplicatedTaskCreatePayload } f
/** Database row shape for the tasks table (all columns). */
interface TaskRow {
id: string;
lineageId: string | null;
title: string | null;
description: string;
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 {
id: string;
taskId: string;
@@ -750,6 +766,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private rowToTask(row: TaskRow): Task {
return {
id: row.id,
lineageId: row.lineageId || generateTaskLineageId(),
title: row.title || undefined,
description: row.description,
priority: normalizeTaskPriority(row.priority),
@@ -894,6 +911,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private archiveEntryToTask(entry: ArchivedTaskEntry, slim = false): Task {
return {
id: entry.id,
lineageId: entry.lineageId || generateTaskLineageId(),
title: entry.title,
description: entry.description,
priority: normalizeTaskPriority(entry.priority),
@@ -1019,6 +1037,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return {
id: task.id,
lineageId: task.lineageId || generateTaskLineageId(),
title: task.title,
description: task.description,
priority: normalizeTaskPriority(task.priority),
@@ -1105,7 +1124,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const prefix = tableAlias ? `${tableAlias}.` : "";
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",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
@@ -1154,7 +1173,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
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",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
@@ -1199,7 +1218,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private upsertTask(task: Task): void {
this.db.prepare(`
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,
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, nextRecoveryAt, error,
@@ -1211,9 +1230,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
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
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
lineageId = excluded.lineageId,
title = excluded.title,
description = excluded.description,
priority = excluded.priority,
@@ -1304,6 +1324,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
checkoutLeaseEpoch = excluded.checkoutLeaseEpoch
`).run(
task.id,
task.lineageId ?? generateTaskLineageId(),
task.title ?? null,
task.description,
normalizeTaskPriority(task.priority),
@@ -2509,6 +2530,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const now = options?.createdAt ?? new Date().toISOString();
const task: Task = {
id,
lineageId: input.lineageId ?? generateTaskLineageId(),
title,
description: input.description,
priority: normalizeTaskPriority(input.priority),
@@ -2586,6 +2608,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Create new task with copied title/description, but fresh state
const newTask: Task = {
id: newId,
lineageId: generateTaskLineageId(),
title: sourceTask.title,
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
priority: normalizeTaskPriority(sourceTask.priority),
@@ -2666,6 +2689,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Create new refinement task
const newTask: Task = {
id: newId,
lineageId: generateTaskLineageId(),
title: `Refinement: ${sourceLabel}`,
description: `${feedback.trim()}\n\nRefines: ${id}`,
priority: normalizeTaskPriority(sourceTask.priority),
@@ -6418,6 +6442,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Build restored task (clear transient fields)
const restoredTask: Task = {
id: entry.id,
lineageId: entry.lineageId || generateTaskLineageId(),
title: entry.title,
description: entry.description,
priority: normalizeTaskPriority(entry.priority),
@@ -7328,6 +7353,63 @@ ${notificationsSection}`;
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) ────────────────────────
}

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 {
id: string;
/** Immutable lineage identity used for durable commit/task attribution. */
lineageId: string;
title?: string;
description: string;
/**
@@ -1325,6 +1327,8 @@ export interface InboxTask {
export interface TaskCreateInput {
title?: string;
/** Optional lineage override for trusted replication/import paths only. */
lineageId?: string;
description: string;
/** Configured merge target/base branch for this task (task intent).
* Defaults to the project default branch when omitted. */
@@ -2587,6 +2591,28 @@ export interface MergeResult extends MergeDetails {
_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> = {
triage: "Planning",
todo: "Todo",
@@ -2623,6 +2649,8 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
*/
export interface ArchivedTaskEntry {
id: string;
/** Immutable lineage identity preserved across archive/restore. */
lineageId: string;
title?: string;
description: string;
/**

View File

@@ -32,6 +32,7 @@ import { createHash } from "node:crypto";
import { join } from "node:path";
import { hostname } from "node:os";
import {
buildTaskLineageTrailer,
getTaskMergeBlocker,
normalizeMergeConflictStrategy,
resolveTaskMergeTarget,
@@ -3241,7 +3242,12 @@ export async function commitOrAmendMergeWithFixes(
aiSummary,
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) {
// 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}`,
{ 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)`);
return { ok: true, reason: "completed" };
}
@@ -3263,6 +3283,20 @@ export async function commitOrAmendMergeWithFixes(
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ 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)`);
return { ok: true, reason: "completed" };
} 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
* invocations. Returns a leading space + quoted -m arg. */
function buildTaskIdTrailerArg(taskId: string): string {
// Task IDs are constrained ([A-Z]+-[0-9]+) so embedding directly is safe.
return ` -m "${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}"`;
function buildTaskTrailerArgs(taskId: string, lineageId?: string): string {
const taskIdTrailer = `${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>`
@@ -3807,27 +3842,28 @@ async function headCarriesTaskIdTrailer(rootDir: string, taskId: string): Promis
* agent didn't include it (especially under includeTaskIdInCommit=false,
* where the subject also lacks the task ID and recovery has nothing to
* 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 {
const { stdout: existingMessage } = await execAsync("git log -1 --pretty=%B", {
cwd: rootDir,
encoding: "utf-8",
});
const trailerLine = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`;
if (existingMessage.includes(trailerLine)) return;
// git interpret-trailers is the canonical way to add trailers without
// disturbing the rest of the body. --if-exists addIfDifferentNeighbor
// ensures we don't double-up if a slightly different trailer is present.
await execAsync(
`git -c trailer.ifExists=addIfDifferent commit --amend --no-edit --trailer "${trailerLine}"`,
{ cwd: rootDir },
);
const taskIdTrailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${task.id}`;
const trailersToAdd: string[] = [];
if (!existingMessage.includes(taskIdTrailer)) trailersToAdd.push(taskIdTrailer);
if (task.lineageId) {
const lineageTrailer = buildTaskLineageTrailer(task.lineageId);
if (!existingMessage.includes(lineageTrailer)) trailersToAdd.push(lineageTrailer);
}
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) {
// 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);
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,
agentId: "merger",
taskId,
taskLineageId: task.lineageId,
phase: "merge",
};
@@ -5916,6 +5953,20 @@ export async function aiMergeTask(
};
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>"})`);
// Surface the high-level outcome on the agent-log timeline so users can
@@ -6456,7 +6507,7 @@ async function executeMergeAttempt(
signal: options.signal,
});
const authorArg = getCommitAuthorArg(settings);
const trailerArg = buildTaskIdTrailerArg(taskId);
const trailerArg = buildTaskTrailerArgs(taskId);
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId,
branch,
@@ -6674,7 +6725,7 @@ async function executeMergeAttempt(
aiSummary,
aiSubject,
});
const trailerArg = buildTaskIdTrailerArg(taskId);
const trailerArg = buildTaskTrailerArgs(taskId);
await execAsync(
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir },
@@ -6802,7 +6853,7 @@ async function attemptWithSideStrategy(
signal: params.options.signal,
});
const authorArg = getCommitAuthorArg(settings);
const trailerArg = buildTaskIdTrailerArg(taskId);
const trailerArg = buildTaskTrailerArgs(taskId);
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId,
@@ -7150,7 +7201,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
signal: options.signal,
});
const authorArg = getCommitAuthorArg(settings);
const trailerArg = buildTaskIdTrailerArg(taskId);
const trailerArg = buildTaskTrailerArgs(taskId);
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
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}`);
}
} else {
// The agent committed. Idempotently ensure the Fusion-Task-Id trailer
// is present on HEAD — recovery (findLandedTaskCommit) relies on it
// when includeTaskIdInCommit=false, since the subject won't carry the
// task ID and subject grep would miss the commit.
await ensureTaskIdTrailerOnHead(rootDir, taskId);
// The agent committed. Idempotently ensure canonical task trailers are
// present on HEAD for durable lineage attribution and fallback recovery.
await ensureTaskTrailersOnHead(rootDir, task);
}
return { success: true };

View File

@@ -52,6 +52,8 @@ export interface EngineRunContext {
agentId: string;
/** Task ID being operated on (if applicable). */
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"). */
phase?: string;
/** Invocation source for heartbeat runs (e.g., "timer", "on_demand", "assignment"). */
@@ -193,6 +195,7 @@ export function createRunAuditor(store: TaskStore, context: EngineRunContext | n
metadata: {
phase: context.phase,
...(context.source ? { source: context.source } : {}),
...(context.taskLineageId ? { taskLineageId: context.taskLineageId } : {}),
...input.metadata,
},
};
@@ -217,6 +220,7 @@ export function createRunAuditor(store: TaskStore, context: EngineRunContext | n
metadata: {
phase: context.phase,
...(context.source ? { source: context.source } : {}),
...(context.taskLineageId ? { taskLineageId: context.taskLineageId } : {}),
...input.metadata,
},
};
@@ -234,6 +238,7 @@ export function createRunAuditor(store: TaskStore, context: EngineRunContext | n
metadata: {
phase: context.phase,
...(context.source ? { source: context.source } : {}),
...(context.taskLineageId ? { taskLineageId: context.taskLineageId } : {}),
...input.metadata,
},
};

View File

@@ -137,6 +137,7 @@ type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id";
interface AlreadyMergedLookupInput {
taskId: string;
lineageId?: string;
repoDir: string;
baseBranch: string;
taskBranch?: string;
@@ -148,7 +149,10 @@ interface AlreadyMergedLookupResult {
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);
}
@@ -506,9 +510,9 @@ export class SelfHealingManager {
// Search strategies, tried in order of reliability:
// 1. mergeDetails.commitSha — already stored by the merger; verify it's
// reachable from HEAD before trusting it.
// 2. Fusion-Task-Id trailer — emitted into every Fusion-managed merge
// commit body; survives `includeTaskIdInCommit: false`.
// 3. Subject grep — legacy/AI commits where the task ID lives in the
// 2. Fusion-Task-Lineage trailer — canonical immutable lineage marker.
// 3. Fusion-Task-Id trailer — legacy human task-id marker.
// 4. Subject grep — legacy/AI commits where the task ID lives in the
// subject line (e.g. `feat(FN-123): …`).
//
// (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 },
);
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 };
try {
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
// same range-resolution logic (bounded, then full HEAD if empty).
// Search canonical lineage trailer, then legacy task-id trailer, then
// legacy subject fallback. All share bounded/full HEAD range resolution.
const search = async (grepArg: string, fixedStrings: boolean): Promise<string> => {
let out: string;
try {
@@ -590,11 +594,20 @@ export class SelfHealingManager {
return out;
};
// (2) Trailer — anchored regex so we don't false-match ID substrings.
const trailerPattern = `^Fusion-Task-Id: ${task.id}$`;
let stdout = await search(shellQuote(trailerPattern), false);
// (2) Canonical lineage trailer.
let stdout = "";
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()) {
stdout = await search(shellQuote(task.id), true);
}
@@ -626,9 +639,30 @@ export class SelfHealingManager {
private async findAlreadyMergedTaskCommit(
input: AlreadyMergedLookupInput,
): Promise<AlreadyMergedLookupResult | null> {
const { taskId, repoDir, baseBranch, taskBranch, baseCommitSha } = input;
const { taskId, lineageId, repoDir, baseBranch, taskBranch, baseCommitSha } = input;
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 trailerCommand = [
"git log",
@@ -1854,6 +1888,7 @@ export class SelfHealingManager {
const landed = await this.findAlreadyMergedTaskCommit({
taskId: task.id,
lineageId: task.lineageId,
repoDir: this.options.rootDir,
baseBranch,
taskBranch: task.branch,

View File

@@ -744,7 +744,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(71);
expect(db.getSchemaVersion()).toBe(72);
});
});