feat(FN-686): persist mission linkage on linked tasks

- Store missionId alongside sliceId when MissionStore links a feature to a task
- Clear both linkage fields together when features are unlinked from tasks
- Preserve mission linkage fields through TaskStore reads, writes, and updates
- Expand mission and task store coverage for mission linkage persistence and cleanup
This commit is contained in:
gsxdsm
2026-04-01 14:42:06 -07:00
parent 4e297104b3
commit d1f4eff9db
7 changed files with 167 additions and 24 deletions

View File

@@ -70,7 +70,7 @@ Mission ("Build Auth System")
- **Slice** — Parallel work areas within a milestone (e.g., "Backend Implementation", "Frontend Components") - **Slice** — Parallel work areas within a milestone (e.g., "Backend Implementation", "Frontend Components")
- **Feature** — Individual deliverables linked to kb tasks (e.g., "Login Form", "JWT Middleware") - **Feature** — Individual deliverables linked to kb tasks (e.g., "Login Form", "JWT Middleware")
Status flows automatically: when features are linked to tasks and completed, slice status updates. When all slices in a milestone are complete, the milestone becomes complete. When all milestones are done, the mission is complete. Status flows automatically: when features are linked to tasks and completed, slice status updates. Linked tasks persist both `missionId` and `sliceId` so mission progress can be observed through normal task reads. When all slices in a milestone are complete, the milestone becomes complete. When all milestones are done, the mission is complete.
**CLI Commands:** **CLI Commands:**
```bash ```bash

View File

