fix(FN-1952): recover routines schedules merge
This commit is contained in:
@@ -8,15 +8,28 @@
|
||||
*/
|
||||
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
RoutineStore,
|
||||
Routine,
|
||||
RoutineExecutionResult,
|
||||
AutomationRunResult,
|
||||
AutomationStep,
|
||||
AutomationStepResult,
|
||||
Column,
|
||||
TaskCreateInput,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
|
||||
import type { AiPromptExecutor } from "./cron-runner.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const log = createLogger("routine-runner");
|
||||
const execAsync = promisify(exec);
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const MAX_BUFFER = 1024 * 1024;
|
||||
const MAX_OUTPUT_LENGTH = 10 * 1024;
|
||||
|
||||
/** Options for RoutineRunner constructor */
|
||||
export interface RoutineRunnerOptions {
|
||||
@@ -26,6 +39,10 @@ export interface RoutineRunnerOptions {
|
||||
heartbeatMonitor: HeartbeatMonitor;
|
||||
/** Project root directory */
|
||||
rootDir: string;
|
||||
/** Optional task store used when routines execute schedule-style actions. */
|
||||
taskStore?: TaskStore;
|
||||
/** Optional AI prompt executor for ai-prompt action steps. */
|
||||
aiPromptExecutor?: AiPromptExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +94,7 @@ export class RoutineRunner {
|
||||
throw new Error(`Routine '${routineId}' is disabled`);
|
||||
}
|
||||
|
||||
if (!routine.agentId) {
|
||||
if (!this.hasRoutineAction(routine) && !routine.agentId) {
|
||||
throw new Error(`Routine '${routineId}' has no assigned agent`);
|
||||
}
|
||||
|
||||
@@ -138,47 +155,29 @@ export class RoutineRunner {
|
||||
const routineId = routine.id;
|
||||
|
||||
try {
|
||||
// Execute via heartbeat monitor
|
||||
const run = await this.options.heartbeatMonitor.executeHeartbeat({
|
||||
agentId: routine.agentId,
|
||||
source: "routine",
|
||||
triggerDetail: `routine:${routine.id}:${triggerType}`,
|
||||
contextSnapshot: {
|
||||
routineId: routine.id,
|
||||
routineName: routine.name,
|
||||
triggerType,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
const actionResult = this.hasRoutineAction(routine)
|
||||
? await this.executeRoutineAction(routine, startedAt)
|
||||
: await this.executeAgentRoutine(routine, triggerType, context);
|
||||
|
||||
// Determine status from run
|
||||
let success = true;
|
||||
let output = "";
|
||||
let error: string | undefined;
|
||||
|
||||
if (run.status === "failed" || run.status === "terminated") {
|
||||
success = false;
|
||||
error = run.stderrExcerpt || `Run ${run.status}`;
|
||||
output = error;
|
||||
} else {
|
||||
output = run.resultJson ? JSON.stringify(run.resultJson) : "Routine completed successfully";
|
||||
}
|
||||
|
||||
// Complete the execution
|
||||
await this.options.routineStore.completeRoutineExecution(routineId, {
|
||||
completedAt: new Date().toISOString(),
|
||||
success,
|
||||
resultJson: run.resultJson,
|
||||
error,
|
||||
completedAt: actionResult.completedAt,
|
||||
success: actionResult.success,
|
||||
resultJson: actionResult.success ? { output: actionResult.output } : undefined,
|
||||
output: actionResult.output,
|
||||
error: actionResult.error,
|
||||
triggerType: triggerType as RoutineExecutionResult["triggerType"],
|
||||
stepResults: actionResult.stepResults,
|
||||
});
|
||||
|
||||
return {
|
||||
routineId,
|
||||
success,
|
||||
output,
|
||||
success: actionResult.success,
|
||||
output: actionResult.output,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
error,
|
||||
completedAt: actionResult.completedAt,
|
||||
error: actionResult.error,
|
||||
triggerType: triggerType as RoutineExecutionResult["triggerType"],
|
||||
stepResults: actionResult.stepResults,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -206,6 +205,205 @@ export class RoutineRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private hasRoutineAction(routine: Routine): boolean {
|
||||
return Boolean((routine.steps && routine.steps.length > 0) || routine.command?.trim());
|
||||
}
|
||||
|
||||
private async executeAgentRoutine(
|
||||
routine: Routine,
|
||||
triggerType: string,
|
||||
context: Record<string, unknown> | undefined,
|
||||
): Promise<AutomationRunResult> {
|
||||
const run = await this.options.heartbeatMonitor.executeHeartbeat({
|
||||
agentId: routine.agentId,
|
||||
source: "routine",
|
||||
triggerDetail: `routine:${routine.id}:${triggerType}`,
|
||||
contextSnapshot: {
|
||||
routineId: routine.id,
|
||||
routineName: routine.name,
|
||||
triggerType,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
|
||||
if (run.status === "failed" || run.status === "terminated") {
|
||||
const error = run.stderrExcerpt || `Run ${run.status}`;
|
||||
return {
|
||||
success: false,
|
||||
output: error,
|
||||
error,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: run.resultJson ? JSON.stringify(run.resultJson) : "Routine completed successfully",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async executeRoutineAction(
|
||||
routine: Routine,
|
||||
startedAt: string,
|
||||
): Promise<AutomationRunResult> {
|
||||
if (routine.steps && routine.steps.length > 0) {
|
||||
return this.executeSteps(routine, startedAt);
|
||||
}
|
||||
return this.executeCommand(routine.command ?? "", routine.timeoutMs, startedAt);
|
||||
}
|
||||
|
||||
private async executeCommand(
|
||||
command: string,
|
||||
timeoutMs: number | undefined,
|
||||
startedAt: string,
|
||||
): Promise<AutomationRunResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
shell: "/bin/sh",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: truncateOutput(stdout, stderr),
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err: any) {
|
||||
const stdout = err.stdout ?? "";
|
||||
const stderr = err.stderr ?? "";
|
||||
const error = err.killed
|
||||
? `Command timed out after ${(timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
|
||||
: err.message ?? String(err);
|
||||
return {
|
||||
success: false,
|
||||
output: truncateOutput(stdout, stderr),
|
||||
error,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async executeSteps(routine: Routine, startedAt: string): Promise<AutomationRunResult> {
|
||||
const steps = routine.steps ?? [];
|
||||
const stepResults: AutomationStepResult[] = [];
|
||||
let overallSuccess = true;
|
||||
let stoppedEarly = false;
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const result = await this.executeStep(routine, step, i);
|
||||
stepResults.push(result);
|
||||
|
||||
if (!result.success) {
|
||||
overallSuccess = false;
|
||||
if (!step.continueOnFailure) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outputParts: string[] = [];
|
||||
for (const sr of stepResults) {
|
||||
outputParts.push(`=== Step ${sr.stepIndex + 1}: ${sr.stepName} (${sr.success ? "success" : "FAILED"}) ===`);
|
||||
if (sr.output) outputParts.push(sr.output);
|
||||
if (sr.error) outputParts.push(`Error: ${sr.error}`);
|
||||
}
|
||||
const failedSteps = stepResults.filter((sr) => !sr.success);
|
||||
|
||||
return {
|
||||
success: overallSuccess,
|
||||
output: truncateOutput(outputParts.join("\n"), ""),
|
||||
error: failedSteps.length > 0
|
||||
? `${failedSteps.length} step(s) failed: ${failedSteps.map((s) => s.stepName).join(", ")}${stoppedEarly ? " (execution stopped)" : ""}`
|
||||
: undefined,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
stepResults,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeStep(
|
||||
routine: Routine,
|
||||
step: AutomationStep,
|
||||
stepIndex: number,
|
||||
): Promise<AutomationStepResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const timeoutMs = step.timeoutMs ?? routine.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
if (step.type === "command") {
|
||||
const result = await this.executeCommand(step.command ?? "", timeoutMs, startedAt);
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
startedAt,
|
||||
completedAt: result.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (step.type === "ai-prompt") {
|
||||
if (!step.prompt?.trim()) {
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "AI prompt step has no prompt specified", startedAt, completedAt: new Date().toISOString() };
|
||||
}
|
||||
if (!this.options.aiPromptExecutor) {
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "AI execution is not configured", startedAt, completedAt: new Date().toISOString() };
|
||||
}
|
||||
try {
|
||||
const output = await Promise.race([
|
||||
this.options.aiPromptExecutor(step.prompt, step.modelProvider, step.modelId),
|
||||
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs)),
|
||||
]);
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: true, output: truncateOutput(output, ""), startedAt, completedAt: new Date().toISOString() };
|
||||
} catch (err) {
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: err instanceof Error ? err.message : String(err), startedAt, completedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
if (step.type === "create-task") {
|
||||
if (!this.options.taskStore) {
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "Task creation is not configured", startedAt, completedAt: new Date().toISOString() };
|
||||
}
|
||||
if (!step.taskDescription?.trim()) {
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "Create-task step has no task description specified", startedAt, completedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
const taskInput: TaskCreateInput = {
|
||||
title: step.taskTitle?.trim() || undefined,
|
||||
description: step.taskDescription.trim(),
|
||||
column: (step.taskColumn as Column) || "triage",
|
||||
modelProvider: step.modelProvider?.trim() || undefined,
|
||||
modelId: step.modelId?.trim() || undefined,
|
||||
};
|
||||
try {
|
||||
const task = await this.options.taskStore.createTask(taskInput);
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: true, output: `Created task ${task.id}: ${task.title || task.description.slice(0, 80)}`, startedAt, completedAt: new Date().toISOString() };
|
||||
} catch (err) {
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: err instanceof Error ? err.message : String(err), startedAt, completedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: false,
|
||||
output: "",
|
||||
error: `Unknown step type: "${(step as any).type}"`,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle catch-up for missed routine executions based on the catch-up policy.
|
||||
*
|
||||
@@ -332,3 +530,15 @@ export class RoutineRunner {
|
||||
return this.inFlightExecutions.has(routineId);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateOutput(stdout: string, stderr: string): string {
|
||||
let output = stdout;
|
||||
if (stderr) {
|
||||
output += stdout ? "\n--- stderr ---\n" : "";
|
||||
output += stderr;
|
||||
}
|
||||
if (output.length > MAX_OUTPUT_LENGTH) {
|
||||
return `${output.slice(0, MAX_OUTPUT_LENGTH)}\n[output truncated]`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AgentSemaphore } from "../concurrency.js";
|
||||
import { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "../agent-heartbeat.js";
|
||||
import { RoutineRunner, type RoutineRunnerOptions } from "../routine-runner.js";
|
||||
import { RoutineScheduler } from "../routine-scheduler.js";
|
||||
import { createAiPromptExecutor } from "../cron-runner.js";
|
||||
import type {
|
||||
ProjectRuntime,
|
||||
ProjectRuntimeConfig,
|
||||
@@ -467,10 +468,13 @@ export class InProcessRuntime
|
||||
this.routineStore = routineStore;
|
||||
|
||||
if (this.heartbeatMonitor) {
|
||||
const aiPromptExecutor = await createAiPromptExecutor(this.config.workingDirectory);
|
||||
const routineRunnerOptions: RoutineRunnerOptions = {
|
||||
routineStore,
|
||||
heartbeatMonitor: this.heartbeatMonitor,
|
||||
rootDir: this.config.workingDirectory,
|
||||
taskStore: this.taskStore,
|
||||
aiPromptExecutor,
|
||||
};
|
||||
this.routineRunner = new RoutineRunner(routineRunnerOptions);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user