feat(FN-3990): add task lineage identity and association storage
Adds task lineage storage infrastructure (FN-3990 Step 2), introducing a dedicated `task-lineage.ts` module and related types to track task identity and association relationships, with corresponding database schema and store support plus tests and documentation for the lineage reconciliation process Fusion-Task-Id: FN-3990
This commit is contained in:
11
docs/task-lineage-reconciliation.md
Normal file
11
docs/task-lineage-reconciliation.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Task Lineage Reconciliation Notes
|
||||
|
||||
## FN-3953 historical mismatch (GitHub-tracking vs current task)
|
||||
|
||||
- Historical commit subject evidence:
|
||||
- `6871c510a feat(FN-3953): enable tracking issue creation on task edit and document the...`
|
||||
- Current unrelated FN-3953 evidence:
|
||||
- `f6a1862f9 feat(FN-3953): wire agent provisioning approval policy into engine tools`
|
||||
- Reconciled GitHub-tracking task lineage: `FN-3874`, `FN-3940`, `FN-3943`
|
||||
- Summary: raw task-ID references in historical commits can map to different board meanings over time; therefore historical attribution must use immutable lineage IDs plus persisted association records rather than display task ID alone.
|
||||
- Confidence: high
|
||||
@@ -200,7 +200,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
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 }>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
expect(db.getSchemaVersion()).toBe(72);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
expect(db.getSchemaVersion()).toBe(72);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
28
packages/core/src/__tests__/task-lineage.test.ts
Normal file
28
packages/core/src/__tests__/task-lineage.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FUSION_TASK_LINEAGE_TRAILER_KEY,
|
||||
buildTaskLineageTrailer,
|
||||
classifyTaskCommitAssociationConfidence,
|
||||
generateTaskLineageId,
|
||||
parseTaskLineageTrailer,
|
||||
} from "../task-lineage.js";
|
||||
|
||||
describe("task-lineage", () => {
|
||||
it("generates UUID lineage ids", () => {
|
||||
const id = generateTaskLineageId();
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it("round-trips canonical trailer", () => {
|
||||
const lineageId = generateTaskLineageId();
|
||||
const trailer = buildTaskLineageTrailer(lineageId);
|
||||
expect(trailer).toBe(`${FUSION_TASK_LINEAGE_TRAILER_KEY}: ${lineageId}`);
|
||||
expect(parseTaskLineageTrailer(`subject\n\n${trailer}\n`)).toBe(lineageId);
|
||||
});
|
||||
|
||||
it("classifies match confidence", () => {
|
||||
expect(classifyTaskCommitAssociationConfidence("canonical-lineage-trailer")).toBe("canonical");
|
||||
expect(classifyTaskCommitAssociationConfidence("legacy-task-id-trailer")).toBe("legacy");
|
||||
expect(classifyTaskCommitAssociationConfidence("manual-reconciliation")).toBe("ambiguous");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { DatabaseSync } from "./sqlite-adapter.js";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { 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)");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ────────────────────────
|
||||
|
||||
}
|
||||
|
||||
47
packages/core/src/task-lineage.ts
Normal file
47
packages/core/src/task-lineage.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
TaskCommitAssociation,
|
||||
TaskCommitAssociationConfidence,
|
||||
TaskCommitAssociationMatchSource,
|
||||
} from "./types.js";
|
||||
|
||||
export const FUSION_TASK_LINEAGE_TRAILER_KEY = "Fusion-Task-Lineage";
|
||||
|
||||
export function generateTaskLineageId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export function buildTaskLineageTrailer(lineageId: string): string {
|
||||
return `${FUSION_TASK_LINEAGE_TRAILER_KEY}: ${lineageId}`;
|
||||
}
|
||||
|
||||
export function parseTaskLineageTrailer(message: string): string | undefined {
|
||||
const lines = message.split(/\r?\n/);
|
||||
const prefix = `${FUSION_TASK_LINEAGE_TRAILER_KEY}:`;
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
const line = lines[i]?.trim();
|
||||
if (line?.startsWith(prefix)) {
|
||||
const value = line.slice(prefix.length).trim();
|
||||
if (value) return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function classifyTaskCommitAssociationConfidence(
|
||||
matchedBy: TaskCommitAssociationMatchSource,
|
||||
): TaskCommitAssociationConfidence {
|
||||
if (matchedBy === "canonical-lineage-trailer") return "canonical";
|
||||
if (matchedBy === "manual-reconciliation") return "ambiguous";
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
export function normalizeTaskCommitAssociation(
|
||||
row: TaskCommitAssociation,
|
||||
): TaskCommitAssociation {
|
||||
return {
|
||||
...row,
|
||||
note: row.note?.trim() || undefined,
|
||||
confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy),
|
||||
};
|
||||
}
|
||||
@@ -1095,6 +1095,8 @@ export interface TaskBranchContext {
|
||||
|
||||
export interface Task {
|
||||
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;
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user