feat(FN-3389): fix scheduled evaluator integration types

Completes typing for the scheduled evaluator integration in the cron runner and project engine, with corresponding test updates in the evaluator test file.

Fusion-Task-Id: FN-3389
This commit is contained in:
Fusion
2026-05-06 05:59:05 -07:00
committed by gsxdsm
parent 323d6e7b21
commit 5ffb5097c4
20 changed files with 777 additions and 27 deletions

View File

@@ -171,7 +171,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
});
it("seeds lastModified", () => {
const ts = db.getLastModified();
@@ -193,7 +193,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -966,7 +966,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -991,11 +991,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
db.close();
});
@@ -1030,7 +1030,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1071,7 +1071,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1140,7 +1140,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1243,7 +1243,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1317,7 +1317,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1341,7 +1341,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1445,7 +1445,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1914,7 +1914,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2043,7 +2043,7 @@ describe("migration v63 project auth tables", () => {
const migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(63);
expect(migrated.getSchemaVersion()).toBe(64);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%' ORDER BY name")
.all() as Array<{ name: string }>;

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { collectDeterministicSignals } from "../eval-signal-collector.js";
import type { TaskDetail } from "../types.js";
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-1",
description: "desc",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T01:00:00.000Z",
prompt: "prompt",
...overrides,
} as TaskDetail;
}
describe("collectDeterministicSignals", () => {
it("normalizes timing workflow review and commit/log evidence", () => {
const task = makeTask({
id: "FN-22",
status: "in-review",
executionStartedAt: "2026-05-01T00:00:00.000Z",
executionCompletedAt: "2026-05-01T00:05:00.000Z",
timedExecutionMs: 120000,
branch: "fn/fn-22",
mergeDetails: { commitSha: "abc1234", mergedAt: "2026-05-01T00:06:00.000Z" },
workflowStepResults: [
{ workflowStepId: "WS-1", workflowStepName: "lint", status: "passed" },
{ workflowStepId: "WS-2", workflowStepName: "tests", status: "failed" },
],
log: [
{ timestamp: "1", action: "[timing] tests in 450ms" },
{ timestamp: "2", action: "warning: flaky" },
{ timestamp: "3", action: "error: boom" },
],
});
const signals = collectDeterministicSignals(task, { runId: "ER-1", startedAt: "2026-05-02T00:00:00.000Z" });
expect(signals.workflowSummary).toEqual({ total: 2, passed: 1, failed: 1, pending: 0 });
expect(signals.reviewStatus).toBe("in-review");
expect(signals.commitSummary.commitCount).toBe(1);
expect(signals.logSummary).toEqual({ errorCount: 1, warningCount: 1, timingEntries: 1 });
expect(signals.evidence.some((e) => e.kind === "timing")).toBe(true);
});
it("handles missing optional metadata without throwing", () => {
const task = makeTask({ column: "archived", log: [] });
const signals = collectDeterministicSignals(task, { runId: "ER-2", startedAt: "2026-05-02T00:00:00.000Z" });
expect(signals.column).toBe("archived");
expect(signals.workflowSummary).toEqual({ total: 0, passed: 0, failed: 0, pending: 0 });
expect(signals.commitSummary.commitCount).toBe(0);
expect(signals.logSummary).toEqual({ errorCount: 0, warningCount: 0, timingEntries: 0 });
});
});

View File

