feat(FN-1348): export HeartbeatMonitor and HeartbeatTriggerScheduler from @fusion/engine

This commit is contained in:
gsxdsm
2026-04-09 09:42:21 -07:00
parent 32798c0e88
commit ac07be790b
8 changed files with 578 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Optimize mission loading performance by eliminating N+1 query patterns and adding batched API endpoints. The GET /api/missions list endpoint now uses batched queries instead of firing getMissionSummary() per mission. Added new GET /api/missions/health batch endpoint for fetching all mission health metrics in a single request. The frontend now makes 1 request instead of N parallel requests when loading mission health data. Added database indexes on mission hierarchy FK columns (milestones.missionId, slices.milestoneId, mission_features.sliceId, mission_features.taskId) to improve query performance.

5
.factory/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"core@factory-plugins": true
}
}

View File

@@ -281,6 +281,295 @@ describe("MissionStore", () => {
}); });
}); });
// ── Batched Summary Tests ──────────────────────────────────────────────
describe("listMissionsWithSummaries", () => {
it("returns empty array when no missions exist", () => {
const result = store.listMissionsWithSummaries();
expect(result).toEqual([]);
});
it("returns correct summaries for multiple missions", () => {
// Mission 1: 2 milestones, 1 complete, 2 features, 1 done
const m1 = store.createMission({ title: "Mission 1" });
const ms1a = store.addMilestone(m1.id, { title: "MS1a" });
const ms1b = store.addMilestone(m1.id, { title: "MS1b" });
store.updateMilestone(ms1b.id, { status: "complete" });
const sl1 = store.addSlice(ms1a.id, { title: "SL1" });
const f1 = store.addFeature(sl1.id, { title: "F1" });
store.updateFeature(f1.id, { status: "done" });
const f2 = store.addFeature(sl1.id, { title: "F2" });
// f2 not done
// Mission 2: 1 milestone, 0 features
const m2 = store.createMission({ title: "Mission 2" });
store.addMilestone(m2.id, { title: "MS2" });
// Mission 3: 0 milestones
store.createMission({ title: "Mission 3" });
const result = store.listMissionsWithSummaries();
// Should be sorted by createdAt DESC (m3, m2, m1 based on creation order)
expect(result.length).toBe(3);
// Mission 3: 0 milestones, 0 features → 0%
const mission3 = result.find((m) => m.title === "Mission 3")!;
expect(mission3.summary).toEqual({
totalMilestones: 0,
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
progressPercent: 0,
});
// Mission 2: 1 milestone, 0 features → 0%
const mission2 = result.find((m) => m.title === "Mission 2")!;
expect(mission2.summary).toEqual({
totalMilestones: 1,
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
progressPercent: 0,
});
// Mission 1: 2 milestones (1 complete), 2 features (1 done) → 50%
const mission1 = result.find((m) => m.title === "Mission 1")!;
expect(mission1.summary).toEqual({
totalMilestones: 2,
completedMilestones: 1,
totalFeatures: 2,
completedFeatures: 1,
progressPercent: 50,
});
});
it("progress percent matches getMissionSummary behavior", () => {
const mission = store.createMission({ title: "Compare test" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const slice = store.addSlice(milestone.id, { title: "S1" });
const f1 = store.addFeature(slice.id, { title: "F1" });
store.updateFeature(f1.id, { status: "done" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f2.id, { status: "done" });
const f3 = store.addFeature(slice.id, { title: "F3" });
// f3 not done
const singleSummary = store.getMissionSummary(mission.id);
const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!;
expect(batchedResult.summary.totalMilestones).toBe(singleSummary.totalMilestones);
expect(batchedResult.summary.completedMilestones).toBe(singleSummary.completedMilestones);
expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures);
expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures);
expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent);
});
});
// ── Batched Health Tests ──────────────────────────────────────────────
describe("listMissionsHealth", () => {
it("returns empty map when no missions exist", () => {
const result = store.listMissionsHealth();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it("returns correct health for a single empty mission", () => {
const mission = store.createMission({ title: "Empty mission" });
store.updateMission(mission.id, {
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
});
const result = store.listMissionsHealth();
expect(result.size).toBe(1);
expect(result.get(mission.id)).toEqual({
missionId: mission.id,
status: "planning",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: undefined,
currentMilestoneId: undefined,
estimatedCompletionPercent: 0,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "watching",
autopilotEnabled: true,
lastActivityAt: "2026-01-01T10:00:00.000Z",
});
});
it("computes correct health for multiple missions with varying states", async () => {
// Mission 1: 1 milestone (active), 1 slice (active), 4 features (1 done, 2 in-flight, 1 failed)
const m1 = store.createMission({ title: "Mission 1" });
store.updateMission(m1.id, { status: "active" });
const ms1 = store.addMilestone(m1.id, { title: "MS1" });
store.updateMilestone(ms1.id, { status: "active" });
const sl1 = store.addSlice(ms1.id, { title: "SL1" });
store.updateSlice(sl1.id, { status: "active" });
const f1Done = store.addFeature(sl1.id, { title: "F1-done" });
store.updateFeature(f1Done.id, { status: "done" });
const f1Triaged = store.addFeature(sl1.id, { title: "F1-triaged" });
store.updateFeature(f1Triaged.id, { status: "triaged" });
const f1Progress = store.addFeature(sl1.id, { title: "F1-progress" });
store.updateFeature(f1Progress.id, { status: "in-progress" });
createTaskInDb(db, "FN-FAILED-1", "Failed task", "failed");
const f1Failed = store.addFeature(sl1.id, { title: "F1-failed" });
store.linkFeatureToTask(f1Failed.id, "FN-FAILED-1");
await new Promise((r) => setTimeout(r, 10));
// Mission 2: 2 milestones (1 complete, 1 active), 0 features
const m2 = store.createMission({ title: "Mission 2" });
store.updateMission(m2.id, { status: "active" });
const ms2a = store.addMilestone(m2.id, { title: "MS2a" });
store.updateMilestone(ms2a.id, { status: "complete" });
const ms2b = store.addMilestone(m2.id, { title: "MS2b" });
store.updateMilestone(ms2b.id, { status: "active" });
const sl2 = store.addSlice(ms2b.id, { title: "SL2" });
store.updateSlice(sl2.id, { status: "active" });
store.logMissionEvent(m1.id, "error", "Error on mission 1");
const result = store.listMissionsHealth();
expect(result.size).toBe(2);
// Mission 1 health
const health1 = result.get(m1.id)!;
expect(health1).toEqual({
missionId: m1.id,
status: "active",
tasksCompleted: 1,
tasksFailed: 1,
tasksInFlight: 3,
totalTasks: 4,
currentSliceId: sl1.id,
currentMilestoneId: ms1.id,
estimatedCompletionPercent: 25,
lastErrorAt: expect.any(String),
lastErrorDescription: "Error on mission 1",
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: undefined,
});
// Mission 2 health: no features, 1/2 milestones complete → 50%
const health2 = result.get(m2.id)!;
expect(health2).toEqual({
missionId: m2.id,
status: "active",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: sl2.id,
currentMilestoneId: ms2b.id,
estimatedCompletionPercent: 50,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: undefined,
});
});
it("counts failed tasks across missions correctly", () => {
const m1 = store.createMission({ title: "Mission 1" });
const ms1 = store.addMilestone(m1.id, { title: "MS1" });
const sl1 = store.addSlice(ms1.id, { title: "SL1" });
const m2 = store.createMission({ title: "Mission 2" });
const ms2 = store.addMilestone(m2.id, { title: "MS2" });
const sl2 = store.addSlice(ms2.id, { title: "SL2" });
createTaskInDb(db, "FN-FAIL-A", "Task A", "failed");
createTaskInDb(db, "FN-FAIL-B", "Task B", "failed");
createTaskInDb(db, "FN-OK-C", "Task C", "done");
const f1 = store.addFeature(sl1.id, { title: "F1" });
store.linkFeatureToTask(f1.id, "FN-FAIL-A");
const f2 = store.addFeature(sl2.id, { title: "F2" });
store.linkFeatureToTask(f2.id, "FN-FAIL-B");
const f3 = store.addFeature(sl2.id, { title: "F3" });
store.linkFeatureToTask(f3.id, "FN-OK-C");
const result = store.listMissionsHealth();
expect(result.get(m1.id)!.tasksFailed).toBe(1);
expect(result.get(m2.id)!.tasksFailed).toBe(1);
});
it("detects last error per mission independently", () => {
const m1 = store.createMission({ title: "Mission 1" });
const m2 = store.createMission({ title: "Mission 2" });
store.logMissionEvent(m1.id, "error", "Old error on M1");
store.logMissionEvent(m2.id, "error", "Only error on M2");
store.logMissionEvent(m1.id, "error", "Latest error on M1");
const result = store.listMissionsHealth();
expect(result.get(m1.id)!.lastErrorDescription).toBe("Latest error on M1");
expect(result.get(m2.id)!.lastErrorDescription).toBe("Only error on M2");
});
it("produces results consistent with getMissionHealth", () => {
const mission = store.createMission({ title: "Consistency test" });
store.updateMission(mission.id, {
status: "active",
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
});
const milestone = store.addMilestone(mission.id, { title: "M1" });
store.updateMilestone(milestone.id, { status: "active" });
const slice = store.addSlice(milestone.id, { title: "S1" });
store.updateSlice(slice.id, { status: "active" });
const f1 = store.addFeature(slice.id, { title: "F1" });
store.updateFeature(f1.id, { status: "done" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f2.id, { status: "triaged" });
createTaskInDb(db, "FN-FAILED-X", "Failed task", "failed");
const f3 = store.addFeature(slice.id, { title: "F3" });
store.linkFeatureToTask(f3.id, "FN-FAILED-X");
store.logMissionEvent(mission.id, "error", "Test error");
const singleHealth = store.getMissionHealth(mission.id);
const batchedHealth = store.listMissionsHealth().get(mission.id)!;
// Compare all fields except lastErrorAt (may differ by ms due to separate queries)
expect(batchedHealth.missionId).toBe(singleHealth!.missionId);
expect(batchedHealth.status).toBe(singleHealth!.status);
expect(batchedHealth.tasksCompleted).toBe(singleHealth!.tasksCompleted);
expect(batchedHealth.tasksFailed).toBe(singleHealth!.tasksFailed);
expect(batchedHealth.tasksInFlight).toBe(singleHealth!.tasksInFlight);
expect(batchedHealth.totalTasks).toBe(singleHealth!.totalTasks);
expect(batchedHealth.currentSliceId).toBe(singleHealth!.currentSliceId);
expect(batchedHealth.currentMilestoneId).toBe(singleHealth!.currentMilestoneId);
expect(batchedHealth.estimatedCompletionPercent).toBe(singleHealth!.estimatedCompletionPercent);
expect(batchedHealth.lastErrorDescription).toBe(singleHealth!.lastErrorDescription);
expect(batchedHealth.autopilotState).toBe(singleHealth!.autopilotState);
expect(batchedHealth.autopilotEnabled).toBe(singleHealth!.autopilotEnabled);
});
});
// ── Mission Observability Tests ─────────────────────────────────────── // ── Mission Observability Tests ───────────────────────────────────────
describe("Mission observability", () => { describe("Mission observability", () => {

View File

@@ -335,6 +335,253 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}; };
} }
/**
* List all missions with computed summaries in a single batch of queries.
*
* Instead of N×(1 + M×(1 + S×1)) queries (one per mission, then per-milestone,
* per-slice, per-feature), this method fires 4 batch queries total and groups
* the data in-memory for summary computation.
*
* @returns Array of missions with summary, sorted by createdAt DESC
*/
listMissionsWithSummaries(): Array<Mission & { summary: MissionSummary }> {
// 1. Fetch all missions
const missions = this.listMissions();
if (missions.length === 0) return [];
// 2. Batch query all milestones
const milestoneRows = this.db.prepare(
"SELECT * FROM milestones ORDER BY orderIndex ASC"
).all() as any[];
const allMilestones = milestoneRows.map((row) => this.rowToMilestone(row));
// 3. Batch query all slices
const sliceRows = this.db.prepare(
"SELECT * FROM slices ORDER BY orderIndex ASC"
).all() as any[];
const allSlices = sliceRows.map((row) => this.rowToSlice(row));
// 4. Batch query all features
const featureRows = this.db.prepare(
"SELECT * FROM mission_features ORDER BY createdAt ASC"
).all() as any[];
const allFeatures = featureRows.map((row) => this.rowToFeature(row));
// 5. Group in-memory: slices by milestoneId, features by sliceId
const slicesByMilestoneId = new Map<string, Slice[]>();
for (const slice of allSlices) {
const list = slicesByMilestoneId.get(slice.milestoneId) || [];
list.push(slice);
slicesByMilestoneId.set(slice.milestoneId, list);
}
const featuresBySliceId = new Map<string, MissionFeature[]>();
for (const feature of allFeatures) {
const list = featuresBySliceId.get(feature.sliceId) || [];
list.push(feature);
featuresBySliceId.set(feature.sliceId, list);
}
// 6. Group milestones by missionId
const milestonesByMissionId = new Map<string, Milestone[]>();
for (const milestone of allMilestones) {
const list = milestonesByMissionId.get(milestone.missionId) || [];
list.push(milestone);
milestonesByMissionId.set(milestone.missionId, list);
}
// 7. Compute summary for each mission using grouped data
return missions.map((mission) => {
const milestones = milestonesByMissionId.get(mission.id) || [];
const totalMilestones = milestones.length;
const completedMilestones = milestones.filter((m) => m.status === "complete").length;
let totalFeatures = 0;
let completedFeatures = 0;
for (const milestone of milestones) {
const slices = slicesByMilestoneId.get(milestone.id) || [];
for (const slice of slices) {
const features = featuresBySliceId.get(slice.id) || [];
totalFeatures += features.length;
completedFeatures += features.filter((f) => f.status === "done").length;
}
}
let progressPercent = 0;
if (totalFeatures > 0) {
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
} else if (totalMilestones > 0) {
progressPercent = Math.round((completedMilestones / totalMilestones) * 100);
}
return {
...mission,
summary: {
totalMilestones,
completedMilestones,
totalFeatures,
completedFeatures,
progressPercent,
},
};
});
}
/**
* Compute health for ALL missions in a single batch of queries.
*
* Instead of N × (1 + M + S + F + failedTasks + lastError) individual queries,
* this method fires a fixed number of batch queries and groups in-memory.
*
* @returns Map of mission ID → MissionHealth
*/
listMissionsHealth(): Map<string, MissionHealth> {
const missions = this.listMissions();
if (missions.length === 0) return new Map();
// 1. Batch query all milestones
const milestoneRows = this.db.prepare(
"SELECT * FROM milestones ORDER BY orderIndex ASC"
).all() as any[];
const allMilestones = milestoneRows.map((row) => this.rowToMilestone(row));
// 2. Batch query all slices
const sliceRows = this.db.prepare(
"SELECT * FROM slices ORDER BY orderIndex ASC"
).all() as any[];
const allSlices = sliceRows.map((row) => this.rowToSlice(row));
// 3. Batch query all features
const featureRows = this.db.prepare(
"SELECT * FROM mission_features ORDER BY createdAt ASC"
).all() as any[];
const allFeatures = featureRows.map((row) => this.rowToFeature(row));
// 4. Batch query all failed task IDs
const failedTaskRows = this.db.prepare(
"SELECT id FROM tasks WHERE status = 'failed'"
).all() as Array<{ id: string }>;
const failedTaskIds = new Set(failedTaskRows.map((row) => row.id));
// 5. Batch query last error event per mission
const lastErrorRows = this.db.prepare(`
SELECT missionId, timestamp, description
FROM mission_events
WHERE eventType = 'error'
ORDER BY timestamp DESC, id DESC
`).all() as Array<{ missionId: string; timestamp: string; description: string }>;
// Only keep the first (latest) error per missionId
const lastErrorByMission = new Map<string, { timestamp: string; description: string }>();
for (const row of lastErrorRows) {
if (!lastErrorByMission.has(row.missionId)) {
lastErrorByMission.set(row.missionId, { timestamp: row.timestamp, description: row.description });
}
}
// 6. Group hierarchy in-memory
const milestonesByMissionId = new Map<string, Milestone[]>();
for (const milestone of allMilestones) {
const list = milestonesByMissionId.get(milestone.missionId) || [];
list.push(milestone);
milestonesByMissionId.set(milestone.missionId, list);
}
const slicesByMilestoneId = new Map<string, Slice[]>();
for (const slice of allSlices) {
const list = slicesByMilestoneId.get(slice.milestoneId) || [];
list.push(slice);
slicesByMilestoneId.set(slice.milestoneId, list);
}
const featuresBySliceId = new Map<string, MissionFeature[]>();
for (const feature of allFeatures) {
const list = featuresBySliceId.get(feature.sliceId) || [];
list.push(feature);
featuresBySliceId.set(feature.sliceId, list);
}
// 7. Compute health for each mission
const result = new Map<string, MissionHealth>();
for (const mission of missions) {
const milestones = milestonesByMissionId.get(mission.id) || [];
let totalTasks = 0;
let tasksCompleted = 0;
let tasksInFlight = 0;
let tasksFailed = 0;
let currentSliceId: string | undefined;
let currentMilestoneId: string | undefined;
let totalMilestones = milestones.length;
let completedMilestones = 0;
let totalFeatures = 0;
let completedFeatures = 0;
for (const milestone of milestones) {
if (milestone.status === "complete") {
completedMilestones++;
}
if (!currentMilestoneId && milestone.status === "active") {
currentMilestoneId = milestone.id;
}
const slices = slicesByMilestoneId.get(milestone.id) || [];
for (const slice of slices) {
if (!currentSliceId && slice.status === "active") {
currentSliceId = slice.id;
currentMilestoneId ??= milestone.id;
}
const features = featuresBySliceId.get(slice.id) || [];
for (const feature of features) {
totalFeatures++;
totalTasks += 1;
if (feature.status === "done") {
tasksCompleted += 1;
completedFeatures++;
}
if (feature.status === "triaged" || feature.status === "in-progress") {
tasksInFlight += 1;
}
if (feature.taskId && failedTaskIds.has(feature.taskId)) {
tasksFailed++;
}
}
}
}
let progressPercent = 0;
if (totalFeatures > 0) {
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
} else if (totalMilestones > 0) {
progressPercent = Math.round((completedMilestones / totalMilestones) * 100);
}
const lastError = lastErrorByMission.get(mission.id);
result.set(mission.id, {
missionId: mission.id,
status: mission.status,
tasksCompleted,
tasksFailed,
tasksInFlight,
totalTasks,
currentSliceId,
currentMilestoneId,
estimatedCompletionPercent: progressPercent,
lastErrorAt: lastError?.timestamp,
lastErrorDescription: lastError?.description,
autopilotState: mission.autopilotState ?? "inactive",
autopilotEnabled: mission.autopilotEnabled ?? false,
lastActivityAt: mission.lastAutopilotActivityAt,
});
}
return result;
}
/** /**
* Persist a mission lifecycle event for observability and auditing. * Persist a mission lifecycle event for observability and auditing.
*/ */

View File

@@ -3074,6 +3074,11 @@ export function fetchMissionHealth(missionId: string, projectId?: string): Promi
return api<MissionHealth>(withProjectId(`/missions/${encodeURIComponent(missionId)}/health`, projectId)); return api<MissionHealth>(withProjectId(`/missions/${encodeURIComponent(missionId)}/health`, projectId));
} }
/** Fetch health metrics for all missions in a single batched request. */
export function fetchMissionsHealth(projectId?: string): Promise<Record<string, MissionHealth>> {
return api<Record<string, MissionHealth>>(withProjectId("/missions/health", projectId));
}
/** Add milestone to mission */ /** Add milestone to mission */
export function createMilestone( export function createMilestone(
missionId: string, missionId: string,

View File

@@ -71,6 +71,7 @@ import {
startMissionAutopilot, startMissionAutopilot,
stopMissionAutopilot, stopMissionAutopilot,
fetchMissionHealth, fetchMissionHealth,
fetchMissionsHealth,
fetchMissionEvents, fetchMissionEvents,
} from "../api"; } from "../api";
import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types"; import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types";
@@ -429,21 +430,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
return; return;
} }
const healthResults = await Promise.allSettled( // Use batched endpoint for optimal performance (1 request instead of N)
missionList.map(async (mission) => { const healthRecord = await fetchMissionsHealth(projectId);
const health = await fetchMissionHealth(mission.id, projectId);
return [mission.id, health] as const;
}),
);
setMissionHealthById((prev) => { setMissionHealthById((prev) => {
const next = new Map(prev); const next = new Map(prev);
for (const result of healthResults) { for (const [missionId, health] of Object.entries(healthRecord)) {
if (result.status === "fulfilled") { if (isMissionHealth(health)) {
const [missionId, health] = result.value; next.set(missionId, health);
if (isMissionHealth(health)) {
next.set(missionId, health);
}
} }
} }
return next; return next;

View File

@@ -251,22 +251,34 @@ export function createMissionRouter(
/** /**
* GET /api/missions * GET /api/missions
* List all missions ordered by createdAt desc, with status summary * List all missions ordered by createdAt desc, with status summary
* Uses batched query for optimal performance.
*/ */
router.get( router.get(
"/", "/",
catchTypedHandler(async (_req, res) => { catchTypedHandler(async (_req, res) => {
const missions = missionStore.listMissions(); const missionsWithSummary = missionStore.listMissionsWithSummaries();
// Sort by createdAt desc
missions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
// Attach status summary to each mission
const missionsWithSummary = missions.map((mission) => ({
...mission,
summary: missionStore.getMissionSummary(mission.id),
}));
res.json(missionsWithSummary); res.json(missionsWithSummary);
}) })
); );
/**
* GET /api/missions/health
* Get health metrics for all missions in a single batched request.
* Returns a map of mission ID → health object.
*/
router.get(
"/health",
catchTypedHandler(async (_req, res) => {
const healthMap = missionStore.listMissionsHealth();
// Convert Map to Record for JSON serialization
const result: Record<string, ReturnType<typeof healthMap.get>> = {};
for (const [missionId, health] of healthMap) {
result[missionId] = health;
}
res.json(result);
})
);
/** /**
* POST /api/missions * POST /api/missions
* Create a new mission * Create a new mission

View File

@@ -27,6 +27,7 @@ export { PrCommentHandler } from "./pr-comment-handler.js";
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js"; export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js"; export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
export { StuckTaskDetector, type StuckTaskDetectorOptions, type DisposableSession } from "./stuck-task-detector.js"; export { StuckTaskDetector, type StuckTaskDetectorOptions, type DisposableSession } from "./stuck-task-detector.js";
export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "./agent-heartbeat.js";
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js"; export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
export { SelfHealingManager, type SelfHealingOptions } from "./self-healing.js"; export { SelfHealingManager, type SelfHealingOptions } from "./self-healing.js";
export { ProjectManager } from "./project-manager.js"; export { ProjectManager } from "./project-manager.js";