Fix mission task status sync
This commit is contained in:
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user