From 866d01f5326d2355569875ec3a3ce9ec4da649ac Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 15:23:28 -0700 Subject: [PATCH] FN-5959: bridge task goal provenance from mission links Bridge mission-linked task provenance into goal diagnostics. - derive task goal ids and goal records from the linked mission hierarchy with a task mission fallback - record provenanceGoalIds in goal-injection diagnostics, run-audit metadata, and executor task logs - add regression coverage and mission/diagnostics docs for derived task-to-goal provenance Files changed: docs/diagnostics.md | 5 +- docs/missions.md | 19 ++++ packages/core/src/__tests__/mission-store.test.ts | 123 +++++++++++++++++++++ packages/core/src/mission-store.ts | 55 ++++++++- packages/engine/src/__tests__/goal-injection-diagnostics-wiring.test.ts | 35 +++++- packages/engine/src/goal-injection-diagnostics.ts | 21 +++- 6 files changed, 247 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-5959 Fusion-Task-Lineage: 7c76ae98-37b1-421c-b477-a0147d034a4c --- docs/diagnostics.md | 5 +- docs/missions.md | 19 +++ .../core/src/__tests__/mission-store.test.ts | 123 ++++++++++++++++++ packages/core/src/mission-store.ts | 55 +++++++- .../goal-injection-diagnostics-wiring.test.ts | 35 ++++- .../engine/src/goal-injection-diagnostics.ts | 21 ++- 6 files changed, 247 insertions(+), 11 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index f8ae327790..ab0ca6b047 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -4,12 +4,13 @@ Executor, heartbeat, and planning runs emit one goal-injection diagnostic with outcome `applied`, `no-goals`, or `disabled-or-failed`. -- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`. +- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, provenanceGoalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`. - Goal anchoring events also persist `metadata.goalIds` (alongside existing count/tool fields): - `goal:injection-applied` / `goal:injection-skipped` → `{ lane, count, goalIds, truncated?, reason? }` - `goal:retrieval-invoked` → `{ toolName, count, goalIds, notFound }` - Run cited-goals read path: `GET /api/agents/:id/runs/:runId/cited-goals` returns `{ runId, taskId?, injectedGoalIds, retrievedGoalIds, citedGoalIds }` aggregated from `goal:*` + `prompt:goal-injection` run-audit events. -- Task log (executor lane with `taskId`): `[goal-injection] count= ids= truncated= ...`. +- Task log (executor lane with `taskId`): `[goal-injection] count= ids= provenance= truncated= ...`. +- `goalIds` / `goalCount` describe the active goals injected into the prompt; `provenanceGoalIds` additively records mission-derived task provenance and does not affect prompt selection. - Guardrail: diagnostics persist goal IDs/counts only; never prompt text, goal titles, or goal descriptions. ## Insight run sweeper (`[insight-sweeper]`) diff --git a/docs/missions.md b/docs/missions.md index cf137f9852..5ca0da17f7 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -52,6 +52,25 @@ Mission ↔ goal links are created and removed deliberately as part of normal pl Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association. +### Task → Goal provenance + +When a mission feature is linked or triaged into a task, Fusion does **not** copy goal ids onto the task row. Instead, task goal provenance is always derived from the mission link owned by `MissionStore`: + +- `listGoalIdsForTask(taskId)` resolves the owning mission from the linked feature hierarchy first (`feature -> slice -> milestone -> mission`), then falls back to the live task row's `missionId` when needed. +- `listGoalsForTask(taskId)` maps those ids back to full `Goal` records using the same goals-table read path as `getMissionWithHierarchy`, so mission reads and task provenance stay in sync. +- Unknown, unlinked, or partially missing hierarchy state resolves fail-soft to `[]`. +- Archived goals remain part of provenance; only missing goal rows are dropped. + +This derived bridge lets downstream systems recover which strategic goals a task serves without duplicating mission-goal linkage during task creation. + +### Goal-injection diagnostics provenance field + +The engine's `resolveAndEmitGoalContext` seam still injects only the always-on active-goal context into prompts, but diagnostics now add `provenanceGoalIds: string[]` alongside the existing injected `goalIds` / `goalCount` fields. + +- `goalIds` / `goalCount` continue to describe the active goals injected into the prompt. +- `provenanceGoalIds` records which mission-linked goals the task serves. +- Diagnostics and run-audit metadata persist ids/counts only — never goal titles, descriptions, or prompt text. + ## Creating Missions ### Mission base branch defaults diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 2790b84fb6..fd6302efb8 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1940,6 +1940,129 @@ describe("MissionStore", () => { }); }); + describe("task goal provenance", () => { + async function createStoreWithTaskStore() { + const { TaskStore } = await import("../store.js"); + const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true }); + return { ts, ms: ts.getMissionStore(), goals: ts.getGoalStore() }; + } + + it("returns empty arrays for unknown and unlinked tasks", async () => { + const { ts, ms } = await createStoreWithTaskStore(); + const task = await ts.createTask({ title: "Standalone task", description: "No mission link" }); + + expect(ms.listGoalIdsForTask("FN-DOES-NOT-EXIST")).toEqual([]); + expect(ms.listGoalsForTask("FN-DOES-NOT-EXIST")).toEqual([]); + expect(ms.listGoalIdsForTask(task.id)).toEqual([]); + expect(ms.listGoalsForTask(task.id)).toEqual([]); + }); + + it("returns an empty array for mission-linked tasks when the mission has no goals", async () => { + const { ts, ms } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([]); + expect(ms.listGoalsForTask(task.id)).toEqual([]); + }); + + it("preserves stable ordering for multiple linked goals and matches hierarchy mapping", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const goalA = goals.createGoal({ title: "Goal A" }); + const goalB = goals.createGoal({ title: "Goal B" }); + + ms.linkGoal(mission.id, goalA.id); + ms.linkGoal(mission.id, goalB.id); + + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goalA.id, goalB.id]); + expect(ms.listGoalsForTask(task.id)).toEqual(ms.getMissionWithHierarchy(mission.id)?.linkedGoals ?? []); + }); + + it("keeps archived linked goals in task provenance", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const goal = goals.createGoal({ title: "Archived goal" }); + ms.linkGoal(mission.id, goal.id); + const archivedGoal = goals.archiveGoal(goal.id); + + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]); + expect(ms.listGoalsForTask(task.id)).toEqual([archivedGoal]); + }); + + it("falls back through feature linkage when tasks.missionId is unset", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const goal = goals.createGoal({ title: "Fallback goal" }); + ms.linkGoal(mission.id, goal.id); + + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + ms.linkFeatureToTask(feature.id, task.id); + db.prepare("UPDATE tasks SET missionId = NULL WHERE id = ?").run(task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]); + expect(ms.listGoalsForTask(task.id)).toEqual([goal]); + }); + + it("resolves provenance for triaged tasks without storing goal ids on the task row", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const goal = goals.createGoal({ title: "Goal title" }); + const mission = ms.createMission({ title: "Mission" }); + ms.linkGoal(mission.id, goal.id); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature", description: "Desc" }); + + const triaged = await ms.triageFeature(feature.id); + const task = await ts.getTask(triaged.taskId!); + + expect(ms.listGoalsForTask(triaged.taskId!)).toEqual([ + expect.objectContaining({ id: goal.id, title: goal.title }), + ]); + expect(task?.missionId).toBe(mission.id); + expect(task).not.toHaveProperty("goalId"); + expect(task).not.toHaveProperty("goalIds"); + }); + + it("resolves provenance identically for manual feature linkage", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const goal = goals.createGoal({ title: "Manual goal" }); + const mission = ms.createMission({ title: "Mission" }); + ms.linkGoal(mission.id, goal.id); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const task = await ts.createTask({ title: "Manual task", description: "Manual" }); + + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]); + expect(ms.listGoalsForTask(task.id)).toEqual([ + expect.objectContaining({ id: goal.id, title: goal.title }), + ]); + }); + }); + // ── Transaction Tests ──────────────────────────────────────────────── describe("Transaction Handling", () => { diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 70c70ee66b..5e0b716c05 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -487,6 +487,15 @@ export class MissionStore extends EventEmitter { }; } + private listGoalsByIds(goalIds: string[]): Goal[] { + return goalIds + .map((goalId) => this.db + .prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?") + .get(goalId) as GoalRow | undefined) + .filter((row): row is GoalRow => Boolean(row)) + .map((row) => this.rowToGoal(row)); + } + /** * Convert a database row to a MissionContractAssertion object. */ @@ -703,12 +712,7 @@ export class MissionStore extends EventEmitter { const mission = this.getMission(id); if (!mission) return undefined; - const linkedGoals = this.listGoalIdsForMission(id) - .map((goalId) => this.db - .prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?") - .get(goalId) as GoalRow | undefined) - .filter((row): row is GoalRow => Boolean(row)) - .map((row) => this.rowToGoal(row)); + const linkedGoals = this.listGoalsByIds(this.listGoalIdsForMission(id)); const milestones = this.listMilestones(id); const milestonesWithSlices = milestones.map((milestone) => { @@ -1416,6 +1420,45 @@ export class MissionStore extends EventEmitter { return rows.map((row) => row.missionId); } + /** + * Resolve task → goal provenance by deriving the owning mission from mission linkage. + * Goal IDs are never duplicated onto the task row; provenance is always recovered from mission links. + */ + listGoalIdsForTask(taskId: string): string[] { + const feature = this.getFeatureByTaskId(taskId); + const missionIdFromFeature = feature + ? (() => { + const slice = this.getSlice(feature.sliceId); + if (!slice) { + return undefined; + } + const milestone = this.getMilestone(slice.milestoneId); + return milestone?.missionId; + })() + : undefined; + + const missionId = missionIdFromFeature ?? (() => { + const row = this.db + .prepare('SELECT missionId FROM tasks WHERE id = ? AND "deletedAt" IS NULL') + .get(taskId) as { missionId?: string | null } | undefined; + return row?.missionId ?? undefined; + })(); + + if (!missionId) { + return []; + } + + return this.listGoalIdsForMission(missionId); + } + + /** + * Resolve task → goal provenance to full Goal records derived from the owning mission. + * Goal rows are read on demand so archived goals remain visible without storing duplicate task-level goal data. + */ + listGoalsForTask(taskId: string): Goal[] { + return this.listGoalsByIds(this.listGoalIdsForTask(taskId)); + } + // ── Milestone Operations ─────────────────────────────────────────── /** diff --git a/packages/engine/src/__tests__/goal-injection-diagnostics-wiring.test.ts b/packages/engine/src/__tests__/goal-injection-diagnostics-wiring.test.ts index 8d26916d15..72524ca029 100644 --- a/packages/engine/src/__tests__/goal-injection-diagnostics-wiring.test.ts +++ b/packages/engine/src/__tests__/goal-injection-diagnostics-wiring.test.ts @@ -69,6 +69,7 @@ describe("goal injection diagnostics wiring seam", () => { for (const lane of lanes) { const store = { getGoalStore: () => ({ listGoals: () => [goal("G-1", "one", "2026-01-01T00:00:00.000Z")] }), + getMissionStore: () => ({ listGoalIdsForTask: () => ["G-PROV-1", "G-PROV-2"] }), logEntry: vi.fn().mockResolvedValue(undefined), recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), } as any; @@ -86,12 +87,17 @@ describe("goal injection diagnostics wiring seam", () => { expect(audit.database).toHaveBeenCalledTimes(1); expect(audit.database.mock.calls[0][0].metadata.lane).toBe(lane); expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1); - expect(store.recordRunAuditEvent.mock.calls[0][0].metadata.lane).toBe(lane); + expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ + lane, + provenanceGoalIds: ["G-PROV-1", "G-PROV-2"], + }); + expect(store.logEntry.mock.calls[0][1]).toContain('provenance=["G-PROV-1","G-PROV-2"]'); } }); it("resolveAndEmitGoalContext handles missing getGoalStore with disabled classification", async () => { const store = { + getMissionStore: () => ({ listGoalIdsForTask: () => [] }), logEntry: vi.fn().mockResolvedValue(undefined), recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), } as any; @@ -109,6 +115,7 @@ describe("goal injection diagnostics wiring seam", () => { expect(resolution.classification).toMatchObject({ outcome: "disabled-or-failed", reason: "store-unavailable" }); expect(audit.database).toHaveBeenCalledTimes(1); expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1); + expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ provenanceGoalIds: [] }); }); it("emits applied audit metadata for positive injection", async () => { const goals = [goal("G-1", "one", "2026-01-01T00:00:00.000Z"), goal("G-2", "two", "2026-01-02T00:00:00.000Z")]; @@ -151,6 +158,32 @@ describe("goal injection diagnostics wiring seam", () => { expect(recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ outcome: "no-goals", goalCount: 0, goalIds: [] }); }); + it("fails soft when provenance resolution throws", async () => { + const store = { + getGoalStore: () => ({ listGoals: () => [goal("G-1", "one", "2026-01-01T00:00:00.000Z")] }), + getMissionStore: () => ({ + listGoalIdsForTask: () => { + throw new Error("boom"); + }, + }), + logEntry: vi.fn().mockResolvedValue(undefined), + recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), + } as any; + const audit = { database: vi.fn().mockResolvedValue(undefined) } as any; + + await expect(resolveAndEmitGoalContext({ + lane: "executor", + store, + audit, + taskId: "FN-1", + runContext: { runId: "exec-run", agentId: "agent-1", taskId: "FN-1", phase: "execute" }, + })).resolves.toMatchObject({ + classification: { outcome: "applied", goalIds: ["G-1"] }, + }); + + expect(store.recordRunAuditEvent.mock.calls[0][0].metadata).toMatchObject({ provenanceGoalIds: [] }); + }); + it("classifies list failure and keeps prompt construction alive", async () => { const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined); const store = { logEntry: vi.fn().mockResolvedValue(undefined), recordRunAuditEvent } as any; diff --git a/packages/engine/src/goal-injection-diagnostics.ts b/packages/engine/src/goal-injection-diagnostics.ts index 340b312379..bd00fe969f 100644 --- a/packages/engine/src/goal-injection-diagnostics.ts +++ b/packages/engine/src/goal-injection-diagnostics.ts @@ -22,6 +22,7 @@ export interface GoalInjectionDiagnostic { outcome: GoalInjectionOutcome; goalCount: number; goalIds: string[]; + provenanceGoalIds: string[]; truncated: boolean; reason?: GoalInjectionDisabledReason; errorClass?: string; @@ -31,7 +32,8 @@ export interface GoalInjectionDiagnostic { timestamp: string; } -export interface GoalInjectionDiagnosticInput extends Omit { +export interface GoalInjectionDiagnosticInput extends Omit { + provenanceGoalIds?: string[]; store?: TaskStore; runContext?: EngineRunContext | null; } @@ -140,6 +142,17 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext : undefined, }); + let provenanceGoalIds: string[] = []; + if (input.taskId && typeof input.store.getMissionStore === "function") { + try { + provenanceGoalIds = input.store.getMissionStore().listGoalIdsForTask(input.taskId); + } catch (error) { + diagnosticsLog.warn( + `failed to resolve goal provenance for task ${input.taskId} in ${input.lane}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + await emitGoalAnchoringAudit(input.audit, { lane: input.lane, taskId: input.taskId, @@ -152,6 +165,7 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext await emitGoalInjectionDiagnostic({ lane: input.lane, ...resolution.classification, + provenanceGoalIds, runId: input.runContext?.runId, agentId: input.runContext?.agentId, taskId: input.taskId, @@ -164,9 +178,10 @@ export async function resolveAndEmitGoalContext(input: ResolveAndEmitGoalContext function formatAgentLogLine(input: GoalInjectionDiagnostic): string { const ids = JSON.stringify(input.goalIds); + const provenanceIds = JSON.stringify(input.provenanceGoalIds); const reason = input.reason ? ` reason=${input.reason}` : ""; const errorClass = input.errorClass ? ` err=${input.errorClass}` : ""; - return `[goal-injection] ${input.outcome} count=${input.goalCount} ids=${ids} truncated=${String(input.truncated)}${reason}${errorClass}`; + return `[goal-injection] ${input.outcome} count=${input.goalCount} ids=${ids} provenance=${provenanceIds} truncated=${String(input.truncated)}${reason}${errorClass}`; } /** @@ -191,6 +206,7 @@ export async function emitGoalInjectionDiagnostic( outcome: input.outcome, goalCount: input.goalCount, goalIds: [...input.goalIds], + provenanceGoalIds: [...(input.provenanceGoalIds ?? [])], truncated: input.truncated, ...(input.reason ? { reason: input.reason } : {}), ...(input.errorClass ? { errorClass: input.errorClass } : {}), @@ -232,6 +248,7 @@ export async function emitGoalInjectionDiagnostic( outcome: record.outcome, goalCount: record.goalCount, goalIds: record.goalIds, + provenanceGoalIds: record.provenanceGoalIds, truncated: record.truncated, ...(record.reason ? { reason: record.reason } : {}), ...(record.errorClass ? { errorClass: record.errorClass } : {}),