@@ -73,6 +73,28 @@ describe("EvalStore", () => {
expect(updated?.counts.scoredTasks).toBe(1);
});
it("deduplicates per runId/taskId via upsert semantics", () => {
const run = store.createRun({ projectId: "p1", scope: "window" });
const first = store.createTaskResult(run.id, {
taskId: "FN-dup",
taskSnapshot: { taskId: "FN-dup", title: "A" },
status: "scored",
overallScore: 0.2,
});
const second = store.createTaskResult(run.id, {
taskId: "FN-dup",
taskSnapshot: { taskId: "FN-dup", title: "B" },
status: "scored",
overallScore: 0.9,
});
const rows = store.listTaskResults({ runId: run.id, taskId: "FN-dup" });
expect(rows).toHaveLength(1);
expect(rows[0]?.overallScore).toBe(0.9);
expect(rows[0]?.taskSnapshot.title).toBe("B");
expect(second.id).toBe(first.id);
});
it("appends run events with sequential ordering", () => {
const run = store.createRun({ projectId: "p1", scope: "window" });
const evt1 = store.appendRunEvent(run.id, { type: "info", message: "started" });

View File

@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(63);
expect(db1.getSchemaVersion()).toBe(64);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(63);
expect(db3.getSchemaVersion()).toBe(64);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(63);
expect(db1.getSchemaVersion()).toBe(64);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(63);
expect(db2.getSchemaVersion()).toBe(64);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(63);
expect(db1.getSchemaVersion()).toBe(64);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
});
it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
});
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(63);
expect(db.getSchemaVersion()).toBe(64);
const index = db
.prepare(

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 63;
const SCHEMA_VERSION = 64;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -517,6 +517,7 @@ CREATE TABLE IF NOT EXISTS eval_task_results (
CREATE INDEX IF NOT EXISTS idxEvalTaskResultsRunIdCreatedAt ON eval_task_results(runId, createdAt);
CREATE INDEX IF NOT EXISTS idxEvalTaskResultsTaskIdCreatedAt ON eval_task_results(taskId, createdAt);
CREATE INDEX IF NOT EXISTS idxEvalTaskResultsStatusRunId ON eval_task_results(status, runId);
CREATE UNIQUE INDEX IF NOT EXISTS idxEvalTaskResultsRunTaskUnique ON eval_task_results(runId, taskId);
CREATE TABLE IF NOT EXISTS eval_run_events (
id TEXT PRIMARY KEY,
@@ -1123,6 +1124,7 @@ export class Database {
// Compatibility backfills that must run even when schemaVersion is current.
this.ensureRoutinesSchemaCompatibility();
this.ensureInsightRunsSchemaCompatibility();
this.ensureEvalTaskResultsSchemaCompatibility();
// Seed config row idempotently with default settings
const configNow = new Date().toISOString();
@@ -1196,6 +1198,13 @@ export class Database {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxInsightRunsProjectTriggerStatus ON project_insight_runs(projectId, trigger, status)`);
}
private ensureEvalTaskResultsSchemaCompatibility(): void {
if (!this.hasTable("eval_task_results")) {
return;
}
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idxEvalTaskResultsRunTaskUnique ON eval_task_results(runId, taskId)");
}
private migrate(): void {
const version = this.getSchemaVersion() || 1;
@@ -2611,6 +2620,7 @@ export class Database {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxEvalTaskResultsRunIdCreatedAt ON eval_task_results(runId, createdAt)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxEvalTaskResultsTaskIdCreatedAt ON eval_task_results(taskId, createdAt)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxEvalTaskResultsStatusRunId ON eval_task_results(status, runId)`);
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idxEvalTaskResultsRunTaskUnique ON eval_task_results(runId, taskId)`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS eval_run_events (
@@ -2690,6 +2700,12 @@ export class Database {
});
}
if (version < 64) {
this.applyMigration(64, () => {
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idxEvalTaskResultsRunTaskUnique ON eval_task_results(runId, taskId)`);
});
}
}
/**

View File

@@ -0,0 +1,127 @@
import type { DeterministicSignals, EvaluationEvidenceRef } from "./eval-types.js";
import type { TaskDetail, TaskLogEntry, WorkflowStepResult } from "./types.js";
export interface EvalRunContext {
runId: string;
startedAt: string;
}
const TIMING_LOG_RE = /\[timing\].*?\bin\s+(\d+)ms\b/i;
const COMMIT_SHA_RE = /\b[0-9a-f]{7,40}\b/i;
function countWorkflow(results: WorkflowStepResult[] | undefined): DeterministicSignals["workflowSummary"] {
const list = results ?? [];
let passed = 0;
let failed = 0;
let pending = 0;
for (const result of list) {
if (result.status === "passed") passed += 1;
else if (result.status === "failed") failed += 1;
else if (result.status === "pending") pending += 1;
}
return { total: list.length, passed, failed, pending };
}
function summarizeLogs(log: TaskLogEntry[]): {
errorCount: number;
warningCount: number;
timingEntries: number;
evidence: EvaluationEvidenceRef[];
} {
let errorCount = 0;
let warningCount = 0;
let timingEntries = 0;
const evidence: EvaluationEvidenceRef[] = [];
for (const entry of log) {
const text = `${entry.action} ${entry.outcome ?? ""}`.toLowerCase();
if (text.includes("error") || text.includes("failed")) errorCount += 1;
if (text.includes("warn")) warningCount += 1;
const timingMatch = TIMING_LOG_RE.exec(entry.action);
if (timingMatch) {
timingEntries += 1;
evidence.push({
kind: "timing",
label: "Timing entry",
value: `${timingMatch[1]}ms`,
source: entry.timestamp,
});
}
}
return { errorCount, warningCount, timingEntries, evidence };
}
function collectCommitSummary(task: TaskDetail): DeterministicSignals["commitSummary"] {
const mergedAt = task.mergeDetails?.mergedAt;
const commitSet = new Set<string>();
if (task.mergeDetails?.commitSha) commitSet.add(task.mergeDetails.commitSha);
for (const entry of task.log) {
const match = COMMIT_SHA_RE.exec(`${entry.action} ${entry.outcome ?? ""}`);
if (match) commitSet.add(match[0]);
}
return {
commitCount: commitSet.size,
branch: task.branch,
mergedAt,
};
}
export function collectDeterministicSignals(task: TaskDetail, _run: EvalRunContext): DeterministicSignals {
const workflowSummary = countWorkflow(task.workflowStepResults);
const logSummaryWithEvidence = summarizeLogs(task.log ?? []);
const commitSummary = collectCommitSummary(task);
const evidence: EvaluationEvidenceRef[] = [
{
kind: "task",
label: "Task column",
value: task.column,
source: task.id,
},
{
kind: "review",
label: "Task status",
value: task.status ?? "unknown",
source: task.id,
},
...logSummaryWithEvidence.evidence,
];
if (workflowSummary.total > 0) {
evidence.push({
kind: "workflow",
label: "Workflow summary",
value: `${workflowSummary.passed}/${workflowSummary.total} passed`,
source: task.id,
});
}
if (commitSummary.commitCount > 0 || commitSummary.mergedAt) {
evidence.push({
kind: "commit",
label: "Commit summary",
value: `count=${commitSummary.commitCount}`,
source: commitSummary.branch,
});
}
return {
taskId: task.id,
column: task.column === "archived" ? "archived" : "done",
executionStartedAt: task.executionStartedAt,
executionCompletedAt: task.executionCompletedAt,
timedExecutionMs: task.timedExecutionMs,
reviewStatus: task.status,
workflowSummary,
commitSummary,
logSummary: {
errorCount: logSummaryWithEvidence.errorCount,
warningCount: logSummaryWithEvidence.warningCount,
timingEntries: logSummaryWithEvidence.timingEntries,
},
evidence,
};
}

View File

@@ -215,6 +215,21 @@ export class EvalStore extends EventEmitter<EvalStoreEvents> {
categoryScores, rationale, summary, evidence, deterministicSignals, aiSignals,
followUps, provenance, metadata, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(runId, taskId) DO UPDATE SET
taskSnapshot = excluded.taskSnapshot,
status = excluded.status,
overallScore = excluded.overallScore,
maxScore = excluded.maxScore,
categoryScores = excluded.categoryScores,
rationale = excluded.rationale,
summary = excluded.summary,
evidence = excluded.evidence,
deterministicSignals = excluded.deterministicSignals,
aiSignals = excluded.aiSignals,
followUps = excluded.followUps,
provenance = excluded.provenance,
metadata = excluded.metadata,
updatedAt = excluded.updatedAt
`).run(
result.id,
result.runId,
@@ -236,9 +251,10 @@ export class EvalStore extends EventEmitter<EvalStoreEvents> {
result.updatedAt,
);
const persisted = this.getTaskResultByRunTask(runId, input.taskId) ?? result;
this.db.bumpLastModified();
this.emit("result:created", result);
return result;
this.emit("result:created", persisted);
return persisted;
}
getTaskResult(id: string): EvalTaskResult | undefined {
@@ -246,6 +262,11 @@ export class EvalStore extends EventEmitter<EvalStoreEvents> {
return row ? this.rowToResult(row) : undefined;
}
private getTaskResultByRunTask(runId: string, taskId: string): EvalTaskResult | undefined {
const row = this.db.prepare("SELECT * FROM eval_task_results WHERE runId = ? AND taskId = ?").get(runId, taskId) as Record<string, unknown> | undefined;
return row ? this.rowToResult(row) : undefined;
}
listTaskResults(options: EvalTaskResultListOptions = {}): EvalTaskResult[] {
const clauses: string[] = [];
const params: Array<string | number> = [];

View File

@@ -231,6 +231,47 @@ export interface EvalTaskResultListOptions {
offset?: number;
}
export interface EvaluationEvidenceRef {
kind: "task" | "log" | "workflow" | "commit" | "review" | "timing";
label: string;
value?: string;
source?: string;
}
export interface DeterministicSignals {
taskId: string;
column: "done" | "archived";
executionStartedAt?: string;
executionCompletedAt?: string;
timedExecutionMs?: number;
reviewStatus?: string;
workflowSummary: { total: number; passed: number; failed: number; pending: number };
commitSummary: { commitCount: number; branch?: string; mergedAt?: string };
logSummary: { errorCount: number; warningCount: number; timingEntries: number };
evidence: EvaluationEvidenceRef[];
}
export interface FollowUpDraft {
title: string;
description: string;
reason: string;
evidenceRefs: string[];
}
export interface TaskEvaluation {
id: string;
runId: string;
taskId: string;
deterministicSignals: DeterministicSignals;
overallScore: number;
categoryScores: Record<string, number>;
rationale: string;
evidence: EvaluationEvidenceRef[];
followUpDrafts: FollowUpDraft[];
createdAt: string;
updatedAt: string;
}
export interface EvalStoreEvents {
"run:created": [EvalRun];
"run:updated": [EvalRun];

View File

@@ -708,6 +708,8 @@ export type { ResolvedResearchSettings } from "./research-settings.js";
export { TodoStore } from "./todo-store.js";
export type { TodoStoreEvents } from "./todo-store.js";
export { EvalLifecycleError, EvalStore } from "./eval-store.js";
export { collectDeterministicSignals } from "./eval-signal-collector.js";
export type { EvalRunContext } from "./eval-signal-collector.js";
export type {
EvalRun,
EvalRunStatus,
@@ -729,8 +731,30 @@ export type {
EvalFollowUpSuggestion,
EvalProvenance,
EvalStoreEvents,
DeterministicSignals,
EvaluationEvidenceRef,
FollowUpDraft,
TaskEvaluation,
} from "./eval-types.js";
export { EVAL_RUN_STATUSES, EVAL_RUN_TRIGGERS, EVAL_SCORE_CATEGORIES } from "./eval-types.js";
export {
TASK_EVALUATION_SCHEDULE_NAME,
DEFAULT_TASK_EVALUATION_SCHEDULE,
TASK_EVALUATION_SCHEDULE_COMMAND,
resolveTaskEvaluationSettings,
createScheduledEvalBatchAutomation,
syncScheduledEvalBatchAutomation,
runScheduledEvalBatch,
} from "./eval-automation.js";
export type {
ResolvedTaskEvaluationSettings,
EvalBatchWindow,
CompletedTaskEvaluationContext,
CompletedTaskEvaluator,
EvalBatchTaskStore,
RunScheduledEvalBatchParams,
ScheduledEvalBatchResult,
} from "./eval-automation.js";
// ── Agent Companies Types ──────────────────────────────────

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CronRunner, createAiPromptExecutor, isInProcessBackupCommand } from "../cron-runner.js";
import { CronRunner, createAiPromptExecutor, isInProcessBackupCommand, isInProcessScheduledEvalCommand } from "../cron-runner.js";
import type { AiPromptExecutor } from "../cron-runner.js";
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
import { randomUUID } from "node:crypto";
@@ -1842,4 +1842,17 @@ describe("CronRunner", () => {
});
}
});
describe("isInProcessScheduledEvalCommand", () => {
it("matches canonical scheduled eval command", () => {
expect(isInProcessScheduledEvalCommand("fn eval --scheduled-batch")).toBe(true);
expect(isInProcessScheduledEvalCommand("fusion eval --scheduled-batch --flag")).toBe(true);
});
it("rejects non-canonical and shell-metachar commands", () => {
expect(isInProcessScheduledEvalCommand("fn eval")).toBe(false);
expect(isInProcessScheduledEvalCommand("fn eval --scheduled-batch && echo x")).toBe(false);
expect(isInProcessScheduledEvalCommand("echo fn eval --scheduled-batch")).toBe(false);
});
});
});

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { createDatabase, EvalStore, runScheduledEvalBatch, type TaskDetail } from "@fusion/core";
import { HybridEvaluatorService, buildEvaluationPrompt, parseAiResponse, resolveEvaluatorModel } from "../evaluator.js";
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-101",
description: "desc",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [{ timestamp: "1", action: "[timing] build in 50ms" }],
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T01:00:00.000Z",
prompt: "prompt",
...overrides,
} as TaskDetail;
}
describe("evaluator", () => {
it("resolves explicit complete model override before validator lane", () => {
expect(resolveEvaluatorModel({ validatorProvider: "anthropic", validatorModelId: "claude" }, { provider: "openai", modelId: "gpt-4o" }))
.toEqual({ provider: "openai", modelId: "gpt-4o" });
});
it("ignores partial override and falls back to validator lane", () => {
expect(resolveEvaluatorModel({ validatorProvider: "anthropic", validatorModelId: "claude" }, { provider: "openai" }))
.toEqual({ provider: "anthropic", modelId: "claude" });
});
it("parses strict AI JSON response", () => {
const parsed = parseAiResponse('{"overallScore":0.8,"categoryScores":{"quality":0.8},"rationale":"ok","evidence":[],"followUpDrafts":[]}');
expect(parsed.overallScore).toBe(0.8);
expect(parsed.rationale).toBe("ok");
});
it("throws on malformed AI JSON response", () => {
expect(() => parseAiResponse("not-json")).toThrow(/not valid JSON/);
});
it("builds prompt with deterministic signal bundle", () => {
const task = makeTask();
const prompt = buildEvaluationPrompt(task, { runId: "ER-1", startedAt: "2026-05-02T00:00:00.000Z" }, {
taskId: task.id,
column: "done",
workflowSummary: { total: 0, passed: 0, failed: 0, pending: 0 },
commitSummary: { commitCount: 0 },
logSummary: { errorCount: 0, warningCount: 0, timingEntries: 1 },
evidence: [],
});
expect(prompt).toContain("Deterministic signals");
expect(prompt).toContain("ER-1");
});
it("returns merged evaluation payload shape for persistence", async () => {
const service = new HybridEvaluatorService({
cwd: process.cwd(),
runPrompt: async () => '{"overallScore":0.9,"categoryScores":{"quality":0.9},"rationale":"Great","evidence":[{"kind":"task","label":"done"}],"followUpDrafts":[{"title":"Add tests","description":"More tests","reason":"coverage","evidenceRefs":["task:done"]}]}'
});
const result = await service.evaluateTask(makeTask(), { runId: "ER-1", startedAt: "2026-05-01T00:00:00.000Z" }, {});
expect(result.status).toBe("scored");
expect(result.overallScore).toBe(0.9);
expect(result.categoryScores?.[0]?.category).toBe("quality");
expect(result.followUps?.[0]?.title).toBe("Add tests");
expect((result.metadata as any).hybridEvaluation).toBeDefined();
});
it("integrates scheduled batch with evaluator and persists one result per run/task", async () => {
const db = createDatabase("/tmp/fn-evaluator-integration", { inMemory: true });
db.init();
const evalStore = new EvalStore(db);
const doneTask = makeTask({ executionCompletedAt: "2026-05-01T00:04:00.000Z" });
const service = new HybridEvaluatorService({
cwd: process.cwd(),
runPrompt: async () => '{"overallScore":0.7,"categoryScores":{"quality":0.7},"rationale":"Solid","evidence":[],"followUpDrafts":[]}'
});
const mockStore = {
listTasks: async () => [doneTask],
getEvalStore: () => evalStore,
};
const run1 = await runScheduledEvalBatch({
store: mockStore,
projectId: "proj-1",
startedAt: "2026-05-02T00:00:00.000Z",
evaluator: async ({ task, run }) => service.evaluateTask(task as TaskDetail, { runId: run.id, startedAt: run.startedAt ?? "" }, {}),
});
const run2 = await runScheduledEvalBatch({
store: mockStore,
projectId: "proj-1",
startedAt: "2026-05-02T00:00:00.000Z",
evaluator: async ({ task, run }) => service.evaluateTask(task as TaskDetail, { runId: run.id, startedAt: run.startedAt ?? "" }, {}),
});
expect(run1.status).toBe("completed");
expect(run2.tasksSelected).toBe(0);
const all = evalStore.listTaskResults({ taskId: doneTask.id });
expect(all).toHaveLength(1);
expect(all[0]?.overallScore).toBe(0.7);
});
});

View File

@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
syncAutoSummarizeAutomation: vi.fn(),
syncMemoryDreamsAutomation: vi.fn(),
syncScheduledEvalBatchAutomation: vi.fn(),
automationStoreInit: vi.fn(async () => undefined),
createAiPromptExecutor: vi.fn(async () => vi.fn()),
cronRunnerStart: vi.fn(),
@@ -39,6 +40,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomation,
syncAutoSummarizeAutomation: mocks.syncAutoSummarizeAutomation,
syncMemoryDreamsAutomation: mocks.syncMemoryDreamsAutomation,
syncScheduledEvalBatchAutomation: mocks.syncScheduledEvalBatchAutomation,
});
});
@@ -340,12 +342,15 @@ describe("ProjectEngine auto-summarize wiring", () => {
expect(mocks.syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1);
expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1);
expect(mocks.syncScheduledEvalBatchAutomation).toHaveBeenCalledTimes(1);
const insightSettings = mocks.syncInsightExtractionAutomation.mock.calls[0][1];
const autoSummarizeSettings = mocks.syncAutoSummarizeAutomation.mock.calls[0][1];
const memoryDreamsSettings = mocks.syncMemoryDreamsAutomation.mock.calls[0][1];
const scheduledEvalSettings = mocks.syncScheduledEvalBatchAutomation.mock.calls[0][1];
expect(autoSummarizeSettings).toBe(insightSettings);
expect(memoryDreamsSettings).toBe(insightSettings);
expect(scheduledEvalSettings).toBe(insightSettings);
const cronRunnerStartOrder = mocks.cronRunnerStart.mock.invocationCallOrder[0];
expect(mocks.syncInsightExtractionAutomation.mock.invocationCallOrder[0]).toBeLessThan(
@@ -357,6 +362,9 @@ describe("ProjectEngine auto-summarize wiring", () => {
expect(mocks.syncMemoryDreamsAutomation.mock.invocationCallOrder[0]).toBeLessThan(
cronRunnerStartOrder,
);
expect(mocks.syncScheduledEvalBatchAutomation.mock.invocationCallOrder[0]).toBeLessThan(
cronRunnerStartOrder,
);
await engine.stop();
});

View File

@@ -2,6 +2,8 @@ import { exec } from "node:child_process";
import {
resolveProjectDefaultModel,
runScheduledEvalBatch,
resolveTaskEvaluationSettings,
type TaskStore,
type AutomationStore,
type ScheduledTask,
@@ -10,10 +12,12 @@ import {
type AutomationStepResult,
type Column,
type TaskCreateInput,
type TaskDetail,
} from "@fusion/core";
import { createLogger } from "./logger.js";
import { defaultShell } from "./shell-utils.js";
import { createFnAgent, promptWithFallback } from "./pi.js";
import { HybridEvaluatorService } from "./evaluator.js";
const log = createLogger("cron-runner");
@@ -128,6 +132,18 @@ export function isInProcessBackupCommand(command: string | undefined): boolean {
return true;
}
export function isInProcessScheduledEvalCommand(command: string | undefined): boolean {
if (!command) return false;
const trimmed = command.trim();
if (!trimmed || SHELL_METACHARACTERS_REGEX.test(trimmed)) return false;
const tokens = trimmed.split(/\s+/).map((tok) => tok.toLowerCase());
return tokens.length >= 3
&& FUSION_BINARY_TOKENS.has(tokens[0] ?? "")
&& tokens[1] === "eval"
&& tokens[2] === "--scheduled-batch"
&& tokens.slice(3).every((tok) => tok.startsWith("-"));
}
/** Default execution timeout: 5 minutes. */
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
/** Maximum output buffer: 1 MB. */
@@ -150,6 +166,10 @@ export type AiPromptExecutor = (
) => Promise<string>;
export interface CronRunnerOptions {
/** Project working directory used for in-process evaluator sessions */
workingDirectory?: string;
/** Project id for scheduled eval batch persistence */
projectId?: string;
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
pollIntervalMs?: number;
/** Optional AI prompt executor. When not provided, ai-prompt steps return a configuration error. */
@@ -391,6 +411,10 @@ export class CronRunner {
return this.executeBackupInProcess(schedule, startedAt);
}
if (isInProcessScheduledEvalCommand(schedule.command)) {
return this.executeScheduledEvalInProcess(schedule, startedAt);
}
try {
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { stdout, stderr } = await execCommand(schedule.command, {
@@ -458,6 +482,39 @@ export class CronRunner {
* and the command-step path. Returns the success/output/error tuple in
* a shape that callers can wrap into either a run or a step result.
*/
private async executeScheduledEvalInProcess(
schedule: ScheduledTask,
startedAt: string,
): Promise<AutomationRunResult> {
const settings = await this.store.getSettings();
const evalSettings = resolveTaskEvaluationSettings(settings);
const evaluator = new HybridEvaluatorService({ cwd: this.options.workingDirectory ?? process.cwd() });
const result = await runScheduledEvalBatch({
store: this.store,
projectId: this.options.projectId ?? "default-project",
startedAt,
evaluator: async ({ task, run }) => {
const taskDetail = await this.store.getTask(task.id);
if (!taskDetail) {
throw new Error(`Task not found for evaluation: ${task.id}`);
}
return evaluator.evaluateTask(taskDetail as TaskDetail, { runId: run.id, startedAt: run.startedAt ?? startedAt }, settings, {
provider: evalSettings.taskEvaluationProvider,
modelId: evalSettings.taskEvaluationModelId,
});
},
});
return {
success: result.status === "completed",
output: JSON.stringify(result),
error: result.status === "failed" ? "Scheduled eval batch failed" : undefined,
startedAt,
completedAt: new Date().toISOString(),
};
}
private async runBackupActionInProcess(): Promise<{
success: boolean;
output: string;

View File

@@ -0,0 +1,201 @@
import {
collectDeterministicSignals,
resolveValidatorSettingsModel,
type DeterministicSignals,
type EvalTaskResultCreateInput,
type EvaluationEvidenceRef,
type FollowUpDraft,
type Settings,
type TaskDetail,
} from "@fusion/core";
import { createFnAgent, promptWithFallback } from "./pi.js";
import { createLogger } from "./logger.js";
const log = createLogger("evaluator");
export interface EvalRunContext {
runId: string;
startedAt: string;
}
export interface EvaluatorModelOverride {
provider?: string;
modelId?: string;
}
export interface EvaluatorDeps {
cwd: string;
runPrompt?: (prompt: string, provider?: string, modelId?: string) => Promise<string>;
}
interface EvaluatorAiResponse {
overallScore: number;
categoryScores: Record<string, number>;
rationale: string;
evidence: EvaluationEvidenceRef[];
followUpDrafts: FollowUpDraft[];
}
export function resolveEvaluatorModel(
settings: Partial<Settings>,
override?: EvaluatorModelOverride,
): { provider?: string; modelId?: string } {
if (override?.provider && override?.modelId) {
return { provider: override.provider, modelId: override.modelId };
}
// Temporary fallback until FN-3393 introduces dedicated evaluator settings.
return resolveValidatorSettingsModel(settings);
}
export class HybridEvaluatorService {
constructor(private readonly deps: EvaluatorDeps) {}
async evaluateTask(
task: TaskDetail,
run: EvalRunContext,
settings: Partial<Settings>,
modelOverride?: EvaluatorModelOverride,
): Promise<Omit<EvalTaskResultCreateInput, "taskId" | "taskSnapshot">> {
const deterministicSignals = collectDeterministicSignals(task, run);
const model = resolveEvaluatorModel(settings, modelOverride);
const prompt = buildEvaluationPrompt(task, run, deterministicSignals);
const responseText = await this.runPrompt(prompt, model.provider, model.modelId);
const ai = parseAiResponse(responseText);
return {
status: "scored",
overallScore: ai.overallScore,
categoryScores: Object.entries(ai.categoryScores).map(([category, score]) => ({ category, score })),
rationale: ai.rationale,
summary: ai.rationale,
evidence: ai.evidence.map((ev) => ({ type: "other", ref: `${ev.kind}:${ev.label}`, excerpt: ev.value })),
deterministicSignals: deterministicSignalsToEvalSignals(deterministicSignals),
followUps: ai.followUpDrafts.map((draft) => ({
title: draft.title,
description: draft.description,
metadata: { reason: draft.reason, evidenceRefs: draft.evidenceRefs },
})),
metadata: {
runId: run.runId,
evaluatorModel: model,
evaluatorRationale: ai.rationale,
hybridEvaluation: {
deterministicSignals,
ai,
},
},
};
}
private async runPrompt(prompt: string, provider?: string, modelId?: string): Promise<string> {
if (this.deps.runPrompt) {
return this.deps.runPrompt(prompt, provider, modelId);
}
let text = "";
const { session } = await createFnAgent({
cwd: this.deps.cwd,
systemPrompt: "You are a strict evaluator. Reply with JSON only.",
tools: "readonly",
defaultProvider: provider,
defaultModelId: modelId,
onText: (delta) => {
text += delta;
},
});
try {
await promptWithFallback(session, prompt);
return text;
} finally {
try {
session.dispose();
} catch (error) {
log.warn(`Evaluator session disposal failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
}
function deterministicSignalsToEvalSignals(signals: DeterministicSignals): Array<{ signalId: string; kind: string; name: string; value?: string | number; passed?: boolean }> {
return [
{
signalId: "workflow-summary",
kind: "workflow",
name: "workflow-summary",
value: `${signals.workflowSummary.passed}/${signals.workflowSummary.total}`,
passed: signals.workflowSummary.failed === 0,
},
{
signalId: "timing-ms",
kind: "timing",
name: "timed-execution-ms",
value: signals.timedExecutionMs,
},
{
signalId: "commit-count",
kind: "commit",
name: "commit-count",
value: signals.commitSummary.commitCount,
},
];
}
export function buildEvaluationPrompt(task: TaskDetail, run: EvalRunContext, deterministicSignals: DeterministicSignals): string {
return [
"Evaluate the completed task and respond with strict JSON.",
`Run: ${run.runId}`,
"Schema:",
JSON.stringify({
overallScore: 0,
categoryScores: { quality: 0, reliability: 0, testing: 0 },
rationale: "",
evidence: [{ kind: "task", label: "", value: "", source: "" }],
followUpDrafts: [{ title: "", description: "", reason: "", evidenceRefs: [] }],
}, null, 2),
"Task:",
JSON.stringify({
id: task.id,
title: task.title,
column: task.column,
status: task.status,
summary: task.summary,
}, null, 2),
"Deterministic signals:",
JSON.stringify(deterministicSignals, null, 2),
].join("\n\n");
}
export function parseAiResponse(raw: string): EvaluatorAiResponse {
const candidate = extractJson(raw);
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch (error) {
throw new Error(`Evaluator AI response was not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
const record = parsed as Partial<EvaluatorAiResponse>;
if (typeof record.overallScore !== "number") throw new Error("Evaluator response missing numeric overallScore");
if (!record.categoryScores || typeof record.categoryScores !== "object") throw new Error("Evaluator response missing categoryScores");
if (typeof record.rationale !== "string" || !record.rationale.trim()) throw new Error("Evaluator response missing rationale");
return {
overallScore: record.overallScore,
categoryScores: record.categoryScores as Record<string, number>,
rationale: record.rationale,
evidence: Array.isArray(record.evidence) ? record.evidence : [],
followUpDrafts: Array.isArray(record.followUpDrafts) ? record.followUpDrafts : [],
};
}
function extractJson(raw: string): string {
const trimmed = raw.trim();
if (trimmed.startsWith("```")) {
return trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
}
const first = trimmed.indexOf("{");
const last = trimmed.lastIndexOf("}");
if (first >= 0 && last > first) return trimmed.slice(first, last + 1);
return trimmed;
}

View File

@@ -320,6 +320,8 @@ export class ProjectEngine {
this.cronRunner = new CronRunner(store, this.automationStore, {
aiPromptExecutor,
onScheduleRunProcessed: this.buildInsightRunHandler(cwd),
workingDirectory: cwd,
projectId: this.config.projectId,
scope: "project", // Project-scoped execution — global schedules run separately
});
@@ -365,6 +367,19 @@ export class ProjectEngine {
runtimeLog.warn("syncMemoryDreamsAutomation is unavailable; skipping startup sync");
}
// Sync scheduled eval batch automation on startup
if (typeof coreAutomationModule.syncScheduledEvalBatchAutomation === "function") {
try {
await coreAutomationModule.syncScheduledEvalBatchAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`scheduled eval: ${message}`);
runtimeLog.warn(`Scheduled eval automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncScheduledEvalBatchAutomation is unavailable; skipping startup sync");
}
this.cronRunner.start();
if (startupSyncFailures.length > 0) {
@@ -2101,6 +2116,40 @@ export class ProjectEngine {
};
store.on("settings:updated", onAutoSummarizeSettingsChange);
this.settingsHandlers.push(onAutoSummarizeSettingsChange);
// 7. Scheduled eval settings change — sync automation
const onScheduledEvalSettingsChange = async ({
settings: s,
previous: prev,
}: {
settings: Settings;
previous: Settings;
}) => {
const evalKeys = [
"taskEvaluationEnabled",
"taskEvaluationSchedule",
] as const;
const changed = evalKeys.some((key) => (s as any)[key] !== (prev as any)[key]);
if (!changed || !this.automationStore) return;
try {
const { syncScheduledEvalBatchAutomation } = await import("@fusion/core");
if (typeof syncScheduledEvalBatchAutomation === "function") {
await syncScheduledEvalBatchAutomation(this.automationStore, s);
runtimeLog.log("Scheduled eval automation synced with settings");
}
} catch (err) {
const { message, detail } = formatErrorDetails(err);
this.setAutomationSubsystemHealth(
"degraded",
`Failed to sync scheduled eval automation: ${message}`,
);
runtimeLog.warn(`Failed to sync scheduled eval automation:\n${detail}`);
}
};
store.on("settings:updated", onScheduledEvalSettingsChange);
this.settingsHandlers.push(onScheduledEvalSettingsChange);
}
/**