Fix mission task status sync

This commit is contained in:
gsxdsm
2026-04-13 17:14:06 -07:00
parent 0d0e2e6dbe
commit 755b8b2520
3 changed files with 348 additions and 72 deletions

View File

@@ -38,14 +38,28 @@ export async function reconcileMissionFeatureState(
return { kind: "noop" };
}
if (task.column === "archived") {
if (feature.status !== "done") {
return {
kind: "update",
status: "done",
reason: `task ${task.id} was archived after completion`,
};
}
return { kind: "noop" };
}
if (
task.column === "in-progress"
(task.column === "in-progress" || task.column === "in-review")
&& (feature.status === "triaged" || feature.status === "defined")
) {
return {
kind: "update",
status: "in-progress",
reason: `task ${task.id} started`,
reason: task.column === "in-review"
? `task ${task.id} is in review`
: `task ${task.id} started`,
};
}

View File

@@ -124,6 +124,14 @@ describe("Scheduler", () => {
getMissionWithHierarchy: vi.fn(),
findNextPendingSlice: vi.fn(),
activateSlice: vi.fn(),
listFeatures: vi.fn().mockReturnValue([]),
linkFeatureToTask: vi.fn((featureId: string, taskId: string) => ({
id: featureId,
taskId,
sliceId: "SL-001",
title: "Linked feature",
status: "triaged",
})),
...overrides,
};
}
@@ -1148,7 +1156,13 @@ describe("Scheduler", () => {
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "in-progress" }),
});
const store = createMockStore();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "in-progress",
sliceId: "SL-001",
})),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event by calling the registered handler
@@ -1157,9 +1171,9 @@ describe("Scheduler", () => {
expect(movedHandler).toBeDefined();
// Simulate task moving to in-progress with sliceId
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
const task = createMockTask({ id: "FN-001", column: "in-progress", sliceId: "SL-001" });
movedHandler({ task, to: "in-progress" });
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
@@ -1171,15 +1185,21 @@ describe("Scheduler", () => {
updateFeatureStatus: vi.fn(),
});
const store = createMockStore();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "in-progress",
sliceId: "SL-001",
})),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
const task = createMockTask({ id: "FN-001", column: "in-progress", sliceId: "SL-001" });
movedHandler({ task, to: "in-progress" });
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
@@ -1207,7 +1227,13 @@ describe("Scheduler", () => {
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
});
const store = createMockStore();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "done",
sliceId: "SL-001",
})),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any;
@@ -1225,7 +1251,13 @@ describe("Scheduler", () => {
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: false }),
});
const store = createMockStore();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "done",
sliceId: "SL-001",
})),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any;
@@ -1355,15 +1387,21 @@ describe("Scheduler", () => {
updateFeatureStatus: vi.fn(),
});
const store = createMockStore();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
column: "done",
sliceId: "SL-001",
})),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
const task = createMockTask({ id: "FN-001", column: "done", sliceId: "SL-001" });
movedHandler({ task, from: "in-progress", to: "done" });
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(mockMissionStore.getSlice).not.toHaveBeenCalled();
@@ -1769,8 +1807,8 @@ describe("Scheduler", () => {
missionAutopilot: mockAutopilot as any,
});
// Simulate task:moved event: task with sliceId moves to "done"
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
// Simulate task:moved event: task moves to "done"
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
// Feature status should be updated to "done"
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
@@ -1778,6 +1816,72 @@ describe("Scheduler", () => {
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
});
it("marks a linked feature in-progress when a task reaches in-review without task slice metadata", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-1702",
column: "in-review",
sliceId: undefined,
})),
});
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({
id: "F-1702",
sliceId: "SL-001",
status: "triaged",
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
await (scheduler as any).handleMissionTaskMove("FN-1702", "in-review");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "in-progress");
});
it("links a one-way mission task to a matching unlinked feature before marking it done", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-1702",
title: "First-run onboarding trigger",
column: "done",
missionId: "M-001",
sliceId: "SL-001",
} as Partial<Task>)),
});
const matchedFeature = {
id: "F-1702",
sliceId: "SL-001",
title: "First-run onboarding trigger",
status: "triaged",
};
const linkedFeature = {
...matchedFeature,
taskId: "FN-1702",
};
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn()
.mockReturnValueOnce(undefined)
.mockReturnValue(linkedFeature),
listFeatures: vi.fn().mockReturnValue([matchedFeature]),
linkFeatureToTask: vi.fn().mockReturnValue(linkedFeature),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
await (scheduler as any).handleMissionTaskMove("FN-1702", "done");
expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-1702", "FN-1702");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "done");
});
it("falls back to onSliceComplete when no autopilot", async () => {
const store = createMockStore();
const completeSlice = {
@@ -1823,7 +1927,7 @@ describe("Scheduler", () => {
missionStore: mockMissionStore as any,
});
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
// Feature status should be updated
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
@@ -1863,7 +1967,7 @@ describe("Scheduler", () => {
missionAutopilot: mockAutopilot as any,
});
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
// Delegates to autopilot, which internally checks autoAdvance
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
@@ -1899,7 +2003,7 @@ describe("Scheduler", () => {
missionAutopilot: mockAutopilot as any,
});
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(mockAutopilot.handleTaskCompletion).not.toHaveBeenCalled();
@@ -1972,6 +2076,43 @@ describe("Scheduler", () => {
expect(result).toBe(1);
});
it("updates feature to in-progress when task is in-review and feature is triaged", async () => {
const store = createMockStore({
getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-1702", column: "in-review" })),
});
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([
{ id: "M-001", status: "active" },
]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{
id: "MS-001",
slices: [{
id: "SL-001",
status: "active",
features: [{
id: "F-1702",
taskId: "FN-1702",
status: "triaged",
}],
}],
}],
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "in-progress");
expect(result).toBe(1);
});
it("updates feature to done when task is done and feature is not done", async () => {
const store = createMockStore({
getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "done" })),
@@ -2172,6 +2313,60 @@ describe("Scheduler", () => {
expect(result).toBe(0);
});
it("repairs one-way mission task links by exact feature title during reconciliation", async () => {
const matchedTask = createMockTask({
id: "FN-1702",
title: "First-run onboarding trigger",
column: "done",
missionId: "M-001",
sliceId: "SL-001",
} as Partial<Task>);
const matchedFeature = {
id: "F-1702",
sliceId: "SL-001",
title: "First-run onboarding trigger",
taskId: undefined,
status: "triaged",
};
const linkedFeature = {
...matchedFeature,
taskId: "FN-1702",
};
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([matchedTask]),
getTask: vi.fn(),
});
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([
{ id: "M-001", status: "active" },
]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{
id: "MS-001",
slices: [{
id: "SL-001",
status: "active",
features: [matchedFeature],
}],
}],
}),
linkFeatureToTask: vi.fn().mockReturnValue(linkedFeature),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-1702", "FN-1702");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-1702", "done");
expect(result).toBe(2);
});
it("skips features without taskId", async () => {
const store = createMockStore({
getTask: vi.fn(),

View File

@@ -1,4 +1,4 @@
import { getCurrentRepo, resolveDependencyOrder, type TaskStore, type Task, type MissionStore, type PrInfo } from "@fusion/core";
import { getCurrentRepo, resolveDependencyOrder, type TaskStore, type Task, type MissionStore, type MissionFeature, type PrInfo } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@@ -116,7 +116,7 @@ export class Scheduler {
/**
* Async listener guard convention:
* - Any async mission helper invoked from event listeners is wrapped in internal try/catch
* (`handleMissionTaskStart` / `handleMissionTaskCompletion`).
* (`handleMissionTaskMove` / `handleMissionTaskCompletion`).
* - Fire-and-forget Promise chains in listeners terminate with `.catch(...)`.
* Keep this invariant when adding new async EventEmitter callbacks.
*/
@@ -198,14 +198,10 @@ export class Scheduler {
}
}
// Mission progress tracking: when task with sliceId moves to in-progress
if (task.sliceId && this.options.missionStore && to === "in-progress") {
void this.handleMissionTaskStart(task.id, task.sliceId);
}
// Mission progress tracking: when task with sliceId moves to done
if (task.sliceId && this.options.missionStore && to === "done") {
void this.handleMissionTaskCompletion(task.id, task.sliceId);
// Mission progress tracking. Resolve by linked feature instead of only
// task.sliceId so older one-way-linked mission tasks are kept in sync too.
if (this.options.missionStore) {
void this.handleMissionTaskMove(task.id, to);
}
// Mission failure tracking: status/error are cleared during moveTask(in-progress → todo),
@@ -714,43 +710,103 @@ export class Scheduler {
}
/**
* Handle mission task start.
* When a task with a sliceId moves to "in-progress", update the linked
* feature status to "in-progress" to reflect active work.
* Handle a mission-linked task column move.
* Keeps feature state synchronized with task columns across the full task
* lifecycle, including review/merge transitions and older tasks whose task
* row has mission/slice metadata but whose feature row lacks taskId.
*/
private async handleMissionTaskStart(taskId: string, sliceId: string): Promise<void> {
private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").Column): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;
try {
// Find the feature linked to this task
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) {
schedulerLog.log(`Task ${taskId} has sliceId ${sliceId} but no linked feature found`);
const task = await this.store.getTask(taskId);
if (!task) {
return;
}
if (feature.sliceId !== sliceId) {
const feature = this.resolveMissionFeatureForTask(missionStore, task);
if (!feature) {
return;
}
if (task.sliceId && feature.sliceId !== task.sliceId) {
schedulerLog.warn(
`Task ${taskId} sliceId ${sliceId} does not match linked feature ${feature.id} sliceId ${feature.sliceId}; skipping mission start update`,
`Task ${taskId} sliceId ${task.sliceId} does not match linked feature ${feature.id} sliceId ${feature.sliceId}; skipping mission update`,
);
return;
}
// Only update if feature is still in "triaged" status
if (feature.status === "triaged") {
await missionStore.updateFeatureStatus(feature.id, "in-progress");
schedulerLog.log(`Feature ${feature.id} marked in-progress (task ${taskId} started)`);
const reconciliation = await reconcileMissionFeatureState(
this.store,
{ ...task, column: toColumn },
feature,
);
if (reconciliation.kind === "blocked") {
schedulerLog.warn(`Task ${taskId} mission update blocked — ${reconciliation.reason}`);
return;
}
if (reconciliation.kind === "failure") {
schedulerLog.warn(`Task ${taskId} mission update reported failure — ${reconciliation.reason}`);
return;
}
const sliceIdBeforeUpdate = feature.sliceId;
if (reconciliation.kind === "update") {
missionStore.updateFeatureStatus(feature.id, reconciliation.status);
schedulerLog.log(
`Feature ${feature.id} marked ${reconciliation.status} (${reconciliation.reason})`,
);
}
if (toColumn === "done") {
await this.handleMissionTaskCompletion(taskId, sliceIdBeforeUpdate);
}
} catch (err) {
schedulerLog.error(`Error handling mission task start for ${taskId}:`, err);
schedulerLog.error(`Error handling mission task move for ${taskId}:`, err);
}
}
private resolveMissionFeatureForTask(missionStore: MissionStore, task: Task): MissionFeature | undefined {
const linkedFeature = missionStore.getFeatureByTaskId(task.id);
if (linkedFeature) {
return linkedFeature;
}
if (!task.sliceId || !task.title) {
return undefined;
}
const normalizedTaskTitle = this.normalizeMissionFeatureTitle(task.title);
const matchingFeature = missionStore
.listFeatures(task.sliceId)
.find((feature) =>
!feature.taskId
&& this.normalizeMissionFeatureTitle(feature.title) === normalizedTaskTitle
);
if (!matchingFeature) {
return undefined;
}
schedulerLog.warn(
`Repairing one-way mission link: task ${task.id} matched unlinked feature ${matchingFeature.id}`,
);
return missionStore.linkFeatureToTask(matchingFeature.id, task.id);
}
private normalizeMissionFeatureTitle(title: string): string {
return title.trim().replace(/\s+/g, " ").toLowerCase();
}
/**
* Handle mission task completion.
* When a task moves to "done", update the linked feature status to "done".
* When a task moves to "done", advance mission execution after the linked
* feature status has already been reconciled by handleMissionTaskMove().
* updateFeatureStatus cascades via recomputeSliceStatus — if all features
* in the slice are done the slice status becomes "complete" automatically.
*
@@ -764,10 +820,6 @@ export class Scheduler {
const missionStore = this.options.missionStore;
try {
const task = await this.store.getTask(taskId);
if (!task) {
return;
}
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) return;
@@ -778,28 +830,8 @@ export class Scheduler {
return;
}
const reconciliation = await reconcileMissionFeatureState(
this.store,
{ ...task, column: "done" },
feature,
);
if (reconciliation.kind === "blocked") {
schedulerLog.warn(`Task ${taskId} mission completion blocked — ${reconciliation.reason}`);
return;
}
if (reconciliation.kind === "failure") {
schedulerLog.warn(`Task ${taskId} mission completion reported failure — ${reconciliation.reason}`);
return;
}
const sliceIdBeforeUpdate = feature.sliceId;
if (reconciliation.kind === "update" && reconciliation.status === "done") {
missionStore.updateFeatureStatus(feature.id, "done");
schedulerLog.log(`Feature ${feature.id} marked done (task ${taskId} completed)`);
}
// Trigger the mission execution loop to run validation
// This is called regardless of whether the slice is complete - the loop
// handles the validation cycle independently
@@ -951,6 +983,21 @@ export class Scheduler {
try {
const missions = missionStore.listMissions();
const activeMissions = missions.filter((m) => m.status === "active");
const activeMissionIds = new Set(activeMissions.map((mission) => mission.id));
const taskBySliceAndTitle = new Map<string, Task | null>();
const missionTasks = await this.store.listTasks({ slim: true, includeArchived: false });
for (const task of missionTasks) {
if (!task.missionId || !task.sliceId || !task.title || !activeMissionIds.has(task.missionId)) {
continue;
}
const key = this.getMissionFeatureTitleKey(task.sliceId, task.title);
taskBySliceAndTitle.set(
key,
taskBySliceAndTitle.has(key) ? null : task,
);
}
for (const mission of activeMissions) {
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
@@ -962,12 +1009,28 @@ export class Scheduler {
for (const slice of activeSlices) {
for (const feature of slice.features) {
if (!feature.taskId) continue;
let featureForReconciliation = feature;
let task: Task | undefined;
if (feature.taskId) {
task = await this.store.getTask(feature.taskId);
} else {
const matchedTask = taskBySliceAndTitle.get(
this.getMissionFeatureTitleKey(feature.sliceId, feature.title),
);
if (matchedTask) {
schedulerLog.warn(
`Repairing one-way mission link during reconciliation: task ${matchedTask.id} matched unlinked feature ${feature.id}`,
);
featureForReconciliation = missionStore.linkFeatureToTask(feature.id, matchedTask.id);
task = matchedTask;
totalFixed++;
}
}
const task = await this.store.getTask(feature.taskId);
if (!task) continue;
const reconciliation = await reconcileMissionFeatureState(this.store, task, feature);
const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation);
if (reconciliation.kind === "failure") {
if (this.options.onTaskFailed) {
@@ -985,7 +1048,7 @@ export class Scheduler {
}
if (reconciliation.kind === "update") {
missionStore.updateFeatureStatus(feature.id, reconciliation.status);
missionStore.updateFeatureStatus(featureForReconciliation.id, reconciliation.status);
totalFixed++;
}
}
@@ -1001,4 +1064,8 @@ export class Scheduler {
return totalFixed;
}
private getMissionFeatureTitleKey(sliceId: string, title: string): string {
return `${sliceId}\0${this.normalizeMissionFeatureTitle(title)}`;
}
}