fix(FN-1187): integration fixes for mission health and type updates

- Add /missions/health endpoint handling to MissionManager test mocks
- Add listMissionsWithSummaries to mission-e2e test mock
- Add planState to Slice type and mock factories
- Add stuckKillCount to retry task test assertions
- Update log message for stuck-killed retry
This commit is contained in:
gsxdsm
2026-04-09 12:21:51 -07:00
parent 850939d268
commit ced3ad3be6
10 changed files with 210 additions and 3 deletions

View File

@@ -98,6 +98,7 @@ export {
INTERVIEW_STATES,
AUTOPILOT_STATES,
MISSION_EVENT_TYPES,
SLICE_PLAN_STATES,
} from "./mission-types.js";
export type {
MissionStatus,
@@ -106,6 +107,7 @@ export type {
FeatureStatus,
InterviewState,
AutopilotState,
SlicePlanState,
MissionEventType,
AutopilotStatus,
Mission,

View File

@@ -33,6 +33,7 @@ import type {
MissionEvent,
MissionEventType,
MissionHealth,
SlicePlanState,
} from "./mission-types.js";
// ── Mission Summary Type ─────────────────────────────────────────────
@@ -163,6 +164,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: row.status as SliceStatus,
orderIndex: row.orderIndex,
activatedAt: row.activatedAt || undefined,
planState: (row.planState as SlicePlanState) || "not_started",
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -1077,6 +1079,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
title: input.title,
description: input.description,
status: "pending",
planState: "not_started",
orderIndex,
createdAt: now,
updatedAt: now,

View File

@@ -23,6 +23,10 @@ export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
export const SLICE_STATUSES = ["pending", "active", "complete"] as const;
export type SliceStatus = (typeof SLICE_STATUSES)[number];
/** Status values for a Slice's plan state (per-slice planning workflow) */
export const SLICE_PLAN_STATES = ["not_started", "planned", "needs_update"] as const;
export type SlicePlanState = (typeof SLICE_PLAN_STATES)[number];
/** Status values for a Feature within a slice */
export const FEATURE_STATUSES = ["defined", "triaged", "in-progress", "done", "blocked"] as const;
export type FeatureStatus = (typeof FEATURE_STATUSES)[number];
@@ -147,6 +151,10 @@ export interface Milestone {
interviewState: InterviewState;
/** IDs of milestones that must complete before this one can start */
dependencies: string[];
/** Planning notes from interview/planning output */
planningNotes?: string;
/** How to verify milestone completion */
verification?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
@@ -172,6 +180,12 @@ export interface Slice {
orderIndex: number;
/** ISO-8601 timestamp when the slice was activated (if applicable) */
activatedAt?: string;
/** State of the per-slice planning workflow */
planState: SlicePlanState;
/** Planning notes from interview/planning output */
planningNotes?: string;
/** How to verify slice completion */
verification?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
@@ -221,6 +235,10 @@ export interface MilestoneCreateInput {
description?: string;
/** IDs of milestones that must complete before this one can start */
dependencies?: string[];
/** Planning notes from interview/planning output */
planningNotes?: string;
/** How to verify milestone completion */
verification?: string;
}
/** Input for creating a new Slice */
@@ -229,6 +247,10 @@ export interface SliceCreateInput {
title: string;
/** Detailed description of work to be done */
description?: string;
/** Planning notes from interview/planning output */
planningNotes?: string;
/** How to verify slice completion */
verification?: string;
}
/** Input for creating a new Feature */

View File

@@ -265,6 +265,11 @@ class MockEventSource {
/** Fetch mock that returns mission list, detail, health, autopilot, and events endpoints. */
function createFetchMock() {
return vi.fn().mockImplementation((url: string) => {
// Handle batched health endpoint before individual health endpoint
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
}
@@ -289,6 +294,11 @@ function createFetchMock() {
/** Fetch mock for navigating into a mission detail */
function createDetailFetchMock(events = mockMissionEvents) {
return vi.fn().mockImplementation((url: string) => {
// Handle batched health endpoint before individual health endpoint
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, events)));
}

View File

@@ -102,6 +102,21 @@ function createMockMissionStore() {
)
),
listMissionsWithSummaries: vi.fn(() =>
Array.from(missions.values())
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((m) => ({
...m,
summary: {
totalMilestones: 0,
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
progressPercent: 0,
},
}))
),
getMissionSummary: vi.fn((_missionId: string) => ({
totalMilestones: 0,
completedMilestones: 0,

View File

@@ -1050,6 +1050,7 @@ describe("POST /tasks/:id/retry", () => {
error: null,
worktree: null,
branch: null,
stuckKillCount: 0,
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
@@ -1083,6 +1084,7 @@ describe("POST /tasks/:id/retry", () => {
error: null,
worktree: null,
branch: null,
stuckKillCount: 0,
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
@@ -1104,9 +1106,10 @@ describe("POST /tasks/:id/retry", () => {
error: null,
worktree: null,
branch: null,
stuckKillCount: 0,
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (stuck kill budget reset)");
});
});

View File

@@ -2095,8 +2095,44 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
error: null,
worktree: null,
branch: null,
stuckKillCount: 0,
});
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard");
// Reset steps if the branch has no unique commits (work was lost with worktree)
const completedSteps = task.steps.filter(
(s: { status: string }) => s.status === "done" || s.status === "in-progress",
);
if (completedSteps.length > 0) {
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
try {
const { execSync } = await import("node:child_process");
const rootDir = scopedStore.getRootDir();
const mergeBase = execSync(
`git merge-base "${branchName}" HEAD 2>/dev/null`,
{ cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
).trim();
const branchHead = execSync(
`git rev-parse "${branchName}" 2>/dev/null`,
{ cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
).trim();
if (mergeBase === branchHead) {
for (let i = 0; i < task.steps.length; i++) {
if (task.steps[i].status === "done" || task.steps[i].status === "in-progress") {
await scopedStore.updateStep(req.params.id, i, "pending");
}
}
await scopedStore.logEntry(
req.params.id,
`Reset ${completedSteps.length} step(s) to pending — branch had no commits (uncommitted work lost)`,
);
}
} catch {
// Branch may not exist — non-fatal, steps keep their status
}
}
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (stuck kill budget reset)");
const updated = await scopedStore.moveTask(req.params.id, "todo");
res.json(updated);
} catch (err: any) {

View File

@@ -1021,6 +1021,10 @@ export class TaskExecutor {
// Stuck-requeue: clean up worktree and move to todo
if (stuckRequeue === true) {
try {
// Reset steps whose work was never committed before destroying the worktree
const latestTask = await this.store.getTask(task.id);
await this.resetStepsIfWorkLost(latestTask);
if (worktreePath && existsSync(worktreePath)) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: this.rootDir, stdio: "pipe" });
@@ -1597,6 +1601,10 @@ export class TaskExecutor {
// task in "in-progress" with no active session or worktree.
if (stuckRequeue === true) {
try {
// Reset steps whose work was never committed before destroying the worktree
const latestTask = await this.store.getTask(task.id);
await this.resetStepsIfWorkLost(latestTask);
// Clean up the old worktree so the retry gets a fresh one
if (worktreePath && existsSync(worktreePath)) {
try {
@@ -2832,6 +2840,57 @@ If issues are found that need attention, describe them clearly.`;
}
}
/**
* Check whether the task's branch has any unique commits compared to main.
* If the branch has no unique commits and the task has steps marked done,
* those steps represent lost uncommitted work — reset them to "pending"
* so the next execution doesn't skip them.
*
* Called during stuck-kill cleanup when the worktree is about to be destroyed.
*/
private async resetStepsIfWorkLost(task: Task): Promise<void> {
const completedSteps = task.steps.filter(
(s) => s.status === "done" || s.status === "in-progress",
);
if (completedSteps.length === 0) return;
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
try {
// Check if the branch has any unique commits vs main
const mergeBase = execSync(
`git merge-base "${branchName}" HEAD 2>/dev/null`,
{ cwd: this.rootDir, stdio: "pipe", encoding: "utf-8" },
).trim();
const branchHead = execSync(
`git rev-parse "${branchName}" 2>/dev/null`,
{ cwd: this.rootDir, stdio: "pipe", encoding: "utf-8" },
).trim();
if (mergeBase === branchHead) {
// Branch has no unique commits — all step work was lost
executorLog.warn(
`${task.id} branch has no unique commits — resetting ${completedSteps.length} step(s) to pending`,
);
for (let i = 0; i < task.steps.length; i++) {
if (task.steps[i].status === "done" || task.steps[i].status === "in-progress") {
await this.store.updateStep(task.id, i, "pending");
}
}
await this.store.logEntry(
task.id,
`Reset ${completedSteps.length} step(s) to pending — branch had no commits (uncommitted work lost with worktree)`,
);
}
} catch {
// Branch may not exist or git commands may fail — non-fatal.
// Steps keep their current status (safe default: agent can
// inspect the worktree and decide).
}
}
/**
* Mark a task as stuck-aborted so the executor's error handling
* knows not to treat the disposed session as a genuine failure.

View File

@@ -46,6 +46,7 @@ function createMockSlice(overrides: Partial<Slice> = {}): Slice {
milestoneId: "MS-001",
title: "Test Slice",
status: "pending",
planState: "not_started",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),

View File

@@ -221,7 +221,13 @@ export class SelfHealingManager {
status: "failed",
error: `Task stuck ${newCount} times — exceeded maximum of ${maxKills} stuck kills`,
});
await this.store.moveTask(taskId, "in-review");
try {
await this.store.moveTask(taskId, "in-review");
} catch (moveErr: any) {
// moveTask may fail if task was concurrently moved (e.g., dep-abort).
// The task is already marked failed — don't allow requeue.
log.warn(`${taskId} moveTask("in-review") failed (${moveErr.message}) — task already marked failed, not re-queuing`);
}
await this.store.logEntry(
taskId,
`Permanently failed: agent stuck ${newCount} times (max: ${maxKills}) — moved to in-review`,
@@ -244,6 +250,53 @@ export class SelfHealingManager {
}
}
// ── Lost work detection ────────────────────────────────────────────
/**
* Check whether a task's branch has any unique commits compared to main.
* If the branch has no unique commits and the task has steps marked done,
* those steps represent lost uncommitted work — reset them to "pending"
* so the next execution doesn't skip them.
*/
private async resetStepsIfWorkLost(task: Task): Promise<void> {
const completedSteps = task.steps.filter(
(s) => s.status === "done" || s.status === "in-progress",
);
if (completedSteps.length === 0) return;
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
try {
const mergeBase = execSync(
`git merge-base "${branchName}" HEAD 2>/dev/null`,
{ cwd: this.options.rootDir, stdio: "pipe", encoding: "utf-8" },
).trim();
const branchHead = execSync(
`git rev-parse "${branchName}" 2>/dev/null`,
{ cwd: this.options.rootDir, stdio: "pipe", encoding: "utf-8" },
).trim();
if (mergeBase === branchHead) {
log.warn(
`${task.id} branch has no unique commits — resetting ${completedSteps.length} step(s) to pending`,
);
for (let i = 0; i < task.steps.length; i++) {
if (task.steps[i].status === "done" || task.steps[i].status === "in-progress") {
await this.store.updateStep(task.id, i, "pending");
}
}
await this.store.logEntry(
task.id,
`Reset ${completedSteps.length} step(s) to pending — branch had no commits (uncommitted work lost with worktree)`,
);
}
} catch {
// Branch may not exist or git commands may fail — non-fatal
}
}
// ── Periodic maintenance ──────────────────────────────────────────
private async startMaintenance(): Promise<void> {
@@ -423,6 +476,9 @@ export class SelfHealingManager {
? "worktree exists but no active session"
: "missing worktree/session";
// Reset steps whose work was never committed before clearing the worktree
await this.resetStepsIfWorkLost(task);
await this.store.updateTask(task.id, {
status: "stuck-killed",
worktree: null,