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:
@@ -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 }>;
|
||||
|
||||
58
packages/core/src/__tests__/eval-signal-collector.test.ts
Normal file
58
packages/core/src/__tests__/eval-signal-collector.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(63);
|
||||
expect(db.getSchemaVersion()).toBe(64);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
127
packages/core/src/eval-signal-collector.ts
Normal file
127
packages/core/src/eval-signal-collector.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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> = [];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
105
packages/engine/src/__tests__/evaluator.test.ts
Normal file
105
packages/engine/src/__tests__/evaluator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
201
packages/engine/src/evaluator.ts
Normal file
201
packages/engine/src/evaluator.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user