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,
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user