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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user