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