@@ -185,6 +185,47 @@ describe("MissionStore integration with TaskStore", () => {
expect(updatedMission?.status).toBe("active"); expect(updatedMission?.status).toBe("active");
}); });
it("persists missionId and sliceId when linking a feature to a task", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
column: "todo",
});
missionStore.linkFeatureToTask(feature.id, task.id);
const reloaded = await taskStore.getTask(task.id);
expect(reloaded.missionId).toBe(mission.id);
expect(reloaded.sliceId).toBe(slice.id);
});
it("clears missionId and sliceId when unlinking a feature from a task", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
column: "todo",
});
missionStore.linkFeatureToTask(feature.id, task.id);
missionStore.unlinkFeatureFromTask(feature.id);
const reloaded = await taskStore.getTask(task.id);
expect(reloaded.missionId).toBeUndefined();
expect(reloaded.sliceId).toBeUndefined();
});
it("cascades mission deletion across milestones, slices, and features", async () => { it("cascades mission deletion across milestones, slices, and features", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore); const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const milestoneIds = milestones.map((milestone) => milestone.id); const milestoneIds = milestones.map((milestone) => milestone.id);

View File

@@ -494,7 +494,7 @@ describe("MissionStore", () => {
expect(retrieved).toBeUndefined(); expect(retrieved).toBeUndefined();
}); });
it("links a feature to a task", () => { it("links a feature to a task and persists missionId/sliceId on the task row", () => {
createTaskInDb(db, "FN-001"); createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" }); const mission = store.createMission({ title: "Mission" });
@@ -503,9 +503,15 @@ describe("MissionStore", () => {
const feature = store.addFeature(slice.id, { title: "Linkable" }); const feature = store.addFeature(slice.id, { title: "Linkable" });
const linked = store.linkFeatureToTask(feature.id, "FN-001"); const linked = store.linkFeatureToTask(feature.id, "FN-001");
const taskRow = db.prepare("SELECT missionId, sliceId FROM tasks WHERE id = ?").get("FN-001") as {
missionId: string | null;
sliceId: string | null;
};
expect(linked.taskId).toBe("FN-001"); expect(linked.taskId).toBe("FN-001");
expect(linked.status).toBe("triaged"); expect(linked.status).toBe("triaged");
expect(taskRow.missionId).toBe(mission.id);
expect(taskRow.sliceId).toBe(slice.id);
}); });
it("emits feature:linked event", () => { it("emits feature:linked event", () => {
@@ -524,7 +530,7 @@ describe("MissionStore", () => {
expect(handler).toHaveBeenCalledWith({ feature: linked, taskId: "FN-001" }); expect(handler).toHaveBeenCalledWith({ feature: linked, taskId: "FN-001" });
}); });
it("unlinks a feature from a task", () => { it("unlinks a feature from a task and clears missionId/sliceId on the task row", () => {
createTaskInDb(db, "FN-001"); createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" }); const mission = store.createMission({ title: "Mission" });
@@ -534,9 +540,15 @@ describe("MissionStore", () => {
store.linkFeatureToTask(feature.id, "FN-001"); store.linkFeatureToTask(feature.id, "FN-001");
const unlinked = store.unlinkFeatureFromTask(feature.id); const unlinked = store.unlinkFeatureFromTask(feature.id);
const taskRow = db.prepare("SELECT missionId, sliceId FROM tasks WHERE id = ?").get("FN-001") as {
missionId: string | null;
sliceId: string | null;
};
expect(unlinked.taskId).toBeUndefined(); expect(unlinked.taskId).toBeUndefined();
expect(unlinked.status).toBe("defined"); expect(unlinked.status).toBe("defined");
expect(taskRow.missionId).toBeNull();
expect(taskRow.sliceId).toBeNull();
}); });
it("finds feature by task id", () => { it("finds feature by task id", () => {

View File

@@ -904,6 +904,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
this.recomputeSliceStatus(sliceId); this.recomputeSliceStatus(sliceId);
} }
/**
* Resolve the mission hierarchy for a slice.
*
* @param sliceId - Slice ID
* @returns The slice, milestone, and mission IDs for the hierarchy
* @throws Error if the hierarchy is incomplete
*/
private resolveTaskLinkage(sliceId: string): { sliceId: string; missionId: string } {
const slice = this.getSlice(sliceId);
if (!slice) {
throw new Error(`Slice ${sliceId} not found`);
}
const milestone = this.getMilestone(slice.milestoneId);
if (!milestone) {
throw new Error(`Milestone ${slice.milestoneId} not found for slice ${sliceId}`);
}
const mission = this.getMission(milestone.missionId);
if (!mission) {
throw new Error(`Mission ${milestone.missionId} not found for slice ${sliceId}`);
}
return {
sliceId: slice.id,
missionId: mission.id,
};
}
/** /**
* Link a feature to a task. * Link a feature to a task.
* Updates the feature's taskId and emits feature:linked event. * Updates the feature's taskId and emits feature:linked event.
@@ -919,15 +948,22 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
throw new Error(`Feature ${featureId} not found`); throw new Error(`Feature ${featureId} not found`);
} }
const updated = this.updateFeature(featureId, { const linkage = this.resolveTaskLinkage(feature.sliceId);
taskId,
status: "triaged",
});
// Also update the task's sliceId for bidirectional linking const updated = this.db.transaction(() => {
this.db.prepare(` const featureUpdate = this.updateFeature(featureId, {
UPDATE tasks SET sliceId = ? WHERE id = ? taskId,
`).run(feature.sliceId, taskId); status: "triaged",
});
// Also update the task's mission/slice linkage for bidirectional linking.
this.db.prepare(`
UPDATE tasks SET missionId = ?, sliceId = ? WHERE id = ?
`).run(linkage.missionId, linkage.sliceId, taskId);
this.db.bumpLastModified();
return featureUpdate;
});
this.emit("feature:linked", { feature: updated, taskId }); this.emit("feature:linked", { feature: updated, taskId });
@@ -954,17 +990,22 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
// Get the taskId before clearing it // Get the taskId before clearing it
const { taskId } = feature; const { taskId } = feature;
const updated = this.updateFeature(featureId, { const updated = this.db.transaction(() => {
taskId: undefined, const featureUpdate = this.updateFeature(featureId, {
status: "defined", taskId: undefined,
}); status: "defined",
});
// Clear the task's sliceId // Clear the task's mission/slice linkage together.
if (taskId) { if (taskId) {
this.db.prepare(` this.db.prepare(`
UPDATE tasks SET sliceId = NULL WHERE id = ? UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ?
`).run(taskId); `).run(taskId);
} this.db.bumpLastModified();
}
return featureUpdate;
});
// Recompute slice status // Recompute slice status
this.recomputeSliceStatus(updated.sliceId); this.recomputeSliceStatus(updated.sliceId);

View File

@@ -1173,6 +1173,41 @@ describe("TaskStore", () => {
expect(updated.validatorModelId).toBe("gpt-4o"); expect(updated.validatorModelId).toBe("gpt-4o");
expect(updated.title).toBe("Updated title"); expect(updated.title).toBe("Updated title");
}); });
it("sets and clears mission linkage fields via updateTask", async () => {
const task = await createTestTask();
const linked = await store.updateTask(task.id, {
missionId: "M-123",
sliceId: "SL-456",
});
expect(linked.missionId).toBe("M-123");
expect(linked.sliceId).toBe("SL-456");
const reloaded = await store.getTask(task.id);
expect(reloaded.missionId).toBe("M-123");
expect(reloaded.sliceId).toBe("SL-456");
const cleared = await store.updateTask(task.id, {
missionId: null,
sliceId: null,
});
expect(cleared.missionId).toBeUndefined();
expect(cleared.sliceId).toBeUndefined();
});
it("preserves mission linkage when updating unrelated fields", async () => {
const task = await createTestTask();
await store.updateTask(task.id, {
missionId: "M-789",
sliceId: "SL-789",
});
const updated = await store.updateTask(task.id, { title: "Linked task" });
expect(updated.title).toBe("Linked task");
expect(updated.missionId).toBe("M-789");
expect(updated.sliceId).toBe("SL-789");
});
}); });
describe("updateTask — PROMPT.md regeneration", () => { describe("updateTask — PROMPT.md regeneration", () => {

View File

@@ -196,6 +196,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined, breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(), enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
modifiedFiles: (() => { const m = fromJson<string[]>(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(), modifiedFiles: (() => { const m = fromJson<string[]>(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(),
missionId: row.missionId || undefined,
sliceId: row.sliceId || undefined, sliceId: row.sliceId || undefined,
}; };
} }
@@ -212,10 +213,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt, summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments, dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails, comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, sliceId breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
`).run( `).run(
task.id, task.id,
@@ -256,6 +257,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.breakIntoSubtasks ? 1 : 0, task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []), toJson(task.enabledWorkflowSteps || []),
toJson(task.modifiedFiles || []), toJson(task.modifiedFiles || []),
task.missionId ?? null,
task.sliceId ?? null, task.sliceId ?? null,
); );
this.db.bumpLastModified(); this.db.bumpLastModified();
@@ -918,7 +920,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask( async updateTask(
id: string, id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null }, updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
): Promise<Task> { ): Promise<Task> {
return this.withTaskLock(id, async () => { return this.withTaskLock(id, async () => {
// Validate that task doesn't depend on itself // Validate that task doesn't depend on itself
@@ -1012,6 +1014,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.modifiedFiles !== undefined) { } else if (updates.modifiedFiles !== undefined) {
task.modifiedFiles = updates.modifiedFiles; task.modifiedFiles = updates.modifiedFiles;
} }
if (updates.missionId === null) {
task.missionId = undefined;
} else if (updates.missionId !== undefined) {
task.missionId = updates.missionId;
}
if (updates.sliceId === null) {
task.sliceId = undefined;
} else if (updates.sliceId !== undefined) {
task.sliceId = updates.sliceId;
}
task.updatedAt = new Date().toISOString(); task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task); await this.atomicWriteTaskJson(dir, task);

View File

@@ -428,6 +428,8 @@ export interface Task {
summary?: string; summary?: string;
/** Files modified during agent execution, captured at task completion time */ /** Files modified during agent execution, captured at task completion time */
modifiedFiles?: string[]; modifiedFiles?: string[];
/** Optional ID of the mission this task is linked to (derived from its linked slice hierarchy) */
missionId?: string;
/** Optional ID of the slice this task is linked to (for mission-based work) */ /** Optional ID of the slice this task is linked to (for mission-based work) */
sliceId?: string; sliceId?: string;
/** ISO-8601 timestamp of when the task last entered its current column. /** ISO-8601 timestamp of when the task last entered its current column.