Fix mission task status sync

This commit is contained in:
gsxdsm
2026-04-13 17:14:06 -07:00
parent 0690c9ee84
commit b1a34abda5
3 changed files with 348 additions and 72 deletions

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)}`;
}
}