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")
- **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:**
```bash

View File

@@ -185,6 +185,47 @@ describe("MissionStore integration with TaskStore", () => {
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 () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const milestoneIds = milestones.map((milestone) => milestone.id);

View File

@@ -494,7 +494,7 @@ describe("MissionStore", () => {
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");
const mission = store.createMission({ title: "Mission" });
@@ -503,9 +503,15 @@ describe("MissionStore", () => {
const feature = store.addFeature(slice.id, { title: "Linkable" });
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.status).toBe("triaged");
expect(taskRow.missionId).toBe(mission.id);
expect(taskRow.sliceId).toBe(slice.id);
});
it("emits feature:linked event", () => {
@@ -524,7 +530,7 @@ describe("MissionStore", () => {
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");
const mission = store.createMission({ title: "Mission" });
@@ -534,9 +540,15 @@ describe("MissionStore", () => {
store.linkFeatureToTask(feature.id, "FN-001");
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.status).toBe("defined");
expect(taskRow.missionId).toBeNull();
expect(taskRow.sliceId).toBeNull();
});
it("finds feature by task id", () => {

View File

@@ -904,6 +904,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
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.
* 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`);
}
const updated = this.updateFeature(featureId, {
taskId,
status: "triaged",
});
const linkage = this.resolveTaskLinkage(feature.sliceId);
// Also update the task's sliceId for bidirectional linking
this.db.prepare(`
UPDATE tasks SET sliceId = ? WHERE id = ?
`).run(feature.sliceId, taskId);
const updated = this.db.transaction(() => {
const featureUpdate = this.updateFeature(featureId, {
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 });
@@ -954,17 +990,22 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
// Get the taskId before clearing it
const { taskId } = feature;
const updated = this.updateFeature(featureId, {
taskId: undefined,
status: "defined",
});
const updated = this.db.transaction(() => {
const featureUpdate = this.updateFeature(featureId, {
taskId: undefined,
status: "defined",
});
// Clear the task's sliceId
if (taskId) {
this.db.prepare(`
UPDATE tasks SET sliceId = NULL WHERE id = ?
`).run(taskId);
}
// Clear the task's mission/slice linkage together.
if (taskId) {
this.db.prepare(`
UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ?
`).run(taskId);
this.db.bumpLastModified();
}
return featureUpdate;
});
// Recompute slice status
this.recomputeSliceStatus(updated.sliceId);

View File

@@ -1173,6 +1173,41 @@ describe("TaskStore", () => {
expect(updated.validatorModelId).toBe("gpt-4o");
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", () => {

View File

@@ -196,6 +196,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
breakIntoSubtasks: row.breakIntoSubtasks ? true : 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; })(),
missionId: row.missionId || undefined,
sliceId: row.sliceId || undefined,
};
}
@@ -212,10 +213,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, sliceId
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`).run(
task.id,
@@ -256,6 +257,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
toJson(task.modifiedFiles || []),
task.missionId ?? null,
task.sliceId ?? null,
);
this.db.bumpLastModified();
@@ -918,7 +920,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
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> {
return this.withTaskLock(id, async () => {
// Validate that task doesn't depend on itself
@@ -1012,6 +1014,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.modifiedFiles !== undefined) {
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();
await this.atomicWriteTaskJson(dir, task);

View File

@@ -428,6 +428,8 @@ export interface Task {
summary?: string;
/** Files modified during agent execution, captured at task completion time */
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) */
sliceId?: string;
/** ISO-8601 timestamp of when the task last entered its current column.