feat(FN-1725): improve settings management and mission interview robustness
- Fix global settings persistence to load from correct path and handle first-run state - Expose global execution concurrency limit in settings UI and routes - Harden mission-routes.ts utility-lane invariants with null checks - Fix mission interview saturation tests with proper cleanup - Add mission e2e tests covering interview and milestone flows - Update SettingsModal to show scope indicators (global vs project) - Update TaskForm model selection handling
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
submitMissionInterviewResponse,
|
||||
} from "./mission-interview.js";
|
||||
import * as missionInterviewModule from "./mission-interview.js";
|
||||
import * as milestoneSliceInterviewModule from "./milestone-slice-interview.js";
|
||||
import * as projectStoreResolver from "./project-store-resolver.js";
|
||||
|
||||
// Mock MissionStore factory
|
||||
@@ -4306,3 +4307,114 @@ describe("Mission API", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mission Interview Route Saturation-Independence Tests
|
||||
*
|
||||
* These tests verify that mission interview routes (mission, milestone, slice)
|
||||
* are NOT gated on task-lane saturation (maxConcurrent, semaphore, queue depth).
|
||||
*/
|
||||
describe("Mission interview routes are independent of task-lane saturation", () => {
|
||||
// Helper to create a mock AI session store for interview routes
|
||||
function createMockAiSessionStore() {
|
||||
const store = new Map<string, any>();
|
||||
return {
|
||||
store,
|
||||
upsert: vi.fn((row) => store.set(row.id, row)),
|
||||
get: vi.fn((id) => store.get(id) ?? null),
|
||||
delete: vi.fn((id) => store.delete(id)),
|
||||
listRecoverable: vi.fn(() => Array.from(store.values())),
|
||||
acquireLock: vi.fn().mockReturnValue({ acquired: true, currentHolder: null }),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to build an app with saturated settings
|
||||
function buildAppWithSaturatedSettings() {
|
||||
const aiSessionStore = createMockAiSessionStore();
|
||||
const { app, missionStore } = buildApp({ aiSessionStore });
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
// Override getSettings to return saturated settings
|
||||
ms.getSettings = vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 0, // Saturated: zero task slots available
|
||||
promptOverrides: {},
|
||||
});
|
||||
|
||||
return { app, missionStore: ms, aiSessionStore };
|
||||
}
|
||||
|
||||
it("POST /api/missions/interview/start succeeds under saturated settings", async () => {
|
||||
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
// Mock createMissionInterviewSession to return a session
|
||||
const createSessionMock = vi.fn().mockResolvedValue("mission-saturation-test-session");
|
||||
vi.spyOn(missionInterviewModule, "createMissionInterviewSession").mockImplementation(createSessionMock);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/start",
|
||||
JSON.stringify({ missionTitle: "Build auth system" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.sessionId).toBe("mission-saturation-test-session");
|
||||
// Verify no saturation error was introduced
|
||||
expect(res.body.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("POST /api/missions/milestones/:milestoneId/interview/start succeeds under saturated settings", async () => {
|
||||
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
// Create a milestone
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
|
||||
// Mock createTargetInterviewSession to return a session (from milestone-slice-interview module)
|
||||
const createSessionMock = vi.fn().mockResolvedValue("milestone-saturation-test-session");
|
||||
vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/milestones/${milestone.id}/interview/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.sessionId).toBe("milestone-saturation-test-session");
|
||||
// Verify no saturation error was introduced
|
||||
expect(res.body.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("POST /api/missions/slices/:sliceId/interview/start succeeds under saturated settings", async () => {
|
||||
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
// Create a slice
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||
|
||||
// Mock createTargetInterviewSession to return a session (from milestone-slice-interview module)
|
||||
const createSessionMock = vi.fn().mockResolvedValue("slice-saturation-test-session");
|
||||
vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/slices/${slice.id}/interview/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.sessionId).toBe("slice-saturation-test-session");
|
||||
// Verify no saturation error was introduced
|
||||
expect(res.body.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,6 +334,10 @@ export function createMissionRouter(
|
||||
|
||||
// ── Interview Endpoints ─────────────────────────────────────────────────────
|
||||
// Note: These are mounted at /api/missions/interview/* via the router
|
||||
//
|
||||
// UTILITY PATH: All interview routes (mission, milestone, slice) are on a separate
|
||||
// control-plane lane. They must NOT be gated on task-lane saturation (maxConcurrent,
|
||||
// semaphore, queue depth). Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
|
||||
/**
|
||||
* Helper to resolve rootDir for the current request's project scope.
|
||||
@@ -374,6 +378,8 @@ export function createMissionRouter(
|
||||
* Start a mission interview session with AI agent streaming.
|
||||
* Body: { missionTitle: string, modelProvider?: string, modelId?: string }
|
||||
* Returns: { sessionId: string }
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
*/
|
||||
router.post(
|
||||
"/interview/start",
|
||||
@@ -435,6 +441,9 @@ export function createMissionRouter(
|
||||
* POST /api/missions/interview/respond
|
||||
* Submit response to interview question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown> }
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post(
|
||||
"/interview/respond",
|
||||
@@ -492,6 +501,9 @@ export function createMissionRouter(
|
||||
/**
|
||||
* POST /api/missions/interview/:sessionId/retry
|
||||
* Retry a failed interview session by replaying the last user interaction.
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post(
|
||||
"/interview/:sessionId/retry",
|
||||
@@ -2651,11 +2663,14 @@ export function createMissionRouter(
|
||||
);
|
||||
|
||||
// ── Milestone Interview Routes ─────────────────────────────────────────────────
|
||||
// UTILITY PATH: Milestone interview routes are independent of task-lane saturation.
|
||||
|
||||
/**
|
||||
* POST /milestones/:milestoneId/interview/start
|
||||
* Start a milestone interview session with AI agent streaming.
|
||||
* Returns: { sessionId: string }
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/start",
|
||||
@@ -2709,6 +2724,9 @@ export function createMissionRouter(
|
||||
* POST /milestones/:milestoneId/interview/respond
|
||||
* Submit response to milestone interview question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown>, tabId?: string }
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/respond",
|
||||
@@ -2872,6 +2890,9 @@ export function createMissionRouter(
|
||||
/**
|
||||
* POST /milestones/:milestoneId/interview/:sessionId/retry
|
||||
* Retry a failed milestone interview session.
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/:sessionId/retry",
|
||||
@@ -2987,11 +3008,14 @@ export function createMissionRouter(
|
||||
);
|
||||
|
||||
// ── Slice Interview Routes ─────────────────────────────────────────────────
|
||||
// UTILITY PATH: Slice interview routes are independent of task-lane saturation.
|
||||
|
||||
/**
|
||||
* POST /slices/:sliceId/interview/start
|
||||
* Start a slice interview session with AI agent streaming.
|
||||
* Returns: { sessionId: string }
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/start",
|
||||
@@ -3048,6 +3072,9 @@ export function createMissionRouter(
|
||||
* POST /slices/:sliceId/interview/respond
|
||||
* Submit response to slice interview question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown>, tabId?: string }
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/respond",
|
||||
@@ -3211,6 +3238,9 @@ export function createMissionRouter(
|
||||
/**
|
||||
* POST /slices/:sliceId/interview/:sessionId/retry
|
||||
* Retry a failed slice interview session.
|
||||
*
|
||||
* UTILITY PATH: Independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/:sessionId/retry",
|
||||
|
||||
@@ -1847,6 +1847,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a heartbeat wake for an assigned agent based on a comment event.
|
||||
*
|
||||
* UTILITY PATH: This function is on the heartbeat control-plane lane and is
|
||||
* independent of task-lane saturation. It must NOT be gated on maxConcurrent,
|
||||
* semaphore state, or queue depth.
|
||||
*
|
||||
* Skip reasons (these are normal operation, not saturation gates):
|
||||
* - No HeartbeatMonitor available (heartbeat executor not configured)
|
||||
* - No agent assigned to the task
|
||||
* - HeartbeatMonitor is bound to a different project
|
||||
* - Agent's responseMode is not "immediate" (non-immediate mode skips on-demand wakes)
|
||||
* - Agent already has an active heartbeat run (prevents duplicate runs)
|
||||
*/
|
||||
const triggerCommentWakeForAssignedAgent = async (
|
||||
scopedStore: TaskStore,
|
||||
task: Task,
|
||||
@@ -1856,6 +1870,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
triggerDetail: string;
|
||||
},
|
||||
): Promise<void> => {
|
||||
// Skip: no HeartbeatMonitor available
|
||||
if (!hasHeartbeatExecutor || !heartbeatMonitor || !task.assignedAgentId) {
|
||||
return;
|
||||
}
|
||||
@@ -1871,15 +1886,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
await agentStore.init();
|
||||
|
||||
const assignedAgent = await agentStore.getAgent(task.assignedAgentId);
|
||||
// Skip: agent not found
|
||||
if (!assignedAgent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip: agent's responseMode is not "immediate" (non-immediate mode skips on-demand wakes)
|
||||
const responseMode = (assignedAgent.runtimeConfig as { messageResponseMode?: string } | undefined)?.messageResponseMode;
|
||||
if (responseMode !== "immediate") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip: agent already has an active heartbeat run (prevents duplicate runs)
|
||||
const activeRun = await agentStore.getActiveHeartbeatRun(assignedAgent.id);
|
||||
if (activeRun) {
|
||||
return;
|
||||
@@ -6600,6 +6618,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
});
|
||||
|
||||
// ── Planning Mode Routes ──────────────────────────────────────────────────
|
||||
// UTILITY PATH: Planning and subtask session routes are on a separate control-plane lane.
|
||||
// They must NOT be gated on task-lane saturation (maxConcurrent, semaphore, queue depth).
|
||||
// These routes create/manage AI planning and subtask breakdown sessions.
|
||||
|
||||
router.post("/subtasks/start-streaming", async (req, res) => {
|
||||
try {
|
||||
@@ -6870,6 +6891,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/subtasks/:sessionId/retry
|
||||
* Retry a failed subtask breakdown session.
|
||||
*
|
||||
* UTILITY PATH: This route is independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post("/subtasks/:sessionId/retry", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
@@ -6913,6 +6941,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Start a new planning session.
|
||||
* Body: { initialPlan: string }
|
||||
* Returns: { sessionId: string, firstQuestion: PlanningQuestion }
|
||||
*
|
||||
* UTILITY PATH: This route is independent of task-lane saturation.
|
||||
*/
|
||||
router.post("/planning/start", async (req, res) => {
|
||||
try {
|
||||
@@ -6957,9 +6987,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Start a new planning session with AI agent streaming.
|
||||
* Body: { initialPlan: string }
|
||||
* Returns: { sessionId: string }
|
||||
*
|
||||
*
|
||||
* After receiving sessionId, connect to GET /api/planning/:sessionId/stream
|
||||
* for real-time thinking output and questions.
|
||||
*
|
||||
* UTILITY PATH: This route is independent of task-lane saturation.
|
||||
*/
|
||||
router.post("/planning/start-streaming", async (req, res) => {
|
||||
try {
|
||||
@@ -7013,6 +7045,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Submit a response to the current planning question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown> }
|
||||
* Returns: { type: "question" | "complete", data: PlanningQuestion | PlanningSummary }
|
||||
*
|
||||
* UTILITY PATH: This route is independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved (multi-tab coordination, not saturation).
|
||||
*/
|
||||
router.post("/planning/respond", async (req, res) => {
|
||||
try {
|
||||
@@ -7060,6 +7095,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/:sessionId/retry
|
||||
* Retry a failed planning session.
|
||||
*
|
||||
* UTILITY PATH: This route is independent of task-lane saturation.
|
||||
* Session-lock 409 with { error, lockedByTab } is preserved.
|
||||
*/
|
||||
router.post("/planning/:sessionId/retry", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
@@ -10800,6 +10842,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*
|
||||
* When triggerExecution is true AND HeartbeatMonitor is available,
|
||||
* also executes a heartbeat run after recording the heartbeat event.
|
||||
*
|
||||
* UTILITY PATH: Heartbeat routes are on a separate control-plane lane and are
|
||||
* independent of task-lane saturation. They must NOT be gated on maxConcurrent,
|
||||
* semaphore state, or queue depth.
|
||||
*/
|
||||
router.post("/agents/:id/heartbeat", async (req, res) => {
|
||||
try {
|
||||
@@ -10906,6 +10952,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* record is created and fully completed without duplicate startRun calls.
|
||||
*
|
||||
* Returns 409 Conflict if the agent already has an active run.
|
||||
*
|
||||
* UTILITY PATH: Agent run routes are on a separate control-plane lane and are
|
||||
* independent of task-lane saturation. They must NOT be gated on maxConcurrent,
|
||||
* semaphore state, or queue depth. The active-run 409 contract is preserved:
|
||||
* { error: "Agent already has an active run", details: { runId } }.
|
||||
*/
|
||||
router.post("/agents/:id/runs", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user