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
This commit is contained in:
gsxdsm
2026-06-03 15:23:28 -07:00
parent 1853459def
commit 866d01f532
6 changed files with 247 additions and 11 deletions

View File

@@ -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] <outcome> count=<n> ids=<json-array> truncated=<bool> ...`.
- Task log (executor lane with `taskId`): `[goal-injection] <outcome> count=<n> ids=<json-array> provenance=<json-array> truncated=<bool> ...`.
- `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]`)

View File

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

View File

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

View File

@@ -487,6 +487,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};
}
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<MissionStoreEvents> {
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<MissionStoreEvents> {
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 ───────────────────────────────────────────
/**

View File

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

View File

@@ -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<GoalInjectionDiagnostic, "timestamp"> {
export interface GoalInjectionDiagnosticInput extends Omit<GoalInjectionDiagnostic, "timestamp" | "provenanceGoalIds"> {
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 } : {}),