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:
gsxdsm
2026-04-13 20:52:43 -07:00
parent f6f5f14e07
commit 06abd57bb9
3 changed files with 194 additions and 1 deletions

View File

@@ -33,6 +33,7 @@ import {
submitMissionInterviewResponse, submitMissionInterviewResponse,
} from "./mission-interview.js"; } from "./mission-interview.js";
import * as missionInterviewModule 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"; import * as projectStoreResolver from "./project-store-resolver.js";
// Mock MissionStore factory // 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();
});
});

View File

@@ -334,6 +334,10 @@ export function createMissionRouter(
// ── Interview Endpoints ───────────────────────────────────────────────────── // ── Interview Endpoints ─────────────────────────────────────────────────────
// Note: These are mounted at /api/missions/interview/* via the router // 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. * 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. * Start a mission interview session with AI agent streaming.
* Body: { missionTitle: string, modelProvider?: string, modelId?: string } * Body: { missionTitle: string, modelProvider?: string, modelId?: string }
* Returns: { sessionId: string } * Returns: { sessionId: string }
*
* UTILITY PATH: Independent of task-lane saturation.
*/ */
router.post( router.post(
"/interview/start", "/interview/start",
@@ -435,6 +441,9 @@ export function createMissionRouter(
* POST /api/missions/interview/respond * POST /api/missions/interview/respond
* Submit response to interview question. * Submit response to interview question.
* Body: { sessionId: string, responses: Record<string, unknown> } * Body: { sessionId: string, responses: Record<string, unknown> }
*
* UTILITY PATH: Independent of task-lane saturation.
* Session-lock 409 with { error, lockedByTab } is preserved.
*/ */
router.post( router.post(
"/interview/respond", "/interview/respond",
@@ -492,6 +501,9 @@ export function createMissionRouter(
/** /**
* POST /api/missions/interview/:sessionId/retry * POST /api/missions/interview/:sessionId/retry
* Retry a failed interview session by replaying the last user interaction. * 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( router.post(
"/interview/:sessionId/retry", "/interview/:sessionId/retry",
@@ -2651,11 +2663,14 @@ export function createMissionRouter(
); );
// ── Milestone Interview Routes ───────────────────────────────────────────────── // ── Milestone Interview Routes ─────────────────────────────────────────────────
// UTILITY PATH: Milestone interview routes are independent of task-lane saturation.
/** /**
* POST /milestones/:milestoneId/interview/start * POST /milestones/:milestoneId/interview/start
* Start a milestone interview session with AI agent streaming. * Start a milestone interview session with AI agent streaming.
* Returns: { sessionId: string } * Returns: { sessionId: string }
*
* UTILITY PATH: Independent of task-lane saturation.
*/ */
router.post( router.post(
"/milestones/:milestoneId/interview/start", "/milestones/:milestoneId/interview/start",
@@ -2709,6 +2724,9 @@ export function createMissionRouter(
* POST /milestones/:milestoneId/interview/respond * POST /milestones/:milestoneId/interview/respond
* Submit response to milestone interview question. * Submit response to milestone interview question.
* Body: { sessionId: string, responses: Record<string, unknown>, tabId?: string } * 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( router.post(
"/milestones/:milestoneId/interview/respond", "/milestones/:milestoneId/interview/respond",
@@ -2872,6 +2890,9 @@ export function createMissionRouter(
/** /**
* POST /milestones/:milestoneId/interview/:sessionId/retry * POST /milestones/:milestoneId/interview/:sessionId/retry
* Retry a failed milestone interview session. * Retry a failed milestone interview session.
*
* UTILITY PATH: Independent of task-lane saturation.
* Session-lock 409 with { error, lockedByTab } is preserved.
*/ */
router.post( router.post(
"/milestones/:milestoneId/interview/:sessionId/retry", "/milestones/:milestoneId/interview/:sessionId/retry",
@@ -2987,11 +3008,14 @@ export function createMissionRouter(
); );
// ── Slice Interview Routes ───────────────────────────────────────────────── // ── Slice Interview Routes ─────────────────────────────────────────────────
// UTILITY PATH: Slice interview routes are independent of task-lane saturation.
/** /**
* POST /slices/:sliceId/interview/start * POST /slices/:sliceId/interview/start
* Start a slice interview session with AI agent streaming. * Start a slice interview session with AI agent streaming.
* Returns: { sessionId: string } * Returns: { sessionId: string }
*
* UTILITY PATH: Independent of task-lane saturation.
*/ */
router.post( router.post(
"/slices/:sliceId/interview/start", "/slices/:sliceId/interview/start",
@@ -3048,6 +3072,9 @@ export function createMissionRouter(
* POST /slices/:sliceId/interview/respond * POST /slices/:sliceId/interview/respond
* Submit response to slice interview question. * Submit response to slice interview question.
* Body: { sessionId: string, responses: Record<string, unknown>, tabId?: string } * 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( router.post(
"/slices/:sliceId/interview/respond", "/slices/:sliceId/interview/respond",
@@ -3211,6 +3238,9 @@ export function createMissionRouter(
/** /**
* POST /slices/:sliceId/interview/:sessionId/retry * POST /slices/:sliceId/interview/:sessionId/retry
* Retry a failed slice interview session. * Retry a failed slice interview session.
*
* UTILITY PATH: Independent of task-lane saturation.
* Session-lock 409 with { error, lockedByTab } is preserved.
*/ */
router.post( router.post(
"/slices/:sliceId/interview/:sessionId/retry", "/slices/:sliceId/interview/:sessionId/retry",

View File

@@ -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 ( const triggerCommentWakeForAssignedAgent = async (
scopedStore: TaskStore, scopedStore: TaskStore,
task: Task, task: Task,
@@ -1856,6 +1870,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
triggerDetail: string; triggerDetail: string;
}, },
): Promise<void> => { ): Promise<void> => {
// Skip: no HeartbeatMonitor available
if (!hasHeartbeatExecutor || !heartbeatMonitor || !task.assignedAgentId) { if (!hasHeartbeatExecutor || !heartbeatMonitor || !task.assignedAgentId) {
return; return;
} }
@@ -1871,15 +1886,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
await agentStore.init(); await agentStore.init();
const assignedAgent = await agentStore.getAgent(task.assignedAgentId); const assignedAgent = await agentStore.getAgent(task.assignedAgentId);
// Skip: agent not found
if (!assignedAgent) { if (!assignedAgent) {
return; return;
} }
// Skip: agent's responseMode is not "immediate" (non-immediate mode skips on-demand wakes)
const responseMode = (assignedAgent.runtimeConfig as { messageResponseMode?: string } | undefined)?.messageResponseMode; const responseMode = (assignedAgent.runtimeConfig as { messageResponseMode?: string } | undefined)?.messageResponseMode;
if (responseMode !== "immediate") { if (responseMode !== "immediate") {
return; return;
} }
// Skip: agent already has an active heartbeat run (prevents duplicate runs)
const activeRun = await agentStore.getActiveHeartbeatRun(assignedAgent.id); const activeRun = await agentStore.getActiveHeartbeatRun(assignedAgent.id);
if (activeRun) { if (activeRun) {
return; return;
@@ -6600,6 +6618,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}); });
// ── Planning Mode Routes ────────────────────────────────────────────────── // ── 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) => { router.post("/subtasks/start-streaming", async (req, res) => {
try { 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) => { router.post("/subtasks/:sessionId/retry", async (req, res) => {
try { try {
const { sessionId } = req.params; const { sessionId } = req.params;
@@ -6913,6 +6941,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Start a new planning session. * Start a new planning session.
* Body: { initialPlan: string } * Body: { initialPlan: string }
* Returns: { sessionId: string, firstQuestion: PlanningQuestion } * Returns: { sessionId: string, firstQuestion: PlanningQuestion }
*
* UTILITY PATH: This route is independent of task-lane saturation.
*/ */
router.post("/planning/start", async (req, res) => { router.post("/planning/start", async (req, res) => {
try { try {
@@ -6957,9 +6987,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Start a new planning session with AI agent streaming. * Start a new planning session with AI agent streaming.
* Body: { initialPlan: string } * Body: { initialPlan: string }
* Returns: { sessionId: string } * Returns: { sessionId: string }
* *
* After receiving sessionId, connect to GET /api/planning/:sessionId/stream * After receiving sessionId, connect to GET /api/planning/:sessionId/stream
* for real-time thinking output and questions. * 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) => { router.post("/planning/start-streaming", async (req, res) => {
try { try {
@@ -7013,6 +7045,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Submit a response to the current planning question. * Submit a response to the current planning question.
* Body: { sessionId: string, responses: Record<string, unknown> } * Body: { sessionId: string, responses: Record<string, unknown> }
* Returns: { type: "question" | "complete", data: PlanningQuestion | PlanningSummary } * 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) => { router.post("/planning/respond", async (req, res) => {
try { 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) => { router.post("/planning/:sessionId/retry", async (req, res) => {
try { try {
const { sessionId } = req.params; const { sessionId } = req.params;
@@ -10800,6 +10842,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* *
* When triggerExecution is true AND HeartbeatMonitor is available, * When triggerExecution is true AND HeartbeatMonitor is available,
* also executes a heartbeat run after recording the heartbeat event. * 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) => { router.post("/agents/:id/heartbeat", async (req, res) => {
try { try {
@@ -10906,6 +10952,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* record is created and fully completed without duplicate startRun calls. * record is created and fully completed without duplicate startRun calls.
* *
* Returns 409 Conflict if the agent already has an active run. * 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) => { router.post("/agents/:id/runs", async (req, res) => {
try { try {