diff --git a/.changeset/mission-triage-branch-strategy-and-parked-validation.md b/.changeset/mission-triage-branch-strategy-and-parked-validation.md new file mode 100644 index 0000000000..0966318fa7 --- /dev/null +++ b/.changeset/mission-triage-branch-strategy-and-parked-validation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Honor mission branchStrategy when triage omits branchAssignment; skip validation for inactive missions. +category: fix +dev: resolveBranchAssignmentContext returns undefined for absent mode so triage falls back to mission.branchStrategy; processTaskOutcome gates on mission.status === "active" like recoverActiveMissions. diff --git a/packages/dashboard/src/__tests__/branch-selection.test.ts b/packages/dashboard/src/__tests__/branch-selection.test.ts index ab8b548927..8b083baca0 100644 --- a/packages/dashboard/src/__tests__/branch-selection.test.ts +++ b/packages/dashboard/src/__tests__/branch-selection.test.ts @@ -38,7 +38,12 @@ describe("branch-selection", () => { }); it("resolves assignment context", () => { - expect(resolveBranchAssignmentContext(undefined)).toEqual({ mode: "shared" }); + // Absent input resolves to undefined so callers fall back to their own + // default (e.g. mission triage uses mission.branchStrategy). + expect(resolveBranchAssignmentContext(undefined)).toEqual({ mode: undefined }); + expect(resolveBranchAssignmentContext(null)).toEqual({ mode: undefined }); + expect(resolveBranchAssignmentContext({})).toEqual({ mode: undefined }); + expect(resolveBranchAssignmentContext({ mode: "shared" })).toEqual({ mode: "shared" }); expect(resolveBranchAssignmentContext({ mode: "per-task-derived" })).toEqual({ mode: "per-task-derived" }); expect(() => resolveBranchAssignmentContext({ mode: "bad" })).toThrow("branchAssignment.mode must be one of"); }); diff --git a/packages/dashboard/src/routes/branch-selection.ts b/packages/dashboard/src/routes/branch-selection.ts index 67d29d5dbf..fe0a7f7c5c 100644 --- a/packages/dashboard/src/routes/branch-selection.ts +++ b/packages/dashboard/src/routes/branch-selection.ts @@ -68,7 +68,8 @@ export interface BranchAssignmentContext { } export interface ResolvedBranchAssignmentContext { - mode: PlanningBranchMode; + /** undefined when the request did not specify a mode; callers pick their own default. */ + mode: PlanningBranchMode | undefined; } function normalizeOptionalBranch(value: unknown, fieldName: string): string | undefined { @@ -133,7 +134,10 @@ export function resolveBranchSelection( export function resolveBranchAssignmentContext(input: unknown): ResolvedBranchAssignmentContext { if (input === undefined || input === null) { - return { mode: "shared" }; + // No explicit assignment requested: leave mode undefined so callers can + // apply their own default (e.g. mission triage falls back to the + // mission's branchStrategy instead of being forced into a shared group). + return { mode: undefined }; } if (typeof input !== "object" || Array.isArray(input)) { throw badRequest("branchAssignment must be an object"); @@ -143,9 +147,7 @@ export function resolveBranchAssignmentContext(input: unknown): ResolvedBranchAs if (mode !== undefined && mode !== "shared" && mode !== "per-task-derived") { throw badRequest("branchAssignment.mode must be one of: shared, per-task-derived"); } - return { - mode: mode === "per-task-derived" ? "per-task-derived" : "shared", - }; + return { mode }; } export function sanitizeSegment(input: string): string { diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index fba7d7fe1f..7f7c86d114 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -238,7 +238,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = resolveBranchSelection(branchSelection, branch, baseBranch); - const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); + // Planning subtasks have no strategy fallback; keep the historical shared default. + const { mode: branchMode = "shared" } = resolveBranchAssignmentContext(branchAssignment); // Stamp the real BranchGroup id (BG-…) so listTasksByBranchGroup(group.id) // resolves members. The group is only ensured (and the id set) in shared // mode below. Non-shared members get NO groupId — stamping a synthetic @@ -1323,7 +1324,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = resolveBranchSelection(branchSelection, branch, baseBranch); - const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); + // Planning subtasks have no strategy fallback; keep the historical shared default. + const { mode: branchMode = "shared" } = resolveBranchAssignmentContext(branchAssignment); // Stamp the real BranchGroup id (BG-…) so listTasksByBranchGroup(group.id) // resolves members. The group is only ensured (and the id set) in shared // mode below. Non-shared members get NO groupId — stamping a synthetic diff --git a/packages/engine/src/__tests__/mission-execution-loop.test.ts b/packages/engine/src/__tests__/mission-execution-loop.test.ts index 3108d554c9..427e7f576c 100644 --- a/packages/engine/src/__tests__/mission-execution-loop.test.ts +++ b/packages/engine/src/__tests__/mission-execution-loop.test.ts @@ -823,6 +823,56 @@ describe("MissionExecutionLoop", () => { ); }); + it("skips validation when the feature's mission is not active", async () => { + missionStore._setMission(createMockMission({ id: "M-TEST1", status: "planning" })); + const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" }); + missionStore._setFeature(feature); + missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); + + loop = new MissionExecutionLoop({ + taskStore: taskStore as any, + missionStore: missionStore as any, + rootDir: "/tmp", + }); + loop.start(); + + await loop.processTaskOutcome("FN-001"); + + expect(missionStore.startValidatorRun).not.toHaveBeenCalled(); + expect(missionStore.logMissionEvent).toHaveBeenCalledWith( + expect.any(String), + "warning", + expect.stringContaining("Validation skipped"), + expect.objectContaining({ + code: "validation_skipped_mission_inactive", + featureId: "F-001", + taskId: "FN-001", + missionId: "M-TEST1", + missionStatus: "planning", + }), + ); + }); + + it("validates when the feature's mission is active", async () => { + missionStore._setMission(createMockMission({ id: "M-TEST1", status: "active" })); + const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" }); + missionStore._setFeature(feature); + missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); + taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] }); + + loop = new MissionExecutionLoop({ + taskStore: taskStore as any, + missionStore: missionStore as any, + rootDir: "/tmp", + }); + vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" }); + loop.start(); + + await loop.processTaskOutcome("FN-001"); + + expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion"); + }); + it("requeues needs_fix features back through validation", async () => { const assertions = makeAssertions(1); const response = JSON.stringify({ diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 49c39bc5cd..763781e2d4 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -21,6 +21,7 @@ import type { AgentStore, Settings, Milestone, + Mission, } from "@fusion/core"; import { normalizeMissionAssertionType } from "@fusion/core"; import type { VerificationOutcome } from "./mission-verification.js"; @@ -417,6 +418,21 @@ export class MissionExecutionLoop extends EventEmitter { return; } + // Only validate features of active missions — mirrors the + // recoverActiveMissions guard. A parked/blocked/completed mission must + // not keep minting validations (and Fix features) for completed tasks. + // Features that don't resolve to a mission keep the current behavior. + const mission = this.resolveFeatureMission(feature); + if (mission && mission.status !== "active") { + loopLog.log(`Feature ${feature.id} belongs to mission ${mission.id} with status "${mission.status}"; skipping validation`); + this.logFeatureWarningEvent(feature.id, "validation_skipped_mission_inactive", `Validation skipped: mission ${mission.id} status is "${mission.status}" (expected "active").`, { + taskId, + missionId: mission.id, + missionStatus: mission.status, + }); + return; + } + if (feature.loopState === "needs_fix") { this.missionStore.transitionLoopState(feature.id, "implementing"); feature.loopState = "implementing"; @@ -1189,6 +1205,15 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; return this.missionStore.getMilestone(slice.milestoneId); } + private resolveFeatureMission(feature: MissionFeature): Mission | undefined { + const milestone = this.resolveFeatureMilestone(feature); + if (!milestone) { + return undefined; + } + + return this.missionStore.getMission(milestone.missionId); + } + private completeValidatorRunIfStillRunning( runId: string | undefined, status: "passed" | "failed" | "blocked" | "error",