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

@@ -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,