feat(api): agent pre-compute scheduler (ADR-0013 step 5)

Extracts computeAndStore() from the /agents/:agentId/compute route so it
can be called without an HTTP round-trip. startAgentPrecomputeScheduler()
runs every 15 min: fetches active users (tip view in 48h), runs all agents
in parallel per user, then purges outputs expired >24h. Agent IDs are
resolved from ml/serving /health at startup with a fallback hardcoded list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 10:29:50 +00:00
parent 37aec4fee1
commit 7e958a779d
3 changed files with 167 additions and 59 deletions

View File

@@ -0,0 +1,105 @@
/**
* Agent pre-compute scheduler (ADR-0013, Step 5).
*
* Every 15 minutes: for each user who viewed a tip in the last 48 hours,
* run all sub-agents and store their prompt snippets in agent_outputs.
* Also purges rows expired more than 24 hours ago.
*
* Agent IDs are fetched from ml/serving /health at start, falling back to
* a hardcoded list if ml/serving is not yet reachable.
*/
import { db } from '../db/index.js';
import { agentOutputs, tipViews } from '../db/schema.js';
import { gt, lt } from 'drizzle-orm';
import { logger } from '../logger.js';
import { config } from '../config.js';
import { computeAndStore } from '../routes/agent-outputs.js';
const FALLBACK_AGENT_IDS = [
'overdue-task',
'momentum',
'time-of-day',
'recent-patterns',
'focus-area',
];
const DEFAULT_INTERVAL_MS = 15 * 60 * 1000;
async function fetchAgentIds(): Promise<string[]> {
try {
const res = await fetch(`${config.ML_SERVING_URL}/health`, {
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) return FALLBACK_AGENT_IDS;
const data = (await res.json()) as { agents?: string[] };
return data.agents?.length ? data.agents : FALLBACK_AGENT_IDS;
} catch {
return FALLBACK_AGENT_IDS;
}
}
async function getActiveUserIds(): Promise<string[]> {
const cutoff = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
const rows = await db
.selectDistinct({ userId: tipViews.userId })
.from(tipViews)
.where(gt(tipViews.servedAt, cutoff));
return rows.map((r) => r.userId);
}
async function purgeExpired(): Promise<void> {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
await db.delete(agentOutputs).where(lt(agentOutputs.expiresAt, cutoff));
}
async function runCycle(agentIds: string[]): Promise<void> {
let userIds: string[];
try {
userIds = await getActiveUserIds();
} catch (err: any) {
logger.error({ err }, 'agent-scheduler: failed to query active users');
return;
}
if (!userIds.length) return;
let ok = 0;
let failed = 0;
for (const userId of userIds) {
const results = await Promise.allSettled(
agentIds.map((agentId) => computeAndStore(userId, agentId)),
);
for (const r of results) {
if (r.status === 'fulfilled') ok++;
else {
failed++;
logger.error({ err: r.reason, userId }, 'agent-scheduler: compute error');
}
}
}
try {
await purgeExpired();
} catch (err: any) {
logger.error({ err }, 'agent-scheduler: purge failed');
}
logger.info(
{ ok, failed, users: userIds.length, agents: agentIds.length },
'agent-scheduler: cycle complete',
);
}
export async function startAgentPrecomputeScheduler(
intervalMs = DEFAULT_INTERVAL_MS,
): Promise<void> {
const agentIds = await fetchAgentIds();
logger.info({ agentIds }, 'agent-scheduler: starting');
setTimeout(() => {
void runCycle(agentIds);
setInterval(() => void runCycle(agentIds), intervalMs);
}, 15_000);
}