fix(FN-2672): harden automation execution and scheduling flows

- Improve automation startup diagnostics and route handling for manual execution steps
- Add support for full manual automation step execution in dashboard and engine flows
- Expand due-schedule coverage in automation store and dashboard route tests
- Add cron runner regression tests for edge cases and document the automation execution fix via changeset
This commit is contained in:
Fusion
2026-04-27 02:53:04 -07:00
committed by gsxdsm
parent 56edfe340d
commit b969b01b1c
12 changed files with 557 additions and 118 deletions

View File

@@ -325,6 +325,19 @@ describe("CronRunner", () => {
);
});
it("clears inFlight even when recordRun throws", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo in-flight-cleanup" });
const automationStore = createMockAutomationStore([schedule]);
(automationStore.recordRun as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("record failed"));
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(runner["inFlight"].has(schedule.id)).toBe(false);
});
it("captures stderr output", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo err >&2" });
@@ -606,6 +619,22 @@ describe("CronRunner", () => {
expect(result.stepResults).toBeUndefined();
});
it("falls back to legacy mode when steps array is empty", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "echo empty-steps-fallback",
steps: [],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.output).toContain("empty-steps-fallback");
expect(result.stepResults).toBeUndefined();
});
it("executes multiple command steps sequentially", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
@@ -670,6 +699,33 @@ describe("CronRunner", () => {
expect(result.stepResults![1].output).toContain("continued");
});
it("continueOnFailure also advances after a create-task failure", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "",
steps: [
makeStep({
type: "create-task",
name: "Invalid create task",
taskDescription: "",
continueOnFailure: true,
command: undefined,
}),
makeStep({ name: "Still runs", command: "echo still-ran" }),
],
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.stepResults).toHaveLength(2);
expect(result.stepResults![0].success).toBe(false);
expect(result.stepResults![1].success).toBe(true);
expect(result.stepResults![1].output).toContain("still-ran");
});
it("uses per-step timeout override", async () => {
const store = createMockStore();
const schedule = createMockSchedule({

View File

@@ -53,6 +53,23 @@ interface RemoteLifecycleEvaluation {
const isRemoteActive = (ra: Settings["remoteAccess"] | undefined): boolean =>
ra?.activeProvider != null && (ra.providers[ra.activeProvider]?.enabled ?? false);
function formatErrorDetails(error: unknown): { message: string; detail: string } {
if (error instanceof Error) {
return {
message: error.message || error.name,
detail: error.stack ?? `${error.name}: ${error.message}`,
};
}
const detail = String(error);
return { message: detail, detail };
}
export interface AutomationSubsystemHealth {
status: "not-initialized" | "initializing" | "ready" | "degraded";
message: string;
updatedAt: string;
}
export interface ProjectEngineOptions {
/** Project identifier for notification deep links */
projectId?: string;
@@ -119,6 +136,11 @@ export class ProjectEngine {
at: new Date().toISOString(),
provider: null,
};
private automationSubsystemHealth: AutomationSubsystemHealth = {
status: "not-initialized",
message: "Automation subsystem has not been initialized",
updatedAt: new Date().toISOString(),
};
// ── Auto-merge state ──
private mergeQueue: string[] = [];
@@ -204,8 +226,13 @@ export class ProjectEngine {
}
// 4. Initialize AutomationStore + CronRunner
this.setAutomationSubsystemHealth(
"initializing",
"Initializing AutomationStore and CronRunner",
);
try {
const { AutomationStore } = await import("@fusion/core");
const coreAutomationModule = await import("@fusion/core");
const { AutomationStore } = coreAutomationModule;
this.automationStore = new AutomationStore(cwd);
await this.automationStore.init();
@@ -217,44 +244,73 @@ export class ProjectEngine {
});
const settings = await store.getSettings();
const startupSyncFailures: string[] = [];
// Sync insight extraction automation on startup
try {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
if (typeof syncInsightExtractionAutomation === "function") {
await syncInsightExtractionAutomation(this.automationStore, settings);
if (typeof coreAutomationModule.syncInsightExtractionAutomation === "function") {
try {
await coreAutomationModule.syncInsightExtractionAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`insight extraction: ${message}`);
runtimeLog.warn(`Insight extraction automation startup sync failed:\n${detail}`);
}
} catch {
// syncInsightExtractionAutomation may not be exported yet
} else {
runtimeLog.warn("syncInsightExtractionAutomation is unavailable; skipping startup sync");
}
// Sync auto-summarize automation on startup
try {
const { syncAutoSummarizeAutomation } = await import("@fusion/core");
if (typeof syncAutoSummarizeAutomation === "function") {
await syncAutoSummarizeAutomation(this.automationStore, settings);
if (typeof coreAutomationModule.syncAutoSummarizeAutomation === "function") {
try {
await coreAutomationModule.syncAutoSummarizeAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`auto-summarize: ${message}`);
runtimeLog.warn(`Auto-summarize automation startup sync failed:\n${detail}`);
}
} catch {
// syncAutoSummarizeAutomation may not be exported yet
} else {
runtimeLog.warn("syncAutoSummarizeAutomation is unavailable; skipping startup sync");
}
// Sync memory dreams automation on startup
try {
const { syncMemoryDreamsAutomation } = await import("@fusion/core");
if (typeof syncMemoryDreamsAutomation === "function") {
await syncMemoryDreamsAutomation(this.automationStore, settings);
if (typeof coreAutomationModule.syncMemoryDreamsAutomation === "function") {
try {
await coreAutomationModule.syncMemoryDreamsAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`memory dreams: ${message}`);
runtimeLog.warn(`Memory dreams automation startup sync failed:\n${detail}`);
}
} catch {
// syncMemoryDreamsAutomation may not be exported yet
} else {
runtimeLog.warn("syncMemoryDreamsAutomation is unavailable; skipping startup sync");
}
this.cronRunner.start();
if (startupSyncFailures.length > 0) {
this.setAutomationSubsystemHealth(
"degraded",
`CronRunner started with startup sync warnings: ${startupSyncFailures.join("; ")}`,
);
} else {
this.setAutomationSubsystemHealth(
"ready",
"CronRunner initialized and startup automation sync completed",
);
}
runtimeLog.log("CronRunner initialized and started");
} catch (err) {
// Non-fatal — automations are optional
runtimeLog.warn(
"AutomationStore/CronRunner initialization failed (continuing without automations):",
err instanceof Error ? err.message : err,
const { message, detail } = formatErrorDetails(err);
this.cronRunner = undefined;
this.automationStore = undefined;
this.setAutomationSubsystemHealth(
"degraded",
`AutomationStore/CronRunner initialization failed: ${message}`,
);
runtimeLog.error(
`AutomationStore/CronRunner initialization failed (continuing without automations):\n${detail}`,
);
}
@@ -333,6 +389,7 @@ export class ProjectEngine {
// Stop auxiliary subsystems
this.notifier?.stop();
this.cronRunner?.stop();
this.setAutomationSubsystemHealth("not-initialized", "Automation subsystem stopped");
const tunnelManager = this.remoteTunnelManager;
this.remoteTunnelManager = undefined;
@@ -414,6 +471,13 @@ export class ProjectEngine {
return this.automationStore;
}
/**
* Get the automation subsystem health for diagnostics and status reporting.
*/
getAutomationSubsystemHealth(): AutomationSubsystemHealth {
return { ...this.automationSubsystemHealth };
}
/** Get the RoutineStore (if initialized). */
getRoutineStore(): import("@fusion/core").RoutineStore | undefined {
return this.runtime.getRoutineStore();
@@ -555,6 +619,17 @@ export class ProjectEngine {
};
}
private setAutomationSubsystemHealth(
status: AutomationSubsystemHealth["status"],
message: string,
): void {
this.automationSubsystemHealth = {
status,
message,
updatedAt: new Date().toISOString(),
};
}
private async restoreRemoteTunnelIfNeeded(store: TaskStore): Promise<void> {
const manager = this.remoteTunnelManager;
if (!manager) {
@@ -1558,10 +1633,12 @@ export class ProjectEngine {
runtimeLog.log("Memory dreams automation synced with settings");
}
} catch (err) {
runtimeLog.warn(
"Failed to sync memory maintenance automation:",
err instanceof Error ? err.message : err,
const { message, detail } = formatErrorDetails(err);
this.setAutomationSubsystemHealth(
"degraded",
`Failed to sync memory maintenance automation: ${message}`,
);
runtimeLog.warn(`Failed to sync memory maintenance automation:\n${detail}`);
}
};
store.on("settings:updated", onInsightSettingsChange);
@@ -1592,10 +1669,12 @@ export class ProjectEngine {
runtimeLog.log("Auto-summarize automation synced with settings");
}
} catch (err) {
runtimeLog.warn(
"Failed to sync auto-summarize automation:",
err instanceof Error ? err.message : err,
const { message, detail } = formatErrorDetails(err);
this.setAutomationSubsystemHealth(
"degraded",
`Failed to sync auto-summarize automation: ${message}`,
);
runtimeLog.warn(`Failed to sync auto-summarize automation:\n${detail}`);
}
};
store.on("settings:updated", onAutoSummarizeSettingsChange);