FN-5715: gate mission feature completion on assertion validation
Prevent mission-linked done tasks from bypassing assertion validation and recover stalled validation triggers after restart. - require assertion-linked done features to have a passed validator result before slice/milestone completion - keep assertion-linked features in progress when linked tasks move to done/archived until validation passes - restart mission loop recovery when needed and re-trigger done/archived implementing features that still need validation - add regression coverage for scheduler, mission loop recovery, mission store completion gating, and reliability trigger continuity - document the new FN-5715 reliability backstop and recovery behavior Files changed: .changeset/fn-5715-mission-validation-trigger.md | 7 + AGENTS.md | 1 + docs/architecture.md | 1 + docs/missions.md | 11 +- packages/core/src/__tests__/mission-integration.test.ts | 2 +- packages/core/src/__tests__/mission-store.test.ts | 30 +++- packages/core/src/mission-store.ts | 19 ++- packages/engine/src/__tests__/mission-execution-loop.test.ts | 95 +++++++++++++ packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts | 151 +++++++++++++++++++++ packages/engine/src/__tests__/scheduler.test.ts | 54 ++++++++ packages/engine/src/mission-autopilot.ts | 7 +- packages/engine/src/mission-execution-loop.ts | 25 ++++ packages/engine/src/mission-feature-sync.ts | 32 ++++- packages/engine/src/scheduler.ts | 12 +- 14 files changed, 431 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-5715 Fusion-Task-Lineage: 98b1cbd6-6b68-40a0-b4b3-c512183adf8b
This commit is contained in:
7
.changeset/fn-5715-mission-validation-trigger.md
Normal file
7
.changeset/fn-5715-mission-validation-trigger.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix mission assertion-validation trigger gaps so mission-linked tasks reaching done no longer bypass validator execution.
|
||||
|
||||
Assertion-linked features now stay completion-gated until validator pass, and startup recovery replays implementing features whose linked tasks are already done/archived but still lack a passing validator status.
|
||||
@@ -159,6 +159,7 @@ When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a huma
|
||||
- FN-5432 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` extends FN-5256 coverage with long-cycle ambiguous sweep, write-boundary/sweep race, self-defeating+cycle non-contradiction across one maintenance flow, and audit-event shape regression; core regression cases (long cycle, self-loop via update, incremental-update closes a loop, moveTask seam invariant, DependencyCycleError shape) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`. User-facing pull/stash audit event behavior (`pull:fast-forward`, `stash:pop-conflict`) is documented in `docs/dashboard-guide.md` under Merge Advance Notice / Smart Pull.
|
||||
- FN-5403 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-stop-aborts-execution.test.ts` locks stop-ordering behavior so engine shutdown aborts executor AI sessions before drain wait and preserves task-row lifecycle semantics.
|
||||
- FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` guards reclaim/unpause no-progress oscillation recovery by capping repeated no-progress resumes, escalating to preserve-work `todo` rebound, and emitting `task:resume-limbo-escalated` audit metadata while exempting progress/user-paused/autoMerge-off cases.
|
||||
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` guards mission validation trigger continuity so done task completion and startup recovery both route assertion-linked features through validator runs before completion.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1767,6 +1767,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
||||
- FN-5168 backstop: `packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts` covers loop→compact recovery followed by ignored-step-update churn escalation, terminal `beforeRequeue(false)` behavior, audit/log payloads, and FN-5147 autoMerge-off composition.
|
||||
- FN-5219 backstop: `packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts` covers `recoverInProgressLimbo` composition with `recoverOrphanedExecutions` (no double-recovery), `reconcile-task-worktree-metadata` (live rebindable worktree wins), `recoverMissingWorktreeReviewFailures` (in-review vs in-progress disjoint), and executor task-id claim skip, plus an explicit FN-5149 reproduction case.
|
||||
- FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` covers bounded no-progress reclaim/resume detection, preserve-work escalation to `todo`, `task:resume-limbo-escalated` audit metadata, progress-signal reset behavior, and user-paused/autoMerge-off non-escalation guards.
|
||||
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` locks the mission-validation trigger invariant so done mission-linked tasks still start validation when the mission loop was stopped, startup recovery replays done implementing features with unpassed assertions, no-assertions auto-pass remains unchanged, and recovery remains idempotent for already-passed features.
|
||||
- FN-5337 backstop: `packages/engine/src/__tests__/reliability-interactions/orphan-detected-no-requeue.test.ts` locks observation-only orphan detection across FN-5279 repro metadata desync, worktree-present and worktree-missing candidates, FN-5219 ordering, FN-5147 in-review isolation, FN-5083 branch-cleared composition, lease-manager non-invocation, and per-sweep idempotent audit emission.
|
||||
- FN-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`.
|
||||
- FN-5325 backstop: `packages/engine/src/__tests__/reliability-interactions/scheduler-overlap-priority-inversion.test.ts` covers queued-overlap priority/age deferral, equal-priority age ordering, FN-4969 fanout composition, and one-shot per-pass `scheduler:overlap-priority-inversion` audit surfacing against running lower-priority blockers.
|
||||
|
||||
@@ -334,9 +334,10 @@ When a feature enters the `implementing` state, `implementationAttemptCount` is
|
||||
On task completion, the scheduler calls `MissionExecutionLoop.processTaskOutcome()` to run AI validation:
|
||||
|
||||
1. Find the feature linked to the completed task
|
||||
2. Transition feature to `validating` state
|
||||
3. Fire AI validator agent against contract assertions
|
||||
4. Record `MissionValidatorRun` with per-assertion results
|
||||
2. If assertions are linked, keep feature completion gated until validation passes
|
||||
3. Transition feature to `validating` state
|
||||
4. Fire AI validator agent against contract assertions
|
||||
5. Record `MissionValidatorRun` with per-assertion results
|
||||
|
||||
Validation runs are internal mission-loop operations: Fusion does **not** create visible `🔍 Validate:` board tasks for single-feature validation.
|
||||
|
||||
@@ -389,7 +390,7 @@ A feature transitions to `blocked` when:
|
||||
- `MilestoneValidationRollup.state` reflects `blocked` assertions
|
||||
- The feature remains in `blocked` state until operator intervention
|
||||
|
||||
On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states from the `activeValidations` set, ensuring no validation work is lost.
|
||||
On engine restart, `recoverActiveMissions()` re-enqueues features in `validating` or `needs_fix` states from the `activeValidations` set, ensuring no validation work is lost. It also re-triggers `implementing` features whose linked task is already `done`/`archived` and whose assertion validation has not passed yet.
|
||||
|
||||
### Autopilot / Scheduler Interplay
|
||||
|
||||
@@ -452,7 +453,7 @@ interface MissionAssertionFailureRecord {
|
||||
| Blocked mission not advancing | `MilestoneValidationRollup.state` shows `blocked` | Identify blocked assertions; operator must resolve root cause |
|
||||
| Validation agent errors | AI session creation failed or `VALIDATION_TIMEOUT_MS` (10 min) exceeded | Check model configuration and logs; verify AI provider auth |
|
||||
| No validation runs after task completion | `processTaskOutcome()` not called; check scheduler logs | Verify mission linkage on feature → task mapping; check scheduler event handlers |
|
||||
| Recovery after engine restart | Features in `validating`/`needs_fix` state may not re-enqueue | `recoverActiveMissions()` should run on startup; check recovery log count |
|
||||
| Recovery after engine restart | Features in `validating`/`needs_fix`/stalled `implementing` state may not re-enqueue | `recoverActiveMissions()` should run on startup; check recovery log count and mission-loop logs |
|
||||
|
||||
### Parity Verification Tests
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("MissionStore integration with TaskStore", () => {
|
||||
for (const { featureId, taskId } of linkedFeatures) {
|
||||
await taskStore.moveTask(taskId, "in-review");
|
||||
await taskStore.moveTask(taskId, "done");
|
||||
missionStore.updateFeature(featureId, { taskId, status: "done" });
|
||||
missionStore.updateFeature(featureId, { taskId, status: "done", lastValidatorStatus: "passed" });
|
||||
}
|
||||
|
||||
updatedSlice = missionStore.getSlice(firstSlice.id);
|
||||
|
||||
@@ -1422,6 +1422,26 @@ describe("MissionStore", () => {
|
||||
const status = store.computeSliceStatus(slice.id);
|
||||
expect(status).toBe("active");
|
||||
});
|
||||
|
||||
it("does not complete slice when done feature has linked assertions without validator pass", () => {
|
||||
const mission = store.createMission({ title: "Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature" });
|
||||
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "AC",
|
||||
assertion: "Must pass",
|
||||
});
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
|
||||
store.transitionLoopState(feature.id, "implementing");
|
||||
store.updateFeature(feature.id, { status: "done" });
|
||||
expect(store.computeSliceStatus(slice.id)).toBe("pending");
|
||||
|
||||
store.updateFeature(feature.id, { lastValidatorStatus: "passed" });
|
||||
expect(store.computeSliceStatus(slice.id)).toBe("complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeMilestoneStatus", () => {
|
||||
@@ -1537,13 +1557,13 @@ describe("MissionStore", () => {
|
||||
expect(store.computeMilestoneStatus(milestone.id)).toBe("active");
|
||||
|
||||
// Update f1 status to done (not changing taskId)
|
||||
store.updateFeature(f1.id, { status: "done" });
|
||||
store.updateFeature(f1.id, { status: "done", lastValidatorStatus: "passed" });
|
||||
|
||||
// Slice should still be "active" (partial completion)
|
||||
expect(store.computeSliceStatus(slice.id)).toBe("active");
|
||||
|
||||
// Update f2 status to done
|
||||
store.updateFeature(f2.id, { status: "done" });
|
||||
store.updateFeature(f2.id, { status: "done", lastValidatorStatus: "passed" });
|
||||
|
||||
// Now slice should be "complete"
|
||||
expect(store.computeSliceStatus(slice.id)).toBe("complete");
|
||||
@@ -1574,9 +1594,9 @@ describe("MissionStore", () => {
|
||||
|
||||
// Mark all features as "done" using updateFeature (not updateFeatureStatus)
|
||||
// → milestone should become "complete"
|
||||
store.updateFeature(f1.id, { status: "done" });
|
||||
store.updateFeature(f2.id, { status: "done" });
|
||||
store.updateFeature(f3.id, { status: "done" });
|
||||
store.updateFeature(f1.id, { status: "done", lastValidatorStatus: "passed" });
|
||||
store.updateFeature(f2.id, { status: "done", lastValidatorStatus: "passed" });
|
||||
store.updateFeature(f3.id, { status: "done", lastValidatorStatus: "passed" });
|
||||
|
||||
expect(store.computeMilestoneStatus(milestone.id)).toBe("complete");
|
||||
});
|
||||
|
||||
@@ -3476,8 +3476,23 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
// Check if all features are done (linked to done tasks)
|
||||
const allDone = features.every((f) => f.status === "done");
|
||||
// Check if all features are done. For features linked to contract assertions,
|
||||
// a passed validator run is required before they count toward slice completion.
|
||||
const allDone = features.every((feature) => {
|
||||
if (feature.status !== "done") {
|
||||
return false;
|
||||
}
|
||||
const hasLinkedAssertions = this.listAssertionsForFeature(feature.id).length > 0;
|
||||
if (!hasLinkedAssertions) {
|
||||
return true;
|
||||
}
|
||||
if (feature.lastValidatorStatus === "passed") {
|
||||
return true;
|
||||
}
|
||||
// Gate completion for assertion-linked features that are in the execution loop.
|
||||
// Legacy/manual rows that remain idle retain prior completion behavior.
|
||||
return feature.loopState === "idle" || feature.loopState === undefined;
|
||||
});
|
||||
if (allDone) {
|
||||
return "complete";
|
||||
}
|
||||
|
||||
@@ -255,6 +255,9 @@ function createMockMissionStore() {
|
||||
// Internal setters for test setup
|
||||
_setMission: (m: Mission) => missions.set(m.id, m),
|
||||
_setFeature: (f: MissionFeature) => features.set(f.id, f),
|
||||
_setAssertionsForFeature: (featureId: string, assertions: Array<{ id: string; milestoneId: string; title: string; assertion: string; status: "pending" | "passed" | "failed" | "blocked"; orderIndex: number; createdAt: string; updatedAt: string; sourceFeatureId?: string }>) => {
|
||||
assertionsByFeature.set(featureId, assertions);
|
||||
},
|
||||
_addFeatureWithManagedAssertion: (f: MissionFeature) => {
|
||||
features.set(f.id, f);
|
||||
const now = new Date().toISOString();
|
||||
@@ -1561,6 +1564,98 @@ describe("MissionExecutionLoop", () => {
|
||||
expect(missionStore.transitionLoopState).toHaveBeenCalledWith("F-VALIDATING-IN-PROGRESS", "implementing");
|
||||
});
|
||||
|
||||
it("should recover implementing features whose linked task is already done and assertions are unpassed", async () => {
|
||||
const feature = createMockFeature({
|
||||
id: "F-IMPLEMENTING-DONE",
|
||||
sliceId: "SL-001",
|
||||
loopState: "implementing",
|
||||
status: "done",
|
||||
taskId: "FN-DONE",
|
||||
lastValidatorStatus: undefined,
|
||||
});
|
||||
missionStore._setFeature(feature);
|
||||
missionStore._setAssertionsForFeature("F-IMPLEMENTING-DONE", [
|
||||
{
|
||||
id: "CA-1",
|
||||
milestoneId: "MS-001",
|
||||
title: "Must pass",
|
||||
assertion: "Assertion",
|
||||
status: "pending",
|
||||
orderIndex: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
|
||||
...createMockMission(),
|
||||
milestones: [
|
||||
{
|
||||
...createMockMilestone(),
|
||||
slices: [{ ...createMockSlice(), features: [feature] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
loop = new MissionExecutionLoop({
|
||||
taskStore: taskStore as any,
|
||||
missionStore: missionStore as any,
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
|
||||
taskStore._setTask({ id: "FN-DONE", column: "done" });
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
|
||||
expect(processTaskOutcomeSpy).toHaveBeenCalledWith("FN-DONE");
|
||||
});
|
||||
|
||||
it("does not re-trigger implementing features when validator already passed", async () => {
|
||||
const feature = createMockFeature({
|
||||
id: "F-IMPLEMENTING-PASSED",
|
||||
sliceId: "SL-001",
|
||||
loopState: "implementing",
|
||||
status: "done",
|
||||
taskId: "FN-PASSED",
|
||||
lastValidatorStatus: "passed",
|
||||
});
|
||||
missionStore._setFeature(feature);
|
||||
missionStore._setAssertionsForFeature("F-IMPLEMENTING-PASSED", [
|
||||
{
|
||||
id: "CA-1",
|
||||
milestoneId: "MS-001",
|
||||
title: "Must pass",
|
||||
assertion: "Assertion",
|
||||
status: "passed",
|
||||
orderIndex: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
|
||||
...createMockMission(),
|
||||
milestones: [
|
||||
{
|
||||
...createMockMilestone(),
|
||||
slices: [{ ...createMockSlice(), features: [feature] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
loop = new MissionExecutionLoop({
|
||||
taskStore: taskStore as any,
|
||||
missionStore: missionStore as any,
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
|
||||
taskStore._setTask({ id: "FN-PASSED", column: "done" });
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
|
||||
expect(processTaskOutcomeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call processTaskOutcome for needs_fix features without taskId", async () => {
|
||||
const feature = createMockFeature({
|
||||
id: "F-NO-TASK",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MissionFeature, MissionStore, TaskStore } from "@fusion/core";
|
||||
import { Scheduler } from "../../scheduler.js";
|
||||
import { MissionExecutionLoop } from "../../mission-execution-loop.js";
|
||||
|
||||
function makeTaskStore(taskColumn: "done" | "archived" | "in-progress" = "done") {
|
||||
return {
|
||||
getTask: vi.fn(async (taskId: string) => ({
|
||||
id: taskId,
|
||||
title: "Mission task",
|
||||
description: "desc",
|
||||
column: taskColumn,
|
||||
status: taskColumn === "in-progress" ? "in-progress" : "done",
|
||||
sliceId: "SL-001",
|
||||
log: [],
|
||||
})),
|
||||
getRootDir: vi.fn(() => "/test/project"),
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function makeFeature(overrides: Partial<MissionFeature> = {}): MissionFeature {
|
||||
return {
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "Feature",
|
||||
status: "in-progress",
|
||||
loopState: "implementing",
|
||||
implementationAttemptCount: 0,
|
||||
validatorAttemptCount: 0,
|
||||
taskId: "FN-001",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
it("starts mission loop + processes completion when done task lands while loop is stopped", async () => {
|
||||
const feature = makeFeature();
|
||||
const missionStore = {
|
||||
getFeatureByTaskId: vi.fn(() => feature),
|
||||
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
|
||||
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
|
||||
} as unknown as MissionStore;
|
||||
const missionExecutionLoop = {
|
||||
isRunning: vi.fn(() => false),
|
||||
start: vi.fn(),
|
||||
processTaskOutcome: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
const scheduler = new Scheduler(makeTaskStore("done"), {
|
||||
missionStore,
|
||||
missionExecutionLoop: missionExecutionLoop as any,
|
||||
});
|
||||
|
||||
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
|
||||
|
||||
expect(missionExecutionLoop.start).toHaveBeenCalledTimes(1);
|
||||
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done");
|
||||
});
|
||||
|
||||
it("keeps no-assertion completion path unchanged", async () => {
|
||||
const feature = makeFeature();
|
||||
const missionStore = {
|
||||
getFeatureByTaskId: vi.fn(() => feature),
|
||||
listAssertionsForFeature: vi.fn(() => []),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
|
||||
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
|
||||
} as unknown as MissionStore;
|
||||
|
||||
const scheduler = new Scheduler(makeTaskStore("done"), {
|
||||
missionStore,
|
||||
missionExecutionLoop: {
|
||||
isRunning: vi.fn(() => true),
|
||||
start: vi.fn(),
|
||||
processTaskOutcome: vi.fn(async () => undefined),
|
||||
} as any,
|
||||
});
|
||||
|
||||
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
|
||||
|
||||
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
|
||||
});
|
||||
|
||||
it("recovers implementing features whose task is already done at startup", async () => {
|
||||
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined });
|
||||
const missionStore = {
|
||||
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
|
||||
getMissionWithHierarchy: vi.fn(() => ({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
|
||||
})),
|
||||
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
|
||||
transitionLoopState: vi.fn(),
|
||||
};
|
||||
const taskStore = {
|
||||
getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })),
|
||||
};
|
||||
|
||||
const loop = new MissionExecutionLoop({
|
||||
missionStore: missionStore as any,
|
||||
taskStore: taskStore as any,
|
||||
rootDir: process.cwd(),
|
||||
});
|
||||
const processSpy = vi.spyOn(loop, "processTaskOutcome").mockResolvedValue(undefined);
|
||||
loop.start();
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
|
||||
expect(processSpy).toHaveBeenCalledWith("FN-001");
|
||||
loop.stop();
|
||||
});
|
||||
|
||||
it("is idempotent for already-passed implementing features", async () => {
|
||||
const feature = makeFeature({ status: "done", lastValidatorStatus: "passed" });
|
||||
const missionStore = {
|
||||
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
|
||||
getMissionWithHierarchy: vi.fn(() => ({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
|
||||
})),
|
||||
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
|
||||
transitionLoopState: vi.fn(),
|
||||
};
|
||||
const taskStore = {
|
||||
getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })),
|
||||
};
|
||||
|
||||
const loop = new MissionExecutionLoop({
|
||||
missionStore: missionStore as any,
|
||||
taskStore: taskStore as any,
|
||||
rootDir: process.cwd(),
|
||||
});
|
||||
const processSpy = vi.spyOn(loop, "processTaskOutcome").mockResolvedValue(undefined);
|
||||
loop.start();
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
|
||||
expect(processSpy).not.toHaveBeenCalled();
|
||||
loop.stop();
|
||||
});
|
||||
});
|
||||
@@ -3839,6 +3839,60 @@ describe("Scheduler", () => {
|
||||
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
it("keeps assertion-linked features non-done until validator pass", async () => {
|
||||
const feature = {
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
status: "triaged",
|
||||
loopState: "implementing",
|
||||
taskId: "FN-001",
|
||||
};
|
||||
const missionStore = {
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue(feature),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
listAssertionsForFeature: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "CA-1",
|
||||
milestoneId: "MS-001",
|
||||
title: "Must pass",
|
||||
assertion: "Should pass",
|
||||
status: "pending",
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
};
|
||||
const missionExecutionLoop = {
|
||||
isRunning: vi.fn().mockReturnValue(true),
|
||||
processTaskOutcome: vi.fn().mockResolvedValue(undefined),
|
||||
start: vi.fn(),
|
||||
};
|
||||
const taskStore = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(createMockTask({
|
||||
id: "FN-001",
|
||||
title: "Mission task",
|
||||
description: "done",
|
||||
column: "done",
|
||||
sliceId: "SL-001",
|
||||
log: [],
|
||||
})),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(taskStore, {
|
||||
missionStore: missionStore as any,
|
||||
missionExecutionLoop: missionExecutionLoop as any,
|
||||
});
|
||||
|
||||
await (scheduler as any).handleMissionTaskMove("FN-001", "done");
|
||||
|
||||
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
|
||||
expect(missionExecutionLoop.processTaskOutcome).toHaveBeenCalledWith("FN-001");
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done");
|
||||
});
|
||||
|
||||
it("starts validator run through missionExecutionLoop when a linked task moves to done", async () => {
|
||||
const feature = {
|
||||
id: "F-001",
|
||||
|
||||
@@ -860,7 +860,12 @@ export class MissionAutopilot {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reconciliation = await reconcileMissionFeatureState(this.taskStore, task, feature);
|
||||
const hasLinkedAssertions = typeof this.missionStore.listAssertionsForFeature === "function"
|
||||
? this.missionStore.listAssertionsForFeature(feature.id).length > 0
|
||||
: false;
|
||||
const reconciliation = await reconcileMissionFeatureState(this.taskStore, task, feature, {
|
||||
hasLinkedAssertions,
|
||||
});
|
||||
|
||||
if (reconciliation.kind === "failure") {
|
||||
await this.handleTaskFailure(feature.taskId);
|
||||
|
||||
@@ -141,6 +141,11 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
async recoverActiveMissions(): Promise<{ recoveredCount: number }> {
|
||||
loopLog.log("Starting active mission recovery...");
|
||||
|
||||
if (!this.running) {
|
||||
loopLog.warn("recoverActiveMissions called while loop is stopped; starting loop for recovery");
|
||||
this.start();
|
||||
}
|
||||
|
||||
try {
|
||||
const missions = this.missionStore.listMissions();
|
||||
let recoveredCount = 0;
|
||||
@@ -202,6 +207,26 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
recoveredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Features that remained implementing while their linked task already finished
|
||||
// can be stranded after restart; recover by re-triggering task outcome.
|
||||
if (
|
||||
feature.loopState === "implementing"
|
||||
&& feature.taskId
|
||||
&& feature.lastValidatorStatus !== "passed"
|
||||
&& this.missionStore.listAssertionsForFeature(feature.id).length > 0
|
||||
) {
|
||||
try {
|
||||
const linkedTask = await this.taskStore.getTask(feature.taskId).catch(() => null);
|
||||
if (linkedTask && (linkedTask.column === "done" || linkedTask.column === "archived")) {
|
||||
loopLog.log(`Recovery: re-triggering implementing feature ${feature.id} from completed task ${feature.taskId}`);
|
||||
await this.processTaskOutcome(feature.taskId);
|
||||
recoveredCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
loopLog.error(`Recovery failed for implementing feature ${feature.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import { getTaskCompletionBlockerForStore } from "./task-completion.js";
|
||||
|
||||
export type MissionFeatureSyncTargetStatus = "done" | "in-progress" | "triaged";
|
||||
|
||||
export interface MissionFeatureSyncContext {
|
||||
hasLinkedAssertions?: boolean;
|
||||
}
|
||||
|
||||
export type MissionFeatureSyncDecision =
|
||||
| { kind: "failure"; reason: string }
|
||||
| { kind: "blocked"; reason: string }
|
||||
@@ -12,7 +16,8 @@ export type MissionFeatureSyncDecision =
|
||||
export async function reconcileMissionFeatureState(
|
||||
taskStore: Pick<TaskStore, "getTask">,
|
||||
task: Task,
|
||||
feature: Pick<MissionFeature, "id" | "status">,
|
||||
feature: Pick<MissionFeature, "id" | "status" | "lastValidatorStatus">,
|
||||
context: MissionFeatureSyncContext = {},
|
||||
): Promise<MissionFeatureSyncDecision> {
|
||||
if (task.status === "failed" && feature.status === "in-progress") {
|
||||
return {
|
||||
@@ -21,12 +26,26 @@ export async function reconcileMissionFeatureState(
|
||||
};
|
||||
}
|
||||
|
||||
const hasUnvalidatedAssertions = context.hasLinkedAssertions === true
|
||||
&& feature.lastValidatorStatus !== "passed";
|
||||
|
||||
if (task.column === "done") {
|
||||
const blocker = await getTaskCompletionBlockerForStore(taskStore, task);
|
||||
if (blocker) {
|
||||
return { kind: "blocked", reason: blocker };
|
||||
}
|
||||
|
||||
if (hasUnvalidatedAssertions) {
|
||||
if (feature.status !== "in-progress") {
|
||||
return {
|
||||
kind: "update",
|
||||
status: "in-progress",
|
||||
reason: `task ${task.id} completed; awaiting assertion validation`,
|
||||
};
|
||||
}
|
||||
return { kind: "noop" };
|
||||
}
|
||||
|
||||
if (feature.status !== "done") {
|
||||
return {
|
||||
kind: "update",
|
||||
@@ -39,6 +58,17 @@ export async function reconcileMissionFeatureState(
|
||||
}
|
||||
|
||||
if (task.column === "archived") {
|
||||
if (hasUnvalidatedAssertions) {
|
||||
if (feature.status !== "in-progress") {
|
||||
return {
|
||||
kind: "update",
|
||||
status: "in-progress",
|
||||
reason: `task ${task.id} archived; awaiting assertion validation`,
|
||||
};
|
||||
}
|
||||
return { kind: "noop" };
|
||||
}
|
||||
|
||||
if (feature.status !== "done") {
|
||||
return {
|
||||
kind: "update",
|
||||
|
||||
@@ -1751,10 +1751,15 @@ export class Scheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function"
|
||||
? missionStore.listAssertionsForFeature(feature.id).length > 0
|
||||
: false;
|
||||
|
||||
const reconciliation = await reconcileMissionFeatureState(
|
||||
this.store,
|
||||
{ ...task, column: toColumn },
|
||||
feature,
|
||||
{ hasLinkedAssertions },
|
||||
);
|
||||
|
||||
if (reconciliation.kind === "blocked") {
|
||||
@@ -2050,7 +2055,12 @@ export class Scheduler {
|
||||
|
||||
if (!task) continue;
|
||||
|
||||
const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation);
|
||||
const hasLinkedAssertions = typeof missionStore.listAssertionsForFeature === "function"
|
||||
? missionStore.listAssertionsForFeature(featureForReconciliation.id).length > 0
|
||||
: false;
|
||||
const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation, {
|
||||
hasLinkedAssertions,
|
||||
});
|
||||
|
||||
if (reconciliation.kind === "failure") {
|
||||
if (this.options.onTaskFailed) {
|
||||
|
||||
Reference in New Issue
Block a user